mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 14:52:36 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b7ef823a | ||
|
|
8533c5c06f | ||
|
|
b1050d9bed | ||
|
|
5e561f8a23 | ||
|
|
ce335de1cd | ||
|
|
6688c92bc6 | ||
|
|
8cf5942a5f | ||
|
|
9113542c5a | ||
|
|
2618686677 | ||
|
|
86ef468003 | ||
|
|
0dad1d7a61 | ||
|
|
63587a9c03 | ||
|
|
a88aa49f4c | ||
|
|
eef9be8863 | ||
|
|
650f3ca7f6 | ||
|
|
b6bfdedf58 | ||
|
|
1d4c3a2722 | ||
|
|
6c813d1a91 | ||
|
|
aeb240be98 | ||
|
|
7080648e17 | ||
|
|
463415ae84 | ||
|
|
693280d996 | ||
|
|
3c6e4e39ec | ||
|
|
ed628ff82e | ||
|
|
667664847d | ||
|
|
7ea4288117 | ||
|
|
42b6d99d60 |
@@ -46,7 +46,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [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/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/source-editor.md](docs/source-editor.md) | Shared CodeMirror runtime, native bridge boundary, line-ending preservation, and embedded bundle workflow |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [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 |
|
||||
|
||||
@@ -111,8 +111,8 @@ paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
paseo send abc123 "also add tests" # follow-up task
|
||||
|
||||
# run on a remote daemon
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
# run on a remote daemon; --cwd is a path on that host
|
||||
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
|
||||
```
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
@@ -103,12 +103,13 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
|
||||
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
|
||||
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Source editing uses one CodeMirror runtime on web and in an offline native WebView. Its ownership and bridge constraints are documented in [docs/source-editor.md](source-editor.md).
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
@@ -393,6 +393,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
},
|
||||
"params": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"rpcTimeoutMs": 60000,
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
"slowModel": "anthropic/claude-opus-4-1",
|
||||
"planModel": "openai/o3"
|
||||
@@ -403,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. `params.rpcTimeoutMs` overrides the 60-second OMP control-plane RPC deadline.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
@@ -416,7 +417,8 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions"
|
||||
"sessionDir": "~/.my-pi-fork/sessions",
|
||||
"rpcTimeoutMs": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +426,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
|
||||
}
|
||||
```
|
||||
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-2
@@ -556,12 +556,15 @@ npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
Use `--host` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
npm run cli -- ls -a --host localhost:7777
|
||||
npm run cli -- ls -a --host ssh://user@host
|
||||
```
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||
|
||||
+12
-21
@@ -132,30 +132,21 @@ quietly relying on:
|
||||
|
||||
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||
includes Reanimated-applied transforms (Reanimated updates native view
|
||||
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||
with Reanimated worklets the result is not always stable.
|
||||
Move the chat surface with worklet-driven bottom padding from
|
||||
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
|
||||
and native hit testing in the same layout. Do not translate the stream and
|
||||
composer independently.
|
||||
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
|
||||
the composer uses**:
|
||||
Do not use the controller's raw keyboard progress for the padding. It can retain
|
||||
a nonzero value after the keyboard closes. The shared provider normalizes that
|
||||
state and reconciles native animation-end events.
|
||||
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
to the popover wrapper.
|
||||
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed. Do not call
|
||||
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
|
||||
can briefly report a stale nonzero height with closed progress, and the shared
|
||||
provider is where that is normalized.
|
||||
`measureInWindow` already includes the dock's current padding layout. When a
|
||||
portal opens, snapshot the current shift and apply only the subsequent shift
|
||||
delta to its animated `bottom`. Adding the full shift moves the portal twice and
|
||||
can place it over the composer controls.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
@@ -24,10 +24,10 @@ receive only `hub.execution.*` authority, so execution credentials cannot manage
|
||||
## Session grants and execution ownership
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
grants: trusted clients receive `*`, while an enrolled Hub connection receives its persisted
|
||||
`hub.execution.*` grant. One matcher handles exact RPC names and trailing namespace wildcards for
|
||||
both inbound requests and outbound messages. A denied request returns the ordinary `rpc_error`
|
||||
shape.
|
||||
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||
connection receives `hub.execute`. The current enrollment and persistence format still carries
|
||||
`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied
|
||||
request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
|
||||
@@ -259,7 +259,9 @@ background music stays paused. On iOS this is not just a "while recording" probl
|
||||
`.playAndRecord`/`.voiceChat` category is non-mixing and survives backgrounding, and iOS re-asserts
|
||||
it every time the app returns to the foreground — so one dictation turn kills music for the life of
|
||||
the process, on every open, until the app is force-quit. Android holds
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect.
|
||||
`AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE` with the same effect. It must also reset
|
||||
`MODE_IN_COMMUNICATION` and clear the selected communication device; abandoning focus alone can
|
||||
leave Bluetooth earbuds on their narrow-band call route without an active microphone indicator.
|
||||
|
||||
`createAudioEngine` (`packages/app/src/voice/audio-engine.native.ts`) calls
|
||||
`releaseAudioSession()` whenever capture stops and the playback queue drains, and the iOS module
|
||||
@@ -267,9 +269,9 @@ also releases on `OnAppEntersBackground`. The native side re-guards on `isRecord
|
||||
`speechPlayer.isPlaying`, because the native engine is a singleton shared by multiple JS engine
|
||||
wrappers (voice provider + dictation) and only it knows the true state.
|
||||
|
||||
This cannot be validated by a JS test — verify on a device: play music, open Paseo, use dictation
|
||||
once, stop, and confirm the music resumes; then background/foreground the app and confirm it keeps
|
||||
playing.
|
||||
This cannot be validated by a JS test — verify on a device: play music through Bluetooth earbuds,
|
||||
open Paseo, use dictation once, stop, and confirm the music resumes at full quality; then
|
||||
background/foreground the app and confirm it keeps playing at full quality.
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Daemon permissions
|
||||
|
||||
The daemon authorizes principals with semantic permissions. RPC names and protocol namespaces are not authority.
|
||||
|
||||
## Model
|
||||
|
||||
```text
|
||||
principal -> grants
|
||||
|
|
||||
+-- authenticated by a device or service credential
|
||||
`-- opens a session with equal or narrower authority
|
||||
```
|
||||
|
||||
A principal is the durable identity the daemon authorizes. A credential proves that a device or service represents it. Keep them separate so you can rotate credentials, attach more than one device, and revoke a Hub user without inventing daemon user accounts.
|
||||
|
||||
A pairing invitation is neither. It is an expiring, single-use exchange that creates a principal and credential with the permissions selected by its issuer.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Authority |
|
||||
| ------------------- | -------------------------------------------------------------------------- |
|
||||
| `daemon.read` | Daemon status, diagnostics, configuration, and provider information |
|
||||
| `daemon.manage` | Restart, update, configuration changes, providers, skills, and plugins |
|
||||
| `tunnel.manage` | Relay, Hub, service tunnel, and public endpoint relationships |
|
||||
| `access.manage` | Pairing invitations, principals, credentials, grants, and revocation |
|
||||
| `workspace.read` | Projects, workspaces, agents, timelines, files, diffs, and terminal output |
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | The Hub-owned execution lifecycle |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
## Resources
|
||||
|
||||
Permissions are daemon-wide today. Future grants may select workspaces or agents, but operation classification remains inside the authorization module:
|
||||
|
||||
```ts
|
||||
type Grant = {
|
||||
permission: Permission;
|
||||
resource: { kind: "daemon" } | { kind: "workspace"; ids: string[] };
|
||||
};
|
||||
```
|
||||
|
||||
A delegating principal can grant only authority it already possesses. A session may attenuate its principal's grants but cannot widen them.
|
||||
|
||||
Workspace-scoped grants require every resource-bearing operation and outbound observation to enforce the same workspace boundary. File preview currently accepts any daemon-readable regular file, so it must gain resource enforcement before workspace-specific access ships.
|
||||
|
||||
## Hub
|
||||
|
||||
The Hub authenticates as a service principal. Its locally selected grants decide whether it may execute agents, manage the daemon, manage tunnels, or manage access.
|
||||
|
||||
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
|
||||
|
||||
The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions.
|
||||
+4
-3
@@ -201,9 +201,10 @@ when moved between hosts. Explorer configuration can create workspace-context pa
|
||||
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
||||
workspace-layout store access.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
|
||||
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
|
||||
+5
-1
@@ -67,6 +67,8 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi and OMP control-plane RPCs wait 60 seconds by default. Override the provider's `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs a different deadline. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
@@ -75,7 +77,9 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Source editor
|
||||
|
||||
The source editor has one CodeMirror runtime and two hosts:
|
||||
|
||||
- Web mounts the runtime directly into the page.
|
||||
- iOS and Android mount the same runtime from a generated, offline HTML document in a WebView.
|
||||
|
||||
Keep editor mechanics in `packages/app/src/source-editor/`. File loading, conflict handling, and
|
||||
persistence belong to the file pane's `FileEditorModel`. The source editor receives a document and
|
||||
emits edits; it does not know about workspaces or daemon RPCs.
|
||||
|
||||
## Native bridge
|
||||
|
||||
The native bridge sends compact change ranges in normalized LF coordinates. The host-side document
|
||||
mirror validates and applies them, then restores the file's original line separator before calling
|
||||
the file model. Full document replacements cross the bridge only for mount and external changes.
|
||||
|
||||
Every message carries an editor key. Ignore messages from an old key after remounting or switching
|
||||
files. Keep the bridge protocol private to this module; React components outside it use
|
||||
`SourceEditorProps`.
|
||||
|
||||
Do not expose raw CodeMirror extensions as the cross-platform API. Add named capabilities to the
|
||||
source editor contract, then implement them once in the shared runtime or explicitly in each host.
|
||||
|
||||
## Embedded bundle
|
||||
|
||||
After changing the CodeMirror runtime, bridge protocol, or WebView entry point, run:
|
||||
|
||||
```bash
|
||||
npm --prefix packages/app run build:source-editor-webview
|
||||
```
|
||||
|
||||
The generated `webview-html.ts` is committed so native builds do not need a runtime bundler or
|
||||
network access. `build:source-editor-webview:check` fails when the committed artifact is stale, and
|
||||
EAS post-install rebuilds all embedded WebViews.
|
||||
@@ -41,6 +41,7 @@
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
|
||||
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
|
||||
@@ -23,7 +23,7 @@ async function completeSubmittedTurn(
|
||||
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ async function expectTurnCompletesNormally(
|
||||
): Promise<void> {
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,49 @@ test("switching between settled agent tabs keeps a real assistant PNG rendered",
|
||||
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
|
||||
});
|
||||
|
||||
test("opens a timeline image in a zoomable lightbox", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Zoomable timeline image",
|
||||
fileName: "zoomable-timeline-image.png",
|
||||
});
|
||||
const imageAgent = await createSettledMockAgent(workspace, "Zoomable image timeline");
|
||||
await emitSettledAssistantImage(workspace.client, imageAgent, image);
|
||||
await openAssistantImageTimeline(page, imageAgent);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
|
||||
await page.getByRole("button", { name: "Open image attachment" }).click();
|
||||
const lightboxImage = page.getByTestId("attachment-lightbox-image");
|
||||
const canvas = page.getByTestId("attachment-lightbox-canvas");
|
||||
await expect(lightboxImage).toBeVisible();
|
||||
await canvas.hover();
|
||||
const transformedContent = canvas.locator(":scope > div").first();
|
||||
const initialBox = await transformedContent.boundingBox();
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
expect(initialBox).not.toBeNull();
|
||||
expect(canvasBox).not.toBeNull();
|
||||
expect(initialBox!.width).toBeLessThan(canvasBox!.width);
|
||||
const zoomIn = page.getByRole("button", { name: "Zoom in", exact: true });
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await zoomIn.click();
|
||||
await expect
|
||||
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(canvasBox!.width);
|
||||
|
||||
await canvas.dblclick({ position: { x: canvasBox!.width / 2, y: canvasBox!.height / 2 } });
|
||||
await expect
|
||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||
.toBe(Math.round(initialBox!.width));
|
||||
|
||||
await canvas.click({ position: { x: 4, y: 4 } });
|
||||
await expect(lightboxImage).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
@@ -144,6 +146,8 @@ test.describe("Archive tab reconciliation", () => {
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
|
||||
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
|
||||
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string): Locator {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
function action(panel: Locator, title: string): Locator {
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = workspaceRow(page, workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
|
||||
// that used to make the sidebar-owned rename dialog unreachable.
|
||||
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: project.projectDisplayName });
|
||||
await expect(header).toHaveCount(1, { timeout: 30_000 });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
|
||||
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
|
||||
// surface under test anyway, and it works from every route.
|
||||
async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, query: string, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(query);
|
||||
const entry = action(panel, title);
|
||||
await expect(entry).toBeVisible({ timeout: 15_000 });
|
||||
await entry.click();
|
||||
}
|
||||
|
||||
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
|
||||
const workspaces = await workspace.client.fetchWorkspaces();
|
||||
for (const entry of workspaces.entries) {
|
||||
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||
let rejectNext = true;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
let request: { type?: string; requestId?: string } | null = null;
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
message?: { type?: string; requestId?: string };
|
||||
};
|
||||
request = envelope.type === "session" ? (envelope.message ?? null) : null;
|
||||
} catch {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
rejectNext &&
|
||||
request?.type === "workspace.label.assignment.set.request" &&
|
||||
request.requestId
|
||||
) {
|
||||
rejectNext = false;
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
requestType: request.type,
|
||||
error: "Injected label mutation failure.",
|
||||
code: "handler_error",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Command center workspace management", () => {
|
||||
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
|
||||
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||
|
||||
try {
|
||||
await workspace.client.setWorkspaceLabel({
|
||||
workspaceId: workspace.workspaceId,
|
||||
label: { name: "Urgent", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("urgent");
|
||||
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
// Unassigning the only label drops the field entirely rather than leaving an empty array.
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
|
||||
|
||||
const reopened = await openCommandCenter(page);
|
||||
await reopened.getByTestId("command-center-input").fill("urgent");
|
||||
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an error toast when assigning a label fails", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
|
||||
|
||||
try {
|
||||
await rejectNextLabelAssignment(page);
|
||||
await workspace.client.setWorkspaceLabel({
|
||||
workspaceId: workspace.workspaceId,
|
||||
label: { name: "Urgent", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("urgent");
|
||||
await panel.getByTestId("command-center-workspace-label-Urgent").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toContainText(
|
||||
"Injected label mutation failure.",
|
||||
);
|
||||
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-",
|
||||
title: "Composer focus",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -538,6 +538,16 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await imageCanvas.hover();
|
||||
const transformedContent = imageCanvas.locator(":scope > div").first();
|
||||
const fittedImageBox = await transformedContent.boundingBox();
|
||||
expect(fittedImageBox).not.toBeNull();
|
||||
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(fittedImageBox!.width * 1.2);
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
|
||||
const GREETING = "Say hello back.";
|
||||
|
||||
test.use({ e2eInjectPaseoTools: true });
|
||||
|
||||
function verificationPrompt(workspacePath: string): string {
|
||||
return [
|
||||
"Use Paseo tools to perform this exact UI verification.",
|
||||
"Do not edit files or run shell commands. Every prompt below is intentionally non-actionable.",
|
||||
`1. Create a local workspace at ${workspacePath} titled "Presentation QA workspace".`,
|
||||
`2. In that workspace, create a codex/gpt-5.4-mini agent titled "Greeting child" with the initial prompt "${GREETING}".`,
|
||||
`3. Create a schedule named "Greeting schedule" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", provider "codex/gpt-5.4-mini", cwd ${workspacePath}, maxRuns 1, and expiresIn "1h". Delete that schedule using its returned id.`,
|
||||
`4. Create a heartbeat named "Greeting heartbeat" with prompt "${GREETING}", cron "0 0 1 1 *", timezone "Europe/Berlin", maxRuns 1, and expiresIn "1h". Delete that heartbeat using its returned id.`,
|
||||
`5. After the child agent finishes, send it the follow-up prompt "${GREETING}" synchronously.`,
|
||||
"6. Archive the workspace from step 1.",
|
||||
"7. Reply with exactly TOOL_CALL_QA_DONE.",
|
||||
"Use each named Paseo tool exactly once except for the matching schedule and heartbeat deletion tools.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function expandedToolCall(page: Page, label: string): Promise<Locator> {
|
||||
const badge = page.getByTestId("tool-call-badge").filter({ hasText: label }).last();
|
||||
await expect(badge).toBeVisible({ timeout: 30_000 });
|
||||
await badge.scrollIntoViewIfNeeded();
|
||||
await badge.click();
|
||||
return badge;
|
||||
}
|
||||
|
||||
async function captureToolCall(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
input: { label: string; artifact: string; expectedText: Array<string | RegExp> },
|
||||
): Promise<void> {
|
||||
const badge = await expandedToolCall(page, input.label);
|
||||
for (const text of input.expectedText) {
|
||||
await expect(badge).toContainText(text);
|
||||
}
|
||||
const screenshot = testInfo.outputPath(`${input.artifact}.png`);
|
||||
await badge.screenshot({ path: screenshot });
|
||||
await testInfo.attach(input.artifact, { path: screenshot, contentType: "image/png" });
|
||||
await badge.click();
|
||||
}
|
||||
|
||||
async function runPresentationJourney(handle: AgentHandle, workspacePath: string): Promise<void> {
|
||||
await sendMessage(handle, verificationPrompt(workspacePath));
|
||||
await expect(
|
||||
handle.page.getByTestId("assistant-message").filter({ hasText: "TOOL_CALL_QA_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("renders owned tool inputs and results as readable text", async ({ page }, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-tool-call-presentation-")));
|
||||
const childWorkspacePath = path.join(cwd, "presentation-workspace");
|
||||
mkdirSync(childWorkspacePath);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
|
||||
await runPresentationJourney(handle, childWorkspacePath);
|
||||
await page.setViewportSize({ width: 1280, height: 1600 });
|
||||
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create workspace",
|
||||
artifact: "create-workspace-presentation",
|
||||
expectedText: ["Details", "Presentation QA workspace", childWorkspacePath, "local"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create agent",
|
||||
artifact: "create-agent-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send agent prompt",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create schedule",
|
||||
artifact: "schedule-presentation",
|
||||
expectedText: [
|
||||
"Prompt",
|
||||
GREETING,
|
||||
"0 0 1 1 *",
|
||||
"Europe/Berlin",
|
||||
"Greeting schedule",
|
||||
"Result",
|
||||
],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Create heartbeat",
|
||||
artifact: "heartbeat-presentation",
|
||||
expectedText: [
|
||||
"Prompt",
|
||||
GREETING,
|
||||
"0 0 1 1 *",
|
||||
"Europe/Berlin",
|
||||
"Greeting heartbeat",
|
||||
"Result",
|
||||
],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Archive workspace",
|
||||
artifact: "archive-workspace-presentation",
|
||||
expectedText: ["Details", "Workspace", "Result", "Removed directory"],
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,9 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||
}
|
||||
|
||||
function pluginSource(): string {
|
||||
function pluginSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
@@ -55,8 +55,14 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
|
||||
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
|
||||
}
|
||||
|
||||
function DirectCollisionSurface() {
|
||||
return <View><Text>Direct collision surface</Text></View>;
|
||||
function DirectCollisionSurface({ navigation }) {
|
||||
return <View>
|
||||
<Text>Direct collision surface</Text>
|
||||
{navigation ? <>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
|
||||
</> : null}
|
||||
</View>;
|
||||
}
|
||||
|
||||
function SidebarCollisionSurface() {
|
||||
@@ -178,7 +184,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource());
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
|
||||
);
|
||||
|
||||
try {
|
||||
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -239,6 +248,38 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await page.getByTestId("plugin-surface-close").click();
|
||||
});
|
||||
|
||||
await test.step("surface navigation opens host-owned workspace and agent routes", async () => {
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await page.getByRole("button", { name: "Open workspace from plugin", exact: true }).click();
|
||||
await page.waitForURL(isSettledWorkspaceUrl);
|
||||
await expect(page.getByTestId("workspace-header-title")).toBeVisible();
|
||||
|
||||
const agent = await primary.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: primary.repoPath,
|
||||
workspaceId: primary.workspaceId,
|
||||
title: "Plugin navigation agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const navigationAgentId = agent.id;
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
|
||||
);
|
||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
await page.getByRole("button", { name: "Open agent from plugin", exact: true }).click();
|
||||
await page.waitForURL(isSettledWorkspaceUrl);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-agent_${navigationAgentId}`)
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("switching hosts removes commands from an uninstalled host", async () => {
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)"
|
||||
STATE_DIR="${PASEO_COMPOSER_KEYBOARD_STATE_DIR:-${REPO_ROOT}/.dev/agent-device-composer-keyboard}"
|
||||
ARTIFACTS_DIR="${REPO_ROOT}/.dev/agent-device-artifacts/composer-keyboard-android"
|
||||
SESSION="${PASEO_COMPOSER_KEYBOARD_SESSION:-composer-keyboard-android}"
|
||||
APP_ID="${PASEO_COMPOSER_KEYBOARD_APP_ID:-sh.paseo.debug}"
|
||||
DEVICE="${PASEO_COMPOSER_KEYBOARD_DEVICE:-paseo-api35}"
|
||||
HELPER_IME="com.callstack.agentdevice.imehelper/.TestInputMethodService"
|
||||
GBOARD_IME="com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"
|
||||
ASSERT="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs"
|
||||
STALL_HERMES="${REPO_ROOT}/packages/app/e2e/mobile/composer-keyboard/stall-hermes.mjs"
|
||||
METRO_PORT="${PASEO_MOBILE_E2E_METRO_PORT:-8082}"
|
||||
DAEMON_HOST="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOST:-127.0.0.1:6770}"
|
||||
DAEMON_HOME="${PASEO_COMPOSER_KEYBOARD_DAEMON_HOME:-${REPO_ROOT}/.dev/composer-e2e-home}"
|
||||
SERVER_ID="${PASEO_COMPOSER_KEYBOARD_SERVER_ID:-}"
|
||||
MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four'
|
||||
LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')"
|
||||
AGENT_TITLE="Keyboard dismiss QA $(date +%s)"
|
||||
|
||||
if [[ -z "${SERVER_ID}" ]]; then
|
||||
if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then
|
||||
echo "Missing ${DAEMON_HOME}/server-id; set PASEO_COMPOSER_KEYBOARD_SERVER_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
SERVER_ID="$(<"${DAEMON_HOME}/server-id")"
|
||||
fi
|
||||
|
||||
ad() {
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device "$@" --session "${SESSION}"
|
||||
}
|
||||
|
||||
capture_screen() {
|
||||
local output_path="$1"
|
||||
ad screenshot "${output_path}" >/dev/null
|
||||
}
|
||||
|
||||
snapshot_json() {
|
||||
local output_path="$1"
|
||||
ad snapshot -i --json >"${output_path}"
|
||||
}
|
||||
|
||||
capture_ui_xml() {
|
||||
local output_path="$1"
|
||||
local device_path="/sdcard/paseo-composer-keyboard-window.xml"
|
||||
# Android exposes one UI Automation connection at a time. Release the
|
||||
# persistent agent-device snapshot helper before asking uiautomator for the
|
||||
# app and IME windows; agent-device reconnects it on the next interaction.
|
||||
adb shell am force-stop com.callstack.agentdevice.snapshothelper
|
||||
sleep 0.2
|
||||
adb shell uiautomator dump "${device_path}" >/dev/null
|
||||
adb pull "${device_path}" "${output_path}" >/dev/null
|
||||
}
|
||||
|
||||
clear_input() {
|
||||
ad fill 'editable=true' "x" --settle
|
||||
adb shell input keycombination 113 29
|
||||
adb shell input keyevent 67
|
||||
}
|
||||
|
||||
clear_focused_input_with_adb() {
|
||||
adb shell input keycombination 113 29
|
||||
adb shell input keyevent 67
|
||||
}
|
||||
|
||||
wait_for_ime() {
|
||||
local expected="$1"
|
||||
local previous_top=""
|
||||
local stable_count=0
|
||||
for _ in $(seq 1 30); do
|
||||
local window_state
|
||||
window_state="$(adb shell dumpsys window)"
|
||||
if [[ "${expected}" == "true" ]]; then
|
||||
local ime_top
|
||||
ime_top="$(printf '%s' "${window_state}" | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
|
||||
if [[ -n "${ime_top}" && "${ime_top}" -gt 0 ]]; then
|
||||
if [[ "${ime_top}" == "${previous_top}" ]]; then
|
||||
stable_count="$((stable_count + 1))"
|
||||
else
|
||||
stable_count=0
|
||||
previous_top="${ime_top}"
|
||||
fi
|
||||
if [[ "${stable_count}" -ge 2 ]]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
elif ! printf '%s' "${window_state}" | rg -q "type=ime .*visible=true"; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "IME did not become visible=${expected}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ime_is_visible() {
|
||||
adb shell dumpsys window | rg -q "type=ime .*visible=true"
|
||||
}
|
||||
|
||||
open_gboard() {
|
||||
local x="$1"
|
||||
local y="$2"
|
||||
adb shell ime set "${GBOARD_IME}" >/dev/null
|
||||
if ime_is_visible; then
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
fi
|
||||
adb shell input tap "${x}" "${y}"
|
||||
wait_for_ime true
|
||||
local keyboard_shift
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${x}" "$((y - keyboard_shift))"
|
||||
}
|
||||
|
||||
read_keyboard_shift() {
|
||||
local ime_top
|
||||
local navigation_top
|
||||
ime_top="$(adb shell dumpsys window | sed -n 's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1)"
|
||||
navigation_top="$(adb shell dumpsys window | sed -n 's/.*type=navigationBars frame=\[0,\([0-9][0-9]*\)\].*/\1/p' | head -1)"
|
||||
printf '%s\n' "$((navigation_top - ime_top))"
|
||||
}
|
||||
|
||||
read_ime_top() {
|
||||
adb shell dumpsys window | sed -n \
|
||||
's/.*type=ime frame=\[0,\([0-9][0-9]*\)\].*visible=true.*/\1/p' | head -1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null 2>&1 || true
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device daemon stop --clean >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
cleanup
|
||||
mkdir -p "${STATE_DIR}" "${ARTIFACTS_DIR}"
|
||||
|
||||
workspaces_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- workspace ls --json --host "${DAEMON_HOST}")"
|
||||
workspace_id="$(node -e '
|
||||
const workspaces = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
const cwd = process.argv[1];
|
||||
const workspace = workspaces.find((candidate) => candidate.cwd === cwd);
|
||||
if (!workspace) process.exit(1);
|
||||
process.stdout.write(workspace.workspaceId);
|
||||
' "${REPO_ROOT}" <<<"${workspaces_json}")"
|
||||
|
||||
AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device open "${APP_ID}" \
|
||||
--platform android \
|
||||
--device "${DEVICE}" \
|
||||
--session "${SESSION}"
|
||||
adb shell am force-stop "${APP_ID}"
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A${METRO_PORT}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
sleep 3
|
||||
|
||||
# Create the fixture while the client is connected so its directory replica observes the insert.
|
||||
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
|
||||
--background \
|
||||
--title "${AGENT_TITLE}" \
|
||||
--provider mock \
|
||||
--model e2e-fast-stream \
|
||||
--workspace "${workspace_id}" \
|
||||
--cwd "${REPO_ROOT}" \
|
||||
--host "${DAEMON_HOST}" \
|
||||
--json)"
|
||||
agent_id="$(node -e '
|
||||
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
process.stdout.write(result.agentId);
|
||||
' <<<"${run_json}")"
|
||||
sleep 3
|
||||
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
sleep 5
|
||||
# The development-client bootstrap can consume the first link while Expo Router is mounting.
|
||||
# Deliver the target again after the root navigator is live.
|
||||
adb shell am start \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "paseo://h/${SERVER_ID}/agent/${agent_id}" \
|
||||
"${APP_ID}" >/dev/null
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 45000
|
||||
ad wait 'editable=true' 10000
|
||||
clear_input
|
||||
ad keyboard dismiss || true
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
snapshot_json "${ARTIFACTS_DIR}/baseline.json"
|
||||
capture_screen "${ARTIFACTS_DIR}/baseline.png"
|
||||
read -r input_x input_y baseline_input_height < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands"
|
||||
)
|
||||
read -r model_x model_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector"
|
||||
)
|
||||
read -r attach_x attach_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button"
|
||||
)
|
||||
read -r _ header_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger"
|
||||
)
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/command-popover.png"
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
model_open_y="$((model_y - keyboard_shift))"
|
||||
adb shell input tap "${model_x}" "${model_open_y}"
|
||||
sleep 1
|
||||
capture_screen "${ARTIFACTS_DIR}/model-selector-open.png"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell input tap "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
clear_focused_input_with_adb
|
||||
adb shell input keyevent 76
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
ad wait 'text="/exit"' 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/command-popover.json"
|
||||
node "${ASSERT}" above-y \
|
||||
"${ARTIFACTS_DIR}/command-popover.json" \
|
||||
"/exit, Archive the current agent" \
|
||||
"$((input_y - baseline_input_height / 2))"
|
||||
adb shell input keyevent 67
|
||||
ad wait 'label="Message, @files, /commands"' 10000
|
||||
|
||||
ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-first.json"
|
||||
ad fill 'editable=true focused=true' "${MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/multiline-second.json"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/multiline-first.json" \
|
||||
"${ARTIFACTS_DIR}/multiline-second.json"
|
||||
|
||||
adb shell input keyevent HOME
|
||||
adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null
|
||||
sleep 1
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
capture_screen "${ARTIFACTS_DIR}/keyboard-open.png"
|
||||
|
||||
node "${ASSERT}" same-header \
|
||||
"${ARTIFACTS_DIR}/baseline.png" \
|
||||
"${ARTIFACTS_DIR}/keyboard-open.png" \
|
||||
"$((header_y + 48))"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-above-y \
|
||||
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
|
||||
"Add attachment" \
|
||||
"$(read_ime_top)"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${MESSAGE}" --settle
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
|
||||
read -r changes_x changes_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab"
|
||||
)
|
||||
adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))"
|
||||
ad wait 'id="changes-header"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/changes-control-open.png"
|
||||
ad press 'label="Close Explorer sidebar"' --settle
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
ad wait 'editable=true' 10000
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))"
|
||||
sleep 1
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
ad press 540 600 --settle
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap 983 "$((attach_y - keyboard_shift))"
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad wait text "keyboard invariant line one" 10000
|
||||
snapshot_json "${ARTIFACTS_DIR}/after-submit.json"
|
||||
node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/baseline.json" \
|
||||
"${ARTIFACTS_DIR}/after-submit.json"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png"
|
||||
|
||||
open_gboard "${input_x}" "${input_y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png"
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-during-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
node "${ASSERT}" same-region \
|
||||
"${ARTIFACTS_DIR}/hidden-focused-baseline.png" \
|
||||
"${ARTIFACTS_DIR}/hidden-after-js-stall.png" \
|
||||
1400 \
|
||||
430
|
||||
|
||||
echo "Composer keyboard invariants passed"
|
||||
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import sharp from "sharp";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
if (command === "rect") {
|
||||
const [snapshotPath, identifier] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const node = snapshot.data.nodes.find(
|
||||
(candidate) => candidate.identifier === identifier || candidate.label === identifier,
|
||||
);
|
||||
if (!node) throw new Error(`Missing node: ${identifier}`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
process.stdout.write(`${Math.round(x + width / 2)} ${Math.round(y + height / 2)} ${height}\n`);
|
||||
} else if (command === "above-y") {
|
||||
const [snapshotPath, upperIdentifier, lowerYArgument] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const upper = snapshot.data.nodes.find(
|
||||
(candidate) => candidate.identifier === upperIdentifier || candidate.label === upperIdentifier,
|
||||
);
|
||||
if (!upper) throw new Error(`Missing node: ${upperIdentifier}`);
|
||||
const upperBottom = upper.rect.y + upper.rect.height;
|
||||
const lowerY = Number(lowerYArgument);
|
||||
if (upperBottom > lowerY) {
|
||||
throw new Error(
|
||||
`${upperIdentifier} extends below the composer top: ${upperBottom} > ${lowerY}`,
|
||||
);
|
||||
}
|
||||
} else if (command === "xml-above-y") {
|
||||
const [snapshotPath, contentDescription, lowerYArgument] = args;
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const escapedDescription = contentDescription.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = snapshot.match(
|
||||
new RegExp(
|
||||
`<node[^>]*content-desc="${escapedDescription}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`,
|
||||
),
|
||||
);
|
||||
if (!match) throw new Error(`Missing node: ${contentDescription}`);
|
||||
const upperBottom = Number(match[4]);
|
||||
const lowerY = Number(lowerYArgument);
|
||||
if (upperBottom > lowerY) {
|
||||
throw new Error(`${contentDescription} extends below the keyboard: ${upperBottom} > ${lowerY}`);
|
||||
}
|
||||
} else if (command === "same-header") {
|
||||
const [baselinePath, keyboardPath, headerBottomArgument] = args;
|
||||
const headerBottom = Number(headerBottomArgument);
|
||||
const crop = { left: 0, top: 64, width: 1080, height: headerBottom - 64 };
|
||||
const [baseline, keyboard] = await Promise.all([
|
||||
sharp(baselinePath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
sharp(keyboardPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
]);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < baseline.length; index += 1) {
|
||||
if (Math.abs(baseline[index] - keyboard[index]) > 8) changed += 1;
|
||||
}
|
||||
const changedRatio = changed / baseline.length;
|
||||
if (changedRatio > 0.01) {
|
||||
throw new Error(`Header changed while the keyboard opened (${changedRatio.toFixed(3)})`);
|
||||
}
|
||||
} else if (command === "same-input-height") {
|
||||
const [firstPath, secondPath] = args;
|
||||
const readInputHeight = async (snapshotPath) => {
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
|
||||
if (!input) throw new Error(`Missing composer input in ${snapshotPath}`);
|
||||
return input.rect.height;
|
||||
};
|
||||
const [firstHeight, secondHeight] = await Promise.all([
|
||||
readInputHeight(firstPath),
|
||||
readInputHeight(secondPath),
|
||||
]);
|
||||
if (firstHeight !== secondHeight) {
|
||||
throw new Error(`Composer height drifted from ${firstHeight} to ${secondHeight}`);
|
||||
}
|
||||
} else if (command === "has-exact-text") {
|
||||
const [snapshotPath, expected] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) {
|
||||
throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`);
|
||||
}
|
||||
} else if (command === "same-region") {
|
||||
const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args;
|
||||
const crop = {
|
||||
left: 0,
|
||||
top: Number(topArgument),
|
||||
width: 1080,
|
||||
height: Number(heightArgument),
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
sharp(firstPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
sharp(secondPath).extract(crop).removeAlpha().raw().toBuffer(),
|
||||
]);
|
||||
let changed = 0;
|
||||
for (let index = 0; index < first.length; index += 1) {
|
||||
if (Math.abs(first[index] - second[index]) > 8) changed += 1;
|
||||
}
|
||||
const changedRatio = changed / first.length;
|
||||
if (changedRatio > 0.02) {
|
||||
throw new Error(
|
||||
`${failureMessage ?? "Composer region changed during the JS stall"} (${changedRatio.toFixed(3)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import process from "node:process";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const [, , metroPortArgument, durationArgument, appId] = process.argv;
|
||||
const metroPort = Number(metroPortArgument);
|
||||
const duration = Number(durationArgument);
|
||||
const targets = await fetch(`http://127.0.0.1:${metroPort}/json/list`).then((response) =>
|
||||
response.json(),
|
||||
);
|
||||
const target = targets.find((candidate) => candidate.appId === appId);
|
||||
if (!target) throw new Error(`Missing Hermes target for ${appId}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
const timeout = setTimeout(() => reject(new Error("Hermes stall timed out")), duration + 5000);
|
||||
socket.on("open", () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: "Runtime.evaluate",
|
||||
params: {
|
||||
expression: `(() => { const deadline = Date.now() + ${duration}; while (Date.now() < deadline) {} })()`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
socket.on("message", (message) => {
|
||||
const response = JSON.parse(message.toString());
|
||||
if (response.id !== 1) return;
|
||||
clearTimeout(timeout);
|
||||
socket.close();
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
@@ -34,13 +34,20 @@ const metroTest = base.extend({
|
||||
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
{
|
||||
e2eForkProviders: string[];
|
||||
e2eInjectPaseoTools: boolean;
|
||||
e2eWorker: void;
|
||||
e2eWorkerClient: SeedDaemonClient;
|
||||
}
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
||||
e2eWorker: [
|
||||
async ({ e2eForkProviders }, provide, workerInfo) => {
|
||||
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||
forkProviders: e2eForkProviders,
|
||||
injectPaseoTools: e2eInjectPaseoTools,
|
||||
});
|
||||
try {
|
||||
await provide();
|
||||
|
||||
@@ -38,6 +38,10 @@ export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -49,6 +53,10 @@ export async function fillComposerDraft(page: Page, text: string): Promise<void>
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function typeIntoFocusedComposer(page: Page, text: string): Promise<void> {
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -155,7 +156,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
||||
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[] } = {},
|
||||
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
|
||||
): Promise<E2EWorker> {
|
||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||
const paseoHome = requestedRoot
|
||||
@@ -168,6 +169,9 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
if (options.injectPaseoTools) {
|
||||
await enablePaseoTools(paseoHome);
|
||||
}
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
paseoHome,
|
||||
preserveHome,
|
||||
@@ -201,3 +205,28 @@ export async function startE2EWorker(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function enablePaseoTools(paseoHome: string): Promise<void> {
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
const existing = existsSync(configPath)
|
||||
? JSON.parse(await readFile(configPath, "utf8"))
|
||||
: { version: 1 };
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
daemon: {
|
||||
...existing.daemon,
|
||||
mcp: {
|
||||
...existing.daemon?.mcp,
|
||||
enabled: true,
|
||||
injectIntoAgents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "npm run start:expo",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:embedded-webviews",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -35,7 +35,10 @@
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:embedded-webviews": "npm run build:terminal-webview && npm run build:source-editor-webview",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:source-editor-webview": "node ./scripts/build-source-editor-webview-html.mjs",
|
||||
"build:source-editor-webview:check": "node ./scripts/build-source-editor-webview-html.mjs --check",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(scriptDirectory, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/source-editor/codemirror/webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/source-editor/codemirror/webview-html.ts");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if ((await fs.stat(candidate)).isFile()) return candidate;
|
||||
} catch {
|
||||
// Try the next TypeScript resolution candidate.
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const javascript = result.outputFiles[0]?.text;
|
||||
if (!javascript) throw new Error("source editor WebView bundle produced no JavaScript");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<style>
|
||||
html, body, #source-editor-root, #source-editor-host {
|
||||
width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden;
|
||||
}
|
||||
html, body { overscroll-behavior: none; }
|
||||
#source-editor-root, #source-editor-host { display: flex; min-width: 0; min-height: 0; }
|
||||
#source-editor-host { flex: 1; }
|
||||
* { -webkit-tap-highlight-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>${javascript}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-source-editor-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const sourceEditorWebViewHtml =
|
||||
${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await fs.readFile(output, "utf8").catch(() => null);
|
||||
if (existing !== contents) {
|
||||
throw new Error(
|
||||
`${path.relative(repoRoot, output)} is stale. Run npm --prefix packages/app run build:source-editor-webview.`,
|
||||
);
|
||||
}
|
||||
console.log(`${path.relative(repoRoot, output)} is current (${html.length} bytes)`);
|
||||
} else {
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { RootErrorBoundary } from "@/components/root-error-boundary";
|
||||
@@ -595,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
|
||||
@@ -9,17 +9,19 @@ import {
|
||||
History,
|
||||
Home,
|
||||
Keyboard,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
@@ -54,6 +56,9 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for
|
||||
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
function PlusIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPlus size={size} strokeWidth={2.4} />;
|
||||
@@ -91,6 +96,10 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function PanelLeftIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPanelLeft size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
export function CommandCenterRootActions() {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
@@ -106,6 +115,10 @@ export function CommandCenterRootActions() {
|
||||
// each time host filters are reconciled.
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const shortcutPlatform = useMemo(
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
[],
|
||||
@@ -227,6 +240,33 @@ export function CommandCenterRootActions() {
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
// Toggle left sidebar is global: it calls the panel store directly and works on every route.
|
||||
// The right sidebar and focus toggles do NOT belong here — their handlers live in
|
||||
// workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally
|
||||
// would list entries that silently no-op off a workspace route. They live in
|
||||
// workspace-contributions.ts instead. That is why the three toggles render in two
|
||||
// non-adjacent sections; don't "tidy" them back together.
|
||||
{
|
||||
id: "toggle-left-sidebar",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 7,
|
||||
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
toggleAgentList();
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("settings.shortcuts.help.toggleLeftSidebar"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: PanelLeftIcon,
|
||||
shortcutKeys:
|
||||
resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ??
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (shortcutsAvailable) {
|
||||
@@ -278,6 +318,7 @@ export function CommandCenterRootActions() {
|
||||
shortcutPlatform,
|
||||
shortcutsAvailable,
|
||||
t,
|
||||
toggleAgentList,
|
||||
]);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
|
||||
@@ -23,9 +23,14 @@ function source(gitActions: GitActions): {
|
||||
value: WorkspaceCommandCenterSource;
|
||||
runGitActions: GitAction[];
|
||||
dispatched: KeyboardActionDefinition[];
|
||||
copiedPaths: number;
|
||||
copiedBranchNames: number;
|
||||
toggledLabels: Array<{ name: string; assigned: boolean }>;
|
||||
} {
|
||||
const runGitActions: GitAction[] = [];
|
||||
const dispatched: KeyboardActionDefinition[] = [];
|
||||
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
|
||||
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
|
||||
return {
|
||||
value: {
|
||||
gitActions,
|
||||
@@ -62,19 +67,51 @@ function source(gitActions: GitActions): {
|
||||
moveTabDown: "Move tab down",
|
||||
closePane: "Close pane",
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
toggleExplorerSidebar: "Toggle side panel",
|
||||
toggleExplorerSidebar: "Toggle Explorer sidebar",
|
||||
rename: "Rename workspace",
|
||||
copyPath: "Copy workspace path",
|
||||
copyBranchName: "Copy branch name",
|
||||
pin: "Pin to top",
|
||||
unpin: "Unpin",
|
||||
showSetup: "Show setup",
|
||||
labelsGroup: "Labels",
|
||||
},
|
||||
icons: {},
|
||||
shortcuts: {},
|
||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||
capabilities: {
|
||||
canSplitPanes: true,
|
||||
canOpenBrowserTabs: true,
|
||||
isGit: false,
|
||||
canPin: false,
|
||||
canShowSetup: false,
|
||||
},
|
||||
activeTabKind: null,
|
||||
activeTabIndex: -1,
|
||||
activeTabCount: 0,
|
||||
currentBranch: null,
|
||||
isPinned: false,
|
||||
labelCatalog: null,
|
||||
dispatch: (action) => dispatched.push(action),
|
||||
runGitAction: (action) => runGitActions.push(action),
|
||||
copyPath: () => {
|
||||
counters.copiedPaths += 1;
|
||||
},
|
||||
copyBranchName: () => {
|
||||
counters.copiedBranchNames += 1;
|
||||
},
|
||||
toggleLabel: (name, assigned) => {
|
||||
toggledLabels.push({ name, assigned });
|
||||
},
|
||||
},
|
||||
runGitActions,
|
||||
dispatched,
|
||||
toggledLabels,
|
||||
get copiedPaths() {
|
||||
return counters.copiedPaths;
|
||||
},
|
||||
get copiedBranchNames() {
|
||||
return counters.copiedBranchNames;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +184,8 @@ describe("workspace command center contributions", () => {
|
||||
canSplitPanes: false,
|
||||
canOpenBrowserTabs: false,
|
||||
isGit: false,
|
||||
canPin: false,
|
||||
canShowSetup: false,
|
||||
};
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
@@ -155,6 +194,8 @@ describe("workspace command center contributions", () => {
|
||||
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
|
||||
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
|
||||
expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true);
|
||||
expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true);
|
||||
});
|
||||
|
||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||
@@ -164,7 +205,14 @@ describe("workspace command center contributions", () => {
|
||||
for (const contribution of contributions) contribution.run();
|
||||
|
||||
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||
// workspace:rename dispatches to workspace scope
|
||||
expect(
|
||||
fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"),
|
||||
).toBe(true);
|
||||
// workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope
|
||||
expect(
|
||||
fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps workspace creation commands available outside Git", () => {
|
||||
@@ -179,4 +227,164 @@ describe("workspace command center contributions", () => {
|
||||
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches rename to the workspace scope", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
const rename = contributions.find((item) => item.id === "workspace:rename");
|
||||
expect(rename?.presentation).toMatchObject({ title: "Rename workspace" });
|
||||
rename?.run();
|
||||
|
||||
expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]);
|
||||
});
|
||||
|
||||
it("copies the workspace path without going through the dispatcher", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
contributions.find((item) => item.id === "workspace:copy-path")?.run();
|
||||
|
||||
expect(fixture.copiedPaths).toBe(1);
|
||||
expect(fixture.dispatched).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits Copy branch name when the workspace has no branch, and copies when it does", () => {
|
||||
const withoutBranch = source({ primary: null, secondary: [], menu: [] });
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(withoutBranch.value).some(
|
||||
(item) => item.id === "workspace:copy-branch-name",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const withBranch = source({ primary: null, secondary: [], menu: [] });
|
||||
withBranch.value.currentBranch = "feature/login";
|
||||
const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find(
|
||||
(item) => item.id === "workspace:copy-branch-name",
|
||||
);
|
||||
|
||||
expect(contribution?.keywords).toContain("feature/login");
|
||||
contribution?.run();
|
||||
expect(withBranch.copiedBranchNames).toBe(1);
|
||||
});
|
||||
|
||||
it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => {
|
||||
const unpinned = source({ primary: null, secondary: [], menu: [] });
|
||||
unpinned.value.capabilities.canPin = true;
|
||||
const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find(
|
||||
(item) => item.id === "workspace:pin",
|
||||
);
|
||||
expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" });
|
||||
expect(pinContribution?.visibility).toBe("always");
|
||||
pinContribution?.run();
|
||||
expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]);
|
||||
|
||||
const pinned = source({ primary: null, secondary: [], menu: [] });
|
||||
pinned.value.capabilities.canPin = true;
|
||||
pinned.value.isPinned = true;
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(pinned.value).find(
|
||||
(item) => item.id === "workspace:pin",
|
||||
)?.presentation,
|
||||
).toMatchObject({ title: "Unpin" });
|
||||
|
||||
const unsupported = source({ primary: null, secondary: [], menu: [] });
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(unsupported.value).some(
|
||||
(item) => item.id === "workspace:pin",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lists Show setup only when the workspace has setup to show", () => {
|
||||
const withoutSetup = source({ primary: null, secondary: [], menu: [] });
|
||||
expect(
|
||||
buildWorkspaceCommandCenterContributions(withoutSetup.value).some(
|
||||
(item) => item.id === "workspace:show-setup",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const withSetup = source({ primary: null, secondary: [], menu: [] });
|
||||
withSetup.value.capabilities.canShowSetup = true;
|
||||
buildWorkspaceCommandCenterContributions(withSetup.value)
|
||||
.find((item) => item.id === "workspace:show-setup")
|
||||
?.run();
|
||||
|
||||
expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]);
|
||||
});
|
||||
|
||||
// Regression guard for the registration split: these two are handled only by workspace-screen.tsx
|
||||
// behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global
|
||||
// root set, where they would silently no-op off a workspace route.
|
||||
it("builds the Explorer sidebar and focus toggles in the workspace set", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.capabilities.canSplitPanes = false;
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run();
|
||||
contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run();
|
||||
|
||||
expect(fixture.dispatched).toEqual([
|
||||
{ id: "sidebar.toggle.right", scope: "sidebar" },
|
||||
{ id: "workspace.focus.toggle", scope: "workspace" },
|
||||
]);
|
||||
for (const contribution of contributions) {
|
||||
expect(contribution.group).toBe("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
// `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split
|
||||
// panes are available, so the standalone entry must step aside there — otherwise the palette
|
||||
// lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action.
|
||||
it("omits the standalone focus toggle when the pane set already covers it", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.capabilities.canSplitPanes = true;
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
const focusToggles = contributions.filter(
|
||||
(item) =>
|
||||
item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode",
|
||||
);
|
||||
expect(focusToggles).toHaveLength(1);
|
||||
expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle");
|
||||
});
|
||||
|
||||
it("omits the labels group when the catalog hasn't loaded", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.labelCatalog = [
|
||||
{ name: "bug", assigned: true },
|
||||
{ name: "urgent", assigned: false },
|
||||
];
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
const bug = contributions.find((item) => item.id === "workspace:label:bug");
|
||||
const urgent = contributions.find((item) => item.id === "workspace:label:urgent");
|
||||
|
||||
expect(bug?.presentation).toMatchObject({
|
||||
kind: "choice",
|
||||
path: ["Labels", "bug"],
|
||||
selected: true,
|
||||
});
|
||||
expect(urgent?.presentation).toMatchObject({
|
||||
kind: "choice",
|
||||
path: ["Labels", "urgent"],
|
||||
selected: false,
|
||||
});
|
||||
|
||||
bug?.run();
|
||||
urgent?.run();
|
||||
|
||||
expect(fixture.toggledLabels).toEqual([
|
||||
{ name: "bug", assigned: false },
|
||||
{ name: "urgent", assigned: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
export interface WorkspaceCommandCenterLabelChoice {
|
||||
name: string;
|
||||
/** Whether the current workspace already carries this label. */
|
||||
assigned: boolean;
|
||||
icon?: CommandCenterIcon;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterLabels {
|
||||
section: string;
|
||||
newAgent: string;
|
||||
@@ -42,6 +49,14 @@ export interface WorkspaceCommandCenterLabels {
|
||||
closePane: string;
|
||||
toggleFocusMode: string;
|
||||
toggleExplorerSidebar: string;
|
||||
// Workspace management actions
|
||||
rename: string;
|
||||
copyPath: string;
|
||||
copyBranchName: string;
|
||||
pin: string;
|
||||
unpin: string;
|
||||
showSetup: string;
|
||||
labelsGroup: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterIcons {
|
||||
@@ -63,6 +78,14 @@ export interface WorkspaceCommandCenterIcons {
|
||||
moveTab?: CommandCenterIcon;
|
||||
focusMode?: CommandCenterIcon;
|
||||
explorerSidebar?: CommandCenterIcon;
|
||||
// Workspace management action icons
|
||||
copyPath?: CommandCenterIcon;
|
||||
copyBranchName?: CommandCenterIcon;
|
||||
pin?: CommandCenterIcon;
|
||||
unpin?: CommandCenterIcon;
|
||||
showSetup?: CommandCenterIcon;
|
||||
toggleFocusMode?: CommandCenterIcon;
|
||||
label?: CommandCenterIcon;
|
||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||
}
|
||||
|
||||
@@ -78,6 +101,7 @@ export interface WorkspaceCommandCenterShortcuts {
|
||||
closePane?: ShortcutKey[][];
|
||||
toggleFocusMode?: ShortcutKey[][];
|
||||
toggleExplorerSidebar?: ShortcutKey[][];
|
||||
pinWorkspace?: ShortcutKey[][];
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterSource {
|
||||
@@ -89,12 +113,27 @@ export interface WorkspaceCommandCenterSource {
|
||||
canSplitPanes: boolean;
|
||||
canOpenBrowserTabs: boolean;
|
||||
isGit: boolean;
|
||||
/** Host supports the `workspacePinning` feature. */
|
||||
canPin: boolean;
|
||||
/** The workspace has setup commands or a setup error — same gate as the header menu. */
|
||||
canShowSetup: boolean;
|
||||
};
|
||||
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||
activeTabIndex: number;
|
||||
activeTabCount: number;
|
||||
/** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */
|
||||
currentBranch: string | null;
|
||||
isPinned: boolean;
|
||||
/**
|
||||
* The host's label catalog, each entry told whether the current workspace carries it. Null
|
||||
* before the catalog has loaded — omits the whole group rather than showing it empty.
|
||||
*/
|
||||
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
|
||||
dispatch(action: KeyboardActionDefinition): void;
|
||||
runGitAction(action: GitAction): void;
|
||||
copyPath(): void;
|
||||
copyBranchName(): void;
|
||||
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
|
||||
}
|
||||
|
||||
function buildGitContribution(
|
||||
@@ -459,6 +498,35 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
|
||||
return paneActions.map((action) => buildQueryAction(source, action));
|
||||
}
|
||||
|
||||
function buildWorkspaceCallback(input: {
|
||||
source: WorkspaceCommandCenterSource;
|
||||
id: string;
|
||||
rank: number;
|
||||
title: string;
|
||||
keywords: readonly string[];
|
||||
icon?: CommandCenterIcon;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
run: () => void;
|
||||
visibility: "always" | "query";
|
||||
}): CommandCenterContribution {
|
||||
return {
|
||||
id: input.id,
|
||||
group: "workspace",
|
||||
groupRank: -1,
|
||||
rank: input.rank,
|
||||
keywords: input.keywords,
|
||||
visibility: input.visibility,
|
||||
run: input.run,
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: input.title,
|
||||
sectionTitle: input.source.labels.section,
|
||||
icon: input.icon,
|
||||
shortcutKeys: input.shortcutKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCreationContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
@@ -511,20 +579,150 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
...buildPanelContributions(source),
|
||||
...buildActiveTabContributions(source),
|
||||
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||
buildQueryAction(source, {
|
||||
id: "explorer-sidebar:toggle",
|
||||
rank: 70,
|
||||
title: source.labels.toggleExplorerSidebar,
|
||||
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
|
||||
icon: source.icons.explorerSidebar,
|
||||
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
|
||||
action: { id: "sidebar.toggle.right", scope: "workspace" },
|
||||
}),
|
||||
];
|
||||
|
||||
const primary = source.gitActions.primary;
|
||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||
if (action.id === primary?.id) continue;
|
||||
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||
}
|
||||
|
||||
if (source.capabilities.canPin) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:pin",
|
||||
rank: 20,
|
||||
title: source.isPinned ? source.labels.unpin : source.labels.pin,
|
||||
keywords: ["pin", "unpin", "favorite", "sticky"],
|
||||
icon: source.isPinned ? source.icons.unpin : source.icons.pin,
|
||||
shortcutKeys: source.shortcuts.pinWorkspace,
|
||||
action: { id: "workspace.pin", scope: "sidebar" },
|
||||
visibility: "always",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:rename",
|
||||
rank: 21,
|
||||
title: source.labels.rename,
|
||||
keywords: ["rename", "title", "name", "label"],
|
||||
icon: source.icons.rename,
|
||||
action: { id: "workspace.rename", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
buildWorkspaceCallback({
|
||||
source,
|
||||
id: "workspace:copy-path",
|
||||
rank: 22,
|
||||
title: source.labels.copyPath,
|
||||
keywords: ["copy", "path", "directory", "folder", "cwd"],
|
||||
icon: source.icons.copyPath,
|
||||
run: source.copyPath,
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
|
||||
// Omitted rather than present-and-failing: a non-git workspace has nothing to copy.
|
||||
if (source.currentBranch) {
|
||||
contributions.push(
|
||||
buildWorkspaceCallback({
|
||||
source,
|
||||
id: "workspace:copy-branch-name",
|
||||
rank: 23,
|
||||
title: source.labels.copyBranchName,
|
||||
keywords: ["copy", "branch", "git", source.currentBranch],
|
||||
icon: source.icons.copyBranchName,
|
||||
run: source.copyBranchName,
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (source.capabilities.canShowSetup) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:show-setup",
|
||||
rank: 24,
|
||||
title: source.labels.showSetup,
|
||||
keywords: ["setup", "provision", "install", "bootstrap"],
|
||||
icon: source.icons.showSetup,
|
||||
action: { id: "workspace.setup.show", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx:
|
||||
// their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a
|
||||
// global registration would list two entries that silently no-op on /settings, /sessions,
|
||||
// /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why
|
||||
// the three toggles render in two non-adjacent sections. Don't "tidy" them back together.
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:toggle-explorer-sidebar",
|
||||
rank: 25,
|
||||
title: source.labels.toggleExplorerSidebar,
|
||||
keywords: ["toggle", "sidebar", "explorer", "files", "changes"],
|
||||
icon: source.icons.explorerSidebar,
|
||||
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
|
||||
action: { id: "sidebar.toggle.right", scope: "sidebar" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
|
||||
// `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as
|
||||
// `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry
|
||||
// where that function is skipped — otherwise the palette lists "Toggle focus mode" twice.
|
||||
if (!source.capabilities.canSplitPanes) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "workspace:toggle-focus-mode",
|
||||
rank: 26,
|
||||
title: source.labels.toggleFocusMode,
|
||||
keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"],
|
||||
icon: source.icons.toggleFocusMode,
|
||||
shortcutKeys: source.shortcuts.toggleFocusMode,
|
||||
action: { id: "workspace.focus.toggle", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
contributions.push(...buildLabelContributions(source));
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* One choice per catalog label, ticked when the current workspace carries it. Selecting toggles
|
||||
* assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a
|
||||
* multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing.
|
||||
*/
|
||||
function buildLabelContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
if (!source.labelCatalog) return [];
|
||||
return source.labelCatalog.map((choice, index) => ({
|
||||
id: `workspace:label:${choice.name}`,
|
||||
group: "workspace",
|
||||
groupRank: -1,
|
||||
rank: 30 + index,
|
||||
keywords: ["label", "tag", choice.name],
|
||||
visibility: "query" as const,
|
||||
run: () => source.toggleLabel(choice.name, !choice.assigned),
|
||||
presentation: {
|
||||
kind: "choice" as const,
|
||||
path: [source.labels.labelsGroup, choice.name] as const,
|
||||
icon: choice.icon,
|
||||
selected: choice.assigned,
|
||||
testId: `command-center-workspace-label-${choice.name}`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
Copy,
|
||||
Files,
|
||||
Focus,
|
||||
GitBranch,
|
||||
GitCompareArrows,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
ListChecks,
|
||||
Move,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
SquarePen,
|
||||
@@ -25,23 +29,35 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import {
|
||||
buildWorkspaceLabelPickerRows,
|
||||
useWorkspaceLabelProjection,
|
||||
workspaceLabelErrorMessage,
|
||||
workspaceLabels,
|
||||
} from "@/workspace-labels";
|
||||
import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { getCommandCenterIcon } from "./icon";
|
||||
import type { CommandCenterIcon } from "./contributions";
|
||||
import { useCommandCenterActions } from "./provider";
|
||||
import {
|
||||
buildWorkspaceCommandCenterContributions,
|
||||
type WorkspaceCommandCenterLabelChoice,
|
||||
type WorkspaceCommandCenterShortcuts,
|
||||
} from "./workspace-contributions";
|
||||
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
|
||||
@@ -65,6 +81,13 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
||||
moveTab: getCommandCenterIcon(Move),
|
||||
focusMode: getCommandCenterIcon(ArrowDownToLine),
|
||||
explorerSidebar: getCommandCenterIcon(PanelRight),
|
||||
// Workspace management action icons
|
||||
copyPath: getCommandCenterIcon(Copy),
|
||||
copyBranchName: getCommandCenterIcon(GitBranch),
|
||||
pin: getCommandCenterIcon(Pin),
|
||||
unpin: getCommandCenterIcon(PinOff),
|
||||
showSetup: getCommandCenterIcon(ListChecks),
|
||||
toggleFocusMode: getCommandCenterIcon(Focus),
|
||||
};
|
||||
|
||||
const OPEN_PANEL_LABEL_KEYS = {
|
||||
@@ -86,6 +109,58 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
|
||||
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
|
||||
}
|
||||
|
||||
/**
|
||||
* The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to
|
||||
* keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the
|
||||
* group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for
|
||||
* the sidebar's own picker.
|
||||
*/
|
||||
function useWorkspaceLabelCatalog(
|
||||
serverId: string | null,
|
||||
fields: { id: string; labels: readonly string[] } | null,
|
||||
): {
|
||||
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
|
||||
toggleLabel: (name: string, assigned: boolean) => void;
|
||||
} {
|
||||
const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection(
|
||||
serverId ?? undefined,
|
||||
);
|
||||
const toast = useToast();
|
||||
const assignedLabels = fields?.labels;
|
||||
const labelCatalog = useMemo<readonly WorkspaceCommandCenterLabelChoice[] | null>(
|
||||
() =>
|
||||
labelHost?.status === "online" && assignedLabels
|
||||
? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map(
|
||||
(row) => ({
|
||||
name: row.name,
|
||||
assigned: row.assigned,
|
||||
icon: getLabelCommandCenterIcon(row.color),
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
[assignedLabels, labelDefinitions, labelHost?.status],
|
||||
);
|
||||
const toggleLabel = useCallback(
|
||||
async (name: string, assigned: boolean) => {
|
||||
if (!serverId || !fields) return;
|
||||
const definition = labelDefinitions.find((label) => label.name === name);
|
||||
if (!definition) return;
|
||||
try {
|
||||
await workspaceLabels.setAssignment({
|
||||
serverId,
|
||||
workspaceId: fields.id,
|
||||
label: definition,
|
||||
assigned,
|
||||
});
|
||||
} catch (cause) {
|
||||
toast.error(workspaceLabelErrorMessage(cause));
|
||||
}
|
||||
},
|
||||
[fields, labelDefinitions, serverId, toast],
|
||||
);
|
||||
return { labelCatalog, toggleLabel };
|
||||
}
|
||||
|
||||
export function useWorkspaceCommandCenterActions(): void {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
@@ -104,8 +179,29 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||
const activeTabKind =
|
||||
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||
// One narrow projection for the workspace management contribution fields. The registry's snapshot
|
||||
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
|
||||
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
|
||||
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
|
||||
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
|
||||
id: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory ?? null,
|
||||
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
|
||||
pinnedAt: workspace.pinnedAt ?? null,
|
||||
labels: workspace.labels ?? [],
|
||||
}));
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const currentBranch = fields?.currentBranch ?? null;
|
||||
const isPinned = fields?.pinnedAt != null;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const canPin = useHostFeature(serverId, "workspacePinning");
|
||||
const persistenceKey =
|
||||
serverId && fields
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
|
||||
: null;
|
||||
const canShowSetup = useWorkspaceSetupStore((state) =>
|
||||
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
|
||||
);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
serverId: serverId ?? "",
|
||||
@@ -113,6 +209,27 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
icons: GIT_ACTION_ICONS,
|
||||
});
|
||||
const runGitAction = useGitActionRunner();
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
|
||||
const copyPath = useCallback(() => {
|
||||
if (!fields) return;
|
||||
clipboard.copyPath({
|
||||
workspaceId: fields.id,
|
||||
workspaceDirectory: fields.workspaceDirectory,
|
||||
currentBranch: fields.currentBranch,
|
||||
});
|
||||
}, [clipboard, fields]);
|
||||
|
||||
const copyBranchName = useCallback(() => {
|
||||
if (!fields) return;
|
||||
clipboard.copyBranchName({
|
||||
workspaceId: fields.id,
|
||||
workspaceDirectory: fields.workspaceDirectory,
|
||||
currentBranch: fields.currentBranch,
|
||||
});
|
||||
}, [clipboard, fields]);
|
||||
|
||||
const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
@@ -152,6 +269,14 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
closePane: t("settings.shortcuts.help.closePane"),
|
||||
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
|
||||
// Workspace management labels
|
||||
rename: t("sidebar.workspace.actions.rename"),
|
||||
copyPath: t("workspace.header.actions.copyPath"),
|
||||
copyBranchName: t("workspace.header.actions.copyBranchName"),
|
||||
pin: t("sidebar.workspace.actions.pin"),
|
||||
unpin: t("sidebar.workspace.actions.unpin"),
|
||||
showSetup: t("workspace.header.actions.showSetup"),
|
||||
labelsGroup: t("workspaceLabels.title"),
|
||||
},
|
||||
icons: {
|
||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||
@@ -162,27 +287,43 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||
canOpenBrowserTabs: getIsElectron(),
|
||||
isGit,
|
||||
canPin,
|
||||
canShowSetup,
|
||||
},
|
||||
activeTabKind,
|
||||
activeTabIndex,
|
||||
activeTabCount: focusedTabs.length,
|
||||
currentBranch,
|
||||
isPinned,
|
||||
labelCatalog,
|
||||
dispatch: (action) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
keyboardActionDispatcher.dispatch(action);
|
||||
},
|
||||
runGitAction,
|
||||
copyPath,
|
||||
copyBranchName,
|
||||
toggleLabel,
|
||||
}),
|
||||
[
|
||||
activeTabIndex,
|
||||
activeTabKind,
|
||||
canPin,
|
||||
canShowSetup,
|
||||
copyBranchName,
|
||||
copyPath,
|
||||
currentBranch,
|
||||
focusedTabs.length,
|
||||
gitActions,
|
||||
isCompact,
|
||||
isGit,
|
||||
isPinned,
|
||||
keyboardActionDispatcher,
|
||||
labelCatalog,
|
||||
overrides,
|
||||
runGitAction,
|
||||
t,
|
||||
toggleLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ export function resolveWorkspaceCommandCenterShortcuts({
|
||||
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
|
||||
closePane:
|
||||
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
|
||||
toggleFocusMode: resolveShortcutKeysForAction("toggle-focus", overrides, platform) ?? undefined,
|
||||
toggleExplorerSidebar:
|
||||
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
|
||||
// Workspace management shortcuts
|
||||
pinWorkspace: resolveShortcutKeysForAction("pin-workspace", overrides, platform) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedQrCode = withUnistyles(QrCode);
|
||||
const ThemedLink2 = withUnistyles(Link2);
|
||||
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
const foregroundIconMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
@@ -37,6 +45,7 @@ export interface AddHostMethodModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDirectConnection: () => void;
|
||||
onRemoteSsh: () => void;
|
||||
onScanQr: () => void;
|
||||
onPasteLink: () => void;
|
||||
}
|
||||
@@ -45,10 +54,10 @@ export function AddHostMethodModal({
|
||||
visible,
|
||||
onClose,
|
||||
onDirectConnection,
|
||||
onRemoteSsh,
|
||||
onScanQr,
|
||||
onPasteLink,
|
||||
}: AddHostMethodModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
|
||||
|
||||
@@ -60,6 +69,10 @@ export function AddHostMethodModal({
|
||||
onScanQr();
|
||||
}, [onScanQr]);
|
||||
|
||||
const handleRemoteSsh = useCallback(() => {
|
||||
onRemoteSsh();
|
||||
}, [onRemoteSsh]);
|
||||
|
||||
const handlePaste = useCallback(() => {
|
||||
onPasteLink();
|
||||
}, [onPasteLink]);
|
||||
@@ -78,7 +91,7 @@ export function AddHostMethodModal({
|
||||
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<ThemedLink2 size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
@@ -87,6 +100,24 @@ export function AddHostMethodModal({
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{isElectronRuntime() ? (
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleRemoteSsh}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.connectionMethods.remoteSsh.title")}
|
||||
testID="add-host-method-remote-ssh"
|
||||
>
|
||||
<ThemedTerminal size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.remoteSsh.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{isNative && !isFdroidBuild ? (
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
@@ -94,7 +125,7 @@ export function AddHostMethodModal({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<ThemedQrCode size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
@@ -111,7 +142,7 @@ export function AddHostMethodModal({
|
||||
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<ThemedClipboardPaste size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Terminal } from "lucide-react-native";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
export interface AddRemoteSshHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
profile: HostProfile;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
isNewHost: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function AddRemoteSshHostModal({
|
||||
visible,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: AddRemoteSshHostModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const hosts = useHosts();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||
const targetRef = useRef("");
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
targetRef.current = "";
|
||||
inputRef.current?.replaceText("");
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clear();
|
||||
onClose();
|
||||
}, [clear, isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clear();
|
||||
(onCancel ?? onClose)();
|
||||
}, [clear, isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
const rawTarget = targetRef.current.trim();
|
||||
if (!rawTarget) {
|
||||
setErrorMessage(t("pairing.remoteSsh.errors.targetRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
let target: ReturnType<typeof parseSshTransportUri>;
|
||||
try {
|
||||
target = parseSshTransportUri(rawTarget);
|
||||
} catch {
|
||||
setErrorMessage(t("pairing.remoteSsh.errors.invalidTarget"));
|
||||
return;
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof probeAndUpsertRemoteSshConnection>>;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
result = await probeAndUpsertRemoteSshConnection(target);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof DaemonConnectionTestError
|
||||
? t("pairing.remoteSsh.errors.failedToConnect", { detail: error.message })
|
||||
: t("common.errors.unableToSave");
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
clear();
|
||||
onClose();
|
||||
onSaved?.({
|
||||
...result,
|
||||
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
|
||||
});
|
||||
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
}, []);
|
||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="add-remote-ssh-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>{t("pairing.remoteSsh.helper")}</Text>
|
||||
<Field
|
||||
label={t("pairing.remoteSsh.fields.target")}
|
||||
error={errorMessage}
|
||||
testID="remote-ssh-target"
|
||||
>
|
||||
<FormTextInput
|
||||
ref={inputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-target-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.target")}
|
||||
initialValue=""
|
||||
onChangeText={handleTargetChange}
|
||||
placeholder="ssh://user@host"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
variant="secondary"
|
||||
onPress={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("pairing.remoteSsh.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSaving}
|
||||
leftIcon={ThemedTerminal}
|
||||
testID="remote-ssh-submit"
|
||||
>
|
||||
{isSaving
|
||||
? t("pairing.remoteSsh.actions.connecting")
|
||||
: t("pairing.remoteSsh.actions.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { AttachmentLightbox } from "./attachment-lightbox";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
const hoistedTheme = {
|
||||
@@ -42,6 +42,11 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
),
|
||||
};
|
||||
});
|
||||
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
|
||||
const assistantImageSource: ImageLightboxSource = {
|
||||
type: "uri",
|
||||
uri: "blob:assistant-image",
|
||||
};
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
@@ -70,6 +75,11 @@ vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
@@ -78,16 +88,34 @@ vi.mock("lucide-react-native", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-image", () => ({
|
||||
Image: (props: Record<string, unknown>) => {
|
||||
const source = props.source as { uri?: string } | string | undefined;
|
||||
const uri = typeof source === "string" ? source : source?.uri;
|
||||
return React.createElement("div", {
|
||||
"data-testid": props.testID,
|
||||
"data-source": uri,
|
||||
"data-style": JSON.stringify(props.style ?? null),
|
||||
role: "img",
|
||||
});
|
||||
vi.mock("@/components/zoomable-viewport/image", () => ({
|
||||
ZoomableImage: (props: Record<string, unknown>) => {
|
||||
const actions = props.actions as Array<{
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}>;
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": `${String(props.testID)}-image`,
|
||||
"data-source": props.uri,
|
||||
role: "img",
|
||||
},
|
||||
actions.map((action) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-label": action.label,
|
||||
"data-testid": action.testID,
|
||||
key: action.label,
|
||||
onClick: action.onPress,
|
||||
type: "button",
|
||||
},
|
||||
action.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -150,42 +178,32 @@ function queryByTestId(testID: string): HTMLElement | null {
|
||||
}
|
||||
|
||||
describe("AttachmentLightbox", () => {
|
||||
it("renders nothing when metadata is null", () => {
|
||||
render(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
|
||||
it("renders nothing when the source is null", () => {
|
||||
render(<AttachmentLightbox source={null} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the image when metadata is provided", () => {
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
const image = queryByTestId("attachment-lightbox-image");
|
||||
expect(image).not.toBeNull();
|
||||
expect(image?.getAttribute("data-source")).toBe("blob:preview");
|
||||
});
|
||||
|
||||
it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => {
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||
it("renders a direct timeline image without resolving attachment storage", () => {
|
||||
render(<AttachmentLightbox source={assistantImageSource} onClose={vi.fn()} />);
|
||||
|
||||
const image = queryByTestId("attachment-lightbox-image");
|
||||
const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as {
|
||||
position?: string;
|
||||
top?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
} | null;
|
||||
expect(style?.position).toBe("absolute");
|
||||
expect(style?.top).toBe(0);
|
||||
expect(style?.left).toBe(0);
|
||||
expect(style?.right).toBe(0);
|
||||
expect(style?.bottom).toBe(0);
|
||||
expect(image?.getAttribute("data-source")).toBe("blob:assistant-image");
|
||||
expect(useAttachmentPreviewUrlMock).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls onClose when the backdrop is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
|
||||
const backdrop = queryByTestId("attachment-lightbox-backdrop");
|
||||
expect(backdrop).not.toBeNull();
|
||||
@@ -196,7 +214,7 @@ describe("AttachmentLightbox", () => {
|
||||
|
||||
it("calls onClose when the close button is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
|
||||
const closeButton = document.querySelector(
|
||||
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
|
||||
@@ -209,7 +227,7 @@ describe("AttachmentLightbox", () => {
|
||||
|
||||
it("shows error text when the preview URL resolves to null", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue(null);
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").toContain("Couldn't load image");
|
||||
@@ -217,7 +235,7 @@ describe("AttachmentLightbox", () => {
|
||||
|
||||
it("closes on Escape key on web", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
| { type: "uri"; uri: string; contentSize?: ViewportSize };
|
||||
|
||||
interface AttachmentLightboxProps {
|
||||
metadata: AttachmentMetadata | null;
|
||||
source: ImageLightboxSource | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ModalRoot = isNative ? GestureHandlerRootView : View;
|
||||
const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const url = useAttachmentPreviewUrl(metadata);
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id]);
|
||||
}, [metadata?.id, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !metadata) return;
|
||||
if (!isWeb || !source) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
@@ -37,27 +49,35 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
}, [metadata, onClose]);
|
||||
}, [onClose, source]);
|
||||
|
||||
const closeButtonRowStyle = useMemo(
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.closeButtonRow,
|
||||
styles.contentLayer,
|
||||
{
|
||||
top: insets.top + theme.spacing[3],
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.top, theme.spacing],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
const closeButtonStyle = useMemo(
|
||||
() => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }],
|
||||
[insets.right, theme.spacing],
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: X,
|
||||
label: t("message.attachments.closeImage"),
|
||||
onPress: onClose,
|
||||
testID: "attachment-lightbox-close",
|
||||
},
|
||||
],
|
||||
[onClose, t],
|
||||
);
|
||||
|
||||
const handleImageError = useCallback(() => setErrored(true), []);
|
||||
const noopPress = useCallback(() => {}, []);
|
||||
const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]);
|
||||
|
||||
if (!metadata) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -65,61 +85,47 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
testID="attachment-lightbox-image"
|
||||
source={imageSource}
|
||||
contentFit="contain"
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
actions={actions}
|
||||
contentSize={contentSize}
|
||||
fit={LIGHTBOX_FIT}
|
||||
onError={handleImageError}
|
||||
style={imageFillStyle}
|
||||
onPressOutsideContent={onClose}
|
||||
style={styles.imageViewport}
|
||||
testID="attachment-lightbox"
|
||||
uri={url}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={closeButtonRowStyle}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</WindowChromeRootRegion>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const imageFillStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
} as const;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
@@ -135,42 +141,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
closeButtonRow: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "flex-end",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
imageArea: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
imagePressable: {
|
||||
imageViewport: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
alignSelf: "center",
|
||||
maxWidth: 960,
|
||||
maxHeight: 640,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { ModelBrowser, ModelProviderGlyph, useModelBrowser } from "@/components/model-browser";
|
||||
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -87,6 +89,8 @@ export function CombinedModelSelector({
|
||||
toolbar,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const modelBrowserScrolling = resolveModelBrowserScrolling({ isNative, isCompact });
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
||||
@@ -198,7 +202,7 @@ export function CombinedModelSelector({
|
||||
onEditProfile={onEditProfile ? handleEditProfile : undefined}
|
||||
onRetryProvider={onRetryProvider}
|
||||
isRetryingProvider={isRetryingProvider}
|
||||
scrolling={isWeb ? "independent" : "sheet"}
|
||||
scrolling={modelBrowserScrolling}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./keyboard-dock.native";
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardPaddingStyle = useAnimatedStyle(() => ({
|
||||
paddingBottom: shift.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, keyboardPaddingStyle]} {...props}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock(props: KeyboardDockProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { Code, Workflow } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
import type { MermaidRenderRequest } from "./render-model";
|
||||
@@ -21,13 +14,9 @@ import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
import { getDiagramBoxStyle } from "./presentation";
|
||||
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
interface MermaidIframeRuntimeProps {
|
||||
request: MermaidRenderRequest | null;
|
||||
height: number;
|
||||
height: React.CSSProperties["height"];
|
||||
onRendered: (message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
@@ -61,9 +50,7 @@ function MermaidIframeRuntime({
|
||||
|
||||
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (!current || !target) {
|
||||
return;
|
||||
}
|
||||
if (!current || !target) return;
|
||||
const message: MermaidRuntimeRenderMessage = {
|
||||
type: "render",
|
||||
revision: current.revision,
|
||||
@@ -80,13 +67,9 @@ function MermaidIframeRuntime({
|
||||
|
||||
useEffect(() => {
|
||||
function receiveMessage(event: MessageEvent): void {
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const message = parseMermaidRuntimeMessage(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (!message) return;
|
||||
if (message.type === "bridgeReady") {
|
||||
sendRequest(driverRef.current?.ready() ?? null);
|
||||
return;
|
||||
@@ -116,8 +99,9 @@ function MermaidIframeRuntime({
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 8;
|
||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
|
||||
function MermaidFenceHostImpl({
|
||||
code,
|
||||
@@ -133,6 +117,9 @@ function MermaidFenceHostImpl({
|
||||
colorScheme,
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
@@ -153,152 +140,18 @@ function MermaidFenceHostImpl({
|
||||
);
|
||||
const visible = state.visible;
|
||||
const canShowDiagram = visible !== null && hasRuntimeContent;
|
||||
const diagramVisible = canShowDiagram && !showSource;
|
||||
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const controlsRegionRef = useRef<HTMLDivElement | null>(null);
|
||||
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
|
||||
const applyTransformToElement = useCallback(
|
||||
(element: HTMLDivElement, transform: { x: number; y: number; scale: number }) => {
|
||||
const { x, y, scale } = transform;
|
||||
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
|
||||
},
|
||||
[],
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: Code,
|
||||
label: t("message.diagram.viewSource"),
|
||||
onPress: showSourcePress,
|
||||
},
|
||||
],
|
||||
[showSourcePress, t],
|
||||
);
|
||||
const applyTransform = useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
applyTransformToElement(contentRef.current, transformRef.current);
|
||||
}
|
||||
}, [applyTransformToElement]);
|
||||
const setContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
if (node) {
|
||||
applyTransformToElement(node, transformRef.current);
|
||||
}
|
||||
},
|
||||
[applyTransformToElement],
|
||||
);
|
||||
const setControlsRegionRef = useCallback((node: unknown) => {
|
||||
controlsRegionRef.current = node instanceof HTMLDivElement ? node : null;
|
||||
}, []);
|
||||
const hasFocusedControls = useCallback(() => {
|
||||
const region = controlsRegionRef.current;
|
||||
const active = document.activeElement;
|
||||
return Boolean(region && active instanceof Node && region.contains(active));
|
||||
}, []);
|
||||
const [isFocusWithin, setIsFocusWithin] = useState(false);
|
||||
const handleFocusWithin = useCallback(() => setIsFocusWithin(true), []);
|
||||
const handleBlurWithin = useCallback(() => {
|
||||
window.requestAnimationFrame(() => setIsFocusWithin(hasFocusedControls()));
|
||||
}, [hasFocusedControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || !visible) {
|
||||
return;
|
||||
}
|
||||
const activeViewport = viewport;
|
||||
function zoomWithWheel(event: WheelEvent): void {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const transform = transformRef.current;
|
||||
const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
|
||||
const rect = activeViewport.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
transform.x = x - ((x - transform.x) / transform.scale) * scale;
|
||||
transform.y = y - ((y - transform.y) / transform.scale) * scale;
|
||||
transform.scale = scale;
|
||||
applyTransform();
|
||||
}
|
||||
viewport.addEventListener("wheel", zoomWithWheel, { passive: false });
|
||||
return () => viewport.removeEventListener("wheel", zoomWithWheel);
|
||||
}, [applyTransform, visible]);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || event.pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
const transform = transformRef.current;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: transform.x,
|
||||
originY: transform.y,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}, []);
|
||||
const onPointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || event.pointerId !== drag.pointerId) {
|
||||
return;
|
||||
}
|
||||
transformRef.current.x = drag.originX + event.clientX - drag.startX;
|
||||
transformRef.current.y = drag.originY + event.clientY - drag.startY;
|
||||
applyTransform();
|
||||
},
|
||||
[applyTransform],
|
||||
);
|
||||
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) {
|
||||
dragRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const resetTransform = useCallback(() => {
|
||||
transformRef.current = { x: 0, y: 0, scale: 1 };
|
||||
applyTransform();
|
||||
}, [applyTransform]);
|
||||
const zoomBy = useCallback(
|
||||
(factor: number) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const x = rect.width / 2;
|
||||
const y = rect.height / 2;
|
||||
const transform = transformRef.current;
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
|
||||
transform.x = x - ((x - transform.x) / transform.scale) * scale;
|
||||
transform.y = y - ((y - transform.y) / transform.scale) * scale;
|
||||
transform.scale = scale;
|
||||
applyTransform();
|
||||
},
|
||||
[applyTransform],
|
||||
);
|
||||
const zoomInPress = useCallback(() => zoomBy(1.25), [zoomBy]);
|
||||
const zoomOutPress = useCallback(() => zoomBy(0.8), [zoomBy]);
|
||||
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const controlsVisible = isHovered || isCompact || isFocusWithin;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSource && contentRef.current) {
|
||||
applyTransformToElement(contentRef.current, transformRef.current);
|
||||
}
|
||||
}, [applyTransformToElement, showSource]);
|
||||
|
||||
const sourceView = useMemo(() => {
|
||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||
const margins: ViewStyle = {
|
||||
@@ -306,182 +159,90 @@ function MermaidFenceHostImpl({
|
||||
marginBottom: marginBottom ?? marginVertical,
|
||||
};
|
||||
const text: TextStyle = sourceTextStyle;
|
||||
return {
|
||||
container: [margins, sourceContainerStyle],
|
||||
text,
|
||||
};
|
||||
return { container: [margins, sourceContainerStyle], text };
|
||||
}, [textStyle]);
|
||||
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
|
||||
|
||||
const diagramVisible = canShowDiagram && !showSource;
|
||||
const sourceVisible = !canShowDiagram || showSource;
|
||||
let rootStyle: StyleProp<ViewStyle> = sourceContainerStyle;
|
||||
if (diagramVisible) {
|
||||
rootStyle = [diagramBoxStyle, containerStyle];
|
||||
} else if (showSource) {
|
||||
rootStyle = sourceView.container;
|
||||
}
|
||||
const diagramStyle = useMemo(
|
||||
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
const diagramSize = useMemo(
|
||||
() => (visible ? { width: visible.width, height: visible.height } : MEASURING_SIZE),
|
||||
[visible],
|
||||
);
|
||||
const sourceVisible = !diagramVisible;
|
||||
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
|
||||
const sourceTextStyle = showSource ? sourceView.text : textStyle;
|
||||
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
|
||||
|
||||
const runtime = (
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
height={runtimeHeight}
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<View
|
||||
ref={setControlsRegionRef}
|
||||
style={rootStyle}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onFocus={handleFocusWithin}
|
||||
onBlur={handleBlurWithin}
|
||||
>
|
||||
<>
|
||||
{sourceVisible ? (
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
/>
|
||||
<View style={sourceContainer}>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
/>
|
||||
{showSource && canShowDiagram ? (
|
||||
<Pressable
|
||||
accessibilityLabel={t("message.diagram.viewDiagram")}
|
||||
accessibilityRole="button"
|
||||
hitSlop={4}
|
||||
onPress={showDiagramPress}
|
||||
style={controlStyles.sourceButton}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Workflow
|
||||
size={14}
|
||||
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role={diagramVisible ? "img" : undefined}
|
||||
aria-label={diagramVisible ? t("message.diagram.diagram") : undefined}
|
||||
aria-hidden={!diagramVisible}
|
||||
style={diagramVisible ? viewportDomStyle : measuringRuntimeStyle}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onDoubleClick={resetTransform}
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={t("message.diagram.diagram")}
|
||||
actions={actions}
|
||||
contentSize={diagramSize}
|
||||
style={viewportStyle}
|
||||
testID="mermaid-viewport"
|
||||
wheelActivation="modifier"
|
||||
>
|
||||
<div ref={setContentRef} style={contentDomStyle}>
|
||||
{runtime}
|
||||
</div>
|
||||
</div>
|
||||
{diagramVisible ? (
|
||||
<View style={controlStyles.cluster}>
|
||||
<DiagramControlButton
|
||||
icon={ZoomIn}
|
||||
label={t("message.diagram.zoomIn")}
|
||||
onPress={zoomInPress}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
<DiagramControlButton
|
||||
icon={ZoomOut}
|
||||
label={t("message.diagram.zoomOut")}
|
||||
onPress={zoomOutPress}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
<DiagramControlButton
|
||||
icon={Scan}
|
||||
label={t("message.diagram.resetZoom")}
|
||||
onPress={resetTransform}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
<DiagramControlButton
|
||||
icon={Code}
|
||||
label={t("message.diagram.viewSource")}
|
||||
onPress={showSourcePress}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{showSource && canShowDiagram ? (
|
||||
<View style={[controlStyles.cluster, controlStyles.clusterSourceOffset]}>
|
||||
<DiagramControlButton
|
||||
icon={Workflow}
|
||||
label={t("message.diagram.viewDiagram")}
|
||||
onPress={showDiagramPress}
|
||||
visible={controlsVisible}
|
||||
plain
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiagramControlButtonProps {
|
||||
icon: ComponentType<{ size?: number; color?: string }>;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
plain?: boolean;
|
||||
}
|
||||
|
||||
const DiagramControlButton = React.memo(function DiagramControlButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onPress,
|
||||
visible,
|
||||
plain = false,
|
||||
}: DiagramControlButtonProps) {
|
||||
const pillStyle = visible ? controlStyles.button : controlStyles.buttonHidden;
|
||||
const plainStyle = visible ? controlStyles.plainButton : controlStyles.plainButtonHidden;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={plain ? plainStyle : pillStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
hitSlop={4}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={14}
|
||||
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
height="100%"
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</ZoomableViewport>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const MEASURING_SIZE = { width: 240, height: 240 };
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
|
||||
const measuringStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 240,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const controlStyles = StyleSheet.create((theme) => ({
|
||||
cluster: {
|
||||
sourceButton: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
clusterSourceOffset: { right: 40 },
|
||||
button: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
buttonHidden: {
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
plainButton: { padding: theme.spacing[1], opacity: 1, pointerEvents: "auto" },
|
||||
plainButtonHidden: { padding: theme.spacing[1], opacity: 0, pointerEvents: "none" },
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
iconHovered: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
|
||||
const viewportDomStyle: React.CSSProperties = { cursor: "grab", userSelect: "none" };
|
||||
const contentDomStyle: React.CSSProperties = { transformOrigin: "0 0" };
|
||||
const measuringRuntimeStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
};
|
||||
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
|
||||
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
CircleDot,
|
||||
Copy,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
@@ -103,7 +102,7 @@ import {
|
||||
AttachmentLabel,
|
||||
AttachmentThumbnail,
|
||||
} from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
@@ -441,6 +440,10 @@ export const UserMessage = memo(function UserMessage({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
|
||||
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(
|
||||
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
|
||||
[lightboxMetadata],
|
||||
);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
@@ -566,7 +569,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -816,6 +819,10 @@ function AssistantMarkdownImage({
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => ({
|
||||
marginTop: hasLeadingContent ? 16 : 0,
|
||||
@@ -848,6 +855,14 @@ function AssistantMarkdownImage({
|
||||
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||
if (!viewerOpen || !imageUri) return null;
|
||||
return {
|
||||
type: "uri",
|
||||
uri: imageUri,
|
||||
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||
};
|
||||
}, [aspectRatio, imageUri, viewerOpen]);
|
||||
|
||||
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
@@ -877,21 +892,34 @@ function AssistantMarkdownImage({
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={surfaceStyle} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
disabled={image.status !== "loaded"}
|
||||
onPress={openViewer}
|
||||
style={surfaceStyle}
|
||||
>
|
||||
<View
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={alt}
|
||||
>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -2261,8 +2289,6 @@ function taskActivityIcon(activity: TaskActivity) {
|
||||
return CircleDot;
|
||||
case "completed":
|
||||
return Check;
|
||||
case "reopened":
|
||||
return RotateCcw;
|
||||
default:
|
||||
return CheckSquare;
|
||||
}
|
||||
@@ -3120,6 +3146,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!shouldRenderInline) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
@@ -3133,6 +3160,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
@@ -3173,6 +3201,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
if (!shouldRenderInline) return null;
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={effectiveDetail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={maxDetailHeight}
|
||||
@@ -3181,6 +3210,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
);
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toolName,
|
||||
effectiveDetail,
|
||||
presentation.errorText,
|
||||
presentation.isLoadingDetails,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveInitialModelBrowserView,
|
||||
resolveModelBrowserAllView,
|
||||
groupProfilesByProviderModel,
|
||||
resolveModelBrowserScrolling,
|
||||
} from "./model-browser-view";
|
||||
|
||||
function provider(
|
||||
@@ -37,6 +38,20 @@ function modelRow(
|
||||
};
|
||||
}
|
||||
|
||||
describe("model browser scrolling", () => {
|
||||
it("participates in native compact bottom-sheet scrolling", () => {
|
||||
expect(resolveModelBrowserScrolling({ isNative: true, isCompact: true })).toBe("sheet");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ platform: "native wide", isNative: true, isCompact: false },
|
||||
{ platform: "compact web", isNative: false, isCompact: true },
|
||||
{ platform: "wide web", isNative: false, isCompact: false },
|
||||
])("owns scrolling on $platform surfaces", ({ isNative, isCompact }) => {
|
||||
expect(resolveModelBrowserScrolling({ isNative, isCompact })).toBe("independent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("model browser initial view", () => {
|
||||
const codex = provider("codex", "Codex");
|
||||
const pi = provider("pi", "Pi");
|
||||
|
||||
@@ -9,6 +9,16 @@ export type ModelBrowserView =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
export function resolveModelBrowserScrolling({
|
||||
isNative,
|
||||
isCompact,
|
||||
}: {
|
||||
isNative: boolean;
|
||||
isCompact: boolean;
|
||||
}): "sheet" | "independent" {
|
||||
return isNative && isCompact ? "sheet" : "independent";
|
||||
}
|
||||
|
||||
/** A profile's model reference; used to match profiles back to model rows. */
|
||||
export interface ModelProfileRef {
|
||||
provider: string;
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -36,7 +34,8 @@ import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -135,7 +134,6 @@ import {
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import {
|
||||
getCurrentProjectRemoveReadiness,
|
||||
@@ -1263,42 +1261,14 @@ function WorkspaceRowWithMenu({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.currentBranch]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
@@ -1308,13 +1278,6 @@ function WorkspaceRowWithMenu({
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
@@ -1373,14 +1336,10 @@ function WorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -40,14 +40,11 @@ import {
|
||||
CircleX,
|
||||
} from "lucide-react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -626,42 +623,17 @@ function StatusWorkspaceRowWithMenu({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
@@ -719,14 +691,10 @@ function StatusWorkspaceRowWithMenu({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel="Rename"
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -2,23 +2,20 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
@@ -108,42 +105,14 @@ export function SidebarWorkspaceRow({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.currentBranch]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
@@ -153,13 +122,6 @@ export function SidebarWorkspaceRow({
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
@@ -206,14 +168,10 @@ export function SidebarWorkspaceRow({
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,10 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
buildPaseoToolDetailSections,
|
||||
type PaseoToolDetailSection,
|
||||
} from "@getpaseo/protocol/paseo-tool-call-detail";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
@@ -27,6 +31,7 @@ const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
// ---- Content Component ----
|
||||
|
||||
interface ToolCallDetailsContentProps {
|
||||
toolName?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
maxHeight?: number;
|
||||
@@ -626,7 +631,42 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles, t: TFunct
|
||||
return out;
|
||||
}
|
||||
|
||||
function PaseoDetailSection({ section }: { section: PaseoToolDetailSection }) {
|
||||
return (
|
||||
<View style={styles.paseoSection}>
|
||||
<Text style={styles.paseoSectionTitle}>{section.title}</Text>
|
||||
{section.kind === "prose" ? (
|
||||
<Text selectable style={styles.paseoProse}>
|
||||
{section.text}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.paseoFields}>
|
||||
{section.fields.map((field) => (
|
||||
<View key={field.label} style={styles.paseoFieldRow}>
|
||||
<Text style={styles.paseoFieldLabel}>{field.label}</Text>
|
||||
<Text selectable style={styles.paseoFieldValue}>
|
||||
{field.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPaseoUnknownSections(
|
||||
toolName: string | undefined,
|
||||
detail: UnknownDetail,
|
||||
): ReactNode[] | null {
|
||||
if (!toolName) return null;
|
||||
const sections = buildPaseoToolDetailSections(toolName, detail.input, detail.output);
|
||||
if (!sections) return null;
|
||||
return sections.map((section) => <PaseoDetailSection key={section.title} section={section} />);
|
||||
}
|
||||
|
||||
function buildDetailSections(
|
||||
toolName: string | undefined,
|
||||
detail: ToolCallDetail | undefined,
|
||||
diffLines: DiffLine[] | undefined,
|
||||
ds: DetailStyles,
|
||||
@@ -701,7 +741,7 @@ function buildDetailSections(
|
||||
return [<ScrollablePlainTextSection key="plain-text" text={detail.text} ds={ds} />];
|
||||
}
|
||||
if (detail.type === "unknown") {
|
||||
return buildUnknownSections(detail, ds, t);
|
||||
return buildPaseoUnknownSections(toolName, detail) ?? buildUnknownSections(detail, ds, t);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -741,6 +781,7 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
toolName,
|
||||
detail,
|
||||
errorText,
|
||||
maxHeight,
|
||||
@@ -752,7 +793,7 @@ export function ToolCallDetailsContent({
|
||||
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
|
||||
const diffLines = useDiffLines(detail);
|
||||
|
||||
const sections: ReactNode[] = buildDetailSections(detail, diffLines, ds, t);
|
||||
const sections: ReactNode[] = buildDetailSections(toolName, detail, diffLines, ds, t);
|
||||
|
||||
if (errorText) {
|
||||
sections.push(<ErrorSection key="error" errorText={errorText} ds={ds} />);
|
||||
@@ -796,6 +837,46 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
paseoSection: {
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
paseoSectionTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
paseoProse: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.5),
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
paseoFields: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
paseoFieldRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
paseoFieldLabel: {
|
||||
width: 120,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.5),
|
||||
},
|
||||
paseoFieldValue: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.5),
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
// ----- Types -----
|
||||
|
||||
export interface ToolCallSheetData {
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
@@ -155,7 +156,14 @@ interface ToolCallSheetContentProps {
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||
const {
|
||||
toolName,
|
||||
displayName,
|
||||
detail,
|
||||
errorText,
|
||||
icon: IconComponent,
|
||||
showLoadingSkeleton,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -181,6 +189,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
{/* Content */}
|
||||
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={detail}
|
||||
errorText={errorText}
|
||||
fillAvailableHeight
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactElement, type RefObject } from
|
||||
import { Keyboard, View, useWindowDimensions } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
@@ -63,9 +64,10 @@ export function AutocompletePopover({
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const { shift } = useKeyboardShift();
|
||||
const openShift = useSharedValue(0);
|
||||
const measuredShift = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || (options.length > 0 && selectedIndex < 0)) {
|
||||
@@ -88,7 +90,7 @@ export function AutocompletePopover({
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
openShift.value = shift.value;
|
||||
measuredShift.value = shift.value;
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
@@ -110,7 +112,7 @@ export function AutocompletePopover({
|
||||
selectedIndex,
|
||||
anchorRef,
|
||||
portalHostName,
|
||||
openShift,
|
||||
measuredShift,
|
||||
shift,
|
||||
windowDimensions.width,
|
||||
windowDimensions.height,
|
||||
@@ -120,20 +122,22 @@ export function AutocompletePopover({
|
||||
if (!relativeAnchorRect) return null;
|
||||
return inlineUnistylesStyle({
|
||||
position: "absolute" as const,
|
||||
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
|
||||
left: relativeAnchorRect.x,
|
||||
width: relativeAnchorRect.width,
|
||||
});
|
||||
}, [relativeAnchorRect]);
|
||||
|
||||
const animatedTransformStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: openShift.value - shift.value }],
|
||||
}));
|
||||
|
||||
const composedStyle = useMemo(
|
||||
() => [baseStyle, animatedTransformStyle],
|
||||
[baseStyle, animatedTransformStyle],
|
||||
);
|
||||
const anchorY = relativeAnchorRect?.y ?? 0;
|
||||
const baseBottom = relativeAnchorRect
|
||||
? relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR
|
||||
: 0;
|
||||
const keyboardLayoutStyle = useAnimatedStyle(() => {
|
||||
const shiftDelta = shift.value - measuredShift.value;
|
||||
return {
|
||||
bottom: baseBottom + shiftDelta,
|
||||
maxHeight: Math.max(0, anchorY - shiftDelta - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2),
|
||||
};
|
||||
}, [anchorY, baseBottom, safeAreaInsets.top]);
|
||||
|
||||
if (!visible || !relativeAnchorRect || !baseStyle) return null;
|
||||
if (options.length > 0 && selectedIndex < 0) return null;
|
||||
@@ -141,7 +145,10 @@ export function AutocompletePopover({
|
||||
return (
|
||||
<Portal hostName={portalHostName}>
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<Animated.View testID="composer-autocomplete-popover" style={composedStyle}>
|
||||
<Animated.View
|
||||
testID="composer-autocomplete-popover"
|
||||
style={[baseStyle, keyboardLayoutStyle]}
|
||||
>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
|
||||
@@ -288,6 +288,8 @@ export function Autocomplete({
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
outerWrapper: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailCard: {
|
||||
@@ -315,6 +317,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
container: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
|
||||
@@ -92,6 +92,45 @@ describe("EditingTextInputNative", () => {
|
||||
expect(handleRef.current?.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("replaces the native input when clearing text", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<EditingTextInput
|
||||
ref={handleRef}
|
||||
initialValue="line one\nline two\nline three"
|
||||
onChangeText={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const grownInput = container?.querySelector("input");
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
});
|
||||
|
||||
expect(container?.querySelector("input")).not.toBe(grownInput);
|
||||
});
|
||||
|
||||
it("restores focus after replacing a cleared native input", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
root?.render(<EditingTextInput ref={handleRef} initialValue="message" />);
|
||||
});
|
||||
const originalInput = container?.querySelector("input");
|
||||
if (!originalInput) throw new Error("Expected native input");
|
||||
Object.assign(originalInput, { isFocused: () => true });
|
||||
originalInput.focus();
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(container?.querySelector("input"));
|
||||
});
|
||||
|
||||
it("updates textRef and text when replaceText receives non-empty text", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
|
||||
import { TextInput } from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import PasteInput, {
|
||||
@@ -11,7 +11,7 @@ import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
|
||||
type NativeInput = (TextInput | PasteTextInputInstance) & {
|
||||
blur(): void;
|
||||
focus(): void;
|
||||
isFocused(): boolean;
|
||||
isFocused?(): boolean;
|
||||
clear?(): void;
|
||||
replaceText?(text: string, selection?: { start: number; end: number }): void;
|
||||
setNativeProps?(props: { text?: string; selection?: { start: number; end: number } }): void;
|
||||
@@ -35,26 +35,41 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
const inputRef = useRef<NativeInput | null>(null);
|
||||
const initialTextRef = useRef(initialValue);
|
||||
const textRef = useRef(initialTextRef.current);
|
||||
const restoreFocusAfterResetRef = useRef(false);
|
||||
const [nativeInputRevision, setNativeInputRevision] = useState(0);
|
||||
|
||||
const assignInputRef = useCallback((input: NativeInput | null) => {
|
||||
inputRef.current = input;
|
||||
if (!input || !restoreFocusAfterResetRef.current) return;
|
||||
restoreFocusAfterResetRef.current = false;
|
||||
input.focus();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
blur: () => inputRef.current?.blur(),
|
||||
isFocused: () => inputRef.current?.isFocused() ?? false,
|
||||
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
if (nextText === "") {
|
||||
restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false;
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText(nextText, selection);
|
||||
} else {
|
||||
inputRef.current?.clear?.();
|
||||
}
|
||||
setNativeInputRevision((revision) => revision + 1);
|
||||
return;
|
||||
}
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText(nextText, selection);
|
||||
return;
|
||||
}
|
||||
if (nextText === "") {
|
||||
inputRef.current?.clear?.();
|
||||
} else {
|
||||
inputRef.current?.setNativeProps?.({
|
||||
text: nextText,
|
||||
...(selection ? { selection } : {}),
|
||||
});
|
||||
}
|
||||
inputRef.current?.setNativeProps?.({
|
||||
text: nextText,
|
||||
...(selection ? { selection } : {}),
|
||||
});
|
||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
},
|
||||
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
||||
@@ -82,8 +97,9 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
return (
|
||||
<PasteInput
|
||||
{...props}
|
||||
ref={inputRef as React.Ref<PasteTextInputInstance>}
|
||||
defaultValue={initialTextRef.current}
|
||||
key={nativeInputRevision}
|
||||
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
|
||||
defaultValue={textRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
@@ -93,8 +109,9 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
return (
|
||||
<BottomSheetTextInput
|
||||
{...props}
|
||||
ref={inputRef as unknown as React.Ref<never>}
|
||||
defaultValue={initialTextRef.current}
|
||||
key={nativeInputRevision}
|
||||
ref={assignInputRef as unknown as React.Ref<never>}
|
||||
defaultValue={textRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
@@ -102,8 +119,9 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
ref={inputRef as React.Ref<TextInput>}
|
||||
defaultValue={initialTextRef.current}
|
||||
key={nativeInputRevision}
|
||||
ref={assignInputRef as React.Ref<TextInput>}
|
||||
defaultValue={textRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,19 @@ import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
||||
import {
|
||||
QrCode,
|
||||
Link2,
|
||||
ClipboardPaste,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
@@ -17,9 +25,10 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
|
||||
interface WelcomeAction {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
key: "scan-qr" | "direct-connection" | "remote-ssh" | "paste-pairing-link";
|
||||
label: string;
|
||||
testID: string;
|
||||
primary: boolean;
|
||||
@@ -165,6 +174,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
@@ -188,6 +198,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
|
||||
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
|
||||
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
|
||||
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
|
||||
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
|
||||
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
|
||||
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
|
||||
const handleScanQr = useCallback(() => {
|
||||
@@ -249,6 +261,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
];
|
||||
|
||||
if (isElectronRuntime()) {
|
||||
actions.splice(1, 0, {
|
||||
key: "remote-ssh",
|
||||
label: t("pairing.connectionMethods.remoteSsh.title"),
|
||||
testID: "welcome-remote-ssh",
|
||||
primary: false,
|
||||
icon: Terminal,
|
||||
onPress: handleOpenRemoteSsh,
|
||||
});
|
||||
}
|
||||
|
||||
const scrollContentContainerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
|
||||
[theme.spacing, insets.bottom],
|
||||
@@ -300,6 +323,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<AddRemoteSshHostModal
|
||||
visible={isRemoteSshOpen}
|
||||
onClose={handleCloseRemoteSsh}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={handleClosePasteLink}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
|
||||
const WORKSPACE_RENAME_ACTIONS: readonly KeyboardActionId[] = ["workspace.rename"];
|
||||
|
||||
/**
|
||||
* The rename dialog used to live only on the sidebar row, so it was unreachable whenever that row
|
||||
* was not rendered — a collapsed project or status group, a collapsed Pinned section, or focus
|
||||
* mode. This is the same fix `use-global-workspace-pin-action.ts` applies to pin: one registration
|
||||
* keyed on the active route selection.
|
||||
*
|
||||
* Sidebar rows keep their own modal instances. They rename *their* workspace, not the active one.
|
||||
*/
|
||||
export function WorkspaceRenameHost() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const routeWorkspaceId = selection?.workspaceId ?? null;
|
||||
// Narrow projection so the dialog doesn't re-render on every gitRuntime/diffStat tick.
|
||||
// `id` is projected rather than reusing the route id: the route carries an opaque workspace id
|
||||
// that is not guaranteed to equal the descriptor id, and setWorkspaceTitle needs the descriptor.
|
||||
const fields = useWorkspaceFields(serverId, routeWorkspaceId, (workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
title: workspace.title ?? null,
|
||||
}));
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const openFrameRef = useRef<number | null>(null);
|
||||
|
||||
const cancelPendingOpen = useCallback(() => {
|
||||
if (openFrameRef.current !== null) {
|
||||
cancelAnimationFrame(openFrameRef.current);
|
||||
openFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => cancelPendingOpen, [cancelPendingOpen]);
|
||||
|
||||
// The command center closes and dispatches in the same React batch, so opening synchronously
|
||||
// would mount this modal while the palette is still unmounting — on Android the portal stacking
|
||||
// lands the dialog behind it, and on web the palette's teardown steals focus back from the
|
||||
// input. Wait a frame. (The caller has already cleared the focus-restore element.)
|
||||
const handle = useCallback(() => {
|
||||
if (!fields) return false;
|
||||
cancelPendingOpen();
|
||||
openFrameRef.current = requestAnimationFrame(() => {
|
||||
openFrameRef.current = null;
|
||||
setIsOpen(true);
|
||||
});
|
||||
return true;
|
||||
}, [cancelPendingOpen, fields]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
cancelPendingOpen();
|
||||
setIsOpen(false);
|
||||
}, [cancelPendingOpen]);
|
||||
|
||||
// Closing on workspace change avoids renaming whatever the user navigated to instead.
|
||||
useEffect(() => {
|
||||
cancelPendingOpen();
|
||||
setIsOpen(false);
|
||||
}, [cancelPendingOpen, serverId, routeWorkspaceId]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: "workspace-rename-global",
|
||||
actions: WORKSPACE_RENAME_ACTIONS,
|
||||
enabled: serverId !== null && fields !== null,
|
||||
priority: 0,
|
||||
handle,
|
||||
});
|
||||
|
||||
const workspace = useMemo(
|
||||
() =>
|
||||
serverId && fields
|
||||
? { serverId, workspaceId: fields.id, name: fields.name, title: fields.title }
|
||||
: null,
|
||||
[fields, serverId],
|
||||
);
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceRenameModal
|
||||
visible={isOpen}
|
||||
workspace={workspace}
|
||||
onClose={handleClose}
|
||||
testID="workspace-rename-modal-global"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
|
||||
// command center can build one from the active route selection without a sidebar row.
|
||||
export interface RenamableWorkspace {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceRenameModalProps {
|
||||
visible: boolean;
|
||||
workspace: RenamableWorkspace;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Prefix for the modal's testIDs. The sidebar callers must keep passing
|
||||
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
|
||||
* builds its locators from exactly that prefix.
|
||||
*/
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
|
||||
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
|
||||
*/
|
||||
export function WorkspaceRenameModal({
|
||||
visible,
|
||||
workspace,
|
||||
onClose,
|
||||
testID,
|
||||
}: WorkspaceRenameModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
const renameAsync = renameMutation.mutateAsync;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
await renameAsync(value.trim());
|
||||
},
|
||||
[renameAsync],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveRenameModal
|
||||
visible={visible}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FIT_TRANSFORM,
|
||||
clampTransform,
|
||||
fitContentSize,
|
||||
isActivePinchUpdate,
|
||||
isPointInsideTransformedContent,
|
||||
panContent,
|
||||
zoomContentAtPoint,
|
||||
} from "./geometry";
|
||||
|
||||
const viewport = { width: 800, height: 600 };
|
||||
const fittedContent = { width: 800, height: 400 };
|
||||
|
||||
describe("zoomable viewport geometry", () => {
|
||||
it("rejects Android's terminal pinch update after a touch has lifted", () => {
|
||||
expect(isActivePinchUpdate(1, 2)).toBe(false);
|
||||
expect(isActivePinchUpdate(2, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("fits content inside the viewport without changing its aspect ratio", () => {
|
||||
expect(fitContentSize({ width: 1600, height: 800 }, viewport)).toEqual(fittedContent);
|
||||
expect(fitContentSize({ width: 400, height: 800 }, viewport)).toEqual({
|
||||
width: 300,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("fits within presentation padding and caps without shrinking the zoom viewport", () => {
|
||||
expect(
|
||||
fitContentSize({ width: 1600, height: 800 }, viewport, {
|
||||
padding: 20,
|
||||
maxWidth: 600,
|
||||
maxHeight: 500,
|
||||
}),
|
||||
).toEqual({ width: 600, height: 300 });
|
||||
});
|
||||
|
||||
it("distinguishes backdrop taps from taps on transformed content", () => {
|
||||
expect(
|
||||
isPointInsideTransformedContent({
|
||||
point: { x: 400, y: 300 },
|
||||
transform: FIT_TRANSFORM,
|
||||
fittedContent,
|
||||
viewport,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPointInsideTransformedContent({
|
||||
point: { x: 400, y: 50 },
|
||||
transform: FIT_TRANSFORM,
|
||||
fittedContent,
|
||||
viewport,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPointInsideTransformedContent({
|
||||
point: { x: 10, y: 10 },
|
||||
transform: { scale: 2, x: 0, y: 0 },
|
||||
fittedContent,
|
||||
viewport,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns to the centered fit transform at the minimum scale", () => {
|
||||
expect(
|
||||
clampTransform({ scale: 0.5, x: 200, y: -200 }, fittedContent, viewport, {
|
||||
minScale: 1,
|
||||
maxScale: 8,
|
||||
}),
|
||||
).toEqual(FIT_TRANSFORM);
|
||||
});
|
||||
|
||||
it("keeps the focal content point under the cursor while zooming", () => {
|
||||
expect(
|
||||
zoomContentAtPoint({
|
||||
transform: FIT_TRANSFORM,
|
||||
scale: 2,
|
||||
focalPoint: { x: 600, y: 300 },
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits: { minScale: 1, maxScale: 8 },
|
||||
}),
|
||||
).toEqual({ scale: 2, x: -200, y: 0 });
|
||||
});
|
||||
|
||||
it("clamps panning to the scaled content edges", () => {
|
||||
expect(
|
||||
panContent({
|
||||
transform: { scale: 2, x: 0, y: 0 },
|
||||
delta: { x: 900, y: -900 },
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits: { minScale: 1, maxScale: 8 },
|
||||
}),
|
||||
).toEqual({ scale: 2, x: 400, y: -100 });
|
||||
});
|
||||
|
||||
it("allows Mermaid consumers to zoom below fit without inventing translation", () => {
|
||||
expect(
|
||||
clampTransform({ scale: 0.25, x: 100, y: 100 }, fittedContent, viewport, {
|
||||
minScale: 0.25,
|
||||
maxScale: 8,
|
||||
}),
|
||||
).toEqual({ scale: 0.25, x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
export interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ViewportPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ViewportTransform {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ViewportScaleLimits {
|
||||
minScale: number;
|
||||
maxScale: number;
|
||||
}
|
||||
|
||||
export interface ViewportFitOptions {
|
||||
padding?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const FIT_TRANSFORM: ViewportTransform = { scale: 1, x: 0, y: 0 };
|
||||
|
||||
export function isActivePinchUpdate(activeTouches: number, reportedPointers: number): boolean {
|
||||
"worklet";
|
||||
return activeTouches >= 2 && reportedPointers >= 2;
|
||||
}
|
||||
|
||||
export function isPointInsideTransformedContent(input: {
|
||||
point: ViewportPoint;
|
||||
transform: ViewportTransform;
|
||||
fittedContent: ViewportSize;
|
||||
viewport: ViewportSize;
|
||||
}): boolean {
|
||||
"worklet";
|
||||
const centerX = input.viewport.width / 2 + input.transform.x;
|
||||
const centerY = input.viewport.height / 2 + input.transform.y;
|
||||
const halfWidth = (input.fittedContent.width * input.transform.scale) / 2;
|
||||
const halfHeight = (input.fittedContent.height * input.transform.scale) / 2;
|
||||
return (
|
||||
input.point.x >= centerX - halfWidth &&
|
||||
input.point.x <= centerX + halfWidth &&
|
||||
input.point.y >= centerY - halfHeight &&
|
||||
input.point.y <= centerY + halfHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function fitContentSize(
|
||||
content: ViewportSize,
|
||||
viewport: ViewportSize,
|
||||
options: ViewportFitOptions = {},
|
||||
): ViewportSize {
|
||||
const padding = options.padding ?? 0;
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
Math.min(viewport.width - padding * 2, options.maxWidth ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
const availableHeight = Math.max(
|
||||
0,
|
||||
Math.min(viewport.height - padding * 2, options.maxHeight ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
const fitScale = Math.min(availableWidth / content.width, availableHeight / content.height);
|
||||
return {
|
||||
width: content.width * fitScale,
|
||||
height: content.height * fitScale,
|
||||
};
|
||||
}
|
||||
|
||||
function clampScale(scale: number, limits: ViewportScaleLimits): number {
|
||||
return Math.min(limits.maxScale, Math.max(limits.minScale, scale));
|
||||
}
|
||||
|
||||
function clampOffset(offset: number, maximum: number): number {
|
||||
if (maximum === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(maximum, Math.max(-maximum, offset));
|
||||
}
|
||||
|
||||
export function clampTransform(
|
||||
transform: ViewportTransform,
|
||||
fittedContent: ViewportSize,
|
||||
viewport: ViewportSize,
|
||||
limits: ViewportScaleLimits,
|
||||
): ViewportTransform {
|
||||
const scale = clampScale(transform.scale, limits);
|
||||
const maxX = Math.max(0, (fittedContent.width * scale - viewport.width) / 2);
|
||||
const maxY = Math.max(0, (fittedContent.height * scale - viewport.height) / 2);
|
||||
return {
|
||||
scale,
|
||||
x: clampOffset(transform.x, maxX),
|
||||
y: clampOffset(transform.y, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function zoomContentAtPoint(input: {
|
||||
transform: ViewportTransform;
|
||||
scale: number;
|
||||
focalPoint: ViewportPoint;
|
||||
fittedContent: ViewportSize;
|
||||
viewport: ViewportSize;
|
||||
limits: ViewportScaleLimits;
|
||||
}): ViewportTransform {
|
||||
const scale = clampScale(input.scale, input.limits);
|
||||
const viewportCenter = {
|
||||
x: input.viewport.width / 2,
|
||||
y: input.viewport.height / 2,
|
||||
};
|
||||
const contentPoint = {
|
||||
x: (input.focalPoint.x - viewportCenter.x - input.transform.x) / input.transform.scale,
|
||||
y: (input.focalPoint.y - viewportCenter.y - input.transform.y) / input.transform.scale,
|
||||
};
|
||||
return clampTransform(
|
||||
{
|
||||
scale,
|
||||
x: input.focalPoint.x - viewportCenter.x - contentPoint.x * scale,
|
||||
y: input.focalPoint.y - viewportCenter.y - contentPoint.y * scale,
|
||||
},
|
||||
input.fittedContent,
|
||||
input.viewport,
|
||||
input.limits,
|
||||
);
|
||||
}
|
||||
|
||||
export function panContent(input: {
|
||||
transform: ViewportTransform;
|
||||
delta: ViewportPoint;
|
||||
fittedContent: ViewportSize;
|
||||
viewport: ViewportSize;
|
||||
limits: ViewportScaleLimits;
|
||||
}): ViewportTransform {
|
||||
return clampTransform(
|
||||
{
|
||||
...input.transform,
|
||||
x: input.transform.x + input.delta.x,
|
||||
y: input.transform.y + input.delta.y,
|
||||
},
|
||||
input.fittedContent,
|
||||
input.viewport,
|
||||
input.limits,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ViewportFitOptions, ViewportSize } from "./geometry";
|
||||
import { ZoomableViewport } from "./index";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
interface ZoomableImageProps {
|
||||
uri: string;
|
||||
accessibilityLabel?: string;
|
||||
actions?: ZoomableViewportAction[];
|
||||
contentSize?: ViewportSize;
|
||||
fit?: ViewportFitOptions;
|
||||
maxScale?: number;
|
||||
minScale?: number;
|
||||
onError?: () => void;
|
||||
onPressOutsideContent?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
wheelActivation?: "always" | "modifier";
|
||||
}
|
||||
|
||||
export function ZoomableImage({
|
||||
uri,
|
||||
accessibilityLabel,
|
||||
actions,
|
||||
contentSize,
|
||||
fit,
|
||||
maxScale,
|
||||
minScale = 1,
|
||||
onError,
|
||||
onPressOutsideContent,
|
||||
style,
|
||||
testID = "zoomable-image",
|
||||
wheelActivation = "always",
|
||||
}: ZoomableImageProps) {
|
||||
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
|
||||
const resolvedSize = contentSize ?? loadedSize;
|
||||
const source = useMemo(() => ({ uri }), [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentSize) return;
|
||||
let active = true;
|
||||
setLoadedSize(null);
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
|
||||
},
|
||||
() => {
|
||||
if (active) onError?.();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [contentSize, onError, uri]);
|
||||
|
||||
if (!resolvedSize) {
|
||||
return (
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<Image
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ZoomableViewport
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
actions={actions}
|
||||
contentSize={resolvedSize}
|
||||
fit={fit}
|
||||
maxScale={maxScale}
|
||||
minScale={minScale}
|
||||
onPressOutsideContent={onPressOutsideContent}
|
||||
style={style}
|
||||
testID={testID}
|
||||
wheelActivation={wheelActivation}
|
||||
>
|
||||
<Image
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
image: { width: "100%", height: "100%" },
|
||||
}));
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
FIT_TRANSFORM,
|
||||
fitContentSize,
|
||||
isActivePinchUpdate,
|
||||
isPointInsideTransformedContent,
|
||||
zoomContentAtPoint,
|
||||
type ViewportSize,
|
||||
} from "./geometry";
|
||||
import { ViewportToolbar } from "./toolbar";
|
||||
import type { ZoomableViewportProps } from "./types";
|
||||
|
||||
const DEFAULT_MAX_SCALE = 8;
|
||||
const DEFAULT_MIN_SCALE = 0.25;
|
||||
|
||||
export function ZoomableViewport({
|
||||
contentSize,
|
||||
children,
|
||||
actions = [],
|
||||
accessibilityLabel,
|
||||
fit,
|
||||
maxScale = DEFAULT_MAX_SCALE,
|
||||
minScale = DEFAULT_MIN_SCALE,
|
||||
onPressOutsideContent,
|
||||
style,
|
||||
testID,
|
||||
}: ZoomableViewportProps) {
|
||||
const [viewport, setViewport] = useState<ViewportSize | null>(null);
|
||||
const [toolbarScale, setToolbarScale] = useState(1);
|
||||
const scale = useSharedValue(1);
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const startScale = useSharedValue(1);
|
||||
const startX = useSharedValue(0);
|
||||
const startY = useSharedValue(0);
|
||||
const pinchFocalX = useSharedValue(0);
|
||||
const pinchFocalY = useSharedValue(0);
|
||||
const pinchReady = useSharedValue(false);
|
||||
const pinchTouchCount = useSharedValue(0);
|
||||
const fittedContent = useMemo(
|
||||
() => (viewport ? fitContentSize(contentSize, viewport, fit) : null),
|
||||
[contentSize, fit, viewport],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
scale.value = FIT_TRANSFORM.scale;
|
||||
translateX.value = FIT_TRANSFORM.x;
|
||||
translateY.value = FIT_TRANSFORM.y;
|
||||
setToolbarScale(FIT_TRANSFORM.scale);
|
||||
}, [scale, translateX, translateY]);
|
||||
|
||||
useEffect(() => reset(), [contentSize.height, contentSize.width, reset, viewport]);
|
||||
|
||||
const panGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.maxPointers(1)
|
||||
.onBegin(() => {
|
||||
startX.value = translateX.value;
|
||||
startY.value = translateY.value;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!fittedContent || !viewport || scale.value <= minScale) return;
|
||||
const maxX = Math.max(0, (fittedContent.width * scale.value - viewport.width) / 2);
|
||||
const maxY = Math.max(0, (fittedContent.height * scale.value - viewport.height) / 2);
|
||||
translateX.value = Math.min(maxX, Math.max(-maxX, startX.value + event.translationX));
|
||||
translateY.value = Math.min(maxY, Math.max(-maxY, startY.value + event.translationY));
|
||||
}),
|
||||
[fittedContent, minScale, scale, startX, startY, translateX, translateY, viewport],
|
||||
);
|
||||
|
||||
const pinchGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pinch()
|
||||
.onTouchesDown((event) => {
|
||||
pinchTouchCount.value = event.numberOfTouches;
|
||||
})
|
||||
.onTouchesUp((event) => {
|
||||
pinchTouchCount.value = event.numberOfTouches;
|
||||
})
|
||||
.onBegin((event) => {
|
||||
pinchTouchCount.value = event.numberOfPointers;
|
||||
startScale.value = scale.value;
|
||||
startX.value = translateX.value;
|
||||
startY.value = translateY.value;
|
||||
pinchReady.value = false;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!fittedContent || !viewport) return;
|
||||
// Android emits one final pinch update after a finger lifts. Its focal point belongs to
|
||||
// the remaining pointer, so applying it makes the content jump as the gesture ends.
|
||||
if (!isActivePinchUpdate(pinchTouchCount.value, event.numberOfPointers)) return;
|
||||
if (!pinchReady.value) {
|
||||
pinchFocalX.value = event.focalX;
|
||||
pinchFocalY.value = event.focalY;
|
||||
pinchReady.value = true;
|
||||
}
|
||||
const nextScale = Math.min(maxScale, Math.max(minScale, startScale.value * event.scale));
|
||||
const centerX = viewport.width / 2;
|
||||
const centerY = viewport.height / 2;
|
||||
const contentPointX = (pinchFocalX.value - centerX - startX.value) / startScale.value;
|
||||
const contentPointY = (pinchFocalY.value - centerY - startY.value) / startScale.value;
|
||||
const nextX = event.focalX - centerX - contentPointX * nextScale;
|
||||
const nextY = event.focalY - centerY - contentPointY * nextScale;
|
||||
const maxX = Math.max(0, (fittedContent.width * nextScale - viewport.width) / 2);
|
||||
const maxY = Math.max(0, (fittedContent.height * nextScale - viewport.height) / 2);
|
||||
scale.value = nextScale;
|
||||
translateX.value = Math.min(maxX, Math.max(-maxX, nextX));
|
||||
translateY.value = Math.min(maxY, Math.max(-maxY, nextY));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pinchTouchCount.value = 0;
|
||||
runOnJS(setToolbarScale)(scale.value);
|
||||
}),
|
||||
[
|
||||
fittedContent,
|
||||
maxScale,
|
||||
minScale,
|
||||
pinchFocalX,
|
||||
pinchFocalY,
|
||||
pinchReady,
|
||||
pinchTouchCount,
|
||||
scale,
|
||||
startScale,
|
||||
startX,
|
||||
startY,
|
||||
translateX,
|
||||
translateY,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
const doubleTapGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd((_event, success) => {
|
||||
if (success) runOnJS(reset)();
|
||||
}),
|
||||
[reset],
|
||||
);
|
||||
const backdropTapGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Tap()
|
||||
.numberOfTaps(1)
|
||||
.onEnd((event, success) => {
|
||||
if (!success || !fittedContent || !viewport || !onPressOutsideContent) return;
|
||||
const isInside = isPointInsideTransformedContent({
|
||||
point: { x: event.x, y: event.y },
|
||||
transform: { scale: scale.value, x: translateX.value, y: translateY.value },
|
||||
fittedContent,
|
||||
viewport,
|
||||
});
|
||||
if (!isInside) runOnJS(onPressOutsideContent)();
|
||||
}),
|
||||
[fittedContent, onPressOutsideContent, scale, translateX, translateY, viewport],
|
||||
);
|
||||
const tapGesture = useMemo(
|
||||
() => Gesture.Exclusive(doubleTapGesture, backdropTapGesture),
|
||||
[backdropTapGesture, doubleTapGesture],
|
||||
);
|
||||
const transformGesture = useMemo(
|
||||
() => Gesture.Simultaneous(panGesture, pinchGesture),
|
||||
[panGesture, pinchGesture],
|
||||
);
|
||||
const gesture = useMemo(
|
||||
() => Gesture.Exclusive(transformGesture, tapGesture),
|
||||
[tapGesture, transformGesture],
|
||||
);
|
||||
|
||||
const zoomFromCenter = useCallback(
|
||||
(factor: number) => {
|
||||
if (!fittedContent || !viewport) return;
|
||||
const next = zoomContentAtPoint({
|
||||
transform: { scale: scale.value, x: translateX.value, y: translateY.value },
|
||||
scale: scale.value * factor,
|
||||
focalPoint: { x: viewport.width / 2, y: viewport.height / 2 },
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits: { minScale, maxScale },
|
||||
});
|
||||
scale.value = next.scale;
|
||||
translateX.value = next.x;
|
||||
translateY.value = next.y;
|
||||
setToolbarScale(next.scale);
|
||||
},
|
||||
[fittedContent, maxScale, minScale, scale, translateX, translateY, viewport],
|
||||
);
|
||||
const zoomIn = useCallback(() => zoomFromCenter(1.25), [zoomFromCenter]);
|
||||
const zoomOut = useCallback(() => zoomFromCenter(0.8), [zoomFromCenter]);
|
||||
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
if (width > 0 && height > 0) setViewport({ width, height });
|
||||
}, []);
|
||||
|
||||
const translationStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
|
||||
}));
|
||||
const scaleStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
|
||||
const frameStyle = useMemo(
|
||||
() => [
|
||||
styles.contentFrame,
|
||||
{ width: fittedContent?.width ?? 0, height: fittedContent?.height ?? 0 },
|
||||
translationStyle,
|
||||
],
|
||||
[fittedContent, translationStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<View onLayout={handleLayout} style={[styles.root, style]} testID={testID}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<View
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole={accessibilityLabel ? "image" : undefined}
|
||||
style={styles.canvas}
|
||||
testID={testID ? `${testID}-canvas` : undefined}
|
||||
>
|
||||
{fittedContent ? (
|
||||
<Animated.View style={frameStyle}>
|
||||
<Animated.View style={[styles.content, scaleStyle]}>{children}</Animated.View>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
<ViewportToolbar
|
||||
actions={actions}
|
||||
maxScale={maxScale}
|
||||
minScale={minScale}
|
||||
onReset={reset}
|
||||
onZoomIn={zoomIn}
|
||||
onZoomOut={zoomOut}
|
||||
scale={toolbarScale}
|
||||
visible
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0, overflow: "hidden", position: "relative" },
|
||||
canvas: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
contentFrame: { flexShrink: 0 },
|
||||
content: { width: "100%", height: "100%" },
|
||||
}));
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
FIT_TRANSFORM,
|
||||
fitContentSize,
|
||||
isPointInsideTransformedContent,
|
||||
panContent,
|
||||
zoomContentAtPoint,
|
||||
type ViewportPoint,
|
||||
type ViewportSize,
|
||||
type ViewportTransform,
|
||||
} from "./geometry";
|
||||
import { ViewportToolbar } from "./toolbar";
|
||||
import type { ZoomableViewportProps } from "./types";
|
||||
|
||||
const DEFAULT_MAX_SCALE = 8;
|
||||
const DEFAULT_MIN_SCALE = 0.25;
|
||||
|
||||
interface DragGesture {
|
||||
type: "drag";
|
||||
pointerId: number;
|
||||
startPoint: ViewportPoint;
|
||||
startTransform: ViewportTransform;
|
||||
}
|
||||
|
||||
interface PinchGesture {
|
||||
type: "pinch";
|
||||
distance: number;
|
||||
midpoint: ViewportPoint;
|
||||
}
|
||||
|
||||
type ActiveGesture = DragGesture | PinchGesture;
|
||||
|
||||
function midpoint(points: ViewportPoint[]): ViewportPoint {
|
||||
return {
|
||||
x: (points[0].x + points[1].x) / 2,
|
||||
y: (points[0].y + points[1].y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function distance(points: ViewportPoint[]): number {
|
||||
return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
|
||||
}
|
||||
|
||||
export function ZoomableViewport({
|
||||
contentSize,
|
||||
children,
|
||||
actions = [],
|
||||
accessibilityLabel,
|
||||
fit,
|
||||
maxScale = DEFAULT_MAX_SCALE,
|
||||
minScale = DEFAULT_MIN_SCALE,
|
||||
onPressOutsideContent,
|
||||
style,
|
||||
testID,
|
||||
wheelActivation = "modifier",
|
||||
}: ZoomableViewportProps) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointersRef = useRef(new Map<number, ViewportPoint>());
|
||||
const gestureRef = useRef<ActiveGesture | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const transformRef = useRef<ViewportTransform>(FIT_TRANSFORM);
|
||||
const [viewport, setViewport] = useState<ViewportSize | null>(null);
|
||||
const [transform, setTransform] = useState<ViewportTransform>(FIT_TRANSFORM);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isFocusWithin, setIsFocusWithin] = useState(false);
|
||||
const [hasTouchInput, setHasTouchInput] = useState(false);
|
||||
const fittedContent = useMemo(
|
||||
() => (viewport ? fitContentSize(contentSize, viewport, fit) : null),
|
||||
[contentSize, fit, viewport],
|
||||
);
|
||||
const limits = useMemo(() => ({ minScale, maxScale }), [maxScale, minScale]);
|
||||
|
||||
const commitTransform = useCallback((next: ViewportTransform) => {
|
||||
transformRef.current = next;
|
||||
setTransform(next);
|
||||
}, []);
|
||||
const reset = useCallback(() => commitTransform(FIT_TRANSFORM), [commitTransform]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (!entry || entry.contentRect.width <= 0 || entry.contentRect.height <= 0) return;
|
||||
setViewport({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
});
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => reset(), [contentSize.height, contentSize.width, reset, viewport]);
|
||||
|
||||
const zoomAt = useCallback(
|
||||
(scale: number, focalPoint: ViewportPoint) => {
|
||||
if (!fittedContent || !viewport) return;
|
||||
commitTransform(
|
||||
zoomContentAtPoint({
|
||||
transform: transformRef.current,
|
||||
scale,
|
||||
focalPoint,
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[commitTransform, fittedContent, limits, viewport],
|
||||
);
|
||||
const zoomFromCenter = useCallback(
|
||||
(factor: number) => {
|
||||
if (!viewport) return;
|
||||
zoomAt(transformRef.current.scale * factor, {
|
||||
x: viewport.width / 2,
|
||||
y: viewport.height / 2,
|
||||
});
|
||||
},
|
||||
[viewport, zoomAt],
|
||||
);
|
||||
const zoomIn = useCallback(() => zoomFromCenter(1.25), [zoomFromCenter]);
|
||||
const zoomOut = useCallback(() => zoomFromCenter(0.8), [zoomFromCenter]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const activeCanvas = canvas;
|
||||
function zoomWithWheel(event: WheelEvent) {
|
||||
const hasModifier = event.ctrlKey || event.metaKey;
|
||||
if (wheelActivation === "modifier" && !hasModifier) return;
|
||||
event.preventDefault();
|
||||
const bounds = activeCanvas.getBoundingClientRect();
|
||||
const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
|
||||
zoomAt(transformRef.current.scale * factor, {
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
});
|
||||
}
|
||||
activeCanvas.addEventListener("wheel", zoomWithWheel, { passive: false });
|
||||
return () => activeCanvas.removeEventListener("wheel", zoomWithWheel);
|
||||
}, [wheelActivation, zoomAt]);
|
||||
|
||||
const beginPointer = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
if (event.pointerType !== "mouse") setHasTouchInput(true);
|
||||
if (pointersRef.current.size === 0) suppressClickRef.current = false;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const points = Array.from(pointersRef.current.values());
|
||||
if (points.length >= 2) {
|
||||
gestureRef.current = {
|
||||
type: "pinch",
|
||||
distance: distance(points),
|
||||
midpoint: midpoint(points),
|
||||
};
|
||||
setIsDragging(true);
|
||||
return;
|
||||
}
|
||||
gestureRef.current = {
|
||||
type: "drag",
|
||||
pointerId: event.pointerId,
|
||||
startPoint: points[0],
|
||||
startTransform: transformRef.current,
|
||||
};
|
||||
setIsDragging(transformRef.current.scale > 1);
|
||||
}, []);
|
||||
|
||||
const movePointer = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!pointersRef.current.has(event.pointerId) || !fittedContent || !viewport) return;
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const gesture = gestureRef.current;
|
||||
const points = Array.from(pointersRef.current.values());
|
||||
if (gesture?.type === "pinch" && points.length >= 2) {
|
||||
suppressClickRef.current = true;
|
||||
const nextMidpoint = midpoint(points);
|
||||
const nextDistance = distance(points);
|
||||
const panned = panContent({
|
||||
transform: transformRef.current,
|
||||
delta: {
|
||||
x: nextMidpoint.x - gesture.midpoint.x,
|
||||
y: nextMidpoint.y - gesture.midpoint.y,
|
||||
},
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits,
|
||||
});
|
||||
const next = zoomContentAtPoint({
|
||||
transform: panned,
|
||||
scale: panned.scale * (nextDistance / gesture.distance),
|
||||
focalPoint: nextMidpoint,
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits,
|
||||
});
|
||||
gestureRef.current = { type: "pinch", distance: nextDistance, midpoint: nextMidpoint };
|
||||
commitTransform(next);
|
||||
return;
|
||||
}
|
||||
if (gesture?.type !== "drag" || gesture.pointerId !== event.pointerId) return;
|
||||
if (
|
||||
Math.hypot(event.clientX - gesture.startPoint.x, event.clientY - gesture.startPoint.y) > 3
|
||||
) {
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
commitTransform(
|
||||
panContent({
|
||||
transform: gesture.startTransform,
|
||||
delta: {
|
||||
x: event.clientX - gesture.startPoint.x,
|
||||
y: event.clientY - gesture.startPoint.y,
|
||||
},
|
||||
fittedContent,
|
||||
viewport,
|
||||
limits,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[commitTransform, fittedContent, limits, viewport],
|
||||
);
|
||||
|
||||
const endPointer = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const remaining = Array.from(pointersRef.current.entries());
|
||||
if (remaining.length === 1) {
|
||||
const [pointerId, point] = remaining[0];
|
||||
gestureRef.current = {
|
||||
type: "drag",
|
||||
pointerId,
|
||||
startPoint: point,
|
||||
startTransform: transformRef.current,
|
||||
};
|
||||
setIsDragging(transformRef.current.scale > 1);
|
||||
return;
|
||||
}
|
||||
gestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleCanvasClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!fittedContent || !viewport || !onPressOutsideContent) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const isInside = isPointInsideTransformedContent({
|
||||
point: { x: event.clientX - bounds.left, y: event.clientY - bounds.top },
|
||||
transform: transformRef.current,
|
||||
fittedContent,
|
||||
viewport,
|
||||
});
|
||||
if (!isInside) onPressOutsideContent();
|
||||
},
|
||||
[fittedContent, onPressOutsideContent, viewport],
|
||||
);
|
||||
|
||||
const renderedCanvasStyle = useMemo<React.CSSProperties>(() => {
|
||||
let cursor: React.CSSProperties["cursor"] = "default";
|
||||
if (transform.scale > 1) cursor = isDragging ? "grabbing" : "grab";
|
||||
return { ...canvasDomStyle, cursor };
|
||||
}, [isDragging, transform.scale]);
|
||||
const renderedContentStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
...contentDomStyle,
|
||||
width: fittedContent?.width ?? 0,
|
||||
height: fittedContent?.height ?? 0,
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
|
||||
}),
|
||||
[fittedContent, transform],
|
||||
);
|
||||
const controlsVisible = isHovered || isFocusWithin || hasTouchInput;
|
||||
const handleFocus = useCallback(() => setIsFocusWithin(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocusWithin(false), []);
|
||||
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
return (
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<div
|
||||
aria-label={accessibilityLabel}
|
||||
data-testid={testID ? `${testID}-canvas` : undefined}
|
||||
onClick={handleCanvasClick}
|
||||
onDoubleClick={reset}
|
||||
onFocusCapture={handleFocus}
|
||||
onBlurCapture={handleBlur}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onPointerCancel={endPointer}
|
||||
onPointerDown={beginPointer}
|
||||
onPointerMove={movePointer}
|
||||
onPointerUp={endPointer}
|
||||
ref={canvasRef}
|
||||
role={accessibilityLabel ? "img" : undefined}
|
||||
style={renderedCanvasStyle}
|
||||
>
|
||||
<div style={renderedContentStyle}>{children}</div>
|
||||
</div>
|
||||
<div
|
||||
onBlurCapture={handleBlur}
|
||||
onFocusCapture={handleFocus}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={toolbarDomStyle}
|
||||
>
|
||||
<ViewportToolbar
|
||||
actions={actions}
|
||||
maxScale={maxScale}
|
||||
minScale={minScale}
|
||||
onReset={reset}
|
||||
onZoomIn={zoomIn}
|
||||
onZoomOut={zoomOut}
|
||||
scale={transform.scale}
|
||||
visible={controlsVisible}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0, overflow: "hidden", position: "relative" },
|
||||
}));
|
||||
|
||||
const canvasDomStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
};
|
||||
const contentDomStyle: React.CSSProperties = {
|
||||
flexShrink: 0,
|
||||
transformOrigin: "center center",
|
||||
};
|
||||
const toolbarDomStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 200,
|
||||
height: 48,
|
||||
zIndex: 1,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Scan, ZoomIn, ZoomOut } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
interface ViewportToolbarProps {
|
||||
actions: ZoomableViewportAction[];
|
||||
maxScale: number;
|
||||
minScale: number;
|
||||
onReset: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
scale: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedViewportAction extends ZoomableViewportAction {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const ViewportToolbar = React.memo(function ViewportToolbar({
|
||||
actions,
|
||||
maxScale,
|
||||
minScale,
|
||||
onReset,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
scale,
|
||||
visible,
|
||||
}: ViewportToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportActions: ResolvedViewportAction[] = [
|
||||
{
|
||||
icon: ZoomIn,
|
||||
label: t("message.diagram.zoomIn"),
|
||||
onPress: onZoomIn,
|
||||
disabled: scale >= maxScale,
|
||||
},
|
||||
{
|
||||
icon: ZoomOut,
|
||||
label: t("message.diagram.zoomOut"),
|
||||
onPress: onZoomOut,
|
||||
disabled: scale <= minScale,
|
||||
},
|
||||
{
|
||||
icon: Scan,
|
||||
label: t("message.diagram.resetZoom"),
|
||||
onPress: onReset,
|
||||
disabled: scale === 1,
|
||||
},
|
||||
...actions.map((action) => ({ ...action, disabled: false })),
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.cluster} testID="zoomable-viewport-toolbar">
|
||||
{viewportActions.map((action) => (
|
||||
<ViewportToolbarButton
|
||||
key={action.label}
|
||||
action={action}
|
||||
disabled={action.disabled}
|
||||
visible={visible}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function ViewportToolbarButton({
|
||||
action,
|
||||
disabled,
|
||||
visible,
|
||||
}: {
|
||||
action: ZoomableViewportAction;
|
||||
disabled: boolean;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const Icon = action.icon;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = iconButtonChromeGlyphSize("small", isCompact);
|
||||
const buttonStyle = React.useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
||||
iconButtonChromeStyle({
|
||||
size: "small",
|
||||
compact: isCompact,
|
||||
state: { hovered, pressed },
|
||||
disabled,
|
||||
style: visible ? styles.button : styles.buttonHidden,
|
||||
}),
|
||||
[disabled, isCompact, visible],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={action.label}
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
hitSlop={isCompact ? 6 : 4}
|
||||
onPress={action.onPress}
|
||||
style={buttonStyle}
|
||||
testID={action.testID}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon size={iconSize} color={hovered ? styles.iconHovered.color : styles.icon.color} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
cluster: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
zIndex: 1,
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
buttonHidden: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
iconHovered: { color: theme.colors.foreground },
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { ViewportFitOptions, ViewportSize } from "./geometry";
|
||||
|
||||
export interface ZoomableViewportAction {
|
||||
icon: ComponentType<{ size?: number; color?: string }>;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface ZoomableViewportProps {
|
||||
contentSize: ViewportSize;
|
||||
children: ReactNode;
|
||||
actions?: ZoomableViewportAction[];
|
||||
accessibilityLabel?: string;
|
||||
fit?: ViewportFitOptions;
|
||||
maxScale?: number;
|
||||
minScale?: number;
|
||||
onPressOutsideContent?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
wheelActivation?: "always" | "modifier";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { resolveModelBrowserScrolling, resolveModelSheetOpening } from "./model-sheet-flow";
|
||||
import { resolveModelSheetOpening } from "./model-sheet-flow";
|
||||
|
||||
function provider(id: string, label: string): ProviderSelectorProvider {
|
||||
return { id, label, modelSelection: { kind: "models", rows: [] } };
|
||||
@@ -40,13 +40,3 @@ describe("model sheet opening", () => {
|
||||
).toEqual({ kind: "provider", providerId: "codex", providerLabel: "Codex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("model sheet gesture ownership", () => {
|
||||
it("uses the sheet-aware model browser inside compact bottom sheets", () => {
|
||||
expect(resolveModelBrowserScrolling(true)).toBe("sheet");
|
||||
});
|
||||
|
||||
it("keeps independent scrolling in the desktop split viewport", () => {
|
||||
expect(resolveModelBrowserScrolling(false)).toBe("independent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { ModelBrowserView } from "@/components/model-browser-view";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
|
||||
export function resolveModelBrowserScrolling(usesBottomSheet: boolean): "sheet" | "independent" {
|
||||
return usesBottomSheet ? "sheet" : "independent";
|
||||
}
|
||||
|
||||
export function resolveModelSheetOpening({
|
||||
canSwitchProvider,
|
||||
providers,
|
||||
|
||||
@@ -9,15 +9,13 @@ import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||
import {
|
||||
resolveModelBrowserScrolling,
|
||||
resolveModelSheetOpening,
|
||||
} from "@/composer/agent-controls/model-sheet-flow";
|
||||
import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-flow";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
|
||||
const SNAP_POINTS = ["80%", "90%"];
|
||||
const MODEL_LIST_TOP_INSET = 4;
|
||||
@@ -102,7 +100,10 @@ export function CompactModelSheet({
|
||||
}: CompactModelSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const usesBottomSheet = useIsCompactFormFactor();
|
||||
const modelBrowserScrolling = resolveModelBrowserScrolling(usesBottomSheet);
|
||||
const modelBrowserScrolling = resolveModelBrowserScrolling({
|
||||
isNative,
|
||||
isCompact: usesBottomSheet,
|
||||
});
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isModelBrowserOpen, setIsModelBrowserOpen] = useState(false);
|
||||
const availableProviders = useMemo(() => {
|
||||
|
||||
@@ -124,7 +124,7 @@ import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
@@ -2141,6 +2141,10 @@ function ComposerContentImpl({
|
||||
const handleLightboxClose = useCallback(() => {
|
||||
setLightboxMetadata(null);
|
||||
}, []);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(
|
||||
() => (lightboxMetadata ? { type: "attachment", metadata: lightboxMetadata } : null),
|
||||
[lightboxMetadata],
|
||||
);
|
||||
|
||||
const handleGithubPickerOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -2263,7 +2267,7 @@ function ComposerContentImpl({
|
||||
isMessageInputFocused={isMessageInputFocused}
|
||||
/>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
@@ -2364,6 +2368,9 @@ function ComposerContentImpl({
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "column",
|
||||
// KeyboardDock reduces the available column height with bottom padding.
|
||||
// Keep the composer's constraint chain shrinkable down to its scrolling input.
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
@@ -2374,6 +2381,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
inputAreaContainer: {
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
marginHorizontal: "auto",
|
||||
@@ -2387,11 +2395,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
opacity: 0.6,
|
||||
},
|
||||
inputAreaContent: {
|
||||
flexShrink: 1,
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
messageInputContainer: {
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
gap: theme.spacing[3],
|
||||
|
||||
@@ -1901,9 +1901,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
container: {
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
inputWrapper: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -1932,6 +1934,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
borderStyle: "dotted",
|
||||
},
|
||||
textInputScrollWrapper: {
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
focusHintText: {
|
||||
@@ -1943,6 +1946,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
textInput: {
|
||||
// Preserve the controls when an ancestor constrains an overlong draft.
|
||||
flexShrink: 1,
|
||||
width: "100%",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
@@ -1964,6 +1969,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
buttonRow: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
|
||||
import {
|
||||
buildDesktopDaemonTransportUrl,
|
||||
createDesktopDaemonTransportFactory,
|
||||
} from "./desktop-daemon-transport";
|
||||
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
|
||||
|
||||
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
|
||||
const LOCAL_URL = "paseo+desktop://socket?path=%2Ftmp%2Fpaseo.sock";
|
||||
|
||||
describe("desktop-daemon-transport", () => {
|
||||
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
||||
it("uses the main-process event as readiness when it races registration", async () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
|
||||
const cleanup = vi.fn();
|
||||
const transportFactory = createDesktopDaemonTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({ url: LOCAL_URL });
|
||||
@@ -15,32 +19,93 @@ describe("desktop-daemon-transport", () => {
|
||||
const onOpen = vi.fn();
|
||||
transport.onOpen(onOpen);
|
||||
|
||||
rpc.emitEvent({ sessionId: "local-session-1", kind: "open" });
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
rpc.resolveListen(cleanup);
|
||||
await Promise.resolve();
|
||||
|
||||
rpc.resolveOpen("local-session-1");
|
||||
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
|
||||
expect(sessionId).not.toBe("");
|
||||
rpc.emitEvent({ sessionId, kind: "open" });
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
|
||||
rpc.resolveRegistration();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up late async setup after the transport is closed", async () => {
|
||||
it("does not start a session when listener setup finishes after close", async () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const cleanup = vi.fn();
|
||||
|
||||
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
|
||||
const transportFactory = createDesktopDaemonTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({ url: LOCAL_URL });
|
||||
|
||||
transport.close();
|
||||
|
||||
rpc.resolveOpen("local-session-2");
|
||||
rpc.resolveListen(cleanup);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rpc.closedSessions).toEqual(["local-session-2"]);
|
||||
expect(rpc.openCalls).toHaveLength(0);
|
||||
expect(rpc.closedSessions).toHaveLength(1);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a registered session while readiness is pending", async () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const transportFactory = createDesktopDaemonTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({ url: LOCAL_URL });
|
||||
rpc.resolveListen(vi.fn());
|
||||
await Promise.resolve();
|
||||
|
||||
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
|
||||
expect(sessionId).not.toBe("");
|
||||
|
||||
transport.close();
|
||||
|
||||
expect(rpc.closedSessions).toEqual([sessionId]);
|
||||
});
|
||||
|
||||
it("passes Remote SSH parameters to the desktop transport bridge", async () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const transportFactory = createDesktopDaemonTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const url = buildDesktopDaemonTransportUrl({
|
||||
transportType: "ssh",
|
||||
host: "deploy@example.com",
|
||||
sshPort: 2222,
|
||||
daemonPort: 7777,
|
||||
});
|
||||
transportFactory!({ url });
|
||||
rpc.resolveListen(vi.fn());
|
||||
await Promise.resolve();
|
||||
|
||||
expect(rpc.openCalls).toHaveLength(1);
|
||||
expect(rpc.openCalls[0]?.target).toEqual({
|
||||
transportType: "ssh",
|
||||
host: "deploy@example.com",
|
||||
sshPort: 2222,
|
||||
daemonPort: 7777,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 65536])("rejects an out-of-range Remote SSH port (%s)", (sshPort) => {
|
||||
const transportFactory = createDesktopDaemonTransportFactory(
|
||||
createFakeLocalDaemonTransportRpc(),
|
||||
);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const url = buildDesktopDaemonTransportUrl({
|
||||
transportType: "ssh",
|
||||
host: "deploy@example.com",
|
||||
sshPort,
|
||||
});
|
||||
|
||||
expect(() => transportFactory!({ url })).toThrow("Invalid SSH transport target");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,15 @@ import type {
|
||||
DaemonTransport,
|
||||
DaemonTransportFactory,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import { validatePort, validateSshHost } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
|
||||
import {
|
||||
defaultLocalDaemonTransportRpc,
|
||||
type LocalDaemonTransportEvent,
|
||||
type LocalDaemonTransportRpc,
|
||||
} from "./local-daemon-transport-rpc";
|
||||
|
||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||
const DESKTOP_TRANSPORT_SCHEME = "paseo+desktop:";
|
||||
|
||||
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
@@ -28,21 +30,32 @@ function decodeBase64ToBytes(base64: string): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
|
||||
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
|
||||
url.searchParams.set("path", target.transportPath);
|
||||
export function buildDesktopDaemonTransportUrl(target: DesktopDaemonTransportTarget): string {
|
||||
const url = new URL(`${DESKTOP_TRANSPORT_SCHEME}//${target.transportType}`);
|
||||
if (target.transportType === "ssh") {
|
||||
url.searchParams.set("host", target.host);
|
||||
if (target.sshPort !== undefined) {
|
||||
url.searchParams.set("port", String(target.sshPort));
|
||||
}
|
||||
if (target.daemonPort !== undefined) {
|
||||
url.searchParams.set("daemonPort", String(target.daemonPort));
|
||||
}
|
||||
} else {
|
||||
url.searchParams.set("path", target.transportPath);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
function parseDesktopDaemonTransportUrl(url: string): DesktopDaemonTransportTarget {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
|
||||
throw new Error(`Unsupported local transport URL: ${url}`);
|
||||
if (parsed.protocol !== DESKTOP_TRANSPORT_SCHEME) {
|
||||
throw new Error(`Unsupported desktop transport URL: ${url}`);
|
||||
}
|
||||
const transportType = parsed.hostname;
|
||||
if (transportType === "ssh") return parseSshDesktopTransportUrl(parsed, url);
|
||||
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
|
||||
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
|
||||
throw new Error(`Invalid local transport target: ${url}`);
|
||||
throw new Error(`Invalid desktop transport target: ${url}`);
|
||||
}
|
||||
return {
|
||||
transportType,
|
||||
@@ -50,12 +63,33 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopLocalDaemonTransportFactory(
|
||||
function parseSshDesktopTransportUrl(parsed: URL, rawUrl: string): DesktopDaemonTransportTarget {
|
||||
try {
|
||||
const host = validateSshHost(parsed.searchParams.get("host") ?? "");
|
||||
const sshPort = parseOptionalUrlPort(parsed, "port", "SSH port");
|
||||
const daemonPort = parseOptionalUrlPort(parsed, "daemonPort", "Daemon port");
|
||||
return {
|
||||
transportType: "ssh",
|
||||
host,
|
||||
...(sshPort !== undefined ? { sshPort } : {}),
|
||||
...(daemonPort !== undefined ? { daemonPort } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid SSH transport target: ${rawUrl}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalUrlPort(parsed: URL, key: string, label: string): number | undefined {
|
||||
const value = parsed.searchParams.get(key);
|
||||
return value === null ? undefined : validatePort(value, label);
|
||||
}
|
||||
|
||||
export function createDesktopDaemonTransportFactory(
|
||||
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
return ({ url }) => {
|
||||
const target = parseLocalDaemonTransportUrl(url);
|
||||
let sessionId: string | null = null;
|
||||
const target = parseDesktopDaemonTransportUrl(url);
|
||||
const sessionId = `local-session-${globalThis.crypto.randomUUID()}`;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
let didEmitOpen = false;
|
||||
@@ -80,6 +114,9 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
}
|
||||
};
|
||||
const emitError = (event?: unknown) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
for (const handler of errorHandlers) {
|
||||
handler(event);
|
||||
}
|
||||
@@ -90,61 +127,49 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
}
|
||||
};
|
||||
|
||||
void rpc
|
||||
.listenToEvents((payload) => {
|
||||
if (disposed || !sessionId || payload.sessionId !== sessionId) {
|
||||
const handleEvent = (payload: LocalDaemonTransportEvent) => {
|
||||
if (disposed || payload.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "open") {
|
||||
emitOpen();
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "message") {
|
||||
if (payload.text) {
|
||||
emitMessage(payload.text, false);
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "open") {
|
||||
emitOpen();
|
||||
return;
|
||||
if (payload.binaryBase64) {
|
||||
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
|
||||
}
|
||||
if (payload.kind === "message") {
|
||||
if (payload.text) {
|
||||
emitMessage(payload.text, false);
|
||||
return;
|
||||
}
|
||||
if (payload.binaryBase64) {
|
||||
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "close") {
|
||||
emitClose(payload);
|
||||
return;
|
||||
}
|
||||
emitError(payload.error ?? "Local daemon transport error");
|
||||
})
|
||||
.then((cleanup) => {
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "close") {
|
||||
emitClose(payload);
|
||||
return;
|
||||
}
|
||||
emitError(payload.error ?? "Local daemon transport error");
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const cleanup = await rpc.listenToEvents(handleEvent);
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
|
||||
void rpc
|
||||
.openSession(target)
|
||||
.then((id) => {
|
||||
if (disposed) {
|
||||
void rpc.closeSession(id).catch((error) => emitError(error));
|
||||
return;
|
||||
}
|
||||
sessionId = id;
|
||||
emitOpen();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
await rpc.openSession({ sessionId, target });
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (!sessionId) {
|
||||
if (!didEmitOpen) {
|
||||
return;
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
@@ -157,12 +182,11 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error));
|
||||
},
|
||||
close: () => {
|
||||
disposed = true;
|
||||
const currentSessionId = sessionId;
|
||||
sessionId = null;
|
||||
if (currentSessionId) {
|
||||
void rpc.closeSession(currentSessionId).catch((error) => emitError(error));
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
void rpc.closeSession(sessionId).catch((error) => emitError(error));
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
},
|
||||
|
||||
@@ -40,6 +40,22 @@ export interface LocalTransportTarget {
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface RemoteSshTransportTarget {
|
||||
[key: string]: unknown;
|
||||
transportType: "ssh";
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
}
|
||||
|
||||
export type DesktopDaemonTransportTarget = LocalTransportTarget | RemoteSshTransportTarget;
|
||||
|
||||
export interface OpenLocalTransportSessionInput {
|
||||
[key: string]: unknown;
|
||||
sessionId: string;
|
||||
target: DesktopDaemonTransportTarget;
|
||||
}
|
||||
|
||||
interface LocalTransportEventPayload {
|
||||
sessionId: string;
|
||||
kind: "open" | "message" | "close" | "error";
|
||||
@@ -179,12 +195,10 @@ export async function listenToLocalTransportEvents(
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
}
|
||||
|
||||
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) {
|
||||
throw new Error("Unexpected local transport session response.");
|
||||
}
|
||||
return raw;
|
||||
export async function openLocalTransportSession(
|
||||
input: OpenLocalTransportSessionInput,
|
||||
): Promise<void> {
|
||||
await invokeDesktopCommand("open_local_daemon_transport", input);
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
listenToLocalTransportEvents,
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
type LocalTransportTarget,
|
||||
type OpenLocalTransportSessionInput,
|
||||
} from "./desktop-daemon";
|
||||
|
||||
export interface LocalDaemonTransportEvent {
|
||||
@@ -17,7 +17,7 @@ export interface LocalDaemonTransportEvent {
|
||||
}
|
||||
|
||||
export interface LocalDaemonTransportRpc {
|
||||
openSession(target: LocalTransportTarget): Promise<string>;
|
||||
openSession(input: OpenLocalTransportSessionInput): Promise<void>;
|
||||
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
|
||||
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
|
||||
closeSession(sessionId: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import type { OpenLocalTransportSessionInput } from "./desktop-daemon";
|
||||
import type {
|
||||
LocalDaemonTransportEvent,
|
||||
LocalDaemonTransportRpc,
|
||||
@@ -11,10 +11,10 @@ export interface RecordedSend {
|
||||
}
|
||||
|
||||
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
readonly openCalls: LocalTransportTarget[];
|
||||
readonly openCalls: OpenLocalTransportSessionInput[];
|
||||
readonly recordedSends: RecordedSend[];
|
||||
readonly closedSessions: string[];
|
||||
resolveOpen(sessionId: string): void;
|
||||
resolveRegistration(): void;
|
||||
rejectOpen(error: Error): void;
|
||||
resolveListen(cleanup: () => void): void;
|
||||
rejectListen(error: Error): void;
|
||||
@@ -22,12 +22,12 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
}
|
||||
|
||||
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
|
||||
const openCalls: LocalTransportTarget[] = [];
|
||||
const openCalls: OpenLocalTransportSessionInput[] = [];
|
||||
const recordedSends: RecordedSend[] = [];
|
||||
const closedSessions: string[] = [];
|
||||
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
|
||||
let resolveOpenSession: ((sessionId: string) => void) | null = null;
|
||||
let rejectOpenSession: ((error: Error) => void) | null = null;
|
||||
let resolveRegistrationPromise: (() => void) | null = null;
|
||||
let rejectRegistrationPromise: ((error: Error) => void) | null = null;
|
||||
let resolveListenPromise: ((cleanup: () => void) => void) | null = null;
|
||||
let rejectListenPromise: ((error: Error) => void) | null = null;
|
||||
|
||||
@@ -35,11 +35,11 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
|
||||
openCalls,
|
||||
recordedSends,
|
||||
closedSessions,
|
||||
openSession(target) {
|
||||
openCalls.push(target);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
resolveOpenSession = resolve;
|
||||
rejectOpenSession = reject;
|
||||
openSession(input) {
|
||||
openCalls.push(input);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
resolveRegistrationPromise = resolve;
|
||||
rejectRegistrationPromise = reject;
|
||||
});
|
||||
},
|
||||
listenToEvents(handler) {
|
||||
@@ -55,17 +55,17 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
|
||||
async closeSession(sessionId) {
|
||||
closedSessions.push(sessionId);
|
||||
},
|
||||
resolveOpen(sessionId) {
|
||||
if (!resolveOpenSession) {
|
||||
resolveRegistration() {
|
||||
if (!resolveRegistrationPromise) {
|
||||
throw new Error("openSession was not called");
|
||||
}
|
||||
resolveOpenSession(sessionId);
|
||||
resolveRegistrationPromise();
|
||||
},
|
||||
rejectOpen(error) {
|
||||
if (!rejectOpenSession) {
|
||||
if (!rejectRegistrationPromise) {
|
||||
throw new Error("openSession was not called");
|
||||
}
|
||||
rejectOpenSession(error);
|
||||
rejectRegistrationPromise(error);
|
||||
},
|
||||
resolveListen(cleanup) {
|
||||
if (!resolveListenPromise) {
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightStyle } from "@getpaseo/highlight";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { SourceEditor } from "@/source-editor/source-editor";
|
||||
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
|
||||
export function FileEditorView(_props: {
|
||||
export function FileEditorView(props: {
|
||||
model: FileEditorModel;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
vimEnabled: boolean;
|
||||
theme: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
foregroundMuted: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
monoFont: string;
|
||||
codeFontSize: number;
|
||||
syntax: Record<HighlightStyle, string>;
|
||||
};
|
||||
theme: SourceEditorTheme;
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}) {
|
||||
const snapshot = useSyncExternalStore(
|
||||
props.model.subscribe,
|
||||
props.model.getSnapshot,
|
||||
props.model.getSnapshot,
|
||||
);
|
||||
const selectionTarget = useMemo(
|
||||
() =>
|
||||
props.location.lineStart
|
||||
? {
|
||||
lineStart: props.location.lineStart,
|
||||
lineEnd: props.location.lineEnd,
|
||||
revision: props.navigationRevision,
|
||||
}
|
||||
: undefined,
|
||||
[props.location.lineEnd, props.location.lineStart, props.navigationRevision],
|
||||
);
|
||||
const handleChange = useCallback((document: string) => props.model.edit(document), [props.model]);
|
||||
const handleSave = useCallback(() => void props.model.save(), [props.model]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
||||
</View>
|
||||
<SourceEditor
|
||||
document={snapshot.content}
|
||||
filename={props.filename}
|
||||
selectionTarget={selectionTarget}
|
||||
vimEnabled={props.vimEnabled}
|
||||
theme={props.theme}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCursorChange={props.onCursorChange}
|
||||
onVimModeChange={props.onVimModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
|
||||
}));
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
|
||||
interface FileEditorViewProps {
|
||||
model: FileEditorModel;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
vimEnabled: boolean;
|
||||
theme: EditorVisualTheme;
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
}
|
||||
|
||||
const languageCompartment = new Compartment();
|
||||
const wrappingCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
function wrappingForFile(filename: string) {
|
||||
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
|
||||
}
|
||||
|
||||
export function FileEditorView({
|
||||
model,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
vimEnabled,
|
||||
theme,
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
}: FileEditorViewProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const values = initial.current;
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: values.content,
|
||||
extensions: [
|
||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
||||
...editorBaseExtensions(() => void values.model.save()),
|
||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
||||
wrappingCompartment.of(wrappingForFile(values.filename)),
|
||||
themeCompartment.of(editorTheme(values.theme)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (
|
||||
update.docChanged &&
|
||||
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
||||
) {
|
||||
const { lineSeparator } = values.model.getSnapshot();
|
||||
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
viewRef.current = view;
|
||||
onCursorChangeRef.current({ line: 1, column: 1 });
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const document = view.state.toText(snapshot.content);
|
||||
if (view.state.doc.eq(document)) return;
|
||||
const head = Math.min(view.state.selection.main.head, document.length);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
||||
selection: { anchor: head },
|
||||
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
||||
});
|
||||
}, [snapshot.content]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !location.lineStart) return;
|
||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
||||
const from = view.state.doc.line(lineStart).from;
|
||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
||||
view.dispatch({
|
||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: [
|
||||
languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
|
||||
wrappingCompartment.reconfigure(wrappingForFile(filename)),
|
||||
],
|
||||
});
|
||||
}, [filename]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
|
||||
if (!vimEnabled) {
|
||||
onVimModeChange(null);
|
||||
return;
|
||||
}
|
||||
const cm = getCM(view);
|
||||
if (!cm) return;
|
||||
function handleModeChange(event: { mode?: string }) {
|
||||
onVimModeChange((event.mode ?? "normal").toUpperCase());
|
||||
}
|
||||
cm.on("vim-mode-change", handleModeChange);
|
||||
onVimModeChange("NORMAL");
|
||||
return () => cm.off("vim-mode-change", handleModeChange);
|
||||
}, [onVimModeChange, vimEnabled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-pmono=""
|
||||
data-testid="file-source-editor"
|
||||
aria-label={`Source editor for ${filename}`}
|
||||
style={HOST_STYLE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const remoteUpdate = Annotation.define<boolean>();
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
@@ -9,7 +9,7 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
|
||||
import { ScrollView as RNScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -22,7 +22,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useLiveFile } from "./live-file/hook";
|
||||
import { useFilePreview } from "./preview-lifecycle/hook";
|
||||
@@ -39,6 +38,7 @@ import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -146,11 +146,6 @@ function FilePreviewBody({
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
|
||||
const imageSource = useMemo(
|
||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||
[imagePreviewUri],
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-preview-loading">
|
||||
@@ -212,22 +207,7 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<RNImage
|
||||
source={imageSource ?? undefined}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -348,10 +328,7 @@ function isEditableTextFile(input: {
|
||||
supportsEditing: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
isWeb &&
|
||||
input.supportsEditing &&
|
||||
input.preview?.kind === "text" &&
|
||||
input.preview.size <= 1024 * 1024,
|
||||
input.supportsEditing && input.preview?.kind === "text" && input.preview.size <= 1024 * 1024,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -711,14 +688,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
previewImage: {
|
||||
width: "100%",
|
||||
height: 420,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
theme: SourceEditorTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||
import { sourceEditorTheme } from "@/source-editor/codemirror/configuration";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
theme: SourceEditorTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ function ReadonlyCodeMirror({
|
||||
languageCompartment.of(
|
||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||
),
|
||||
themeCompartment.of(editorTheme(values.theme)),
|
||||
themeCompartment.of(sourceEditorTheme(values.theme)),
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -97,7 +97,7 @@ function ReadonlyCodeMirror({
|
||||
viewRef.current?.dispatch({
|
||||
effects: [
|
||||
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
||||
themeCompartment.reconfigure(editorTheme(theme)),
|
||||
themeCompartment.reconfigure(sourceEditorTheme(theme)),
|
||||
],
|
||||
});
|
||||
}, [filename, presentation, theme]);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CommitsSection } from "./commits-section";
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function"
|
||||
? factory({
|
||||
borderRadius: { full: 9999, sm: 4 },
|
||||
borderWidth: { 1: 1 },
|
||||
colors: {
|
||||
border: "#333",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
statusDanger: "#c33",
|
||||
surface2: "#222",
|
||||
},
|
||||
fontSize: { base: 15, sm: 13 },
|
||||
spacing: [0, 4, 8, 12],
|
||||
})
|
||||
: factory,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 34, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/git/use-commits-query", () => ({
|
||||
useCheckoutCommitsQuery: () => ({ status: "idle" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/git/themed-chevron", () => ({
|
||||
ThemedChevron: () => null,
|
||||
chevronColorMapping: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("./commit-row", () => ({
|
||||
CommitRow: () => null,
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("CommitsSection", () => {
|
||||
it("keeps its bottom edge above the device safe area", () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CommitsSection serverId="server" cwd="/repo" onCommitPress={vi.fn()} collapsed />,
|
||||
);
|
||||
});
|
||||
|
||||
const header = document.querySelector('[data-testid="commits-section-header"]');
|
||||
expect(header?.parentElement?.getAttribute("style")).toContain("padding-bottom: 34px");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
@@ -91,6 +92,7 @@ export function CommitsSection({
|
||||
onCollapsedChange,
|
||||
}: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||
@@ -119,6 +121,10 @@ export function CommitsSection({
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
if (query.status === "unsupported") {
|
||||
return null;
|
||||
@@ -129,7 +135,7 @@ export function CommitsSection({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={containerStyle}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID="commits-section-header"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
|
||||
// Everything the copy actions actually need. Kept narrower than SidebarWorkspaceEntry so the
|
||||
// command center can build one from the active route selection without a sidebar row.
|
||||
export interface CopyableWorkspace {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
currentBranch: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface WorkspaceClipboardActions {
|
||||
copyPath: (workspace: CopyableWorkspace) => void;
|
||||
copyBranchName: (workspace: CopyableWorkspace) => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceClipboardActions(): WorkspaceClipboardActions {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const copyPath = useCallback(
|
||||
(workspace: CopyableWorkspace) => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
const copyBranchName = useCallback(
|
||||
(workspace: CopyableWorkspace) => {
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ copyPath, copyBranchName }), [copyBranchName, copyPath]);
|
||||
}
|
||||
@@ -422,7 +422,6 @@ describe("translation resources", () => {
|
||||
added: "Added",
|
||||
started: "Started",
|
||||
completed: "Completed",
|
||||
reopened: "Reopened",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -345,7 +345,6 @@ export const ar: TranslationResources = {
|
||||
added: "أُضيفت",
|
||||
started: "بدأت",
|
||||
completed: "اكتملت",
|
||||
reopened: "أُعيد فتحها",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1558,6 +1557,10 @@ export const ar: TranslationResources = {
|
||||
title: "اتصال مباشر",
|
||||
description: "الشبكة المحلية أو VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH عن بُعد",
|
||||
description: "الاتصال عبر عميل SSH لسطح المكتب.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "مسح رمز QR",
|
||||
description: "اتصال التتابع المشفر.",
|
||||
@@ -1610,6 +1613,23 @@ export const ar: TranslationResources = {
|
||||
details: "التفاصيل:{{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH عن بُعد",
|
||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||
fields: {
|
||||
target: "مضيف SSH",
|
||||
},
|
||||
actions: {
|
||||
cancel: "إلغاء",
|
||||
connect: "اتصال",
|
||||
connecting: "جارٍ الاتصال...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "مضيف SSH مطلوب",
|
||||
invalidTarget: "أدخل مضيف ssh:// صالحًا",
|
||||
failedToConnect: "تعذر الاتصال عبر SSH. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "الصق رابط الاقتران",
|
||||
helper: "الصق رابط الاقتران من الخادم الخاص بك.",
|
||||
@@ -2244,6 +2264,7 @@ export const ar: TranslationResources = {
|
||||
badges: {
|
||||
relay: "تتابع",
|
||||
local: "محلي",
|
||||
remoteSsh: "SSH عن بُعد",
|
||||
},
|
||||
connections: {
|
||||
title: "اتصالات",
|
||||
|
||||
@@ -343,7 +343,6 @@ export const en = {
|
||||
added: "Added",
|
||||
started: "Started",
|
||||
completed: "Completed",
|
||||
reopened: "Reopened",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1568,6 +1567,10 @@ export const en = {
|
||||
title: "Direct connection",
|
||||
description: "Local network or VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "Remote SSH",
|
||||
description: "Connect through the desktop SSH client.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "Scan QR code",
|
||||
description: "Encrypted relay connection.",
|
||||
@@ -1619,6 +1622,23 @@ export const en = {
|
||||
details: "Details: {{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "Remote SSH",
|
||||
helper: "Connect to a Paseo daemon running on the remote host.",
|
||||
fields: {
|
||||
target: "SSH host",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancel",
|
||||
connect: "Connect",
|
||||
connecting: "Connecting...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "SSH host is required",
|
||||
invalidTarget: "Enter a valid ssh:// host",
|
||||
failedToConnect: "Unable to connect over SSH. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "Paste pairing link",
|
||||
helper: "Paste the pairing link from your server.",
|
||||
@@ -2342,6 +2362,7 @@ export const en = {
|
||||
badges: {
|
||||
relay: "Relay",
|
||||
local: "Local",
|
||||
remoteSsh: "Remote SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "Connections",
|
||||
|
||||
@@ -348,7 +348,6 @@ export const es: TranslationResources = {
|
||||
added: "Añadida",
|
||||
started: "Iniciada",
|
||||
completed: "Completada",
|
||||
reopened: "Reabierta",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1602,6 +1601,10 @@ export const es: TranslationResources = {
|
||||
title: "Conexión directa",
|
||||
description: "Red local o VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH remoto",
|
||||
description: "Conéctate mediante el cliente SSH de escritorio.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "Escanea el códigoQR",
|
||||
description: "Conexión de retransmisión cifrada.",
|
||||
@@ -1654,6 +1657,23 @@ export const es: TranslationResources = {
|
||||
details: "Detalles:{{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH remoto",
|
||||
helper: "Conéctate a un daemon de Paseo en el host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
connect: "Conectar",
|
||||
connecting: "Conectando...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "El host SSH es obligatorio",
|
||||
invalidTarget: "Introduce un host ssh:// válido",
|
||||
failedToConnect: "No se pudo conectar por SSH. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "Pegar enlace de emparejamiento",
|
||||
helper: "Pegue el enlace de emparejamiento de su servidor.",
|
||||
@@ -2295,6 +2315,7 @@ export const es: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relé",
|
||||
local: "Local",
|
||||
remoteSsh: "SSH remoto",
|
||||
},
|
||||
connections: {
|
||||
title: "Conexiones",
|
||||
|
||||
@@ -349,7 +349,6 @@ export const fr: TranslationResources = {
|
||||
added: "Ajoutée",
|
||||
started: "Commencée",
|
||||
completed: "Terminée",
|
||||
reopened: "Rouverte",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1606,6 +1605,10 @@ export const fr: TranslationResources = {
|
||||
title: "Connexion directe",
|
||||
description: "Réseau local ou VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH distant",
|
||||
description: "Connectez-vous avec le client SSH de bureau.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "Scanner le codeQR",
|
||||
description: "Connexion relais cryptée.",
|
||||
@@ -1658,6 +1661,23 @@ export const fr: TranslationResources = {
|
||||
details: "Détails:{{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH distant",
|
||||
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
|
||||
fields: {
|
||||
target: "Hôte SSH",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Annuler",
|
||||
connect: "Connecter",
|
||||
connecting: "Connexion...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "L’hôte SSH est requis",
|
||||
invalidTarget: "Saisissez un hôte ssh:// valide",
|
||||
failedToConnect: "Connexion SSH impossible. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "Coller le lien d'association",
|
||||
helper: "Collez le lien d'appairage depuis votre serveur.",
|
||||
@@ -2300,6 +2320,7 @@ export const fr: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relais",
|
||||
local: "Locale",
|
||||
remoteSsh: "SSH distant",
|
||||
},
|
||||
connections: {
|
||||
title: "Relations",
|
||||
|
||||
@@ -348,7 +348,6 @@ export const ja: TranslationResources = {
|
||||
added: "追加",
|
||||
started: "開始",
|
||||
completed: "完了",
|
||||
reopened: "再開",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1573,6 +1572,10 @@ export const ja: TranslationResources = {
|
||||
title: "直接接続",
|
||||
description: "ローカルネットワークまたはVPN。",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "リモート SSH",
|
||||
description: "デスクトップの SSH クライアント経由で接続します。",
|
||||
},
|
||||
scanQr: {
|
||||
title: "QRコードをスキャン",
|
||||
description: "暗号化されたリレー接続。",
|
||||
@@ -1624,6 +1627,23 @@ export const ja: TranslationResources = {
|
||||
details: "詳細: {{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "リモート SSH",
|
||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||
fields: {
|
||||
target: "SSH ホスト",
|
||||
},
|
||||
actions: {
|
||||
cancel: "キャンセル",
|
||||
connect: "接続",
|
||||
connecting: "接続中...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "SSH ホストは必須です",
|
||||
invalidTarget: "有効な ssh:// ホストを入力してください",
|
||||
failedToConnect: "SSH で接続できません。{{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "ペアリングリンクを貼り付け",
|
||||
helper: "サーバーからのペアリングリンクを貼り付けてください。",
|
||||
@@ -2263,6 +2283,7 @@ export const ja: TranslationResources = {
|
||||
badges: {
|
||||
relay: "リレー",
|
||||
local: "ローカル",
|
||||
remoteSsh: "リモート SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "接続",
|
||||
|
||||
@@ -346,7 +346,6 @@ export const ko: TranslationResources = {
|
||||
added: "추가됨",
|
||||
started: "시작됨",
|
||||
completed: "완료됨",
|
||||
reopened: "다시 열림",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1568,6 +1567,10 @@ export const ko: TranslationResources = {
|
||||
title: "직접 연결",
|
||||
description: "로컬 네트워크 또는 VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "원격 SSH",
|
||||
description: "데스크톱 SSH 클라이언트를 통해 연결합니다.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "QR 코드 스캔",
|
||||
description: "암호화된 릴레이 연결.",
|
||||
@@ -1618,6 +1621,23 @@ export const ko: TranslationResources = {
|
||||
details: "세부 정보: {{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "원격 SSH",
|
||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||
fields: {
|
||||
target: "SSH 호스트",
|
||||
},
|
||||
actions: {
|
||||
cancel: "취소",
|
||||
connect: "연결",
|
||||
connecting: "연결 중...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "SSH 호스트가 필요합니다",
|
||||
invalidTarget: "유효한 ssh:// 호스트를 입력하세요",
|
||||
failedToConnect: "SSH로 연결할 수 없습니다. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "페어링 링크 붙여넣기",
|
||||
helper: "서버에서 받은 페어링 링크를 붙여넣으세요.",
|
||||
@@ -2254,6 +2274,7 @@ export const ko: TranslationResources = {
|
||||
badges: {
|
||||
relay: "릴레이",
|
||||
local: "로컬",
|
||||
remoteSsh: "원격 SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "연결",
|
||||
|
||||
@@ -348,7 +348,6 @@ export const ptBR: TranslationResources = {
|
||||
added: "Adicionada",
|
||||
started: "Iniciada",
|
||||
completed: "Concluída",
|
||||
reopened: "Reaberta",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1588,6 +1587,10 @@ export const ptBR: TranslationResources = {
|
||||
title: "Conexão direta",
|
||||
description: "Rede local ou VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH remoto",
|
||||
description: "Conecte-se pelo cliente SSH do desktop.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "Escanear QR code",
|
||||
description: "Conexão relay criptografada.",
|
||||
@@ -1640,6 +1643,23 @@ export const ptBR: TranslationResources = {
|
||||
details: "Detalhes: {{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH remoto",
|
||||
helper: "Conecte-se a um daemon Paseo no host remoto.",
|
||||
fields: {
|
||||
target: "Host SSH",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Cancelar",
|
||||
connect: "Conectar",
|
||||
connecting: "Conectando...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "O host SSH é obrigatório",
|
||||
invalidTarget: "Insira um host ssh:// válido",
|
||||
failedToConnect: "Não foi possível conectar por SSH. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "Colar link de pareamento",
|
||||
helper: "Cole o link de pareamento do seu servidor.",
|
||||
@@ -2279,6 +2299,7 @@ export const ptBR: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relay",
|
||||
local: "Local",
|
||||
remoteSsh: "SSH remoto",
|
||||
},
|
||||
connections: {
|
||||
title: "Conexões",
|
||||
|
||||
@@ -347,7 +347,6 @@ export const ru: TranslationResources = {
|
||||
added: "Добавлена",
|
||||
started: "Начата",
|
||||
completed: "Завершена",
|
||||
reopened: "Возобновлена",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1585,6 +1584,10 @@ export const ru: TranslationResources = {
|
||||
title: "Прямое подключение",
|
||||
description: "Локальная сеть или VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "Удалённый SSH",
|
||||
description: "Подключение через SSH-клиент настольного приложения.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "Сканировать QR-код",
|
||||
description: "Зашифрованное подключение через ретранслятор.",
|
||||
@@ -1637,6 +1640,23 @@ export const ru: TranslationResources = {
|
||||
details: "Подробности: {{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "Удалённый SSH",
|
||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||
fields: {
|
||||
target: "Хост SSH",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Отмена",
|
||||
connect: "Подключить",
|
||||
connecting: "Подключение...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "Укажите хост SSH",
|
||||
invalidTarget: "Укажите корректный хост ssh://",
|
||||
failedToConnect: "Не удалось подключиться по SSH. {{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "Вставить ссылку для сопряжения",
|
||||
helper: "Вставьте ссылку на сопряжение с вашего сервера.",
|
||||
@@ -2281,6 +2301,7 @@ export const ru: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Ретранслятор",
|
||||
local: "Локальный",
|
||||
remoteSsh: "Удалённый SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "Подключения",
|
||||
|
||||
@@ -345,7 +345,6 @@ export const zhCN: TranslationResources = {
|
||||
added: "已添加",
|
||||
started: "已开始",
|
||||
completed: "已完成",
|
||||
reopened: "已重新打开",
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
@@ -1541,6 +1540,10 @@ export const zhCN: TranslationResources = {
|
||||
title: "直接连接",
|
||||
description: "本地网络或 VPN。",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "远程 SSH",
|
||||
description: "通过桌面 SSH 客户端连接。",
|
||||
},
|
||||
scanQr: {
|
||||
title: "扫描二维码",
|
||||
description: "加密 relay 连接。",
|
||||
@@ -1591,6 +1594,23 @@ export const zhCN: TranslationResources = {
|
||||
details: "详情:{{detail}}",
|
||||
},
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "远程 SSH",
|
||||
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
||||
fields: {
|
||||
target: "SSH 主机",
|
||||
},
|
||||
actions: {
|
||||
cancel: "取消",
|
||||
connect: "连接",
|
||||
connecting: "正在连接...",
|
||||
},
|
||||
errors: {
|
||||
targetRequired: "SSH 主机为必填项",
|
||||
invalidTarget: "请输入有效的 ssh:// 主机",
|
||||
failedToConnect: "无法通过 SSH 连接。{{detail}}",
|
||||
},
|
||||
},
|
||||
link: {
|
||||
title: "粘贴配对链接",
|
||||
helper: "粘贴来自 server 的配对链接。",
|
||||
@@ -2218,6 +2238,7 @@ export const zhCN: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relay",
|
||||
local: "本地",
|
||||
remoteSsh: "远程 SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "连接",
|
||||
|
||||
@@ -51,7 +51,10 @@ export type KeyboardActionId =
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin";
|
||||
| "workspace.pin"
|
||||
// Command-center only: no keybind, so these are absent from route-shortcut.ts.
|
||||
| "workspace.rename"
|
||||
| "workspace.setup.show";
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "agent.interrupt"; scope: KeyboardActionScope }
|
||||
@@ -106,7 +109,9 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.archive"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pin"; scope: KeyboardActionScope };
|
||||
| { id: "workspace.pin"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.rename"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.setup.show"; scope: KeyboardActionScope };
|
||||
|
||||
export interface KeyboardActionHandler {
|
||||
handlerId: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TFunction } from "i18next";
|
||||
import { SquarePen } from "lucide-react-native";
|
||||
import React, {
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -14,7 +15,6 @@ import React, {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
@@ -22,6 +22,7 @@ import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { KeyboardDock } from "@/components/keyboard-dock";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { Composer } from "@/composer";
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
MAX_CONTENT_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import {
|
||||
@@ -57,7 +58,6 @@ import {
|
||||
useAgentScreenStateMachine,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
|
||||
import {
|
||||
@@ -885,9 +885,6 @@ function ChatAgentContent({
|
||||
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
|
||||
}, [attentionController.clearOnAgentBlur]);
|
||||
|
||||
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
const shouldPresentReconnectToast =
|
||||
isPaneVisible && connectionStatus !== "online" && connectionStatus !== "idle";
|
||||
|
||||
@@ -1169,11 +1166,6 @@ function ChatAgentContent({
|
||||
visibilityCatchUpStatus,
|
||||
]);
|
||||
|
||||
const animatedContentStyle = useMemo(
|
||||
() => [animatedStaticStyles.content, animatedKeyboardStyle],
|
||||
[animatedKeyboardStyle],
|
||||
);
|
||||
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
@@ -1210,7 +1202,6 @@ function ChatAgentContent({
|
||||
toast={toastState}
|
||||
dismiss={dismissToast}
|
||||
streamViewRef={streamViewRef}
|
||||
animatedContentStyle={animatedContentStyle}
|
||||
handleComposerHeightChange={handleComposerHeightChange}
|
||||
handleMessageSent={handleMessageSent}
|
||||
handleRewindComplete={handleRewindComplete}
|
||||
@@ -1240,7 +1231,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
toast,
|
||||
dismiss,
|
||||
streamViewRef,
|
||||
animatedContentStyle,
|
||||
handleComposerHeightChange,
|
||||
handleMessageSent,
|
||||
handleRewindComplete,
|
||||
@@ -1266,7 +1256,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
animatedContentStyle: object[];
|
||||
handleComposerHeightChange: (height: number) => void;
|
||||
handleMessageSent: () => void;
|
||||
handleRewindComplete: () => void;
|
||||
@@ -1362,7 +1351,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</RenderProfile>
|
||||
);
|
||||
const streamContent = (
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
||||
<View style={animatedStaticStyles.content}>
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
@@ -1391,7 +1380,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
hasPluginComposerPills={hasPluginComposerPills}
|
||||
/>
|
||||
) : null}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
);
|
||||
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
|
||||
|
||||
@@ -1402,7 +1391,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onRewindComplete={handleRewindComplete}
|
||||
>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||
<DockedChatSurface disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
|
||||
{showHistorySyncError ? (
|
||||
@@ -1437,7 +1426,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : null}
|
||||
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
||||
</FileDropZone>
|
||||
</DockedChatSurface>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
@@ -1451,6 +1440,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
);
|
||||
});
|
||||
|
||||
function DockedChatSurface({ children, disabled }: { children: ReactNode; disabled: boolean }) {
|
||||
return (
|
||||
<KeyboardDock style={styles.container}>
|
||||
<FileDropZone style={styles.container} disabled={disabled}>
|
||||
{children}
|
||||
</FileDropZone>
|
||||
</KeyboardDock>
|
||||
);
|
||||
}
|
||||
|
||||
const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef,
|
||||
serverId,
|
||||
@@ -1702,26 +1701,19 @@ function ActiveAgentComposer({
|
||||
],
|
||||
);
|
||||
|
||||
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
|
||||
const inputAreaStyle = useMemo(
|
||||
() => [
|
||||
animatedStaticStyles.inputAreaWrapper,
|
||||
{ paddingBottom: insets.bottom },
|
||||
composerKeyboardStyle,
|
||||
],
|
||||
[insets.bottom, composerKeyboardStyle],
|
||||
() => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
blurOnSubmit={isNative}
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.editText}
|
||||
@@ -1742,7 +1734,7 @@ function ActiveAgentComposer({
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1823,6 +1815,7 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
},
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { usePluginHostNavigation } from "./host-navigation";
|
||||
|
||||
vi.mock("@/utils/navigate-to-agent", () => ({
|
||||
navigateToAgent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const navigateToAgentMock = vi.mocked(navigateToAgent);
|
||||
const navigateToWorkspaceMock = vi.mocked(navigateToWorkspace);
|
||||
|
||||
describe("usePluginHostNavigation", () => {
|
||||
beforeEach(() => {
|
||||
navigateToAgentMock.mockReset();
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
});
|
||||
|
||||
it("opens agents and workspaces on the rendering host", () => {
|
||||
const { result } = renderHook(() => usePluginHostNavigation("host-1"));
|
||||
|
||||
act(() => result.current.openAgent({ agentId: "agent-1" }));
|
||||
act(() => result.current.openWorkspace({ workspaceId: "workspace-1" }));
|
||||
|
||||
expect(navigateToAgentMock).toHaveBeenCalledWith({ serverId: "host-1", agentId: "agent-1" });
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith({
|
||||
serverId: "host-1",
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the capability stable until the rendering host changes", () => {
|
||||
const { result, rerender } = renderHook(({ serverId }) => usePluginHostNavigation(serverId), {
|
||||
initialProps: { serverId: "host-1" },
|
||||
});
|
||||
const initialNavigation = result.current;
|
||||
|
||||
rerender({ serverId: "host-1" });
|
||||
expect(result.current).toBe(initialNavigation);
|
||||
|
||||
rerender({ serverId: "host-2" });
|
||||
expect(result.current).not.toBe(initialNavigation);
|
||||
|
||||
act(() => result.current.openAgent({ agentId: "agent-2" }));
|
||||
act(() => result.current.openWorkspace({ workspaceId: "workspace-2" }));
|
||||
expect(navigateToAgentMock).toHaveBeenCalledWith({ serverId: "host-2", agentId: "agent-2" });
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith({
|
||||
serverId: "host-2",
|
||||
workspaceId: "workspace-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PluginSurfaceProps } from "@getpaseo/plugin";
|
||||
import { useMemo } from "react";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export function usePluginHostNavigation(
|
||||
serverId: string,
|
||||
): NonNullable<PluginSurfaceProps["navigation"]> {
|
||||
return useMemo(
|
||||
() => ({
|
||||
openAgent: ({ agentId }) => navigateToAgent({ serverId, agentId }),
|
||||
openWorkspace: ({ workspaceId }) => navigateToWorkspace({ serverId, workspaceId }),
|
||||
}),
|
||||
[serverId],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./evaluate", () => ({
|
||||
evaluatePluginClientBundle: vi.fn(),
|
||||
}));
|
||||
vi.mock("./composer-pills/lifecycle", () => ({
|
||||
startPluginClientSide: vi.fn(),
|
||||
}));
|
||||
|
||||
import { usePluginInstallations } from "./registry";
|
||||
|
||||
it("keeps plugin installation selections stable while the registry is unchanged", () => {
|
||||
const { result, rerender } = renderHook(() => usePluginInstallations("missing-plugin"));
|
||||
const initialInstallations = result.current;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(initialInstallations);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { startPluginClientSide } from "./composer-pills/lifecycle";
|
||||
@@ -166,5 +166,6 @@ export function useInstalledPlugin(serverId: string, pluginId: string): Installe
|
||||
}
|
||||
|
||||
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
|
||||
return useInstalledPlugins().filter((plugin) => plugin.id === pluginId);
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user