From 6cbf7859ac120687f514889f5f8fe731dff7c0b7 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sun, 30 Aug 2026 11:34:29 -0300 Subject: [PATCH 01/32] chore(release): prepare Jig v0.2.0 Synchronize Cargo, npm, Tauri, and protocol catalog versions at 0.2.0. Refresh packaging, installation, known-issue, and release documentation for Jig and add the v0.2.0 changelog. Also satisfy the Rust 1.98 Clippy gate that currently fails on main. --- .github/workflows/packaging.yml | 2 +- CHANGELOG.md | 28 ++++++++++++++++++++++++++ Cargo.lock | 18 ++++++++--------- Cargo.toml | 2 +- README.md | 6 +++--- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- crates/session/src/replay.rs | 2 +- docs/KNOWN_ISSUES.md | 23 ++++----------------- docs/PACKAGING.md | 10 ++++----- docs/RELEASE_CHECKLIST.md | 8 ++++---- docs/backup-and-recovery.md | 2 +- docs/desktop/daemon-sidecar.md | 12 +++++------ docs/first-use.md | 15 +++++++------- docs/install.md | 22 ++++++++++---------- docs/troubleshooting.md | 12 +++++------ docs/uninstall.md | 4 ++-- package.json | 2 +- protocol/catalog.json | 2 +- 19 files changed, 93 insertions(+), 81 deletions(-) diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 02df327..5de7409 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -89,7 +89,7 @@ jobs: - name: Upload unsigned installable artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: cli-master-v0.1.0-${{ matrix.artifact }} + name: jig-v0.2.0-${{ matrix.artifact }} path: dist/artifacts/ if-no-files-found: error retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b894f..29ab13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.2.0 Beta + +Jig now delivers the full local project-to-terminal workflow through the +packaged desktop and daemon. + +### Highlights + +- Added the spatial canvas with persistent, movable, resizable terminal cards, + connections, map controls, and terminal presets. +- Connected project, agent, Git, worktree, and live terminal operations through + the versioned Tauri and daemon IPC bridge. +- Added native repository selection, agent settings, diagnostics, session + recovery, bounded terminal replay, and safe worktree removal. +- Rebranded the desktop experience as Jig and refreshed its app identity. +- Expanded Linux and macOS acceptance coverage and hardened process, path, + confirmation, and error handling. + +### Distribution + +- Linux: unsigned AppImage. +- macOS: unsigned Apple Silicon `.app` and `.dmg`. +- Protocol version remains `1`; application and daemon version is `0.2.0`. +- Windows, code signing, and macOS notarization remain out of scope. + +See [docs/install.md](docs/install.md), +[docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md), and +[docs/RELEASE_CHECKLIST.md](docs/RELEASE_CHECKLIST.md). + ## 0.1.0 Beta packaging Unsigned Linux AppImage and macOS `.app` / `.dmg` builds. The session diff --git a/Cargo.lock b/Cargo.lock index d418288..618eeae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,7 +479,7 @@ dependencies = [ [[package]] name = "cli-master-agents" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cli-master-core", "nix 0.30.1", @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "cli-master-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -500,7 +500,7 @@ dependencies = [ [[package]] name = "cli-master-daemon" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64 0.22.1", "cli-master-agents", @@ -523,7 +523,7 @@ dependencies = [ [[package]] name = "cli-master-desktop" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cli-master-core", "cli-master-daemon", @@ -539,7 +539,7 @@ dependencies = [ [[package]] name = "cli-master-e2e" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cli-master-core", "cli-master-daemon", @@ -554,7 +554,7 @@ dependencies = [ [[package]] name = "cli-master-fake-agent" -version = "0.1.0" +version = "0.2.0" dependencies = [ "rustix", "signal-hook", @@ -562,7 +562,7 @@ dependencies = [ [[package]] name = "cli-master-git" -version = "0.1.0" +version = "0.2.0" dependencies = [ "tempfile", "unicode-normalization", @@ -570,7 +570,7 @@ dependencies = [ [[package]] name = "cli-master-session" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "cli-master-core", @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "cli-master-storage" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "cli-master-core", diff --git a/Cargo.toml b/Cargo.toml index 8373273..145602f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ resolver = "2" [workspace.package] edition = "2024" rust-version = "1.85" -version = "0.1.0" +version = "0.2.0" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 9e90f13..9bee3a4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Terminals on a canvas. ## Install and run -Jig is a local-first desktop app for Linux and macOS. Windows is out of scope for Beta v0.1. +Jig is a local-first desktop app for Linux and macOS. Windows is out of scope for Beta v0.2. ### Prerequisites @@ -40,7 +40,7 @@ Builds are unsigned. macOS notarization is not configured. See [docs/install.md] Jig hosts coding-agent CLIs in real terminals with projects and Git worktrees. It coordinates OpenAI Codex, Claude Code, Gemini CLI, OpenCode, and custom executables in isolated PTY sessions. -**Current status:** Beta v0.1 architecture exists. The daemon, PTY management, and storage layers are operational. The IPC protocol is defined in `crates/core/src/wire`. Session creation, worktree isolation, and the terminal canvas UI are in active development. See [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md) for the current domain-IPC gap and [ARCHITECTURE.md](ARCHITECTURE.md) for accepted design decisions. +**Current status:** Beta v0.2 connects project management, isolated worktrees, agent discovery, live PTY sessions, and the terminal canvas through the packaged desktop and daemon. The stable IPC protocol is defined in `crates/core/src/wire`. See [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md) for current limitations and [ARCHITECTURE.md](ARCHITECTURE.md) for accepted design decisions. This is a local-first application. No cloud account, telemetry, or vendor proxy is required. Each agent CLI keeps its own authentication. @@ -65,7 +65,7 @@ The separate daemon owns live PTYs and SQLite. Closing the desktop window does n - **Linux:** First-class. AppImage is the initial package format. - **macOS:** First-class on Apple Silicon and supported modern releases. `.app` and `.dmg` artifacts. -- **Windows:** Out of scope for Beta v0.1. +- **Windows:** Out of scope for Beta v0.2. ## Validate changes diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fa60765..ee8165b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@cli-master/desktop", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9155626..dba8c0f 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Jig", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.guicybercode.jig", "build": { "beforeDevCommand": "pnpm dev:tauri", diff --git a/crates/session/src/replay.rs b/crates/session/src/replay.rs index 075cffe..0eddde4 100644 --- a/crates/session/src/replay.rs +++ b/crates/session/src/replay.rs @@ -115,7 +115,7 @@ mod tests { fn replay_evicts_by_chunk_count() { let session_id = SessionId::new(); let mut replay = ReplayBuffer::new(32, 2); - for value in [b'a', b'b', b'c'] { + for value in *b"abc" { replay.append(session_id, vec![value], 1).unwrap(); } diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 204841c..d62c703 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -1,39 +1,24 @@ -# Known issues for Beta v0.1 +# Known issues for Beta v0.2 This page lists current product and distribution limits. It does not claim coverage from tests that do not exercise the packaged desktop application. -## Project and session flows are not wired through daemon IPC - -The React UI includes **Repository path**, project workspace, session controls, -and `TerminalGrid`, but the packaged daemon currently returns an empty state -snapshot and `method_not_implemented` for the project, agent, session, and -worktree domain mutations. The packaged desktop therefore cannot yet add or -list a project, create a live session, or populate the terminal grid through -the production IPC path. There is also no native folder picker. - -The corresponding manual release-checklist items must remain unchecked until -that domain bridge is implemented. Rust acceptance tests cover the underlying -services; they are not evidence that these window-level product flows work. - ## Daemon restart cannot reattach terminals At the runtime layer, dropping and recreating a subscriber can preserve a session while the daemon remains alive. If the daemon itself exits, its PTY handles are lost. Startup recovery marks affected session metadata as -`unknown` and does not reattach to, or signal, a stored PID. The packaged -desktop cannot exercise this lifecycle until the domain IPC gap above is -closed. +`unknown` and does not reattach to, or signal, a stored PID. ## Signing and notarization are not configured Release packaging was introduced by -[PR #16](https://github.com/guicybercode/cli-master/pull/16), but the repository +[PR #16](https://github.com/guicybercode/Jig/pull/16), but the repository does not contain a signing identity, certificate, or macOS notarization credentials. Treat generated macOS bundles as unsigned until a separate human-controlled signing process is established. ## Package support is limited to Beta platforms -AppImage is the supported Linux package format for Beta v0.1. Windows is +AppImage is the supported Linux package format for Beta v0.2. Windows is outside the Beta scope; no `.exe`, MSI, or NSIS package is produced or tested. diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index b2a3c98..4a73ef4 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -1,6 +1,6 @@ -# Packaging for Beta v0.1 +# Packaging for Beta v0.2 -CLI Master ships two Rust executables: the Tauri desktop process and +Jig ships two Rust executables: the Tauri desktop process and `cli-masterd`. The daemon is a Tauri `externalBin` sidecar named `cli-masterd-` under `apps/desktop/src-tauri/binaries/`. Those staged files are gitignored. @@ -33,7 +33,7 @@ Configured in `apps/desktop/src-tauri/tauri.conf.json`: | macOS | `.app` and `.dmg` | | Windows | Not built | -Identifier: `com.guicybercode.climaster`. Category: DeveloperTool. +Identifier: `com.guicybercode.jig`. Category: DeveloperTool. `cli-masterd` is copied next to the desktop binary inside the AppImage and inside `Contents/MacOS` on macOS. `scripts/smoke-bundle.sh` fails if that @@ -57,7 +57,7 @@ file is missing, then runs `cli-masterd --version` and `--preflight`. `.github/workflows/packaging.yml` runs on pull requests, `main`, and `workflow_dispatch`. Each OS job uploads -`cli-master-v0.1.0-` with the installable files and checksums. +`jig-v0.2.0-` with the installable files and checksums. Retention is 14 days. The workflow has `contents: read` only. It cannot publish a Release and it @@ -86,4 +86,4 @@ AppImages are valid for this Beta as long as checksums are published. - `apps/desktop/src-tauri/tauri.conf.json` - `protocol/catalog.json` `applicationVersion` -to equal `0.1.0`, with `protocolVersion` equal to `1`. +to equal `0.2.0`, with `protocolVersion` equal to `1`. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 8ca0682..e639423 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,4 +1,4 @@ -# Release checklist for Beta v0.1 +# Release checklist for Beta v0.2 Do this before anyone treats a build as the Beta drop. Do not skip a step by marking CI `continue-on-error`. Do not publish a GitHub Release from automation @@ -19,9 +19,9 @@ GUI acceptance below. - [ ] `scripts/check-versions.sh` passes. - [ ] `package.json`, `apps/desktop/package.json`, `tauri.conf.json`, `Cargo.toml`, and `protocol/catalog.json` `applicationVersion` are - `0.1.0`. + `0.2.0`. - [ ] `protocol/catalog.json` `protocolVersion` is `1`. -- [ ] `cli-masterd --version` prints `cli-masterd 0.1.0 (protocol 1)`. +- [ ] `cli-masterd --version` prints `cli-masterd 0.2.0 (protocol 1)`. - [ ] `Cargo.lock` and `pnpm-lock.yaml` are committed. ## Quality gate @@ -64,7 +64,7 @@ Every item below starts unchecked. A Vite/Playwright smoke run or a Rust acceptance test is useful evidence, but neither substitutes for exercising the real packaged GUI and bundled daemon. -- [ ] Install or mount the actual release-candidate package, launch CLI Master, +- [ ] Install or mount the actual release-candidate package, launch Jig, and confirm the packaged desktop connects to its bundled daemon. - [ ] Add an existing Git repository through **Repository path** and confirm that its path and current branch are displayed correctly. diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index c40cd33..f1d5fdf 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -43,7 +43,7 @@ truth for committed history. 3. Start the app again. Migrations are forward-only. A backup from a newer schema will not open in -an older Beta. Keep the matching `0.1.0` application version. +an older Beta. Keep the matching `0.2.0` application version. ## Daemon crash diff --git a/docs/desktop/daemon-sidecar.md b/docs/desktop/daemon-sidecar.md index ccd4e7b..2072b7e 100644 --- a/docs/desktop/daemon-sidecar.md +++ b/docs/desktop/daemon-sidecar.md @@ -37,19 +37,17 @@ wins: ### Linux -Packaged AppImage and future `.deb` layouts should place `cli-masterd` next to -the `CLI Master` executable, typically `usr/bin/cli-masterd` beside -`usr/bin/cli-master` (or the renamed product binary). The same-directory rule -covers that layout. `bundle.externalBin` is not configured yet; adding it -requires the file to exist at `tauri build` time. +Packaged AppImage layouts place `cli-masterd` next to the Jig desktop +executable. The same-directory rule covers that layout. `bundle.externalBin` +stages the daemon for `tauri build`. ### macOS The application bundle should contain: ```text -CLI Master.app/Contents/MacOS/CLI Master -CLI Master.app/Contents/MacOS/cli-masterd +Jig.app/Contents/MacOS/cli-master-desktop +Jig.app/Contents/MacOS/cli-masterd ``` `current_exe()` is the `MacOS` directory, so the sibling lookup finds the diff --git a/docs/first-use.md b/docs/first-use.md index f7da56b..4dccc81 100644 --- a/docs/first-use.md +++ b/docs/first-use.md @@ -30,19 +30,17 @@ creates Application Support, Caches, and Logs folders. Modes are `0700`. ## 3. Open the desktop app -Launch the AppImage or `CLI Master.app`. The window is the control center. +Launch the AppImage or `Jig.app`. The window is the control center. The session daemon is a separate executable packaged beside it. Closing the window is not the same as stopping agents. That is the product -rule. In this Beta the Tauri process can locate `cli-masterd`; the typed -IPC handshake (`system.hello`) is owned by the daemon and is not a Tauri -command named `protocol_info`. If a build still shows only the shell UI, -the sidecar is present for the next wiring step. Do not treat -`protocol_info` as proof that sessions are live. +rule. The Tauri process locates `cli-masterd` and connects through the typed +IPC handshake (`system.hello`). `protocol_info` only reports the local catalog; +use Diagnostics to confirm the daemon connection. ## 4. Add a Git project -Pick a repository you already have. CLI Master records metadata. It does +Pick a repository you already have. Jig records metadata. It does not copy the repo and it does not delete it when you remove the project from the app. @@ -67,6 +65,9 @@ Linux: macOS: +Jig keeps the legacy `CLI Master` directory names in Beta v0.2 so existing +installations continue to use their current data. + | Kind | Location | |---|---| | Database | `~/Library/Application Support/CLI Master/cli-master.db` | diff --git a/docs/install.md b/docs/install.md index 7d6e42e..a9baaa1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ -# Install CLI Master Beta v0.1 +# Install Jig Beta v0.2 -CLI Master is a local-first desktop app for Linux and macOS. Windows is out +Jig is a local-first desktop app for Linux and macOS. Windows is out of scope for this Beta. Installable artifacts come from GitHub Actions on this repository, not from @@ -14,7 +14,7 @@ an app store. Builds are **unsigned**. macOS notarization is not configured. without them. Sessions that need a missing CLI fail later, with an actionable error. -Do not put vendor API tokens into CLI Master. Each agent CLI keeps its own +Do not put vendor API tokens into Jig. Each agent CLI keeps its own login. ## Linux (AppImage) @@ -30,8 +30,8 @@ login. 3. Mark the AppImage executable and run it: ```bash - chmod +x "CLI Master_0.1.0_amd64.AppImage" - ./CLI\ Master_0.1.0_amd64.AppImage + chmod +x "Jig_0.2.0_amd64.AppImage" + ./Jig_0.2.0_amd64.AppImage ``` The AppImage includes `cli-masterd` next to the desktop binary. It does not @@ -39,20 +39,20 @@ vendor your whole desktop stack. If the window fails to open, install the WebKitGTK 4.1 packages for your distribution. See [the Tauri Linux prerequisites](https://v2.tauri.app/start/prerequisites/). -Debian packages are not a Beta v0.1 distribution format. AppImage is. +Debian packages are not a Beta v0.2 distribution format. AppImage is. ## macOS (application bundle and DMG) Apple Silicon is the CI target. Universal binaries are not produced. -1. Download the `.dmg` or `CLI Master.app.zip` plus `SHA256SUMS`. +1. Download the `.dmg` or `Jig.app.zip` plus `SHA256SUMS`. 2. Verify the checksum: ```bash shasum -a 256 -c SHA256SUMS ``` -3. Open the DMG and drag CLI Master to Applications, or unzip the `.app`. +3. Open the DMG and drag Jig to Applications, or unzip the `.app`. 4. Launch it from Applications. The first launch of an unsigned app is blocked by Gatekeeper. That is @@ -60,7 +60,7 @@ expected. Open System Settings, Privacy & Security, and allow the app after you have verified the checksum yourself. Notarization would remove this step. It is not wired up, and this repository does not contain certificates. -`cli-masterd` lives in `CLI Master.app/Contents/MacOS/cli-masterd`. +`cli-masterd` lives in `Jig.app/Contents/MacOS/cli-masterd`. ## Confirm the daemon binary @@ -72,7 +72,7 @@ cli-masterd --version cli-masterd --preflight ``` -`--version` must print `cli-masterd 0.1.0 (protocol 1)`. `--preflight` must +`--version` must print `cli-masterd 0.2.0 (protocol 1)`. `--preflight` must report Git as available. Missing Codex or Claude is fine. ## Build from source @@ -93,7 +93,7 @@ or creates a GitHub Release. | Step | Status | |---|---| -| Application version `0.1.0` | Locked across Cargo, npm, Tauri, and the protocol catalog | +| Application version `0.2.0` | Locked across Cargo, npm, Tauri, and the protocol catalog | | Linux AppImage | Built unsigned | | macOS `.app` / `.dmg` | Built unsigned, `signingIdentity` is `null` | | Hardened Runtime | Off until a Developer ID exists | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 97db9a5..267546a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,7 @@ ## `cli-masterd --preflight` says Git is missing -Install Git and launch CLI Master from a context that inherits the same +Install Git and launch Jig from a context that inherits the same `PATH`. macOS `.app` bundles do not automatically load your zshrc. `--preflight` prints the resolved Git path when detection succeeds. If you @@ -51,12 +51,12 @@ macOS: `~/Library/Logs/CLI Master/cli-masterd.json.log` Logs are JSON. They rotate after 10 MiB. They must not contain tokens, full environments, or terminal contents. If you see those, that is a bug. -## Desktop reports a protocol catalog but sessions do nothing +## Desktop cannot connect to the daemon -`protocol_info` is a Tauri command that mirrors the frozen method list. It -is not `system.hello`. Live sessions belong to `cli-masterd` over the Unix -socket. If that handshake is not connected in your build, the sidecar is -still the binary to run and inspect. +Open Diagnostics and confirm the `system.hello` handshake. `protocol_info` +only mirrors the frozen method list; it is not proof of a live connection. +Run `cli-masterd --preflight`, inspect the JSON log, and use the reconnect +action after correcting any path or permission error. ## Checksums fail diff --git a/docs/uninstall.md b/docs/uninstall.md index d41194a..0bb222f 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -1,6 +1,6 @@ # Uninstall -Uninstalling CLI Master removes the application. It does not remove your +Uninstalling Jig removes the application. It does not remove your Git repositories. ## Linux AppImage @@ -21,7 +21,7 @@ If you overrode XDG variables, delete those directories instead. ## macOS -Drag `CLI Master.app` to the Trash, or delete it from Applications. +Drag `Jig.app` to the Trash, or delete it from Applications. Optional: remove application state. diff --git a/package.json b/package.json index 168b61d..ab193dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cli-master", - "version": "0.1.0", + "version": "0.2.0", "private": true, "packageManager": "pnpm@11.9.0", "scripts": { diff --git a/protocol/catalog.json b/protocol/catalog.json index 3a5de91..fff6729 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -1,6 +1,6 @@ { "protocolVersion": 1, - "applicationVersion": "0.1.0", + "applicationVersion": "0.2.0", "methods": [ "system.hello", "state.snapshot", From 54399e43fb3a38f4f8e54d9e14219b396be5a68c Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sun, 30 Aug 2026 11:48:30 -0300 Subject: [PATCH 02/32] test(session): stabilize force-kill timing Keep the assertion tied to the two-second supervisor interval while using the production-like one-second reap deadline from the shared test configuration. This avoids treating a loaded macOS runner's inability to reap within 50 ms as a runtime failure. --- crates/session/tests/pty_sessions/lifecycle.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/session/tests/pty_sessions/lifecycle.rs b/crates/session/tests/pty_sessions/lifecycle.rs index 0010d70..bee8e27 100644 --- a/crates/session/tests/pty_sessions/lifecycle.rs +++ b/crates/session/tests/pty_sessions/lifecycle.rs @@ -71,9 +71,9 @@ fn stop_force_kills_a_process_that_ignores_graceful_signals() { #[test] fn force_kill_reaps_without_waiting_for_the_supervisor_interval() { + let supervisor_interval = Duration::from_secs(2); let config = SessionManagerConfig { - supervisor_interval: Duration::from_secs(2), - kill_wait: Duration::from_millis(50), + supervisor_interval, ..test_config() }; let runtime = TestRuntime::with_config(config); @@ -83,8 +83,12 @@ fn force_kill_reaps_without_waiting_for_the_supervisor_interval() { let started = Instant::now(); let snapshot = runtime.manager.kill(session_id).unwrap(); + let elapsed = started.elapsed(); - assert!(started.elapsed() < Duration::from_millis(500)); + assert!( + elapsed < supervisor_interval, + "force kill took {elapsed:?}, so it may have waited for the supervisor" + ); assert_eq!(snapshot.status, SessionStatus::Exited); let (terminal_transitions, exit_events) = count_terminal_events(&mut events); assert_eq!(terminal_transitions, 1); From 60cf3f485f6a88cf74fb4ba3d29d9e7ad6512a5d Mon Sep 17 00:00:00 2001 From: guicybercode Date: Wed, 2 Sep 2026 18:11:55 -0300 Subject: [PATCH 03/32] refactor(desktop): make canvas the only workspace --- apps/desktop/src/app/AppShell.test.tsx | 1108 ++++++++++++----- apps/desktop/src/app/AppShell.tsx | 525 +++++--- apps/desktop/src/app/components/AppHeader.tsx | 84 -- apps/desktop/src/app/components/Icon.tsx | 12 - .../features/canvas/CanvasWorkspace.test.tsx | 906 +++++++++++++- .../app/features/canvas/CanvasWorkspace.tsx | 1027 ++++++++++++--- .../app/features/canvas/canvas-state.test.ts | 134 ++ .../src/app/features/canvas/canvas-state.ts | 180 ++- .../navigation/CanvasSidebar.test.tsx | 75 +- .../app/features/navigation/CanvasSidebar.tsx | 46 +- .../features/navigation/ProjectSidebar.tsx | 131 -- .../features/navigation/SessionSidebar.tsx | 177 --- .../features/sessions/SessionWorkspace.tsx | 276 ---- .../app/features/status/LocalStatusBar.tsx | 4 +- .../src/app/features/workspace/Workspace.tsx | 155 +-- .../workspace/WorkspaceEmptyState.tsx | 42 - .../src/app/hooks/useGlobalShortcuts.ts | 10 - .../src/app/state/WorkspaceContext.tsx | 27 +- apps/desktop/src/styles/canvas.css | 464 ++++--- 19 files changed, 3687 insertions(+), 1696 deletions(-) delete mode 100644 apps/desktop/src/app/components/AppHeader.tsx delete mode 100644 apps/desktop/src/app/features/navigation/ProjectSidebar.tsx delete mode 100644 apps/desktop/src/app/features/navigation/SessionSidebar.tsx delete mode 100644 apps/desktop/src/app/features/sessions/SessionWorkspace.tsx delete mode 100644 apps/desktop/src/app/features/workspace/WorkspaceEmptyState.tsx diff --git a/apps/desktop/src/app/AppShell.test.tsx b/apps/desktop/src/app/AppShell.test.tsx index 89b0e41..75da46d 100644 --- a/apps/desktop/src/app/AppShell.test.tsx +++ b/apps/desktop/src/app/AppShell.test.tsx @@ -1,13 +1,12 @@ import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App"; import { IpcError } from "../ipc/client"; -import { CANVAS_STORAGE_KEY } from "./features/canvas/canvas-state"; import type { - AgentRecord, AgentDetection, + AgentRecord, BootstrapResult, Project, Session, @@ -19,18 +18,35 @@ import { EMPTY_BOOTSTRAP, type MockIpcClient, } from "../test/mockIpc"; +import { CANVAS_STORAGE_KEY } from "./features/canvas/canvas-state"; vi.mock("./features/terminal/LiveTerminal", () => ({ - LiveTerminal: ({ session }: { session: Session }) => ( -
+ LiveTerminal: ({ session }: { readonly session: Session }) => ( +
), })); const TEST_TIME = 1_725_000_000_000; +const viewportScrollTo = vi.fn(); -describe("AppShell project and session workflows", () => { - it("starts a terminal inside the canvas without opening the session view", async () => { +describe("AppShell canvas workflows", () => { + beforeEach(() => { localStorage.removeItem(CANVAS_STORAGE_KEY); + localStorage.removeItem("cli-master.canvas.sidebar-collapsed"); + viewportScrollTo.mockReset(); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + writable: true, + value: viewportScrollTo, + }); + }); + + it("starts a terminal inside the canvas without changing workspace", async () => { const project = createProject(); const shellAgent: AgentRecord = { ...createAgent(), @@ -60,16 +76,15 @@ describe("AppShell project and session workflows", () => { }); const user = userEvent.setup(); render(); - const terminal = await screen.findByRole("article", { - name: "Terminal 1, terminal canvas item", - }); + const terminal = await sessionNode("Terminal 1"); await user.click( within(terminal).getByRole("button", { name: "Start terminal" }), ); - expect(await screen.findByTestId("live-terminal-canvas-terminal-session")) - .toBeVisible(); + expect( + await screen.findByTestId("live-terminal-canvas-terminal-session"), + ).toBeVisible(); expect(screen.getByRole("main")).toHaveClass("canvas-workspace"); expect( screen.getByRole("heading", { name: project.name, level: 1 }), @@ -80,15 +95,17 @@ describe("AppShell project and session workflows", () => { }); }); - it("keeps settings inside the minimal canvas shell", async () => { + it("keeps persistent canvas chrome around settings", async () => { const client = createMockIpcClient({ bootstrap: EMPTY_BOOTSTRAP }); const user = await renderApp(client); + expectPersistentChrome(); await user.click(screen.getByRole("button", { name: "Settings" })); - const shell = document.querySelector(".app-shell"); - expect(shell).toHaveClass("app-shell--canvas"); - expect(screen.getByRole("heading", { name: "Settings", level: 1 })).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Settings", level: 1 }), + ).toBeVisible(); + expectPersistentChrome(); expect(screen.getByRole("button", { name: "Settings" })).toHaveAttribute( "aria-current", "page", @@ -113,7 +130,7 @@ describe("AppShell project and session workflows", () => { expect(contextMenu.defaultPrevented).toBe(true); }); - it("keeps diagnostics inside the minimal canvas shell", async () => { + it("keeps persistent canvas chrome around diagnostics", async () => { const client = createMockIpcClient({ bootstrap: EMPTY_BOOTSTRAP, handlers: { @@ -132,14 +149,10 @@ describe("AppShell project and session workflows", () => { }); const user = await renderApp(client); - await user.click(screen.getByRole("button", { name: "Settings" })); - await user.click(screen.getByRole("button", { name: "Back to canvas" })); await user.click(screen.getByRole("button", { name: "Open diagnostics" })); - expect(document.querySelector(".app-shell")).toHaveClass( - "app-shell--canvas", - ); expect(await screen.findByText("/data/cli-master")).toBeVisible(); + expectPersistentChrome(); expect( screen.getByRole("button", { name: "Open diagnostics" }), ).toHaveAttribute("aria-current", "page"); @@ -147,8 +160,7 @@ describe("AppShell project and session workflows", () => { expect(document.querySelector(".session-pane")).not.toBeInTheDocument(); }); - it("hides and restores the canvas workspace sidebar", async () => { - localStorage.removeItem("cli-master.canvas.sidebar-collapsed"); + it("hides and restores the persistent CanvasSidebar", async () => { const client = createMockIpcClient({ bootstrap: EMPTY_BOOTSTRAP }); const user = userEvent.setup(); const { container } = render(); @@ -156,22 +168,36 @@ describe("AppShell project and session workflows", () => { await user.click( await screen.findByRole("button", { name: "Hide workspace sidebar" }), ); + expect(container.querySelector(".app-shell")).toHaveClass( "app-shell--canvas-sidebar-collapsed", ); expect(localStorage.getItem("cli-master.canvas.sidebar-collapsed")).toBe( "true", ); + expect( + screen.getByRole("complementary", { + name: "Canvas workspaces", + hidden: true, + }), + ).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Show workspace sidebar" }), ); + expect(container.querySelector(".app-shell")).not.toHaveClass( "app-shell--canvas-sidebar-collapsed", ); expect(localStorage.getItem("cli-master.canvas.sidebar-collapsed")).toBe( "false", ); + expect( + screen.getByRole("complementary", { name: "Canvas workspaces" }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Hide navigation" }), + ).toHaveAttribute("aria-expanded", "true"); }); it("adds a daemon-validated project and selects it", async () => { @@ -186,11 +212,7 @@ describe("AppShell project and session workflows", () => { }); const user = await renderApp(client); - await user.click( - within(screen.getByRole("main")).getByRole("button", { - name: "Add Project", - }), - ); + await user.click(screen.getByRole("button", { name: "Add Project" })); const dialog = await screen.findByRole("dialog", { name: "Add Project" }); await user.click( within(dialog).getByRole("button", { name: "Enter a path manually" }), @@ -213,22 +235,26 @@ describe("AppShell project and session workflows", () => { name: "CLI Master", }); expect( - await within(screen.getByRole("main")).findByRole("heading", { + await screen.findByRole("heading", { name: "CLI Master", level: 1, }), ).toBeVisible(); - expect(screen.getByText("/Users/test/code/cli-master")).toBeVisible(); + expect( + within(screen.getByRole("navigation", { name: "Workspaces" })).getByRole( + "button", + { name: /^CLI Master/ }, + ), + ).toHaveAttribute("aria-current", "page"); expect( screen.queryByRole("dialog", { name: "Add Project" }), ).not.toBeInTheDocument(); }); - it("switches projects without showing sessions from the previous project", async () => { + it("filters canvas nodes when switching projects", async () => { const firstProject = createProject({ id: "project-one", name: "First Repository", - currentBranch: "main", lastOpenedAtMs: TEST_TIME + 20, }); const secondProject = createProject({ @@ -244,47 +270,78 @@ describe("AppShell project and session workflows", () => { projectId: firstProject.id, name: "First project session", }); + const secondSession = createSession({ + id: "session-two", + projectId: secondProject.id, + name: "Second project session", + cwd: "/repos/second", + branch: "feature/two", + }); const client = createMockIpcClient({ bootstrap: createBootstrap({ projects: [firstProject, secondProject], - sessions: [firstSession], + sessions: [firstSession, secondSession], }), }); const user = await renderApp(client); + const firstTerminal = await sessionNode(firstSession.name); + expect(firstTerminal).toBeVisible(); + await user.click(firstTerminal); + expect(firstTerminal).toHaveAttribute("data-selected", "true"); expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "First Repository", - level: 1, + screen.queryByRole("article", { + name: secondSession.name + ", terminal canvas item", }), - ).toBeVisible(); - expect(screen.getByText("First project session")).toBeVisible(); + ).not.toBeInTheDocument(); - const projects = screen.getByRole("navigation", { name: "Recent projects" }); + const projects = screen.getByRole("navigation", { name: "Workspaces" }); await user.click( within(projects).getByRole("button", { - name: /^Second Repositoryfeature\/two$/, + name: /^Second Repository/, }), ); + expect(await sessionNode(secondSession.name)).toBeVisible(); + await waitFor(() => { + expect( + screen.queryByRole("article", { + name: firstSession.name + ", terminal canvas item", + }), + ).not.toBeInTheDocument(); + }); expect( - within(screen.getByRole("main")).getByRole("heading", { + screen.getByRole("heading", { name: "Second Repository", level: 1, }), ).toBeVisible(); - expect(screen.queryByText("First project session")).not.toBeInTheDocument(); expect( within(projects).getByRole("button", { - name: /^Second Repositoryfeature\/two$/, + name: /^Second Repository/, }), ).toHaveAttribute("aria-current", "page"); + + await user.click( + within(projects).getByRole("button", { + name: /^First Repository/, + }), + ); + const returnedFirstTerminal = await sessionNode(firstSession.name); + expect(returnedFirstTerminal).not.toHaveAttribute("data-selected"); + const palette = await openCommandPalette(user); + expect( + within(palette).getByRole("option", { name: /Rename Session/ }), + ).toHaveAttribute("aria-disabled", "true"); }); - it("opens New Session and blocks invalid required and relative-path input", async () => { + it("opens New Session and blocks invalid required and relative paths", async () => { const project = createProject(); const client = createMockIpcClient({ - bootstrap: createBootstrap({ projects: [project], agents: [createAgent()] }), + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + }), }); const user = await renderApp(client); @@ -305,7 +362,9 @@ describe("AppShell project and session workflows", () => { within(dialog).getByRole("textbox", { name: /Session name/ }), "Test repository flow", ); - const directory = within(dialog).getByRole("textbox", { name: /Subdirectory/ }); + const directory = within(dialog).getByRole("textbox", { + name: /Subdirectory/, + }); await user.type(directory, "../outside"); await user.click( within(dialog).getByRole("button", { name: "Review Session" }), @@ -337,20 +396,24 @@ describe("AppShell project and session workflows", () => { await openNewSession(user); const dialog = await screen.findByRole("dialog", { name: "New Session" }); + expect(within(dialog).getByText(/Executable not found: Codex/)).toBeVisible(); expect( within(dialog).getByRole("option", { name: "Codex — unavailable" }), ).toBeDisabled(); - expect(within(dialog).getByRole("combobox", { name: /Agent/ })).toHaveValue( - "__custom_agent__", - ); + expect( + within(dialog).getByRole("combobox", { name: /Agent/ }), + ).toHaveValue("__custom_agent__"); }); - it("submits a synchronously resolved session creation only once", async () => { + it("creates one canvas node for a synchronously resolved session", async () => { const project = createProject(); const createdSession = createSession({ name: "Fast session" }); const client = createMockIpcClient({ - bootstrap: createBootstrap({ projects: [project], agents: [createAgent()] }), + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + }), handlers: { createSession: () => Promise.resolve(createdSession), }, @@ -372,20 +435,19 @@ describe("AppShell project and session workflows", () => { isolation: "current", relativeDirectory: undefined, }); - expect( - await within(screen.getByRole("main")).findByRole("heading", { - name: "Fast session", - level: 1, - }), - ).toBeVisible(); + const node = await sessionNode("Fast session"); + expect(node).toHaveAttribute("data-canvas-session-id", createdSession.id); }); - it("keeps a deferred session creation locked against duplicate submission", async () => { + it("keeps deferred session creation locked and adds its node once", async () => { const project = createProject(); const createdSession = createSession({ name: "Deferred session" }); const creation = deferred(); const client = createMockIpcClient({ - bootstrap: createBootstrap({ projects: [project], agents: [createAgent()] }), + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + }), handlers: { createSession: () => creation.promise }, }); const user = await renderApp(client); @@ -405,15 +467,19 @@ describe("AppShell project and session workflows", () => { creation.resolve(createdSession); await creation.promise; }); + + expect(await sessionNode("Deferred session")).toHaveAttribute( + "data-canvas-session-id", + createdSession.id, + ); expect( - await within(screen.getByRole("main")).findByRole("heading", { - name: "Deferred session", - level: 1, + screen.getAllByRole("article", { + name: "Deferred session, terminal canvas item", }), - ).toBeVisible(); + ).toHaveLength(1); }); - it("renders every status plus startup and unsuccessful-exit details", async () => { + it("renders every daemon session status on its canvas node", async () => { const project = createProject(); const statuses = [ "starting", @@ -425,25 +491,15 @@ describe("AppShell project and session workflows", () => { ] as const; const sessions = statuses.map((status, index) => createSession({ - id: `session-${status}`, - name: `${capitalize(status)} session`, + id: "session-" + status, + name: capitalize(status) + " session", status, updatedAtMs: TEST_TIME + index, exitCode: status === "exited" ? 17 : undefined, errorCode: - status === "failed" - ? "executable_not_found" - : undefined, + status === "failed" ? "executable_not_found" : undefined, }), ); - const processFailure = createSession({ - id: "session-failed-exit", - name: "Failed process", - status: "failed", - exitCode: 1, - updatedAtMs: TEST_TIME + statuses.length, - }); - sessions.push(processFailure); const client = createMockIpcClient({ bootstrap: createBootstrap({ projects: [project], @@ -451,113 +507,125 @@ describe("AppShell project and session workflows", () => { sessions, }), }); - const user = await renderApp(client); - const sessionNavigation = screen.getByRole("navigation", { - name: "Project sessions", - }); + + await renderApp(client); for (const status of statuses) { - const badges = within(sessionNavigation).getAllByLabelText( - `Session status: ${capitalize(status)}`, - ); - expect(badges[0]).toBeVisible(); + const node = await sessionNode(capitalize(status) + " session"); + expect( + within(node).getByLabelText( + "Session status: " + capitalize(status), + ), + ).toBeVisible(); } - expect( - within(sessionNavigation).getByText("Process ended · exit 17"), - ).toBeVisible(); - expect( - within(sessionNavigation).getByText("Executable not found"), - ).toBeVisible(); - expect( - within(sessionNavigation).getByText("Exited with code 1"), - ).toBeVisible(); - - await user.click( - within(sessionNavigation).getByRole("button", { - name: /Exited session/, - }), - ); - expect( - within(screen.getByRole("main")).getByText("Ended · exit 17"), - ).toBeVisible(); - - await user.click( - within(sessionNavigation).getByRole("button", { name: /Failed process/ }), - ); - expect( - within(screen.getByRole("main")).getByText("Ended · exit 1"), - ).toBeVisible(); }); - it("lists, filters, and executes the complete command palette", async () => { + it("lists the canvas command set, excludes retired views, and executes actions", async () => { const project = createProject(); + const session = createSession({ status: "exited" }); + const worktree = createWorktree(); + const startedSession = { ...session, status: "running" as const }; const client = createMockIpcClient({ - bootstrap: createBootstrap({ projects: [project] }), + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + sessions: [session], + worktrees: [worktree], + }), + handlers: { + startSession: async () => startedSession, + }, }); const user = await renderApp(client); + await user.click(await sessionNode(session.name)); - await user.click( - screen.getByRole("button", { - name: "Open command palette, Ctrl+K", - }), - ); - const dialog = await screen.findByRole("dialog", { - name: "Command palette", - }); + const dialog = await openCommandPalette(user); const expectedCommands = [ "Add Project", "Switch Project", "New Session", - "Open Session", + "Rename Project", + "Remove Project", + "Start Session", "Stop Session", "Restart Session", - "Open Grid", + "Rename Session", + "Show Git Status", + "Open Session Path", + "Delete Session", + "Remove Session Worktree", + "Open Canvas", "Open Settings", "Open Diagnostics", + "Switch Project: Test Repository", + "Focus Session: Test session", ]; for (const label of expectedCommands) { expect(within(dialog).getByText(label, { exact: true })).toBeVisible(); } + expect( + within(dialog).queryByText("Open Session", { exact: true }), + ).not.toBeInTheDocument(); + expect( + within(dialog).queryByText("Open Grid", { exact: true }), + ).not.toBeInTheDocument(); - const search = within(dialog).getByRole("combobox", { - name: "Search commands", + await selectPaletteCommand(user, dialog, "Start Session"); + await waitFor(() => { + expect(client.startSession).toHaveBeenCalledWith({ + sessionId: session.id, + }); }); - await user.type(search, "settings"); - const matches = within(dialog).getAllByRole("option"); - expect(matches).toHaveLength(1); - expect(matches[0]).toHaveAccessibleName(/Open Settings/); - await user.keyboard("{Enter}"); + const settingsPalette = await openCommandPalette(user); + await selectPaletteCommand(user, settingsPalette, "Open Settings"); expect( - screen.queryByRole("dialog", { name: "Command palette" }), - ).not.toBeInTheDocument(); - expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "Settings", - level: 1, - }), + screen.getByRole("heading", { name: "Settings", level: 1 }), ).toBeVisible(); }); - it("supports Linux Control shortcuts for palette, new session, grid, and numbered focus", async () => { + it("clears stale palette session actions when a non-session node is selected", async () => { const project = createProject(); - const olderSession = createSession({ - id: "older-session", - name: "Older session", - updatedAtMs: TEST_TIME + 1, + const session = createSession({ status: "exited" }); + const client = createMockIpcClient({ + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + sessions: [session], + }), }); - const newestSession = createSession({ - id: "newest-session", - name: "Newest session", - updatedAtMs: TEST_TIME + 2, + const user = await renderApp(client); + await user.click(await sessionNode(session.name)); + await user.click( + screen.getByRole("article", { name: "Notes, note canvas item" }), + ); + + const palette = await openCommandPalette(user); + const restartOption = within(palette).getByRole("option", { + name: /Restart Session/, }); + expect(restartOption).toHaveAttribute("aria-disabled", "true"); + await user.click(restartOption); + expect(client.restartSession).not.toHaveBeenCalled(); + }); + + it("uses Linux shortcuts without reviving grid view and focuses entries 1 through 9", async () => { + const project = createProject(); + const sessions = Array.from({ length: 9 }, (_, index) => + createSession({ + id: "linux-session-" + (index + 1), + name: "Linux session " + (index + 1), + status: "exited", + updatedAtMs: TEST_TIME + index, + }), + ); const client = createMockIpcClient({ platform: "linux", bootstrap: createBootstrap({ projects: [project], agents: [createAgent()], - sessions: [olderSession, newestSession], + sessions, }), }); const user = await renderApp(client); @@ -574,33 +642,40 @@ describe("AppShell project and session workflows", () => { ).toBeVisible(); await user.keyboard("{Escape}"); + const canvas = screen.getByRole("main"); await user.keyboard("{Control>}{Shift>}g{/Shift}{/Control}"); + expect(screen.getByRole("main")).toBe(canvas); expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "Session Grid", - level: 1, - }), - ).toBeVisible(); + screen.queryByRole("heading", { name: "Session Grid" }), + ).not.toBeInTheDocument(); - await user.keyboard("{Control>}1{/Control}"); - expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "Newest session", - level: 1, - }), - ).toBeVisible(); + for (let shortcut = 1; shortcut <= 9; shortcut += 1) { + viewportScrollTo.mockClear(); + await user.keyboard(`{Control>}${shortcut}{/Control}`); + const target = await sessionNode(`Linux session ${10 - shortcut}`); + await waitFor(() => { + expect(target).toHaveFocus(); + expect(target).toHaveAttribute("data-selected", "true"); + expect(viewportScrollTo).toHaveBeenCalled(); + }); + } + + const newest = await sessionNode("Linux session 9"); + expect(newest).not.toHaveAttribute("data-selected"); }); - it("uses the macOS Meta modifier for global shortcuts", async () => { + it("uses macOS Meta shortcuts and numbered canvas focus", async () => { const project = createProject(); const olderSession = createSession({ id: "mac-older-session", name: "Mac older session", + status: "exited", updatedAtMs: TEST_TIME + 1, }); const newestSession = createSession({ id: "mac-newest-session", name: "Mac newest session", + status: "exited", updatedAtMs: TEST_TIME + 2, }); const client = createMockIpcClient({ @@ -625,93 +700,140 @@ describe("AppShell project and session workflows", () => { ).toBeVisible(); await user.keyboard("{Escape}"); + const canvas = screen.getByRole("main"); await user.keyboard("{Meta>}{Shift>}g{/Shift}{/Meta}"); - expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "Session Grid", - level: 1, - }), - ).toBeVisible(); + expect(screen.getByRole("main")).toBe(canvas); + viewportScrollTo.mockClear(); await user.keyboard("{Meta>}1{/Meta}"); - expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "Mac newest session", - level: 1, - }), - ).toBeVisible(); + const newest = await sessionNode(newestSession.name); + await waitFor(() => { + expect(newest).toHaveFocus(); + expect(newest).toHaveAttribute("data-selected", "true"); + expect(viewportScrollTo).toHaveBeenCalled(); + }); }); - it("treats compact navigation as a modal drawer and closes it with Escape", async () => { - const originalMatchMedia = window.matchMedia; - let breakpointListener: - | ((event: MediaQueryListEvent) => void) - | undefined; - Object.defineProperty(window, "matchMedia", { - configurable: true, - value: vi.fn().mockReturnValue({ - matches: true, - media: "(max-width: 47.99rem)", - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn((_type, listener) => { - breakpointListener = listener as (event: MediaQueryListEvent) => void; - }), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - } satisfies MediaQueryList), + it("treats compact navigation as an inert focus-trapped drawer", async () => { + const compactMedia = installCompactMatchMedia(); + const visibleRect = new DOMRect(0, 0, 1, 1); + const visibleRects = Object.assign([visibleRect], { + item: (index: number) => (index === 0 ? visibleRect : null), }); + const clientRectSpy = vi + .spyOn(HTMLElement.prototype, "getClientRects") + .mockReturnValue(visibleRects); try { const client = createMockIpcClient(); - const user = await renderApp(client); - await user.click( - screen.getByRole("button", { name: "Open navigation" }), - ); - + const user = userEvent.setup(); + render(); + await screen.findByRole("heading", { + name: "My Workspace", + hidden: true, + }); + const opener = screen.getByRole("button", { name: "Open navigation" }); const workspace = screen.getByRole("main", { hidden: true }); + const toolbar = screen.getByRole("toolbar", { + name: "Workspace actions", + }); + const statusBar = document.querySelector(".status-bar"); + const skipLink = document.querySelector(".skip-link"); + + await user.click(opener); + await waitFor(() => { expect(workspace).toHaveAttribute("inert"); expect(workspace).toHaveAttribute("aria-hidden", "true"); + expect(toolbar).toHaveAttribute("inert"); + expect(statusBar).toHaveAttribute("inert"); + expect(skipLink).toHaveAttribute("inert"); }); - expect( - screen.getByRole("button", { name: "Close navigation" }), - ).toBeInTheDocument(); - - act(() => { - breakpointListener?.({ matches: false } as MediaQueryListEvent); + const navigation = screen.getByRole("dialog", { + name: "Workspace navigation", }); + expect(navigation).toHaveAttribute("data-open", "true"); + const closeButton = screen.getByRole("button", { + name: "Close navigation", + }); + const lastButton = within(navigation).getByRole("button", { + name: "Open diagnostics", + }); + expect(closeButton).toHaveFocus(); + + closeButton.focus(); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(lastButton).toHaveFocus(); + + lastButton.focus(); + await user.keyboard("{Tab}"); + expect(closeButton).toHaveFocus(); + + await user.keyboard("{Escape}"); await waitFor(() => { expect(workspace).not.toHaveAttribute("inert"); expect(workspace).not.toHaveAttribute("aria-hidden"); + expect(toolbar).not.toHaveAttribute("inert"); + expect(statusBar).not.toHaveAttribute("inert"); + expect(skipLink).not.toHaveAttribute("inert"); + expect(opener).toHaveFocus(); }); + expect(opener).toHaveAccessibleName("Open navigation"); + await user.click(opener); + const reopenedNavigation = screen.getByRole("dialog", { + name: "Workspace navigation", + }); await user.click( - screen.getByRole("button", { name: "Open navigation" }), + within(reopenedNavigation).getByRole("button", { + name: "Add workspace project", + }), ); - await user.keyboard("{Escape}"); + const addProjectDialog = await screen.findByRole("dialog", { + name: "Add Project", + }); await waitFor(() => { + expect(navigation).toHaveAttribute("data-open", "false"); expect(workspace).not.toHaveAttribute("inert"); - expect(workspace).not.toHaveAttribute("aria-hidden"); + const focusedElement = document.activeElement; + expect(focusedElement).toBeInstanceOf(HTMLElement); + if (!(focusedElement instanceof HTMLElement)) { + throw new Error("Expected the Add Project dialog to own focus."); + } + expect(addProjectDialog).toContainElement(focusedElement); }); - } finally { - Object.defineProperty(window, "matchMedia", { - configurable: true, - value: originalMatchMedia, + await user.click( + within(addProjectDialog).getByRole("button", { + name: "Close Add Project", + }), + ); + await waitFor(() => expect(opener).toHaveFocus()); + + await user.click(opener); + await waitFor(() => expect(workspace).toHaveAttribute("inert")); + act(() => compactMedia.exitCompact()); + await waitFor(() => { + expect(workspace).not.toHaveAttribute("inert"); + expect(navigation).toHaveAttribute("data-open", "false"); + expect(opener).toHaveAccessibleName("Hide navigation"); }); + } finally { + clientRectSpy.mockRestore(); + compactMedia.restore(); } }); - it("does not capture application shortcuts from the terminal root", async () => { + it("does not capture application shortcuts inside the terminal root", async () => { const project = createProject(); const firstSession = createSession({ id: "first-session", name: "First session", + status: "running", updatedAtMs: TEST_TIME + 2, }); - const secondSession = createSession({ - id: "second-session", + const terminalOwnedSession = createSession({ + id: "terminal-session", name: "Terminal-owned session", + status: "running", updatedAtMs: TEST_TIME + 1, }); const client = createMockIpcClient({ @@ -719,36 +841,37 @@ describe("AppShell project and session workflows", () => { bootstrap: createBootstrap({ projects: [project], agents: [createAgent()], - sessions: [firstSession, secondSession], + sessions: [firstSession, terminalOwnedSession], }), }); const user = await renderApp(client); - const navigation = screen.getByRole("navigation", { - name: "Project sessions", - }); - await user.click( - within(navigation).getByRole("button", { - name: /Terminal-owned session/, - }), + const ownedNode = await sessionNode(terminalOwnedSession.name); + const terminal = screen.getByTestId( + "live-terminal-" + terminalOwnedSession.id, ); - const terminal = within(screen.getByRole("main")).getByRole("region", { - name: "Terminal host for Terminal-owned session", - }); + await user.click(terminal); expect(terminal).toHaveFocus(); + expect(ownedNode).toHaveAttribute("data-selected", "true"); + viewportScrollTo.mockClear(); await user.keyboard("{Control>}k{/Control}"); + await user.keyboard("{Control>}t{/Control}"); await user.keyboard("{Control>}1{/Control}"); + await user.keyboard("{Control>}{Shift>}g{/Shift}{/Control}"); expect( screen.queryByRole("dialog", { name: "Command palette" }), ).not.toBeInTheDocument(); expect( - within(screen.getByRole("main")).getByRole("heading", { - name: "Terminal-owned session", - level: 1, - }), - ).toBeVisible(); + screen.queryByRole("dialog", { name: "New Session" }), + ).not.toBeInTheDocument(); + expect(terminal).toHaveFocus(); + expect(ownedNode).toHaveAttribute("data-selected", "true"); + expect(await sessionNode(firstSession.name)).not.toHaveAttribute( + "data-selected", + ); + expect(viewportScrollTo).not.toHaveBeenCalled(); }); it("shows an actionable IPC error without dismissing the failed form", async () => { @@ -764,11 +887,7 @@ describe("AppShell project and session workflows", () => { }, }); const user = await renderApp(client); - await user.click( - within(screen.getByRole("main")).getByRole("button", { - name: "Add Project", - }), - ); + await user.click(screen.getByRole("button", { name: "Add Project" })); const dialog = await screen.findByRole("dialog", { name: "Add Project" }); await user.click( within(dialog).getByRole("button", { name: "Enter a path manually" }), @@ -795,9 +914,245 @@ describe("AppShell project and session workflows", () => { expect(dialog).toBeVisible(); }); - it("reports a daemon disconnect and reconnects through a fresh bootstrap", async () => { + it("opens rename and remove project overlays from CanvasSidebar", async () => { const project = createProject(); - const session = createSession({ name: "Offline metadata" }); + const renamedProject = { ...project, name: "Renamed Repository" }; + const client = createMockIpcClient({ + bootstrap: createBootstrap({ projects: [project] }), + handlers: { + renameProject: async () => renamedProject, + removeProject: async () => undefined, + }, + }); + const user = await renderApp(client); + + await user.click( + screen.getByRole("button", { name: "Rename Test Repository" }), + ); + const renameDialog = await screen.findByRole("dialog", { + name: "Rename Project", + }); + const nameInput = within(renameDialog).getByRole("textbox", { + name: "Display name", + }); + await user.clear(nameInput); + await user.type(nameInput, "Renamed Repository"); + await user.click( + within(renameDialog).getByRole("button", { name: "Save Name" }), + ); + + await waitFor(() => { + expect(client.renameProject).toHaveBeenCalledWith({ + projectId: project.id, + name: "Renamed Repository", + }); + }); + expect( + await screen.findByRole("button", { name: /^Renamed Repository/ }), + ).toBeVisible(); + + await user.click( + screen.getByRole("button", { + name: "Remove Renamed Repository from workspaces", + }), + ); + const removeDialog = await screen.findByRole("dialog", { + name: "Remove Project", + }); + expect(removeDialog).toHaveTextContent("Files stay on disk."); + await user.click( + within(removeDialog).getByRole("button", { name: "Remove from App" }), + ); + + await waitFor(() => { + expect(client.removeProject).toHaveBeenCalledWith(project.id); + expect( + screen.queryByRole("button", { name: /^Renamed Repository/ }), + ).not.toBeInTheDocument(); + }); + expect( + screen.getByRole("heading", { name: "My Workspace", level: 1 }), + ).toBeVisible(); + }); + + it("starts, restarts, opens, renames, and reads Git status from a canvas node", async () => { + const project = createProject(); + const session = createSession({ + name: "Automation", + status: "exited", + worktreeId: "worktree-one", + worktreePath: "/repos/project/.worktrees/automation", + }); + const worktree = createWorktree({ + path: session.worktreePath, + }); + const client = createMockIpcClient({ + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + sessions: [session], + worktrees: [worktree], + }), + handlers: { + startSession: async () => ({ + ...session, + status: "running", + pid: 101, + }), + restartSession: async () => ({ + ...session, + status: "running", + pid: 102, + }), + renameSession: async () => ({ + ...session, + name: "Renamed automation", + status: "running", + }), + openPath: async () => undefined, + getGitStatus: async () => ({ + branch: "agent/automation", + files: [ + { + path: "src/app.ts", + kind: "modified", + staged: false, + unstaged: true, + }, + ], + counts: { modified: 1, added: 0, deleted: 0, untracked: 0 }, + hasStaged: false, + hasTrackedChanges: true, + hasUntracked: false, + isDirty: true, + }), + }, + }); + const user = await renderApp(client); + + let actions = await openSessionActions(user, session.name); + await user.click( + within(actions).getByRole("button", { name: "Start session" }), + ); + await waitFor(() => { + expect(client.startSession).toHaveBeenCalledWith({ + sessionId: session.id, + }); + }); + expect( + within(await sessionNode(session.name)).getByLabelText( + "Session status: Running", + ), + ).toBeVisible(); + + actions = await openSessionActions(user, session.name); + await user.click( + within(actions).getByRole("button", { name: "Restart session" }), + ); + await waitFor(() => { + expect(client.restartSession).toHaveBeenCalledWith({ + sessionId: session.id, + }); + }); + + actions = await openSessionActions(user, session.name); + await user.click( + within(actions).getByRole("button", { + name: "Open working directory", + }), + ); + await waitFor(() => { + expect(client.openPath).toHaveBeenCalledWith(worktree.path); + }); + + actions = await openSessionActions(user, session.name); + await user.click( + within(actions).getByRole("button", { name: "Rename session" }), + ); + const renameDialog = await screen.findByRole("dialog", { + name: "Rename Session", + }); + const renameInput = within(renameDialog).getByRole("textbox", { + name: "Session name", + }); + await user.clear(renameInput); + await user.type(renameInput, "Renamed automation"); + await user.click( + within(renameDialog).getByRole("button", { name: "Save Name" }), + ); + await waitFor(() => { + expect(client.renameSession).toHaveBeenCalledWith({ + sessionId: session.id, + name: "Renamed automation", + }); + }); + expect(await sessionNode("Renamed automation")).toBeVisible(); + + actions = await openSessionActions(user, "Renamed automation"); + await user.click( + within(actions).getByRole("button", { name: "Git status" }), + ); + const gitDialog = await screen.findByRole("dialog", { name: "Git Status" }); + expect(await within(gitDialog).findByText("Worktree dirty")).toBeVisible(); + expect(within(gitDialog).getByText("src/app.ts")).toBeVisible(); + expect(client.getGitStatus).toHaveBeenCalledWith({ + kind: "session", + sessionId: session.id, + }); + await user.click(within(gitDialog).getByRole("button", { name: "Close" })); + }); + + it("disables unsafe actions when a managed worktree is missing", async () => { + const session = createSession({ + name: "Missing worktree", + status: "exited", + worktreeId: "missing-worktree", + worktreePath: "/repos/project/.worktrees/missing", + }); + const client = createMockIpcClient({ + bootstrap: createBootstrap({ + projects: [createProject()], + agents: [createAgent()], + sessions: [session], + }), + }); + const user = await renderApp(client); + + const actions = await openSessionActions(user, session.name); + + expect( + within(actions).getByRole("button", { name: "Start session" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + within(actions).getByRole("button", { name: "Restart session" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + within(actions).getByRole("button", { name: "Git status" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + within(actions).getByRole("button", { + name: "Open working directory", + }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + within(actions).getByRole("button", { name: "Remove worktree" }), + ).toHaveAttribute("aria-disabled", "true"); + expect(actions).toHaveTextContent( + "The managed worktree is no longer available.", + ); + expect( + within(actions).getByRole("button", { + name: "Delete session metadata", + }), + ).toBeEnabled(); + }); + + it("reports a disconnect, disables node actions, and reconnects cleanly", async () => { + const project = createProject(); + const session = createSession({ + name: "Offline metadata", + status: "running", + }); const client = createMockIpcClient({ bootstrap: createBootstrap({ projects: [project], @@ -817,34 +1172,24 @@ describe("AppShell project and session workflows", () => { const banner = await screen.findByRole("alert"); expect(banner).toHaveTextContent("Daemon disconnected."); expect(banner).toHaveTextContent("Existing metadata may be stale."); - await user.click( - within( - screen.getByRole("navigation", { name: "Project sessions" }), - ).getByRole("button", { name: /Offline metadata/ }), - ); + const actions = await openSessionActions(user, session.name); expect( - within(screen.getByRole("main")).getByRole("button", { name: "Restart" }), - ).toBeDisabled(); + within(actions).getByRole("button", { name: "Restart session" }), + ).toHaveAttribute("aria-disabled", "true"); expect( - within(screen.getByRole("main")).getByRole("button", { name: "Stop Process" }), - ).toBeDisabled(); + within(actions).getByRole("button", { name: "Stop process" }), + ).toHaveAttribute("aria-disabled", "true"); await user.click( within(banner).getByRole("button", { name: "Reconnect" }), ); await waitFor(() => { expect(client.initialize).toHaveBeenCalledTimes(2); - expect(screen.queryByText(/^Daemon connected/)).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); expect(client.subscribe).toHaveBeenCalledTimes(2); expect(client.listenerCount()).toBe(1); - expect( - within(screen.getByRole("main")).getByRole("heading", { - name: session.name, - level: 1, - }), - ).toBeVisible(); + expect(await sessionNode(session.name)).toBeVisible(); }); it("keeps worktree removal blocked for every daemon safety reason", async () => { @@ -888,22 +1233,19 @@ describe("AppShell project and session workflows", () => { }); const user = await renderApp(client); + const actions = await openSessionActions(user, session.name); await user.click( - within( - screen.getByRole("navigation", { name: "Project sessions" }), - ).getByRole("button", { name: /Test session/ }), - ); - await user.click( - within(screen.getByRole("main")).getByRole("button", { - name: "Remove Worktree", - }), + within(actions).getByRole("button", { name: "Remove worktree" }), ); const dialog = await screen.findByRole("dialog", { name: "Remove Worktree", }); - expect( - within(dialog).getByRole("button", { name: "Remove Worktree" }), - ).toBeDisabled(); + + await waitFor(() => { + expect( + within(dialog).getByRole("button", { name: "Remove Worktree" }), + ).toBeDisabled(); + }); expect(dialog).toHaveTextContent("Ignored files are present"); expect(dialog).toHaveTextContent("assume-unchanged"); expect(dialog).toHaveTextContent("skip-worktree"); @@ -913,7 +1255,7 @@ describe("AppShell project and session workflows", () => { expect(client.removeWorktree).not.toHaveBeenCalled(); }); - it("distinguishes stopping a process, deleting metadata, and removing a worktree", async () => { + it("distinguishes stopping, removing a worktree, and deleting metadata", async () => { const project = createProject(); const runningSession = createSession({ id: "running-session", @@ -958,27 +1300,22 @@ describe("AppShell project and session workflows", () => { }, }); const user = await renderApp(client); - const sessionNavigation = screen.getByRole("navigation", { - name: "Project sessions", - }); - await user.click( - within(sessionNavigation).getByRole("button", { name: /Running work/ }), - ); - const runningWorkspace = within(screen.getByRole("main")); + let actions = await openSessionActions(user, runningSession.name); expect( - runningWorkspace.getByRole("button", { name: "Stop Process" }), + within(actions).getByRole("button", { name: "Stop process" }), ).toBeEnabled(); expect( - runningWorkspace.getByRole("button", { name: "Delete Session" }), - ).toBeDisabled(); - expect( - runningWorkspace.getByRole("button", { name: "Remove Worktree" }), - ).toBeDisabled(); + within(actions).getByRole("button", { + name: "Delete session metadata", + }), + ).toHaveAttribute("aria-disabled", "true"); await user.click( - runningWorkspace.getByRole("button", { name: "Stop Process" }), + within(actions).getByRole("button", { name: "Stop process" }), ); - const stopDialog = await screen.findByRole("dialog", { name: "Stop Process" }); + const stopDialog = await screen.findByRole("dialog", { + name: "Stop Process", + }); expect(stopDialog).toHaveTextContent("Only the live process stops."); expect(stopDialog).toHaveTextContent( "Session metadata and any Git worktree remain available.", @@ -992,12 +1329,9 @@ describe("AppShell project and session workflows", () => { }); }); + actions = await openSessionActions(user, stoppedSession.name); await user.click( - within(sessionNavigation).getByRole("button", { name: /Stopped work/ }), - ); - const stoppedWorkspace = within(screen.getByRole("main")); - await user.click( - stoppedWorkspace.getByRole("button", { name: "Remove Worktree" }), + within(actions).getByRole("button", { name: "Remove worktree" }), ); const worktreeDialog = await screen.findByRole("dialog", { name: "Remove Worktree", @@ -1008,12 +1342,11 @@ describe("AppShell project and session workflows", () => { expect(worktreeDialog).toHaveTextContent( "Removing the worktree does not delete its Git branch.", ); - await waitFor(() => { - expect(client.prepareWorktreeRemoval).toHaveBeenCalledWith(worktree.id); + const removeButton = within(worktreeDialog).getByRole("button", { + name: "Remove Worktree", }); - await user.click( - within(worktreeDialog).getByRole("button", { name: "Remove Worktree" }), - ); + await waitFor(() => expect(removeButton).toBeEnabled()); + await user.click(removeButton); await waitFor(() => { expect(client.removeWorktree).toHaveBeenCalledWith({ worktreeId: worktree.id, @@ -1021,13 +1354,18 @@ describe("AppShell project and session workflows", () => { }); }); + actions = await openSessionActions(user, stoppedSession.name); await user.click( - stoppedWorkspace.getByRole("button", { name: "Delete Session" }), + within(actions).getByRole("button", { + name: "Delete session metadata", + }), ); const deleteDialog = await screen.findByRole("dialog", { name: "Delete Session", }); - expect(deleteDialog).toHaveTextContent("This deletes session metadata only."); + expect(deleteDialog).toHaveTextContent( + "This deletes session metadata only.", + ); expect(deleteDialog).toHaveTextContent( "It does not stop a running process, delete project files, or remove an associated worktree.", ); @@ -1039,9 +1377,14 @@ describe("AppShell project and session workflows", () => { sessionId: stoppedSession.id, }); }); + expect( + within(await sessionNode(stoppedSession.name)).queryByRole("button", { + name: "Session actions for " + stoppedSession.name, + }), + ).not.toBeInTheDocument(); }); - it("keeps a retained worktree manageable after session metadata is deleted", async () => { + it("removes a retained worktree through the command palette", async () => { const project = createProject(); const session = createSession({ status: "exited", @@ -1074,14 +1417,10 @@ describe("AppShell project and session workflows", () => { }); const user = await renderApp(client); + const actions = await openSessionActions(user, session.name); await user.click( - within( - screen.getByRole("navigation", { name: "Project sessions" }), - ).getByRole("button", { name: /Test session/ }), - ); - await user.click( - within(screen.getByRole("main")).getByRole("button", { - name: "Delete Session", + within(actions).getByRole("button", { + name: "Delete session metadata", }), ); await user.click( @@ -1089,50 +1428,132 @@ describe("AppShell project and session workflows", () => { await screen.findByRole("dialog", { name: "Delete Session" }), ).getByRole("button", { name: "Delete Session" }), ); + await waitFor(() => expect(client.deleteSession).toHaveBeenCalledOnce()); + + const palette = await openCommandPalette(user); + const retainedLabel = "Remove Retained Worktree: agent/retained"; + expect( + within(palette).getByText(retainedLabel, { exact: true }), + ).toBeVisible(); + await selectPaletteCommand(user, palette, retainedLabel); - const retainedAction = await screen.findByRole("button", { - name: "Remove retained worktree agent/retained", - }); - expect(retainedAction).toBeEnabled(); - await user.click(retainedAction); const removeDialog = await screen.findByRole("dialog", { name: "Remove Worktree", }); - await user.click( - await within(removeDialog).findByRole("button", { - name: "Remove Worktree", - }), - ); + const removeButton = within(removeDialog).getByRole("button", { + name: "Remove Worktree", + }); + await waitFor(() => expect(removeButton).toBeEnabled()); + await user.click(removeButton); await waitFor(() => { expect(client.removeWorktree).toHaveBeenCalledWith({ worktreeId: worktree.id, confirmationToken: "retained-removal-token", }); - expect( - screen.queryByRole("button", { - name: "Remove retained worktree agent/retained", - }), - ).not.toBeInTheDocument(); }); + const refreshedPalette = await openCommandPalette(user); + expect( + within(refreshedPalette).queryByText(retainedLabel, { exact: true }), + ).not.toBeInTheDocument(); }); + it("opens a session-associated worktree from the command palette", async () => { + const project = createProject(); + const session = createSession({ status: "exited", worktreeId: undefined }); + const worktree = createWorktree({ sessionId: session.id }); + const client = createMockIpcClient({ + bootstrap: createBootstrap({ + projects: [project], + agents: [createAgent()], + sessions: [session], + worktrees: [worktree], + }), + }); + const user = await renderApp(client); + await user.click(await sessionNode(session.name)); + + const palette = await openCommandPalette(user); + await selectPaletteCommand(user, palette, "Remove Session Worktree"); + + expect( + await screen.findByRole("dialog", { name: "Remove Worktree" }), + ).toBeVisible(); + await waitFor(() => { + expect(client.prepareWorktreeRemoval).toHaveBeenCalledWith(worktree.id); + }); + }); }); async function renderApp(client: MockIpcClient) { const user = userEvent.setup(); - render(); + render(); await screen.findByRole("button", { name: "Settings" }); expect(screen.queryByText(/^Daemon connected/)).not.toBeInTheDocument(); return user; } -async function openNewSession(user: ReturnType) { +function expectPersistentChrome() { + const shell = document.querySelector(".app-shell"); + expect(shell).toBeInTheDocument(); + expect(shell).not.toHaveClass("app-shell--canvas"); + expect( + screen.getByRole("complementary", { name: "Canvas workspaces" }), + ).toBeVisible(); + expect( + screen.getByRole("toolbar", { name: "Workspace actions" }), + ).toBeVisible(); + expect(document.querySelector(".status-bar")).toBeVisible(); +} + +async function sessionNode(name: string) { + return screen.findByRole("article", { + name: name + ", terminal canvas item", + }); +} + +async function openSessionActions( + user: ReturnType, + sessionName: string, +) { + const node = await sessionNode(sessionName); + const trigger = within(node).getByRole("button", { + name: "Session actions for " + sessionName, + }); + if (trigger.getAttribute("aria-expanded") !== "true") { + await user.click(trigger); + } + return within(node).findByRole("group", { + name: "Actions for " + sessionName, + }); +} + +async function openCommandPalette( + user: ReturnType, +) { await user.click( screen.getByRole("button", { - name: /^New SessionCtrl T$/, + name: /^Open command palette,/, }), ); + return screen.findByRole("dialog", { name: "Command palette" }); +} + +async function selectPaletteCommand( + user: ReturnType, + dialog: HTMLElement, + label: string, +) { + const search = within(dialog).getByRole("combobox", { + name: "Search commands", + }); + await user.clear(search); + await user.type(search, label); + await user.click(within(dialog).getByText(label, { exact: true })); +} + +async function openNewSession(user: ReturnType) { + await user.click(screen.getByRole("button", { name: "New Session" })); } async function reachSessionReview( @@ -1151,6 +1572,57 @@ async function reachSessionReview( return within(dialog).getByRole("button", { name: "Create Session" }); } +function installCompactMatchMedia() { + const originalMatchMedia = window.matchMedia; + let breakpointListener: EventListenerOrEventListenerObject | undefined; + let compactMatches = true; + const compactQuery = { + get matches() { + return compactMatches; + }, + media: "(max-width: 47.99rem)", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((_type, listener) => { + breakpointListener = listener; + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + } satisfies MediaQueryList; + const reducedMotionQuery = { + ...compactQuery, + matches: false, + media: "(prefers-reduced-motion: reduce)", + } satisfies MediaQueryList; + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn((query: string) => + query === compactQuery.media ? compactQuery : reducedMotionQuery, + ), + }); + return { + exitCompact() { + compactMatches = false; + const event = Object.assign(new Event("change"), { + matches: false, + media: compactQuery.media, + }); + if (typeof breakpointListener === "function") { + breakpointListener(event); + } else { + breakpointListener?.handleEvent(event); + } + }, + restore() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: originalMatchMedia, + }); + }, + }; +} + function createBootstrap( snapshot: Partial = {}, agentDetections?: readonly AgentDetection[], @@ -1164,7 +1636,7 @@ function createBootstrap( mergedSnapshot.agents.map((agent) => ({ agentId: agent.id, available: true, - executablePath: `/usr/local/bin/${agent.command.executable}`, + executablePath: "/usr/local/bin/" + agent.command.executable, })), }; } @@ -1239,5 +1711,5 @@ function deferred() { } function capitalize(value: string): string { - return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; + return value.charAt(0).toUpperCase() + value.slice(1); } diff --git a/apps/desktop/src/app/AppShell.tsx b/apps/desktop/src/app/AppShell.tsx index 6755c3f..4cba997 100644 --- a/apps/desktop/src/app/AppShell.tsx +++ b/apps/desktop/src/app/AppShell.tsx @@ -1,11 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AppHeader } from "./components/AppHeader"; import { Icon } from "./components/Icon"; import { CommandPalette, type CommandPaletteCommand } from "./features/commands/CommandPalette"; import { CanvasSidebar } from "./features/navigation/CanvasSidebar"; -import { ProjectSidebar } from "./features/navigation/ProjectSidebar"; -import { SessionSidebar } from "./features/navigation/SessionSidebar"; import { LocalStatusBar } from "./features/status/LocalStatusBar"; import { Workspace } from "./features/workspace/Workspace"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; @@ -22,7 +19,14 @@ export function AppShell() { const [canvasSidebarCollapsed, setCanvasSidebarCollapsed] = useState( readCanvasSidebarCollapsed, ); + const [sessionFocusRevision, setSessionFocusRevision] = useState(0); + const closeNavigationOnDesktop = useCallback( + () => setNavigationOpen(false), + [], + ); + const isCompactNavigation = useCompactNavigation(closeNavigationOnDesktop); const navigationRef = useRef(null); + const navigationTriggerRef = useRef(null); const modifier = workspace.platform === "macos" ? "⌘" : "Ctrl"; const repositoryUnavailable = workspace.selectedProject?.availability === "missing" || @@ -38,45 +42,38 @@ export function AppShell() { ), [workspace.projectSessions], ); - const selectedAgent = workspace.agents.find( - (agent) => agent.id === workspace.selectedSession?.agentId, - ); - const usesCanvasShell = - workspace.view === "canvas" || - workspace.view === "settings" || - workspace.view === "diagnostics"; const canvasSidebarView = workspace.view === "settings" || workspace.view === "diagnostics" ? workspace.view : "canvas"; + const closeNavigationForOverlay = useCallback(() => { + if (navigationOpen && isCompactNavigation) { + setNavigationOpen(false); + } + }, [isCompactNavigation, navigationOpen]); + const openAddProject = useCallback(() => { if (workspace.isConnected) { + closeNavigationForOverlay(); workspace.openOverlay({ kind: "add-project" }); } - }, [workspace]); + }, [closeNavigationForOverlay, workspace]); const openNewSession = useCallback(() => { if (canCreateSession && workspace.selectedProject) { + closeNavigationForOverlay(); workspace.openOverlay({ kind: "new-session", projectId: workspace.selectedProject.id, }); } - }, [canCreateSession, workspace]); + }, [canCreateSession, closeNavigationForOverlay, workspace]); const openCommandPalette = useCallback(() => { + closeNavigationForOverlay(); workspace.openOverlay({ kind: "command-palette" }); - }, [workspace]); - - const selectProject = useCallback( - (projectId: string) => { - workspace.selectProject(projectId); - workspace.setView("session"); - setNavigationOpen(false); - }, - [workspace], - ); + }, [closeNavigationForOverlay, workspace]); const selectCanvasProject = useCallback( (projectId: string) => { @@ -87,42 +84,57 @@ export function AppShell() { [workspace], ); - const selectSession = useCallback( - (sessionId: string) => { - workspace.selectSession(sessionId); - workspace.setView("session"); - setNavigationOpen(false); - }, - [workspace], - ); - const cycleProject = useCallback(() => { if (!workspace.projects.length) return; const currentIndex = workspace.projects.findIndex( (project) => project.id === workspace.selectedProjectId, ); const nextProject = workspace.projects[(currentIndex + 1) % workspace.projects.length]; - if (nextProject) selectProject(nextProject.id); - }, [selectProject, workspace.projects, workspace.selectedProjectId]); + if (nextProject) selectCanvasProject(nextProject.id); + }, [selectCanvasProject, workspace.projects, workspace.selectedProjectId]); + + const requestSessionFocus = useCallback( + (sessionId: string) => { + workspace.selectSession(sessionId); + workspace.setView("canvas"); + setNavigationOpen(false); + setSessionFocusRevision((revision) => revision + 1); + }, + [workspace], + ); const focusSessionByNumber = useCallback( (sessionNumber: number) => { const session = orderedProjectSessions[sessionNumber - 1]; - if (session) selectSession(session.id); + if (session) requestSessionFocus(session.id); }, - [orderedProjectSessions, selectSession], + [orderedProjectSessions, requestSessionFocus], ); useGlobalShortcuts({ platform: workspace.platform, onOpenCommandPalette: openCommandPalette, onNewSession: openNewSession, - onOpenGrid: () => { - if (workspace.selectedProject) workspace.setView("grid"); - }, onFocusSession: focusSessionByNumber, }); + const toggleNavigation = useCallback(() => { + if (isCompactNavigation) { + setNavigationOpen((open) => !open); + return; + } + setCanvasSidebarCollapsed((collapsed) => !collapsed); + }, [isCompactNavigation]); + + const hideNavigation = useCallback(() => { + if (isCompactNavigation) { + setNavigationOpen(false); + return; + } + setCanvasSidebarCollapsed(true); + queueMicrotask(() => navigationTriggerRef.current?.focus()); + }, [isCompactNavigation]); + useEffect(() => { try { localStorage.setItem( @@ -135,9 +147,7 @@ export function AppShell() { }, [canvasSidebarCollapsed]); useEffect(() => { - if (!navigationOpen) return undefined; - const compactNavigation = window.matchMedia("(max-width: 47.99rem)"); - if (!compactNavigation.matches) { + if (!navigationOpen || !isCompactNavigation) { return undefined; } const navigation = navigationRef.current; @@ -148,7 +158,7 @@ export function AppShell() { : null; const inertTargets = Array.from( document.querySelectorAll( - ".app-header, #workspace, .status-bar, .connection-banner", + ".skip-link, #workspace, .canvas-session-actions, .status-bar, .connection-banner", ), ); const priorAriaHidden = inertTargets.map((target) => @@ -185,24 +195,20 @@ export function AppShell() { } } - function handleNavigationBreakpoint(event: MediaQueryListEvent) { - if (!event.matches) setNavigationOpen(false); - } - window.addEventListener("keydown", handleNavigationKeyDown); - compactNavigation.addEventListener("change", handleNavigationBreakpoint); return () => { window.removeEventListener("keydown", handleNavigationKeyDown); - compactNavigation.removeEventListener("change", handleNavigationBreakpoint); inertTargets.forEach((target, index) => { target.removeAttribute("inert"); const ariaHidden = priorAriaHidden[index]; if (ariaHidden === null) target.removeAttribute("aria-hidden"); else if (ariaHidden !== undefined) target.setAttribute("aria-hidden", ariaHidden); }); - if (previouslyFocused?.isConnected) previouslyFocused.focus(); + if (previouslyFocused?.isConnected) { + previouslyFocused.focus(); + } }; - }, [navigationOpen]); + }, [isCompactNavigation, navigationOpen]); useEffect(() => { const suppressWebviewContextMenu = (event: MouseEvent) => { @@ -216,6 +222,13 @@ export function AppShell() { const commands = useMemo(() => { const session = workspace.selectedSession; + const project = workspace.selectedProject; + const worktree = workspace.selectedWorktree; + const hasMissingManagedWorktree = Boolean(session?.worktreeId && !worktree); + const sessionPath = hasMissingManagedWorktree + ? undefined + : worktree?.path ?? session?.worktreePath ?? session?.cwd; + const isSessionLive = session ? isLiveStatus(session.status) : false; const baseCommands: CommandPaletteCommand[] = [ { id: "project.add", @@ -246,19 +259,53 @@ export function AppShell() { onSelect: openNewSession, }, { - id: "session.open", - label: "Open Session", - description: "Open the selected session workspace.", - disabled: !session, - disabledReason: "Select a session first.", - onSelect: () => workspace.setView("session"), + id: "project.rename", + label: "Rename Project", + description: "Change only the selected project's display name.", + disabled: !project || !workspace.isConnected, + disabledReason: "Select a project and connect the local daemon first.", + onSelect: () => { + if (project) { + workspace.openOverlay({ kind: "rename-project", projectId: project.id }); + } + }, + }, + { + id: "project.remove", + label: "Remove Project", + description: "Forget the selected project without deleting its directory.", + disabled: !project || !workspace.isConnected, + disabledReason: "Select a project and connect the local daemon first.", + onSelect: () => { + if (project) { + workspace.openOverlay({ kind: "remove-project", projectId: project.id }); + } + }, + }, + { + id: "session.start", + label: "Start Session", + description: "Start a fresh process for the selected session.", + disabled: + !session || + !workspace.isConnected || + isSessionLive || + hasMissingManagedWorktree, + disabledReason: hasMissingManagedWorktree + ? "The selected session's managed worktree is unavailable." + : "Select a stopped session and connect the local daemon first.", + onSelect: () => { + if (session) { + void workspace.startSession({ sessionId: session.id }).catch(() => undefined); + } + }, }, { id: "session.stop", label: "Stop Session", description: "Stop only the selected agent process.", disabled: - !session || !workspace.isConnected || !isLiveStatus(session.status), + !session || !workspace.isConnected || !isSessionLive, disabledReason: "Select a live session and connect the local daemon first.", onSelect: () => { @@ -269,8 +316,10 @@ export function AppShell() { id: "session.restart", label: "Restart Session", description: "Restart the selected agent process.", - disabled: !session || !workspace.isConnected, - disabledReason: "Select a session and connect the daemon first.", + disabled: !session || !workspace.isConnected || hasMissingManagedWorktree, + disabledReason: hasMissingManagedWorktree + ? "The selected session's managed worktree is unavailable." + : "Select a session and connect the daemon first.", onSelect: () => { if (session) { void workspace @@ -279,21 +328,80 @@ export function AppShell() { } }, }, + { + id: "session.rename", + label: "Rename Session", + description: "Change only the selected session's display name.", + disabled: !session || !workspace.isConnected, + disabledReason: "Select a session and connect the local daemon first.", + onSelect: () => { + if (session) { + workspace.openOverlay({ kind: "rename-session", sessionId: session.id }); + } + }, + }, + { + id: "session.git-status", + label: "Show Git Status", + description: "Inspect the selected session's working tree.", + disabled: !session || !workspace.isConnected || hasMissingManagedWorktree, + disabledReason: hasMissingManagedWorktree + ? "The selected session's managed worktree is unavailable." + : "Select a session and connect the local daemon first.", + onSelect: () => { + if (session) { + workspace.openOverlay({ kind: "git-status", sessionId: session.id }); + } + }, + }, + { + id: "session.open-path", + label: "Open Session Path", + description: "Reveal the selected session directory in the system file manager.", + disabled: !sessionPath, + disabledReason: hasMissingManagedWorktree + ? "The selected session's managed worktree is unavailable." + : "Select a session first.", + onSelect: () => { + if (sessionPath) { + void workspace.openPath(sessionPath).catch(() => undefined); + } + }, + }, + { + id: "session.delete", + label: "Delete Session", + description: "Delete stopped session metadata without removing its worktree.", + disabled: !session || !workspace.isConnected || isSessionLive, + disabledReason: isSessionLive + ? "Stop the selected session before deleting its metadata." + : "Select a stopped session and connect the local daemon first.", + onSelect: () => { + if (session) { + workspace.openOverlay({ kind: "delete-session", sessionId: session.id }); + } + }, + }, + { + id: "session.remove-worktree", + label: "Remove Session Worktree", + description: "Begin the guarded removal flow for the selected worktree.", + disabled: !session || !worktree || !workspace.isConnected || isSessionLive, + disabledReason: isSessionLive + ? "Stop the selected session before removing its worktree." + : "Select a stopped session with an available managed worktree.", + onSelect: () => { + if (worktree) { + workspace.openOverlay({ kind: "remove-worktree", worktreeId: worktree.id }); + } + }, + }, { id: "view.canvas", label: "Open Canvas", description: "Arrange terminals and notes in the spatial workspace.", onSelect: () => workspace.setView("canvas"), }, - { - id: "view.grid", - label: "Open Grid", - description: "Show sessions for the selected project in a grid.", - shortcut: `${modifier} Shift G`, - disabled: !workspace.selectedProject, - disabledReason: "Select a project first.", - onSelect: () => workspace.setView("grid"), - }, { id: "view.settings", label: "Open Settings", @@ -312,42 +420,66 @@ export function AppShell() { label: `Switch Project: ${project.name}`, description: project.repositoryRoot ?? project.path, keywords: ["recent repository", project.currentBranch ?? ""], - onSelect: () => selectProject(project.id), + onSelect: () => selectCanvasProject(project.id), })); - return [...baseCommands, ...switchCommands]; + const focusCommands: CommandPaletteCommand[] = orderedProjectSessions.map( + (projectSession, index) => ({ + id: `session.focus.${projectSession.id}`, + label: `Focus Session: ${projectSession.name}`, + description: "Reveal and focus this terminal node on the canvas.", + shortcut: index < 9 ? `${modifier} ${index + 1}` : undefined, + onSelect: () => requestSessionFocus(projectSession.id), + }), + ); + const sessionIds = new Set(workspace.sessions.map(({ id }) => id)); + const retainedWorktreeCommands: CommandPaletteCommand[] = workspace.worktrees + .filter( + (candidate) => + candidate.projectId === project?.id && + (!candidate.sessionId || !sessionIds.has(candidate.sessionId)), + ) + .map((candidate) => ({ + id: `worktree.remove.${candidate.id}`, + label: `Remove Retained Worktree: ${candidate.branch}`, + description: candidate.path, + disabled: !workspace.isConnected, + disabledReason: "Connect the local daemon first.", + onSelect: () => + workspace.openOverlay({ + kind: "remove-worktree", + worktreeId: candidate.id, + }), + })); + return [ + ...baseCommands, + ...switchCommands, + ...focusCommands, + ...retainedWorktreeCommands, + ]; }, [ canCreateSession, cycleProject, modifier, openAddProject, openNewSession, + orderedProjectSessions, repositoryUnavailable, - selectProject, + requestSessionFocus, + selectCanvasProject, workspace, ]); + const isNavigationHidden = isCompactNavigation + ? !navigationOpen + : canvasSidebarCollapsed; + return (
Skip to workspace - setNavigationOpen((open) => !open)} - onNewSession={openNewSession} - onOpenPalette={openCommandPalette} - /> {workspace.connection.status === "disconnected" && workspace.snapshot ? (
Daemon disconnected. Existing metadata may be stale. @@ -372,19 +504,15 @@ export function AppShell() { ) : null}
Navigation @@ -392,75 +520,99 @@ export function AppShell() {
- {usesCanvasShell ? ( - { - workspace.setView("canvas"); - setNavigationOpen(false); - }} - onHide={() => setCanvasSidebarCollapsed(true)} - onAddProject={openAddProject} - onOpenSettings={() => { - workspace.setView("settings"); - setNavigationOpen(false); - }} - onOpenDiagnostics={() => { - workspace.setView("diagnostics"); - setNavigationOpen(false); - }} - /> - ) : ( - <> - workspace.openOverlay({ kind: "rename-project", projectId })} - onRemoveProject={(projectId) => workspace.openOverlay({ kind: "remove-project", projectId })} - onOpenSettings={() => { workspace.setView("settings"); setNavigationOpen(false); }} - onOpenDiagnostics={() => { workspace.setView("diagnostics"); setNavigationOpen(false); }} - /> - - worktree.projectId === workspace.selectedProject?.id, - )} - selectedSessionId={workspace.selectedSessionId ?? undefined} - projectSelected={workspace.selectedProject !== null} - onSelectSession={selectSession} - onNewSession={openNewSession} - onRemoveWorktree={(worktreeId) => - workspace.openOverlay({ kind: "remove-worktree", worktreeId }) - } - /> - - )} + { + workspace.setView("canvas"); + setNavigationOpen(false); + }} + onHide={hideNavigation} + onAddProject={openAddProject} + onRenameProject={(projectId) => { + closeNavigationForOverlay(); + workspace.openOverlay({ kind: "rename-project", projectId }); + }} + onRemoveProject={(projectId) => { + closeNavigationForOverlay(); + workspace.openOverlay({ kind: "remove-project", projectId }); + }} + onOpenSettings={() => { + workspace.setView("settings"); + setNavigationOpen(false); + }} + onOpenDiagnostics={() => { + workspace.setView("diagnostics"); + setNavigationOpen(false); + }} + />
- {usesCanvasShell && canvasSidebarCollapsed ? ( + {navigationOpen ? - ) : null} - {navigationOpen ? + {workspace.selectedProject ? ( + + ) : ( + + )} +
workspace.setView("canvas")} - onSelectProject={selectProject} - onSelectSession={selectSession} + onSelectSession={(sessionId) => workspace.selectSession(sessionId)} onCreateCustomAgent={workspace.createCustomAgent} onCreateSession={(input) => workspace.createSession(input, { select: false }) @@ -492,10 +641,9 @@ export function AppShell() { onRenameSession={(sessionId) => workspace.openOverlay({ kind: "rename-session", sessionId })} onStopSession={(sessionId) => workspace.openOverlay({ kind: "stop-session", sessionId })} onDeleteSession={(sessionId) => workspace.openOverlay({ kind: "delete-session", sessionId })} - onRemoveWorktree={(sessionId) => { - const session = workspace.sessions.find((candidate) => candidate.id === sessionId); - if (session?.worktreeId) workspace.openOverlay({ kind: "remove-worktree", worktreeId: session.worktreeId }); - }} + onRemoveWorktree={(worktreeId) => + workspace.openOverlay({ kind: "remove-worktree", worktreeId }) + } onGitStatus={(sessionId) => workspace.openOverlay({ kind: "git-status", sessionId })} onOpenPath={workspace.openPath} onLoadDiagnostics={workspace.getDiagnostics} @@ -526,6 +674,35 @@ function readCanvasSidebarCollapsed(): boolean { } } +const COMPACT_NAVIGATION_QUERY = "(max-width: 47.99rem)"; + +/** Keeps drawer behavior aligned with the CSS compact-navigation breakpoint. */ +function useCompactNavigation(onExitCompact: () => void): boolean { + const [isCompact, setIsCompact] = useState(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia(COMPACT_NAVIGATION_QUERY).matches; + }); + + useEffect(() => { + if (typeof window.matchMedia !== "function") { + return undefined; + } + const query = window.matchMedia(COMPACT_NAVIGATION_QUERY); + const update = (event: MediaQueryListEvent) => { + setIsCompact(event.matches); + if (!event.matches) { + onExitCompact(); + } + }; + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, [onExitCompact]); + + return isCompact; +} + const NAVIGATION_FOCUSABLE = [ "a[href]", "button:not([disabled])", diff --git a/apps/desktop/src/app/components/AppHeader.tsx b/apps/desktop/src/app/components/AppHeader.tsx deleted file mode 100644 index b446e63..0000000 --- a/apps/desktop/src/app/components/AppHeader.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { AppPlatform } from "../../ipc/client"; -import type { Project } from "../../ipc/types"; -import { Icon } from "./Icon"; - -interface AppHeaderProps { - readonly project?: Project; - readonly platform: AppPlatform; - readonly canCreateSession: boolean; - readonly navigationOpen: boolean; - readonly onToggleNavigation: () => void; - readonly onNewSession: () => void; - readonly onOpenPalette: () => void; -} - -/** Renders product identity, current repository context, and primary actions. */ -export function AppHeader({ - project, - platform, - canCreateSession, - navigationOpen, - onToggleNavigation, - onNewSession, - onOpenPalette, -}: AppHeaderProps) { - const modifier = platform === "macos" ? "⌘" : "Ctrl"; - return ( -
-
- -
- - Jig -
- {project ? ( -
- {project.name} - - - {project.currentBranch ?? "Branch unavailable"} - -
- ) : null} -
-
- - - {!canCreateSession ? ( - - Add or select an available project first - - ) : null} -
-
- ); -} diff --git a/apps/desktop/src/app/components/Icon.tsx b/apps/desktop/src/app/components/Icon.tsx index d63add0..81f2c5c 100644 --- a/apps/desktop/src/app/components/Icon.tsx +++ b/apps/desktop/src/app/components/Icon.tsx @@ -11,7 +11,6 @@ export type IconName = | "copy" | "diagnostics" | "folder" - | "grid" | "layers" | "link" | "map" @@ -27,7 +26,6 @@ export type IconName = | "repository" | "sidebar" | "search" - | "session" | "settings" | "stop" | "terminal" @@ -109,15 +107,6 @@ function getIconPaths(name: IconName): ReactNode { return ( ); - case "grid": - return ( - <> - - - - - - ); case "layers": return ( <> @@ -207,7 +196,6 @@ function getIconPaths(name: IconName): ReactNode { ); - case "session": case "terminal": return ( <> diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx index 25912bd..c7e6bb9 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from "react"; import { fireEvent, render, @@ -8,9 +9,17 @@ import { import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { CANVAS_STORAGE_KEY } from "./canvas-state"; +import { IpcError } from "../../../ipc/client"; +import type { Session, Worktree } from "../../../ipc/types"; +import { CANVAS_STORAGE_KEY, parseCanvasDocument } from "./canvas-state"; import { CanvasWorkspace } from "./CanvasWorkspace"; +vi.mock("../terminal/LiveTerminal", () => ({ + LiveTerminal: ({ session }: { readonly session: Session }) => ( +
+ ), +})); + const PROJECT = { id: "0198f000-0000-7000-8000-000000000001", name: "Jig", @@ -29,6 +38,40 @@ const SHELL_AGENT = { enabled: true, } as const; +const OTHER_PROJECT = { + ...PROJECT, + id: "0198f000-0000-7000-8000-000000000010", + name: "Other project", + path: "/workspace/other", + repositoryRoot: "/workspace/other", +} as const; + +const STOPPED_SESSION: Session = { + id: "0198f000-0000-7000-8000-000000000003", + projectId: PROJECT.id, + name: "Review agent", + agentId: SHELL_AGENT.id, + cwd: "/workspace/jig/.worktrees/review", + branch: "agent/review", + worktreeId: "0198f000-0000-7000-8000-000000000004", + worktreePath: "/workspace/jig/.worktrees/review", + status: "exited", + createdAtMs: 2, + updatedAtMs: 3, +}; + +const MANAGED_WORKTREE: Worktree = { + id: "0198f000-0000-7000-8000-000000000004", + projectId: PROJECT.id, + sessionId: STOPPED_SESSION.id, + path: "/workspace/jig/.worktrees/review", + branch: "agent/review", + isDirty: false, + state: "active", + createdAtMs: 2, + updatedAtMs: 3, +}; + describe("CanvasWorkspace", () => { beforeEach(() => { localStorage.clear(); @@ -116,6 +159,7 @@ describe("CanvasWorkspace", () => { status: "running", pid: 123, }); + const onSelectSession = vi.fn(); render( { project={PROJECT} agents={[SHELL_AGENT]} sessions={[]} - onAddProject={vi.fn()} - onNewSession={vi.fn()} - onSelectSession={vi.fn()} + worktrees={[]} + sessionFocusRevision={0} + onSelectSession={onSelectSession} onCreateCustomAgent={vi.fn()} onCreateSession={onCreateSession} onStartSession={onStartSession} + onRestartSession={vi.fn()} + onRenameSession={vi.fn()} + onStopSession={vi.fn()} + onDeleteSession={vi.fn()} + onRemoveWorktree={vi.fn()} + onGitStatus={vi.fn()} + onOpenPath={vi.fn()} subscribeTerminal={vi.fn()} writeTerminal={vi.fn()} resizeTerminal={vi.fn()} @@ -151,6 +202,7 @@ describe("CanvasWorkspace", () => { relativeDirectory: undefined, }); expect(onStartSession).toHaveBeenCalledWith(createdSession.id); + expect(onSelectSession).toHaveBeenCalledWith(createdSession.id); }); const persisted = JSON.parse( localStorage.getItem(CANVAS_STORAGE_KEY) ?? "{}", @@ -330,6 +382,141 @@ describe("CanvasWorkspace", () => { expect(viewport!.scrollTop).toBe(1_440); }); + it("leaves arrow-key editing inside notes to the textarea", () => { + const { container } = renderCanvas(); + const viewport = container.querySelector(".canvas-viewport"); + expect(viewport).not.toBeNull(); + viewport!.scrollLeft = 2_000; + viewport!.scrollTop = 1_500; + const note = screen.getByRole("textbox", { name: "Notes content" }); + + expect(fireEvent.keyDown(note, { key: "ArrowLeft" })).toBe(true); + expect(fireEvent.keyDown(note, { key: "ArrowUp" })).toBe(true); + + expect(viewport!.scrollLeft).toBe(2_000); + expect(viewport!.scrollTop).toBe(1_500); + }); + + it("uses one compact terminal width for rendering and connection geometry", async () => { + const user = userEvent.setup(); + const view = renderProjectCanvas({ isCompact: true }); + const viewport = view.container.querySelector( + ".canvas-viewport", + ); + expect(viewport).not.toBeNull(); + Object.defineProperty(viewport, "clientWidth", { + configurable: true, + value: 320, + }); + fireEvent(window, new Event("resize")); + const terminal = screen.getByRole("article", { + name: "Terminal 1, terminal canvas item", + }); + + await waitFor(() => { + expect(Number.parseFloat(terminal.style.width)).toBeCloseTo(272); + expect(connectionEndpointX(view.container)).toBeCloseTo(442); + }); + + await user.click(terminal); + const resize = within(terminal).getByRole("button", { + name: "Resize Terminal 1", + }); + fireEvent.keyDown(resize, { key: "ArrowRight" }); + await waitFor(() => { + expect(readTerminalSize("terminal-primary").width).toBe(448); + }); + fireEvent.pointerDown(resize, { + pointerId: 73, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(resize, { + pointerId: 73, + clientX: 110, + clientY: 100, + }); + fireEvent.pointerUp(resize, { pointerId: 73 }); + await waitFor(() => { + expect(readTerminalSize("terminal-primary").width).toBe(458); + }); + expect(Number.parseFloat(terminal.style.width)).toBeCloseTo(272); + + const zoomIn = screen.getByRole("button", { name: "Zoom in" }); + for (let step = 0; step < 5; step += 1) { + await user.click(zoomIn); + } + await waitFor(() => { + const modelWidth = Number.parseFloat(terminal.style.width); + expect(modelWidth).toBeCloseTo(272 / 1.5); + expect(modelWidth * 1.5).toBeCloseTo(272); + expect(connectionEndpointX(view.container)).toBeCloseTo( + 170 + 272 / 1.5, + ); + }); + expect( + readCanvasDocument().nodes.find((node) => node.id === "terminal-primary"), + ).toEqual(expect.objectContaining({ width: 458 })); + + view.rerender( + , + ); + await waitFor(() => { + expect(Number.parseFloat(terminal.style.width)).toBe(458); + expect(connectionEndpointX(view.container)).toBe(628); + }); + }); + + it("fits compact nodes using their geometry at the destination zoom", async () => { + localStorage.setItem( + CANVAS_STORAGE_KEY, + JSON.stringify({ + version: 1, + nodes: [ + { + id: "terminal-wide", + kind: "terminal", + title: "Wide terminal", + x: 0, + y: 0, + width: 960, + height: 256, + preset: "shell", + }, + ], + connections: [], + zoom: 1.5, + hiddenSessionIds: [], + }), + ); + const user = userEvent.setup(); + const view = renderCanvas({ isCompact: true }); + const viewport = view.container.querySelector( + ".canvas-viewport", + ); + expect(viewport).not.toBeNull(); + const scrollTo = vi.fn(); + Object.defineProperties(viewport, { + clientWidth: { configurable: true, value: 320 }, + clientHeight: { configurable: true, value: 640 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + fireEvent(window, new Event("resize")); + const terminal = screen.getByRole("article", { + name: "Wide terminal, terminal canvas item", + }); + + await user.click(screen.getByRole("button", { name: "Fit canvas to items" })); + + await waitFor(() => { + expect(screen.getByText("50%")).toBeVisible(); + expect(Number.parseFloat(terminal.style.width) * 0.5).toBeCloseTo(272); + expect(scrollTo).toHaveBeenLastCalledWith( + expect.objectContaining({ left: 1_476, top: 1_244 }), + ); + }); + }); + it("removes a selected item's connection from the inspector", async () => { const user = userEvent.setup(); const { container } = renderCanvas(); @@ -357,26 +544,701 @@ describe("CanvasWorkspace", () => { container.querySelectorAll("[data-connection-id]"), ).toHaveLength(1); }); + + it("reconciles every selected-project session and hides attached nodes from other projects", async () => { + const user = userEvent.setup(); + const otherSession: Session = { + ...STOPPED_SESSION, + id: "0198f000-0000-7000-8000-000000000011", + projectId: OTHER_PROJECT.id, + name: "Other agent", + cwd: OTHER_PROJECT.path, + worktreeId: undefined, + worktreePath: undefined, + }; + const view = renderProjectCanvas({ + projects: [PROJECT, OTHER_PROJECT], + sessions: [STOPPED_SESSION, otherSession], + }); + + const projectTerminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + expect(projectTerminal).toBeVisible(); + await user.click(projectTerminal); + expect(projectTerminal).toHaveAttribute("data-selected", "true"); + expect( + screen.queryByRole("article", { + name: "Other agent, terminal canvas item", + }), + ).not.toBeInTheDocument(); + + view.rerender( + , + ); + + expect( + await screen.findByRole("article", { + name: "Other agent, terminal canvas item", + }), + ).toBeVisible(); + expect( + screen.queryByRole("article", { + name: "Review agent, terminal canvas item", + }), + ).not.toBeInTheDocument(); + + view.rerender( + , + ); + + expect( + await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }), + ).not.toHaveAttribute("data-selected"); + const persisted = readCanvasDocument(); + expect(persisted.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: STOPPED_SESSION.id, + projectId: PROJECT.id, + }), + expect.objectContaining({ + sessionId: otherSession.id, + projectId: OTHER_PROJECT.id, + }), + ]), + ); + }); + + it("removes an attached card only from the canvas and persists its dismissal", async () => { + const user = userEvent.setup(); + const onDeleteSession = vi.fn(); + const firstView = renderProjectCanvas({ + sessions: [STOPPED_SESSION], + onDeleteSession, + }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + + await user.click( + within(terminal).getByRole("button", { + name: "Remove Review agent from canvas", + }), + ); + + await waitFor(() => { + expect( + screen.queryByRole("article", { + name: "Review agent, terminal canvas item", + }), + ).not.toBeInTheDocument(); + expect(readCanvasDocument().hiddenSessionIds).toContain( + STOPPED_SESSION.id, + ); + }); + expect(onDeleteSession).not.toHaveBeenCalled(); + + firstView.unmount(); + renderProjectCanvas({ sessions: [STOPPED_SESSION], onDeleteSession }); + await waitFor(() => { + expect( + screen.queryByRole("article", { + name: "Review agent, terminal canvas item", + }), + ).not.toBeInTheDocument(); + }); + expect(onDeleteSession).not.toHaveBeenCalled(); + }); + + it("reconciles project sessions again after resetting the canvas document", async () => { + const user = userEvent.setup(); + renderProjectCanvas({ sessions: [STOPPED_SESSION] }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + await user.click( + within(terminal).getByRole("button", { + name: "Remove Review agent from canvas", + }), + ); + await waitFor(() => { + expect(terminal).not.toBeInTheDocument(); + expect(readCanvasDocument().hiddenSessionIds).toContain( + STOPPED_SESSION.id, + ); + }); + + await user.click( + screen.getByRole("button", { name: "Reset canvas layout" }), + ); + + await waitFor(() => { + expect( + screen.getByRole("article", { + name: "Review agent, terminal canvas item", + }), + ).toBeVisible(); + expect( + readCanvasDocument().nodes.some( + (node) => + node.kind === "terminal" && + node.sessionId === STOPPED_SESSION.id, + ), + ).toBe(true); + expect(readCanvasDocument().hiddenSessionIds).not.toContain( + STOPPED_SESSION.id, + ); + }); + }); + + it("reveals, selects, focuses, and centers repeated session focus requests", async () => { + localStorage.setItem( + CANVAS_STORAGE_KEY, + JSON.stringify({ + version: 1, + nodes: [], + connections: [], + zoom: 1, + hiddenSessionIds: [STOPPED_SESSION.id], + }), + ); + const scrollTo = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + value: scrollTo, + }); + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ matches: true }), + ); + const view = renderProjectCanvas({ + sessions: [STOPPED_SESSION], + selectedSessionId: STOPPED_SESSION.id, + sessionFocusRevision: 1, + }); + + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + await waitFor(() => { + expect(terminal).toHaveAttribute("data-selected", "true"); + expect(terminal).toHaveFocus(); + expect(scrollTo).toHaveBeenLastCalledWith( + expect.objectContaining({ behavior: "auto" }), + ); + }); + expect(readCanvasDocument().hiddenSessionIds).not.toContain( + STOPPED_SESSION.id, + ); + + scrollTo.mockClear(); + screen.getByRole("main").focus(); + view.rerender( + , + ); + + await waitFor(() => { + expect(terminal).toHaveFocus(); + expect(scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ behavior: "auto" }), + ); + }); + vi.unstubAllGlobals(); + }); + + it("exposes all stopped-session actions and reports direct action errors", async () => { + const user = userEvent.setup(); + const onStartSession = vi.fn().mockResolvedValue(STOPPED_SESSION); + const onRestartSession = vi + .fn() + .mockRejectedValueOnce( + new IpcError({ + code: "restart_failed", + message: "Restart failed safely", + action: "Inspect the session and retry.", + }), + ) + .mockResolvedValue(STOPPED_SESSION); + const onRenameSession = vi.fn(); + const onDeleteSession = vi.fn(); + const onRemoveWorktree = vi.fn(); + const onGitStatus = vi.fn(); + const onOpenPath = vi.fn().mockResolvedValue(undefined); + renderProjectCanvas({ + sessions: [STOPPED_SESSION], + worktrees: [MANAGED_WORKTREE], + onStartSession, + onRestartSession, + onRenameSession, + onDeleteSession, + onRemoveWorktree, + onGitStatus, + onOpenPath, + }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + const actionsTrigger = within(terminal).getByRole("button", { + name: "Session actions for Review agent", + }); + const openActions = async () => { + await user.click(actionsTrigger); + }; + + await openActions(); + const actions = within(terminal).getByRole("group", { + name: "Actions for Review agent", + }); + expect( + within(actions).getByRole("button", { name: "Stop process" }), + ).toHaveAttribute("aria-disabled", "true"); + await user.click( + within(actions).getByRole("button", { name: "Start session" }), + ); + await waitFor(() => + expect(onStartSession).toHaveBeenCalledWith(STOPPED_SESSION.id), + ); + expect(actionsTrigger).toHaveFocus(); + expect(actionsTrigger).toHaveAttribute("aria-expanded", "false"); + + await openActions(); + await user.click( + within(terminal).getByRole("button", { name: "Restart session" }), + ); + expect(await within(terminal).findByRole("alert")).toHaveTextContent( + "Restart failed safely", + ); + await user.click( + within(terminal).getByRole("button", { + name: "Dismiss session action error", + }), + ); + await user.click( + within(terminal).getByRole("button", { name: "Restart session" }), + ); + await waitFor(() => + expect(onRestartSession).toHaveBeenCalledTimes(2), + ); + expect(actionsTrigger).toHaveFocus(); + + const overlayActions = [ + ["Rename session", onRenameSession, STOPPED_SESSION.id], + ["Git status", onGitStatus, STOPPED_SESSION.id], + ["Delete session metadata", onDeleteSession, STOPPED_SESSION.id], + ["Remove worktree", onRemoveWorktree, MANAGED_WORKTREE.id], + ] as const; + for (const [label, callback, expectedId] of overlayActions) { + await openActions(); + await user.click(within(terminal).getByRole("button", { name: label })); + expect(callback).toHaveBeenCalledWith(expectedId); + expect(actionsTrigger).toHaveFocus(); + } + + await openActions(); + await user.click( + within(terminal).getByRole("button", { + name: "Open working directory", + }), + ); + await waitFor(() => + expect(onOpenPath).toHaveBeenCalledWith(MANAGED_WORKTREE.path), + ); + expect( + within(terminal).queryByRole("button", { name: "Session details" }), + ).not.toBeInTheDocument(); + }); + + it("closes session actions with Escape and outside pointer or focus", async () => { + const user = userEvent.setup(); + renderProjectCanvas({ + sessions: [STOPPED_SESSION], + worktrees: [MANAGED_WORKTREE], + }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + const trigger = within(terminal).getByRole("button", { + name: "Session actions for Review agent", + }); + + await user.click(trigger); + const restart = within(terminal).getByRole("button", { + name: "Restart session", + }); + restart.focus(); + await user.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); + expect( + within(terminal).queryByRole("group", { + name: "Actions for Review agent", + }), + ).not.toBeInTheDocument(); + + await user.click(trigger); + const addNote = screen.getByRole("button", { name: "Add note" }); + await user.click(addNote); + expect(addNote).toHaveFocus(); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + await user.click(trigger); + const zoomIn = screen.getByRole("button", { name: "Zoom in" }); + zoomIn.focus(); + await waitFor(() => + expect(trigger).toHaveAttribute("aria-expanded", "false"), + ); + expect(zoomIn).toHaveFocus(); + }); + + it("scopes project-owned notes and terminal drafts while retaining legacy nodes", async () => { + const user = userEvent.setup(); + localStorage.setItem( + CANVAS_STORAGE_KEY, + JSON.stringify({ + version: 1, + zoom: 1, + connections: [], + nodes: [ + { + id: "own-note", + kind: "note", + projectId: PROJECT.id, + title: "Project note", + text: "Only in Jig", + x: 0, + y: 0, + }, + { + id: "other-terminal-draft", + kind: "terminal", + projectId: OTHER_PROJECT.id, + title: "Other draft", + preset: "shell", + width: 432, + height: 256, + x: 20, + y: 20, + }, + { + id: "legacy-note", + kind: "note", + title: "Legacy note", + text: "Shared compatibility node", + x: 60, + y: 60, + }, + ], + }), + ); + const view = renderProjectCanvas({ projects: [PROJECT, OTHER_PROJECT] }); + + expect( + screen.getByRole("article", { name: "Project note, note canvas item" }), + ).toBeVisible(); + expect( + screen.getByRole("article", { name: "Legacy note, note canvas item" }), + ).toBeVisible(); + expect( + screen.queryByRole("article", { + name: "Other draft, terminal canvas item", + }), + ).not.toBeInTheDocument(); + view.rerender( + , + ); + expect( + screen.queryByRole("article", { + name: "Project note, note canvas item", + }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("article", { + name: "Other draft, terminal canvas item", + }), + ).toBeVisible(); + expect( + screen.getByRole("article", { name: "Legacy note, note canvas item" }), + ).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Add note" })); + await waitFor(() => { + expect(readCanvasDocument().nodes).toContainEqual( + expect.objectContaining({ + kind: "note", + title: "Notes", + projectId: OTHER_PROJECT.id, + }), + ); + }); + }); + + it("resets only the selected project's canvas layout", async () => { + const user = userEvent.setup(); + const otherSession: Session = { + ...STOPPED_SESSION, + id: "0198f000-0000-7000-8000-000000000011", + projectId: OTHER_PROJECT.id, + name: "Other review agent", + }; + localStorage.setItem( + CANVAS_STORAGE_KEY, + JSON.stringify({ + version: 1, + zoom: 0.8, + nodes: [ + { + id: "selected-project-note", + kind: "note", + projectId: PROJECT.id, + title: "Selected project note", + text: "Reset me", + x: 0, + y: 0, + }, + { + id: "other-project-note", + kind: "note", + projectId: OTHER_PROJECT.id, + title: "Other project note", + text: "Keep me", + x: 20, + y: 20, + }, + { + id: "legacy-note", + kind: "note", + title: "Legacy note", + text: "Keep compatibility", + x: 40, + y: 40, + }, + ], + connections: [ + { + id: "cross-project-connection", + sourceNodeId: "selected-project-note", + targetNodeId: "other-project-note", + }, + ], + hiddenSessionIds: [STOPPED_SESSION.id, otherSession.id], + }), + ); + renderProjectCanvas({ + projects: [PROJECT, OTHER_PROJECT], + sessions: [STOPPED_SESSION, otherSession], + }); + + await user.click( + screen.getByRole("button", { name: "Reset canvas layout" }), + ); + + await waitFor(() => { + const document = readCanvasDocument(); + expect(document.nodes.map((node) => node.id)).toEqual( + expect.arrayContaining([ + "other-project-note", + "legacy-note", + `terminal-session-${STOPPED_SESSION.id}`, + ]), + ); + expect(document.nodes.map((node) => node.id)).not.toContain( + "selected-project-note", + ); + expect(document.connections).toEqual([]); + expect(document.zoom).toBe(0.8); + expect(document.hiddenSessionIds).toEqual([otherSession.id]); + }); + }); + + it("removes a worktree resolved by session association", async () => { + const user = userEvent.setup(); + const session = { ...STOPPED_SESSION, worktreeId: undefined }; + const onRemoveWorktree = vi.fn(); + renderProjectCanvas({ + sessions: [session], + worktrees: [MANAGED_WORKTREE], + onRemoveWorktree, + }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + + await user.click( + within(terminal).getByRole("button", { + name: "Session actions for Review agent", + }), + ); + await user.click( + within(terminal).getByRole("button", { name: "Remove worktree" }), + ); + + expect(onRemoveWorktree).toHaveBeenCalledWith(MANAGED_WORKTREE.id); + }); + + it("enables stop for a live session while protecting destructive actions", async () => { + const user = userEvent.setup(); + const runningSession: Session = { + ...STOPPED_SESSION, + status: "running", + pid: 811, + }; + const onStopSession = vi.fn(); + renderProjectCanvas({ + sessions: [runningSession], + worktrees: [MANAGED_WORKTREE], + onStopSession, + }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + + await user.click( + within(terminal).getByRole("button", { + name: "Session actions for Review agent", + }), + ); + expect( + within(terminal).getByRole("button", { name: "Start session" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + within(terminal).getByRole("button", { + name: "Delete session metadata", + }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + within(terminal).getByRole("button", { name: "Remove worktree" }), + ).toHaveAttribute("aria-disabled", "true"); + await user.click( + within(terminal).getByRole("button", { name: "Stop process" }), + ); + expect(onStopSession).toHaveBeenCalledWith(runningSession.id); + }); + + it("never falls back to stale paths when a managed worktree is missing", async () => { + const user = userEvent.setup(); + const onStartSession = vi.fn(); + const onRestartSession = vi.fn(); + const onRemoveWorktree = vi.fn(); + const onGitStatus = vi.fn(); + const onOpenPath = vi.fn(); + renderProjectCanvas({ + sessions: [STOPPED_SESSION], + worktrees: [], + onStartSession, + onRestartSession, + onRemoveWorktree, + onGitStatus, + onOpenPath, + }); + const terminal = await screen.findByRole("article", { + name: "Review agent, terminal canvas item", + }); + + const startTerminal = within(terminal).getByRole("button", { + name: "Start terminal", + }); + expect(startTerminal).toHaveAttribute("aria-disabled", "true"); + await user.click(startTerminal); + await user.click( + within(terminal).getByRole("button", { + name: "Session actions for Review agent", + }), + ); + for (const label of [ + "Start session", + "Restart session", + "Git status", + "Open working directory", + "Remove worktree", + ]) { + const action = within(terminal).getByRole("button", { name: label }); + expect(action).toHaveAttribute("aria-disabled", "true"); + await user.click(action); + } + expect(onStartSession).not.toHaveBeenCalled(); + expect(onRestartSession).not.toHaveBeenCalled(); + expect(onRemoveWorktree).not.toHaveBeenCalled(); + expect(onGitStatus).not.toHaveBeenCalled(); + expect(onOpenPath).not.toHaveBeenCalled(); + }); }); -function renderCanvas() { - return render( - , - ); +function renderCanvas( + overrides: Partial> = {}, +) { + const props: ComponentProps = { + isConnected: true, + projects: [], + agents: [], + sessions: [], + worktrees: [], + sessionFocusRevision: 0, + onSelectSession: vi.fn(), + onCreateCustomAgent: vi.fn(), + onCreateSession: vi.fn(), + onStartSession: vi.fn(), + onRestartSession: vi.fn(), + onRenameSession: vi.fn(), + onStopSession: vi.fn(), + onDeleteSession: vi.fn(), + onRemoveWorktree: vi.fn(), + onGitStatus: vi.fn(), + onOpenPath: vi.fn(), + subscribeTerminal: vi.fn(), + writeTerminal: vi.fn(), + resizeTerminal: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +function renderProjectCanvas( + overrides: Partial> = {}, +) { + const props: ComponentProps = { + isConnected: true, + projects: [PROJECT], + project: PROJECT, + agents: [SHELL_AGENT], + sessions: [], + worktrees: [], + sessionFocusRevision: 0, + onSelectSession: vi.fn(), + onCreateCustomAgent: vi.fn().mockResolvedValue(SHELL_AGENT), + onCreateSession: vi.fn().mockResolvedValue(STOPPED_SESSION), + onStartSession: vi.fn().mockResolvedValue(STOPPED_SESSION), + onRestartSession: vi.fn().mockResolvedValue(STOPPED_SESSION), + onRenameSession: vi.fn(), + onStopSession: vi.fn(), + onDeleteSession: vi.fn(), + onRemoveWorktree: vi.fn(), + onGitStatus: vi.fn(), + onOpenPath: vi.fn().mockResolvedValue(undefined), + subscribeTerminal: vi.fn(), + writeTerminal: vi.fn(), + resizeTerminal: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +function readCanvasDocument() { + return parseCanvasDocument(localStorage.getItem(CANVAS_STORAGE_KEY)); +} + +function connectionEndpointX(container: HTMLElement): number { + const path = container.querySelector("[data-connection-id] path"); + const coordinates = path?.getAttribute("d")?.match(/-?\d+(?:\.\d+)?/g); + if (!coordinates || coordinates.length < 2) { + throw new Error("Expected a rendered canvas connection path."); + } + return Number(coordinates[coordinates.length - 2]); } function readNodePosition(nodeId: string) { diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx index fc0db82..d6b4ff1 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx @@ -1,17 +1,29 @@ -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import type { AgentRecord, + ApiErrorData, CreateCustomAgentInput, CreateSessionInput, Project, Session, + Worktree, } from "../../../ipc/types"; import { Icon } from "../../components/Icon"; import { StatusBadge } from "../../components/StatusBadge"; import { createCanvasNode, createInitialCanvasDocument, + createSessionTerminalCanvasNode, createTerminalCanvasNode, getCanvasNodeSize, type CanvasTerminalConfiguration, @@ -32,46 +44,73 @@ import { LiveTerminal, type LiveTerminalTransport, } from "../terminal/LiveTerminal"; -import { isLiveStatus } from "../../utils"; +import { errorData, isLiveStatus } from "../../utils"; + +export interface CanvasSessionFocusRequest { + readonly sessionId: string; + readonly revision: number; +} interface CanvasWorkspaceProps extends LiveTerminalTransport { + readonly isCompact?: boolean; readonly isConnected: boolean; readonly projects: readonly Project[]; readonly project?: Project; readonly agents: readonly AgentRecord[]; readonly sessions: readonly Session[]; - readonly onAddProject: () => void; - readonly onNewSession: () => void; - readonly onSelectSession: (sessionId: string) => void; + readonly worktrees: readonly Worktree[]; + readonly selectedSessionId?: string; + readonly sessionFocusRevision: number; + readonly onSelectSession: (sessionId: string | null) => void; readonly onCreateCustomAgent: ( input: CreateCustomAgentInput, ) => Promise; readonly onCreateSession: (input: CreateSessionInput) => Promise; readonly onStartSession: (sessionId: string) => Promise; + readonly onRestartSession: (sessionId: string) => Promise; + readonly onRenameSession: (sessionId: string) => void; + readonly onStopSession: (sessionId: string) => void; + readonly onDeleteSession: (sessionId: string) => void; + readonly onRemoveWorktree: (worktreeId: string) => void; + readonly onGitStatus: (sessionId: string) => void; + readonly onOpenPath: (path: string) => Promise; } const ZOOM_STEP = 0.1; +const COMPACT_TERMINAL_GUTTER_PX = 48; /** Spatial terminal and notes workspace inspired by the supplied references. */ export function CanvasWorkspace({ + isCompact = false, isConnected, projects, project, agents, sessions, - onAddProject, - onNewSession, + worktrees, + selectedSessionId, + sessionFocusRevision, onSelectSession, onCreateCustomAgent, onCreateSession, onStartSession, + onRestartSession, + onRenameSession, + onStopSession, + onDeleteSession, + onRemoveWorktree, + onGitStatus, + onOpenPath, subscribeTerminal, writeTerminal, resizeTerminal, }: CanvasWorkspaceProps) { const { state, dispatch, persistenceAvailable } = useCanvasState(); const viewportRef = useRef(null); + const viewportWidth = useElementWidth(viewportRef); const viewportInitializedRef = useRef(false); + const nodeElementsRef = useRef(new Map()); + const handledFocusRequestRef = useRef(null); const panRef = useRef<{ readonly pointerId: number; readonly clientX: number; @@ -91,21 +130,212 @@ export function CanvasWorkspace({ () => new Map(sessions.map((session) => [session.id, session])), [sessions], ); - const selectedNode = state.nodes.find( + const projectSessions = useMemo( + () => + project + ? sessions.filter((session) => session.projectId === project.id) + : [], + [project, sessions], + ); + const hiddenSessionIds = useMemo( + () => new Set(state.hiddenSessionIds), + [state.hiddenSessionIds], + ); + const storedVisibleNodes = useMemo( + () => + state.nodes.filter((node) => { + if ( + node.kind === "terminal" && + node.sessionId && + hiddenSessionIds.has(node.sessionId) + ) { + return false; + } + const session = + node.kind === "terminal" && node.sessionId + ? terminalSessions.get(node.sessionId) + : undefined; + const nodeProjectId = session?.projectId ?? node.projectId; + return !nodeProjectId || nodeProjectId === project?.id; + }), + [hiddenSessionIds, project?.id, state.nodes, terminalSessions], + ); + const visibleNodes = useMemo( + () => + effectiveCanvasNodes( + storedVisibleNodes, + isCompact, + viewportWidth, + state.zoom, + ), + [isCompact, state.zoom, storedVisibleNodes, viewportWidth], + ); + const storedVisibleNodesById = useMemo( + () => new Map(storedVisibleNodes.map((node) => [node.id, node])), + [storedVisibleNodes], + ); + const visibleNodeIds = useMemo( + () => new Set(visibleNodes.map((node) => node.id)), + [visibleNodes], + ); + const visibleConnections = useMemo( + () => + state.connections.filter( + (connection) => + visibleNodeIds.has(connection.sourceNodeId) && + visibleNodeIds.has(connection.targetNodeId), + ), + [state.connections, visibleNodeIds], + ); + const visibleConnectionSourceId = + state.connectionSourceId && visibleNodeIds.has(state.connectionSourceId) + ? state.connectionSourceId + : null; + const selectedNode = visibleNodes.find( (node) => node.id === state.selectedNodeId, ); const selectedConnections = selectedNode - ? state.connections.flatMap((connection) => { + ? visibleConnections.flatMap((connection) => { const otherNodeId = connection.sourceNodeId === selectedNode.id ? connection.targetNodeId : connection.targetNodeId === selectedNode.id ? connection.sourceNodeId : null; - const otherNode = state.nodes.find((node) => node.id === otherNodeId); + const otherNode = visibleNodes.find((node) => node.id === otherNodeId); return otherNode ? [{ connection, otherNode }] : []; }) : []; + const sessionCanvasTopologyKey = useMemo( + () => + JSON.stringify({ + attachedSessionIds: state.nodes.flatMap((node) => + node.kind === "terminal" && node.sessionId ? [node.sessionId] : [], + ), + hiddenSessionIds: state.hiddenSessionIds, + }), + [state.hiddenSessionIds, state.nodes], + ); + const focusNode = useCallback( + (node: CanvasNode) => { + const viewport = viewportRef.current; + onSelectSession( + node.kind === "terminal" && node.sessionId ? node.sessionId : null, + ); + dispatch({ type: "node/select", nodeId: node.id }); + setLayersOpen(false); + nodeElementsRef.current.get(node.id)?.focus({ preventScroll: true }); + if (!viewport) { + return; + } + + const size = getCanvasNodeSize(node); + viewport.scrollTo({ + left: Math.max( + 0, + (CANVAS_ORIGIN_X + node.x + size.width / 2) * state.zoom - + viewport.clientWidth / 2, + ), + top: Math.max( + 0, + (CANVAS_ORIGIN_Y + node.y + size.height / 2) * state.zoom - + viewport.clientHeight / 2, + ), + behavior: canvasScrollBehavior(), + }); + }, + [dispatch, onSelectSession, state.zoom], + ); + + useLayoutEffect(() => { + dispatch({ + type: "sessions/reconcile", + knownSessionIds: sessions.map((session) => session.id), + sessionNodes: projectSessions.map((session, index) => + createSessionTerminalCanvasNode( + reconciledSessionPosition(index), + session, + ), + ), + }); + }, [dispatch, projectSessions, sessionCanvasTopologyKey, sessions]); + + useLayoutEffect(() => { + if ( + state.selectedNodeId !== null && + !visibleNodeIds.has(state.selectedNodeId) + ) { + dispatch({ type: "node/select", nodeId: null }); + } + if ( + state.connectionSourceId !== null && + !visibleNodeIds.has(state.connectionSourceId) + ) { + dispatch({ type: "connection/cancel" }); + } + }, [ + dispatch, + state.connectionSourceId, + state.selectedNodeId, + visibleNodeIds, + ]); + + useLayoutEffect(() => { + if (!selectedSessionId) { + handledFocusRequestRef.current = null; + return; + } + const request: CanvasSessionFocusRequest = { + sessionId: selectedSessionId, + revision: sessionFocusRevision, + }; + const handledRequest = handledFocusRequestRef.current; + if ( + handledRequest?.sessionId === request.sessionId && + handledRequest.revision === request.revision + ) { + return; + } + + const session = terminalSessions.get(request.sessionId); + if (!session || session.projectId !== project?.id) { + return; + } + const existingNode = visibleNodes.find( + (node) => + node.kind === "terminal" && node.sessionId === request.sessionId, + ); + if (!existingNode || hiddenSessionIds.has(request.sessionId)) { + const projectSessionIndex = Math.max( + 0, + projectSessions.findIndex((candidate) => candidate.id === session.id), + ); + dispatch({ + type: "session/reveal", + node: createSessionTerminalCanvasNode( + reconciledSessionPosition(projectSessionIndex), + session, + ), + }); + return; + } + if (!nodeElementsRef.current.has(existingNode.id)) { + return; + } + + handledFocusRequestRef.current = request; + focusNode(existingNode); + }, [ + dispatch, + focusNode, + hiddenSessionIds, + project?.id, + projectSessions, + selectedSessionId, + sessionFocusRevision, + terminalSessions, + visibleNodes, + ]); useLayoutEffect(() => { const viewport = viewportRef.current; @@ -141,14 +371,23 @@ export function CanvasWorkspace({ } function addNote() { + const node = createCanvasNode("note", nextNodePosition()); + onSelectSession(null); dispatch({ type: "node/add", - node: createCanvasNode("note", nextNodePosition()), + node: project ? { ...node, projectId: project.id } : node, }); } function addTerminal(configuration: CanvasTerminalConfiguration) { - const node = createTerminalCanvasNode(nextNodePosition(), configuration); + const terminal = createTerminalCanvasNode( + nextNodePosition(), + configuration, + ); + const node = project + ? { ...terminal, projectId: project.id } + : terminal; + onSelectSession(null); dispatch({ type: "node/add", node, @@ -194,7 +433,9 @@ export function CanvasWorkspace({ type: "terminal/attach", nodeId: node.id, sessionId: created.id, + projectId: created.projectId, }); + onSelectSession(created.id); await onStartSession(created.id); } catch (error) { setTerminalErrors((current) => ({ @@ -214,72 +455,87 @@ export function CanvasWorkspace({ dispatch({ type: "zoom/set", zoom: Number(zoom.toFixed(2)) }); } - function focusNode(node: CanvasNode) { + function fitCanvasToItems() { const viewport = viewportRef.current; - dispatch({ type: "node/select", nodeId: node.id }); - setLayersOpen(false); - if (!viewport) { + if (!viewport || storedVisibleNodes.length === 0) { return; } - const size = getCanvasNodeSize(node); + const storedBounds = canvasNodeBounds(storedVisibleNodes); + const measuredViewportWidth = viewport.clientWidth || viewportWidth || 960; + const viewportHeight = viewport.clientHeight || 640; + const nextZoom = Number( + Math.min( + 1, + Math.max( + 0.5, + Math.min( + (measuredViewportWidth - 160) / storedBounds.width, + (viewportHeight - 160) / storedBounds.height, + ), + ), + ).toFixed(2), + ); + const fittedBounds = canvasNodeBounds( + effectiveCanvasNodes( + storedVisibleNodes, + isCompact, + measuredViewportWidth, + nextZoom, + ), + ); + + setZoom(nextZoom); viewport.scrollTo({ left: Math.max( 0, - (CANVAS_ORIGIN_X + node.x + size.width / 2) * state.zoom - - viewport.clientWidth / 2, + (CANVAS_ORIGIN_X + fittedBounds.minimumX + fittedBounds.width / 2) * + nextZoom - + measuredViewportWidth / 2, ), top: Math.max( 0, - (CANVAS_ORIGIN_Y + node.y + size.height / 2) * state.zoom - - viewport.clientHeight / 2, + (CANVAS_ORIGIN_Y + fittedBounds.minimumY + fittedBounds.height / 2) * + nextZoom - + viewportHeight / 2, ), - behavior: "smooth", + behavior: canvasScrollBehavior(), }); } - function fitCanvasToItems() { - const viewport = viewportRef.current; - if (!viewport || state.nodes.length === 0) { + function resetCanvasLayout() { + onSelectSession(null); + if (!project) { + dispatch({ + type: "document/hydrate", + document: createInitialCanvasDocument(), + }); return; } - const minimumX = Math.min(...state.nodes.map((node) => node.x)); - const minimumY = Math.min(...state.nodes.map((node) => node.y)); - const maximumX = Math.max( - ...state.nodes.map((node) => node.x + getCanvasNodeSize(node).width), + const projectNodeIds = new Set( + state.nodes + .filter((node) => node.projectId === project.id) + .map((node) => node.id), ); - const maximumY = Math.max( - ...state.nodes.map((node) => node.y + getCanvasNodeSize(node).height), + const projectSessionIds = new Set( + projectSessions.map((session) => session.id), ); - const contentWidth = maximumX - minimumX; - const contentHeight = maximumY - minimumY; - const viewportWidth = viewport.clientWidth || 960; - const viewportHeight = viewport.clientHeight || 640; - const nextZoom = Math.min( - 1, - Math.max( - 0.5, - Math.min( - (viewportWidth - 160) / contentWidth, - (viewportHeight - 160) / contentHeight, + dispatch({ + type: "document/hydrate", + document: { + version: 1, + nodes: state.nodes.filter((node) => !projectNodeIds.has(node.id)), + connections: state.connections.filter( + (connection) => + !projectNodeIds.has(connection.sourceNodeId) && + !projectNodeIds.has(connection.targetNodeId), ), - ), - ); - - setZoom(nextZoom); - viewport.scrollTo({ - left: Math.max( - 0, - (CANVAS_ORIGIN_X + minimumX + contentWidth / 2) * nextZoom - - viewportWidth / 2, - ), - top: Math.max( - 0, - (CANVAS_ORIGIN_Y + minimumY + contentHeight / 2) * nextZoom - - viewportHeight / 2, - ), - behavior: "smooth", + zoom: state.zoom, + hiddenSessionIds: state.hiddenSessionIds.filter( + (sessionId) => !projectSessionIds.has(sessionId), + ), + }, }); } @@ -298,21 +554,12 @@ export function CanvasWorkspace({ {projects.length} {projects.length === 1 ? "project" : "projects"} - {state.nodes.filter((node) => node.kind === "terminal").length}{" "} + {visibleNodes.filter((node) => node.kind === "terminal").length}{" "} terminals
- -
-
- {project ? ( - - ) : ( - - )} -
- - {state.connectionSourceId ? ( + {visibleConnectionSourceId ? (
Choose another terminal or note to finish the connection. @@ -424,7 +649,7 @@ export function CanvasWorkspace({ aria-label="Pannable canvas" aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight" onKeyDown={(event) => { - if (event.defaultPrevented) { + if (event.defaultPrevented || event.currentTarget !== event.target) { return; } const step = event.altKey ? 16 : 80; @@ -438,11 +663,13 @@ export function CanvasWorkspace({ onPointerDown={(event) => { if ( event.button !== 0 || - (event.target as HTMLElement).closest(".canvas-node") + (event.target instanceof Element && + event.target.closest(".canvas-node")) ) { return; } event.preventDefault(); + onSelectSession(null); dispatch({ type: "node/select", nodeId: null }); panRef.current = { pointerId: event.pointerId, @@ -484,41 +711,57 @@ export function CanvasWorkspace({ style={{ transform: `scale(${state.zoom})` }} > - {state.nodes.map((node) => { + {visibleNodes.map((node) => { + const storedNode = storedVisibleNodesById.get(node.id); const session = node.kind === "terminal" ? terminalSessions.get(node.sessionId ?? "") : undefined; + const worktree = session + ? worktrees.find((candidate) => + session.worktreeId + ? candidate.id === session.worktreeId + : candidate.sessionId === session.id, + ) + : undefined; return ( connection.sourceNodeId === node.id || connection.targetNodeId === node.id, ).length} - onSelect={() => - dispatch({ type: "node/select", nodeId: node.id }) - } + onSelect={() => { + dispatch({ type: "node/select", nodeId: node.id }); + onSelectSession(session?.id ?? null); + }} onConnect={() => { if ( - state.connectionSourceId && - state.connectionSourceId !== node.id + visibleConnectionSourceId && + visibleConnectionSourceId !== node.id ) { dispatch({ type: "connection/complete", targetNodeId: node.id, }); - } else if (state.connectionSourceId === node.id) { + } else if (visibleConnectionSourceId === node.id) { dispatch({ type: "connection/cancel" }); } else { dispatch({ type: "connection/start", nodeId: node.id }); @@ -527,9 +770,10 @@ export function CanvasWorkspace({ onCancelConnection={() => dispatch({ type: "connection/cancel" }) } - onDelete={() => - dispatch({ type: "node/delete", nodeId: node.id }) - } + onDelete={() => { + onSelectSession(null); + dispatch({ type: "node/delete", nodeId: node.id }); + }} zoom={state.zoom} onMove={(position) => dispatch({ type: "node/move", nodeId: node.id, position }) @@ -540,10 +784,24 @@ export function CanvasWorkspace({ onNoteChange={(text) => dispatch({ type: "note/update", nodeId: node.id, text }) } - onOpenSession={() => session && onSelectSession(session.id)} onStartTerminal={() => node.kind === "terminal" && launchTerminal(node, session) } + onStartSession={onStartSession} + onRestartSession={onRestartSession} + onRenameSession={onRenameSession} + onStopSession={onStopSession} + onDeleteSession={onDeleteSession} + onRemoveWorktree={onRemoveWorktree} + onGitStatus={onGitStatus} + onOpenPath={onOpenPath} + elementRef={(element) => { + if (element) { + nodeElementsRef.current.set(node.id, element); + } else { + nodeElementsRef.current.delete(node.id); + } + }} terminalPending={pendingTerminals.has(node.id)} terminalError={terminalErrors[node.id]} terminalTransport={{ @@ -568,16 +826,16 @@ export function CanvasWorkspace({ Workspace

Canvas items

- {state.nodes.length} + {visibleNodes.length}
    - {state.nodes.map((node) => ( + {visibleNodes.map((node) => (
-
+
+ {actionsOpen ? ( +
+ {session.name} + + + void runDirectAction("start", () => onStartSession(session.id)) + } + /> + + void runDirectAction("restart", () => + onRestartSession(session.id), + ) + } + /> + runOverlayAction(() => onRenameSession(session.id))} + /> + runOverlayAction(() => onStopSession(session.id))} + /> + runOverlayAction(() => onGitStatus(session.id))} + /> + { + if (path) { + void runDirectAction("open-path", () => onOpenPath(path)); + } + }} + /> + + runOverlayAction(() => onDeleteSession(session.id)) + } + /> + { + if (worktree) { + runOverlayAction(() => onRemoveWorktree(worktree.id)); + } + }} + /> + {actionError ? ( +
+ {actionError.message} + {actionError.action ? {actionError.action} : null} + +
+ ) : null} +
+ ) : null} +
+ ); +} + +function SessionActionButton({ + label, + icon, + pending = false, + disabledReason, + onClick, +}: { + readonly label: string; + readonly icon: Parameters[0]["name"]; + readonly pending?: boolean; + readonly disabledReason?: string; + readonly onClick: () => void; +}) { + const disabledReasonId = useId(); + const disabled = disabledReason !== undefined; + return ( + <> + + {disabledReason ? ( + + {disabledReason} + + ) : null} + + ); +} + function keyboardMovement( key: string, step: number, @@ -964,6 +1678,33 @@ function keyboardMovement( } } +function reconciledSessionPosition(index: number) { + return { + x: 170 + (index % 3) * 464, + y: 720 + Math.floor(index / 3) * 288, + }; +} + +function canvasScrollBehavior(): ScrollBehavior { + return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +function terminalStartDisabledReason( + session: Session | undefined, + worktree: Worktree | undefined, + isConnected: boolean, +): string | undefined { + if (!isConnected) { + return "Connect the local daemon first."; + } + if (session?.worktreeId && !worktree) { + return "The managed worktree is no longer available."; + } + return undefined; +} + function keyboardResize( key: string, node: TerminalCanvasNode, @@ -986,21 +1727,22 @@ function keyboardResize( function TerminalNodeBody({ node, session, - onOpenSession, onStart, pending, error, + startDisabledReason, transport, }: { readonly node: TerminalCanvasNode; readonly session?: Session; - readonly onOpenSession: () => void; readonly onStart: () => void; readonly pending: boolean; readonly error?: string; + readonly startDisabledReason?: string; readonly transport: LiveTerminalTransport; }) { const live = session ? isLiveStatus(session.status) : false; + const startDisabled = pending || startDisabledReason !== undefined; return (

{error ?? + startDisabledReason ?? (session ? "This terminal is stopped. Start it to attach a fresh live PTY." : `${node.executable ?? "A login shell"} is ready to start in this project.`)} @@ -1036,17 +1779,17 @@ function TerminalNodeBody({

- {session ? ( - - ) : null}
)} diff --git a/apps/desktop/src/app/features/canvas/canvas-state.test.ts b/apps/desktop/src/app/features/canvas/canvas-state.test.ts index 602f8dd..d1f34a6 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.test.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.test.ts @@ -5,6 +5,7 @@ import { createCanvasNode, createInitialCanvasDocument, createInitialCanvasState, + createSessionTerminalCanvasNode, createTerminalCanvasNode, parseCanvasDocument, serializeCanvasDocument, @@ -99,6 +100,21 @@ describe("canvas state", () => { ); }); + it("clears the selected node when the canvas background is selected", () => { + const initial = createInitialCanvasState(); + const selected = canvasReducer(initial, { + type: "node/select", + nodeId: "note-first", + }); + const cleared = canvasReducer(selected, { + type: "node/select", + nodeId: null, + }); + + expect(selected.selectedNodeId).toBe("note-first"); + expect(cleared.selectedNodeId).toBeNull(); + }); + it("connects distinct nodes once and removes their edges with the node", () => { const initial = createInitialCanvasState({ version: 1, @@ -142,6 +158,123 @@ describe("canvas state", () => { expect(deleted.connections).toEqual([]); }); + it("dismisses attached cards without deleting or immediately recreating sessions", () => { + const sessionNode = createSessionTerminalCanvasNode( + { x: 20, y: 30 }, + { + id: "session-one", + projectId: "project-one", + name: "Agent session", + cwd: "/repos/project-one", + }, + ); + const initial = createInitialCanvasState({ + version: 1, + nodes: [sessionNode], + connections: [], + zoom: 1, + hiddenSessionIds: [], + }); + + const dismissed = canvasReducer(initial, { + type: "node/delete", + nodeId: sessionNode.id, + }); + const reconciled = canvasReducer(dismissed, { + type: "sessions/reconcile", + knownSessionIds: ["session-one"], + sessionNodes: [sessionNode], + }); + + expect(reconciled.nodes).toEqual([]); + expect(reconciled.hiddenSessionIds).toEqual(["session-one"]); + expect(parseCanvasDocument(serializeCanvasDocument(reconciled))).toEqual( + expect.objectContaining({ hiddenSessionIds: ["session-one"] }), + ); + }); + + it("reconciles known sessions, prunes stale dismissals, and refreshes names", () => { + const firstNode = createSessionTerminalCanvasNode( + { x: 20, y: 30 }, + { + id: "session-one", + projectId: "project-one", + name: "Old name", + cwd: "/repos/project-one", + }, + ); + const secondNode = createSessionTerminalCanvasNode( + { x: 200, y: 300 }, + { + id: "session-two", + projectId: "project-one", + name: "Hidden session", + cwd: "/repos/project-one", + }, + ); + const initial = createInitialCanvasState({ + version: 1, + nodes: [firstNode], + connections: [], + zoom: 1, + hiddenSessionIds: ["session-two", "deleted-session"], + }); + const renamedFirstNode = createSessionTerminalCanvasNode( + { x: 900, y: 900 }, + { + id: "session-one", + projectId: "project-one", + name: "Renamed session", + cwd: "/repos/project-one", + }, + ); + + const reconciled = canvasReducer(initial, { + type: "sessions/reconcile", + knownSessionIds: ["session-one", "session-two"], + sessionNodes: [renamedFirstNode, secondNode], + }); + + expect(reconciled.nodes).toHaveLength(1); + expect(reconciled.nodes[0]).toMatchObject({ + id: firstNode.id, + sessionId: "session-one", + projectId: "project-one", + title: "Renamed session", + x: 20, + y: 30, + }); + expect(reconciled.hiddenSessionIds).toEqual(["session-two"]); + }); + + it("reveals a dismissed session atomically and selects its terminal node", () => { + const sessionNode = createSessionTerminalCanvasNode( + { x: 20, y: 30 }, + { + id: "session-one", + projectId: "project-one", + name: "Agent session", + cwd: "/repos/project-one", + }, + ); + const initial = createInitialCanvasState({ + version: 1, + nodes: [], + connections: [], + zoom: 1, + hiddenSessionIds: ["session-one"], + }); + + const revealed = canvasReducer(initial, { + type: "session/reveal", + node: sessionNode, + }); + + expect(revealed.nodes).toEqual([sessionNode]); + expect(revealed.hiddenSessionIds).toEqual([]); + expect(revealed.selectedNodeId).toBe(sessionNode.id); + }); + it("round-trips valid documents and drops unsafe persisted references", () => { const serialized = serializeCanvasDocument(createInitialCanvasState()); expect(parseCanvasDocument(serialized)).toEqual( @@ -185,5 +318,6 @@ describe("canvas state", () => { ]); expect(parsed.connections).toEqual([]); expect(parsed.zoom).toBe(1.5); + expect(parsed.hiddenSessionIds).toEqual([]); }); }); diff --git a/apps/desktop/src/app/features/canvas/canvas-state.ts b/apps/desktop/src/app/features/canvas/canvas-state.ts index 2cdb2dc..c671390 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.ts @@ -17,6 +17,8 @@ interface CanvasNodeBase extends CanvasPoint { readonly id: string; readonly title: string; readonly kind: CanvasNodeKind; + /** New nodes are scoped to a project; absent means a legacy shared node. */ + readonly projectId?: string; } export interface TerminalCanvasNode extends CanvasNodeBase { @@ -54,13 +56,23 @@ export interface CanvasDocument { readonly nodes: readonly CanvasNode[]; readonly connections: readonly CanvasConnection[]; readonly zoom: number; + /** Session cards dismissed from the canvas without deleting session metadata. */ + readonly hiddenSessionIds?: readonly string[]; } export interface CanvasState extends CanvasDocument { + readonly hiddenSessionIds: readonly string[]; readonly selectedNodeId: string | null; readonly connectionSourceId: string | null; } +export interface CanvasSessionReference { + readonly id: string; + readonly projectId: string; + readonly name: string; + readonly cwd: string; +} + export type CanvasAction = | { readonly type: "document/hydrate"; readonly document: CanvasDocument } | { readonly type: "node/add"; readonly node: CanvasNode } @@ -93,6 +105,16 @@ export type CanvasAction = readonly type: "terminal/attach"; readonly nodeId: string; readonly sessionId: string; + readonly projectId: string; + } + | { + readonly type: "sessions/reconcile"; + readonly knownSessionIds: readonly string[]; + readonly sessionNodes: readonly TerminalCanvasNode[]; + } + | { + readonly type: "session/reveal"; + readonly node: TerminalCanvasNode; } | { readonly type: "node/delete"; readonly nodeId: string } | { readonly type: "node/select"; readonly nodeId: string | null } @@ -145,6 +167,7 @@ export function createInitialCanvasDocument(): CanvasDocument { createConnection(nodes[0]?.id ?? "", nodes[2]?.id ?? ""), ], zoom: 1, + hiddenSessionIds: [], }; } @@ -153,6 +176,7 @@ export function createInitialCanvasState( ): CanvasState { return { ...document, + hiddenSessionIds: document.hiddenSessionIds ?? [], selectedNodeId: null, connectionSourceId: null, }; @@ -220,10 +244,27 @@ export function canvasReducer( case "terminal/attach": return updateNode(state, action.nodeId, (node) => node.kind === "terminal" - ? { ...node, sessionId: action.sessionId } + ? { + ...node, + sessionId: action.sessionId, + projectId: action.projectId, + } : node, ); - case "node/delete": + case "sessions/reconcile": + return reconcileSessionNodes( + state, + action.knownSessionIds, + action.sessionNodes, + ); + case "session/reveal": + return revealSessionNode(state, action.node); + case "node/delete": { + const deletedNode = state.nodes.find((node) => node.id === action.nodeId); + const hiddenSessionIds = + deletedNode?.kind === "terminal" && deletedNode.sessionId + ? addUnique(state.hiddenSessionIds, deletedNode.sessionId) + : state.hiddenSessionIds; return { ...state, nodes: state.nodes.filter((node) => node.id !== action.nodeId), @@ -240,8 +281,15 @@ export function canvasReducer( state.connectionSourceId === action.nodeId ? null : state.connectionSourceId, + hiddenSessionIds, }; + } case "node/select": + if (action.nodeId === null) { + return state.selectedNodeId === null + ? state + : { ...state, selectedNodeId: null }; + } return nodeExists(state.nodes, action.nodeId) ? { ...state, selectedNodeId: action.nodeId } : state; @@ -278,6 +326,7 @@ export function toCanvasDocument(state: CanvasState): CanvasDocument { nodes: state.nodes, connections: state.connections, zoom: state.zoom, + hiddenSessionIds: state.hiddenSessionIds, }; } @@ -343,6 +392,26 @@ export function createTerminalCanvasNode( }; } +/** Creates the stable canvas representation of an existing daemon session. */ +export function createSessionTerminalCanvasNode( + position: CanvasPoint, + session: CanvasSessionReference, +): TerminalCanvasNode { + return { + ...createTerminalCanvasNode( + position, + { + title: session.name, + preset: "shell", + workingDirectory: session.cwd, + }, + `terminal-session-${session.id}`, + ), + sessionId: session.id, + projectId: session.projectId, + }; +} + export function getCanvasNodeSize( node: CanvasNode, ): { readonly width: number; readonly height: number } { @@ -351,6 +420,86 @@ export function getCanvasNodeSize( : NOTE_SIZE; } +function reconcileSessionNodes( + state: CanvasState, + knownSessionIds: readonly string[], + sessionNodes: readonly TerminalCanvasNode[], +): CanvasState { + const knownSessionIdSet = new Set(knownSessionIds); + const hiddenSessionIds = state.hiddenSessionIds.filter((sessionId) => + knownSessionIdSet.has(sessionId), + ); + const hiddenSessionIdSet = new Set(hiddenSessionIds); + let nodes = state.nodes; + + for (const sessionNode of sessionNodes) { + if (!sessionNode.sessionId) { + continue; + } + const existingIndex = nodes.findIndex( + (node) => + node.kind === "terminal" && node.sessionId === sessionNode.sessionId, + ); + if (existingIndex === -1) { + if (!hiddenSessionIdSet.has(sessionNode.sessionId)) { + nodes = [...nodes, normalizeNode(sessionNode)]; + } + continue; + } + + const existingNode = nodes[existingIndex]; + if ( + existingNode?.kind === "terminal" && + (existingNode.title !== sessionNode.title || + existingNode.projectId !== sessionNode.projectId) + ) { + nodes = nodes.map((node, index) => + index === existingIndex + ? { + ...existingNode, + title: sessionNode.title, + projectId: sessionNode.projectId, + } + : node, + ); + } + } + + if ( + nodes === state.nodes && + stringArraysEqual(hiddenSessionIds, state.hiddenSessionIds) + ) { + return state; + } + return { ...state, nodes, hiddenSessionIds }; +} + +function revealSessionNode( + state: CanvasState, + sessionNode: TerminalCanvasNode, +): CanvasState { + if (!sessionNode.sessionId) { + return state; + } + const existingNode = state.nodes.find( + (node) => + node.kind === "terminal" && node.sessionId === sessionNode.sessionId, + ); + const revealedNode = existingNode ?? normalizeNode(sessionNode); + if (revealedNode.kind !== "terminal") { + return state; + } + return { + ...state, + nodes: existingNode ? state.nodes : [...state.nodes, revealedNode], + hiddenSessionIds: state.hiddenSessionIds.filter( + (sessionId) => sessionId !== sessionNode.sessionId, + ), + selectedNodeId: revealedNode.id, + connectionSourceId: null, + }; +} + function completeConnection( state: CanvasState, targetNodeId: string, @@ -436,6 +585,7 @@ function normalizeDocument(value: unknown): CanvasDocument { nodes, connections, zoom: normalizeNumber(value.zoom, 1, MIN_ZOOM, MAX_ZOOM), + hiddenSessionIds: normalizeStringArray(value.hiddenSessionIds), }; } @@ -443,13 +593,16 @@ function parseNode(value: unknown): CanvasNode | null { if ( !isRecord(value) || typeof value.id !== "string" || - (value.kind !== "terminal" && value.kind !== "note") + value.kind !== "terminal" && + value.kind !== "note" ) { return null; } const base = { id: value.id, kind: value.kind, + projectId: + typeof value.projectId === "string" ? value.projectId : undefined, title: normalizeTitle(value.title, value.kind === "note" ? "Notes" : "Terminal"), x: normalizeNumber(value.x, 0, MIN_POSITION, MAX_POSITION), y: normalizeNumber(value.y, 0, MIN_POSITION, MAX_POSITION), @@ -610,6 +763,27 @@ function normalizeNumber( : fallback; } +function normalizeStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + return [...new Set(value.filter((item): item is string => typeof item === "string"))]; +} + +function stringArraysEqual( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function addUnique(values: readonly string[], value: string): readonly string[] { + return values.includes(value) ? values : [...values, value]; +} + function clamp(value: number, minimum: number, maximum: number): number { return Math.min(maximum, Math.max(minimum, value)); } diff --git a/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx b/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx index 1c873aa..3252b2a 100644 --- a/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx +++ b/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import type { Project } from "../../../ipc/types"; +import type { Project, Session } from "../../../ipc/types"; import { CANVAS_DOCUMENT_UPDATED_EVENT, createInitialCanvasDocument, @@ -17,10 +17,29 @@ const PROJECT: Project = { lastOpenedAtMs: 2, }; +const SESSION: Session = { + id: "session-1", + projectId: PROJECT.id, + name: "Review", + agentId: "agent-1", + cwd: PROJECT.path, + status: "exited", + createdAtMs: 1, + updatedAtMs: 2, +}; + describe("CanvasSidebar", () => { it("shows a minimal default workspace when no project exists", () => { renderSidebar([]); + act(() => { + window.dispatchEvent( + new CustomEvent(CANVAS_DOCUMENT_UPDATED_EVENT, { + detail: createInitialCanvasDocument(), + }), + ); + }); + expect(screen.getByRole("searchbox", { name: "Filter workspaces" })).toBeVisible(); expect(screen.getByText("My Workspace")).toBeVisible(); expect(screen.getByLabelText("2 canvas terminals")).toHaveTextContent("2"); @@ -50,11 +69,26 @@ describe("CanvasSidebar", () => { screen.getByRole("searchbox", { name: "Filter workspaces" }), "master", ); - await user.click(screen.getByRole("button", { name: /CLI Master/ })); + await user.click(screen.getByRole("button", { name: /^CLI Master/ })); expect(onSelectProject).toHaveBeenCalledWith(PROJECT.id); }); + it("reports daemon sessions instead of global canvas terminal cards", () => { + renderSidebar( + [PROJECT], + vi.fn(), + vi.fn(), + "canvas", + vi.fn(), + vi.fn(), + vi.fn(), + [SESSION], + ); + + expect(screen.getByLabelText("1 sessions")).toHaveTextContent("1"); + }); + it("offers a control to hide the workspace sidebar", async () => { const user = userEvent.setup(); const onHide = vi.fn(); @@ -67,6 +101,36 @@ describe("CanvasSidebar", () => { expect(onHide).toHaveBeenCalledOnce(); }); + it("keeps project rename and remove controls visible and connected", async () => { + const user = userEvent.setup(); + const onRenameProject = vi.fn(); + const onRemoveProject = vi.fn(); + renderSidebar( + [PROJECT], + vi.fn(), + vi.fn(), + "canvas", + vi.fn(), + onRenameProject, + onRemoveProject, + ); + + const renameButton = screen.getByRole("button", { + name: `Rename ${PROJECT.name}`, + }); + const removeButton = screen.getByRole("button", { + name: `Remove ${PROJECT.name} from workspaces`, + }); + expect(renameButton).toBeVisible(); + expect(removeButton).toBeVisible(); + + await user.click(renameButton); + await user.click(removeButton); + + expect(onRenameProject).toHaveBeenCalledWith(PROJECT.id); + expect(onRemoveProject).toHaveBeenCalledWith(PROJECT.id); + }); + it("returns to the canvas from the settings view", async () => { const user = userEvent.setup(); const onOpenCanvas = vi.fn(); @@ -88,17 +152,22 @@ function renderSidebar( onHide = vi.fn(), activeView: "canvas" | "settings" | "diagnostics" = "canvas", onOpenCanvas = vi.fn(), + onRenameProject = vi.fn(), + onRemoveProject = vi.fn(), + sessions: readonly Session[] = [], ) { return render( , diff --git a/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx b/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx index f762690..bd2f816 100644 --- a/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx +++ b/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx @@ -19,6 +19,8 @@ interface CanvasSidebarProps { readonly onOpenCanvas: () => void; readonly onHide: () => void; readonly onAddProject: () => void; + readonly onRenameProject: (projectId: string) => void; + readonly onRemoveProject: (projectId: string) => void; readonly onOpenSettings: () => void; readonly onOpenDiagnostics: () => void; } @@ -34,6 +36,8 @@ export function CanvasSidebar({ onOpenCanvas, onHide, onAddProject, + onRenameProject, + onRemoveProject, onOpenSettings, onOpenDiagnostics, }: CanvasSidebarProps) { @@ -54,7 +58,10 @@ export function CanvasSidebar({ useEffect(() => { function handleCanvasDocument(event: Event) { - const document = (event as CustomEvent).detail; + if (!(event instanceof CustomEvent)) { + return; + } + const document: CanvasDocument = event.detail; setCanvasTerminalCount( document.nodes.filter((node) => node.kind === "terminal").length, ); @@ -123,14 +130,13 @@ export function CanvasSidebar({ ) : visibleProjects.length > 0 ? (
    {visibleProjects.map((project) => { - const sessionCount = - project.id === selectedProjectId - ? canvasTerminalCount - : sessions.filter((session) => session.projectId === project.id) - .length; + const sessionCount = sessions.filter( + (session) => session.projectId === project.id, + ).length; return ( -
  • +
  • +
    + + +
  • ); })} diff --git a/apps/desktop/src/app/features/navigation/ProjectSidebar.tsx b/apps/desktop/src/app/features/navigation/ProjectSidebar.tsx deleted file mode 100644 index 92f2619..0000000 --- a/apps/desktop/src/app/features/navigation/ProjectSidebar.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import type { Project } from "../../../ipc/types"; -import { Icon } from "../../components/Icon"; - -interface ProjectSidebarProps { - readonly projects: readonly Project[]; - readonly selectedProjectId?: string; - readonly canManageProjects: boolean; - readonly onSelectProject: (projectId: string) => void; - readonly onAddProject: () => void; - readonly onRenameProject: (projectId: string) => void; - readonly onRemoveProject: (projectId: string) => void; - readonly onOpenSettings: () => void; - readonly onOpenDiagnostics: () => void; -} - -/** Renders recent project navigation and metadata-only project actions. */ -export function ProjectSidebar({ - projects, - selectedProjectId, - canManageProjects, - onSelectProject, - onAddProject, - onRenameProject, - onRemoveProject, - onOpenSettings, - onOpenDiagnostics, -}: ProjectSidebarProps) { - const orderedProjects = [...projects].sort( - (left, right) => right.lastOpenedAtMs - left.lastOpenedAtMs, - ); - - return ( - - ); -} diff --git a/apps/desktop/src/app/features/navigation/SessionSidebar.tsx b/apps/desktop/src/app/features/navigation/SessionSidebar.tsx deleted file mode 100644 index f3fcbbc..0000000 --- a/apps/desktop/src/app/features/navigation/SessionSidebar.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import type { AgentRecord, Session, Worktree } from "../../../ipc/types"; -import { Icon } from "../../components/Icon"; -import { StatusBadge } from "../../components/StatusBadge"; -import { formatActivityTime, toDateTime } from "../../utils"; - -interface SessionSidebarProps { - readonly sessions: readonly Session[]; - readonly agents: readonly AgentRecord[]; - readonly worktrees: readonly Worktree[]; - readonly selectedSessionId?: string; - readonly projectSelected: boolean; - readonly canCreateSession: boolean; - readonly canManageWorktrees: boolean; - readonly onSelectSession: (sessionId: string) => void; - readonly onNewSession: () => void; - readonly onRemoveWorktree: (worktreeId: string) => void; -} - -/** Renders sessions for the selected project without owning process state. */ -export function SessionSidebar({ - sessions, - agents, - worktrees, - selectedSessionId, - projectSelected, - canCreateSession, - canManageWorktrees, - onSelectSession, - onNewSession, - onRemoveWorktree, -}: SessionSidebarProps) { - const orderedSessions = [...sessions].sort( - (left, right) => right.updatedAtMs - left.updatedAtMs, - ); - const activeCount = sessions.filter((session) => - ["starting", "running", "idle"].includes(session.status), - ).length; - const sessionIds = new Set(sessions.map((session) => session.id)); - const retainedWorktrees = projectSelected - ? worktrees.filter( - (worktree) => - worktree.sessionId === undefined || !sessionIds.has(worktree.sessionId), - ) - : []; - - return ( - - ); -} diff --git a/apps/desktop/src/app/features/sessions/SessionWorkspace.tsx b/apps/desktop/src/app/features/sessions/SessionWorkspace.tsx deleted file mode 100644 index 807c710..0000000 --- a/apps/desktop/src/app/features/sessions/SessionWorkspace.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { useState } from "react"; - -import type { AgentRecord, ApiErrorData, Project, Session, Worktree } from "../../../ipc/types"; -import { Icon } from "../../components/Icon"; -import { StatusBadge } from "../../components/StatusBadge"; -import { copyText, errorData, formatActivityTime, isLiveStatus, toDateTime } from "../../utils"; -import { LiveTerminal, type LiveTerminalTransport } from "../terminal/LiveTerminal"; - -interface SessionWorkspaceProps extends LiveTerminalTransport { - readonly session: Session; - readonly project: Project; - readonly agent?: AgentRecord; - readonly worktree?: Worktree; - readonly isConnected: boolean; - readonly onStart: (sessionId: string) => Promise; - readonly onRestart: (sessionId: string) => Promise; - readonly onRename: (sessionId: string) => void; - readonly onStop: (sessionId: string) => void; - readonly onDelete: (sessionId: string) => void; - readonly onRemoveWorktree: (sessionId: string) => void; - readonly onGitStatus: (sessionId: string) => void; - readonly onOpenPath: (path: string) => Promise; -} - -/** Renders session metadata/actions while leaving the terminal host seam untouched. */ -export function SessionWorkspace({ - session, - project, - agent, - worktree, - isConnected, - onStart, - onRestart, - onRename, - onStop, - onDelete, - onRemoveWorktree, - onGitStatus, - onOpenPath, - subscribeTerminal, - writeTerminal, - resizeTerminal, -}: SessionWorkspaceProps) { - const [pendingAction, setPendingAction] = useState(); - const [notice, setNotice] = useState(); - const [actionError, setActionError] = useState(); - const live = isLiveStatus(session.status); - const worktreeUnavailable = Boolean(session.worktreeId && !worktree); - const path = worktreeUnavailable - ? undefined - : (worktree?.path ?? session.worktreePath ?? session.cwd); - - async function runAction( - name: string, - action: () => Promise, - successMessage?: string, - ) { - if (pendingAction) { - return; - } - setPendingAction(name); - setActionError(undefined); - setNotice(undefined); - try { - await action(); - setNotice(successMessage); - } catch (error) { - setActionError(errorData(error)); - } finally { - setPendingAction(undefined); - } - } - - async function handleCopyPath() { - if (!path) return; - await runAction("copy", () => copyText(path), "Path copied to clipboard."); - } - - return ( -
    -
    -
    -

    {project.name} / Session

    -
    -

    {session.name}

    - -
    -
    -
    - {!live ? ( - - ) : null} - - - -
    -
    - - {actionError ? ( -
    -
    - {actionError.message} - {actionError.action ?

    {actionError.action}

    : null} -
    - -
    - ) : null} - {notice ?
    {notice}
    : null} - -
    -
    -
    -

    Session details

    - -
    -
    -
    -
    Agent
    -
    {agent?.displayName ?? "Unknown agent"}
    -
    -
    -
    Last activity
    -
    - -
    -
    -
    -
    Branch
    -
    {session.branch ?? project.currentBranch ?? "Unavailable"}
    -
    -
    -
    Process
    -
    - {session.pid - ? `PID ${session.pid}` - : session.status === "exited" || session.status === "failed" - ? `Ended${session.exitCode !== undefined ? ` · exit ${session.exitCode}` : ""}` - : "No attached process"} -
    -
    -
    -
    Working directory
    -
    {path ?? "Unavailable — managed worktree was removed"}
    -
    -
    -
    Worktree
    -
    - {worktree ? ( - - {worktree.isDirty ? "Dirty — uncommitted changes present" : "Clean"} - - ) : session.worktreeId ? ( - "Managed worktree removed or unavailable" - ) : ( - "Current working tree" - )} -
    -
    -
    -
    - - -
    -
    - -
    -
    - Terminal - Session {session.status} -
    - {live ? ( - - ) : ( -
    - -

    Start this session to open its interactive terminal.

    -
    - )} -
    - -
    -
    -

    Session data

    -

    These operations affect different resources and are never combined implicitly.

    -
    -
    - - -
    -
    -
    -
    - ); -} diff --git a/apps/desktop/src/app/features/status/LocalStatusBar.tsx b/apps/desktop/src/app/features/status/LocalStatusBar.tsx index f21dd12..3989355 100644 --- a/apps/desktop/src/app/features/status/LocalStatusBar.tsx +++ b/apps/desktop/src/app/features/status/LocalStatusBar.tsx @@ -60,8 +60,8 @@ export function LocalStatusBar({ - - - ); -} - -function RecentProjectsWorkspace({ projects, onSelectProject }: { readonly projects: readonly Project[]; readonly onSelectProject: (id: string) => void }) { - return ( -
    -

    Workspace

    Recent projects

    -
    - {projects.map((project) => ( - - ))} -
    -
    - ); -} - -function NoSessionsWorkspace({ canMutate, project, onNewSession }: { readonly canMutate: boolean; readonly project: Project; readonly onNewSession: () => void }) { - const unavailable = project.availability === "missing" || project.availability === "not_repository"; - return ( -
    -
    -

    Project

    {project.name}

    {project.repositoryRoot ?? project.path}

    - {unavailable ? "Repository unavailable" : project.currentBranch ?? "Repository ready"} -
    - {unavailable ? ( -
    - -

    Project moved or is unavailable

    {project.availabilityMessage ?? "Remove this registration and add the repository again from its new location. Files are never deleted."}

    -
    - ) : ( -
    - -

    No sessions yet

    Create a session in the current working tree or an isolated Git worktree.

    - -
    - )} -
    - ); -} - -function GridWorkspace({ project, sessions, onSelectSession }: { readonly project: Project; readonly sessions: readonly Session[]; readonly onSelectSession: (id: string) => void }) { - return ( -
    -

    {project.name}

    Session Grid

    ⌘/Ctrl Shift G
    - {sessions.length === 0 ?

    No sessions are available for the grid.

    : ( -
    - {sessions.map((session) => ( - - ))} -
    - )} -
    - ); -} - function SettingsWorkspace({ platform, onOpenCanvas, @@ -293,7 +191,6 @@ function SettingsWorkspace({
    Command palette
    {modifier}K
    New session
    {modifier}T
    -
    Session grid
    {modifier}ShiftG
    Focus session
    {modifier}1–9

    Application shortcuts pause while a terminal owns keyboard focus.

    diff --git a/apps/desktop/src/app/features/workspace/WorkspaceEmptyState.tsx b/apps/desktop/src/app/features/workspace/WorkspaceEmptyState.tsx deleted file mode 100644 index 9088216..0000000 --- a/apps/desktop/src/app/features/workspace/WorkspaceEmptyState.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Icon } from "../../components/Icon"; - -/** Guides first-time setup without inventing project or session data. */ -export function WorkspaceEmptyState() { - return ( -
    -
    -
    -

    Workspace

    -

    No project selected

    -
    - Local -
    -
    - -
    -

    Add a repository to begin

    -

    - Jig keeps projects and sessions on this device. Connect the - local daemon, then add a repository from the sidebar. -

    -
    -
    -
    - - Connect the local daemon -
    -
    - - Add a repository -
    -
    - - Start a session -
    -
    -
    -
    - ); -} diff --git a/apps/desktop/src/app/hooks/useGlobalShortcuts.ts b/apps/desktop/src/app/hooks/useGlobalShortcuts.ts index ad78929..36c7bf8 100644 --- a/apps/desktop/src/app/hooks/useGlobalShortcuts.ts +++ b/apps/desktop/src/app/hooks/useGlobalShortcuts.ts @@ -14,8 +14,6 @@ export interface GlobalShortcutOptions { readonly onOpenCommandPalette?: () => void; /** Handles Command/Control+T. */ readonly onNewSession?: () => void; - /** Handles Command/Control+Shift+G. */ - readonly onOpenGrid?: () => void; /** Handles Command/Control+1 through 9 using a one-based session number. */ readonly onFocusSession?: (sessionNumber: SessionShortcutNumber) => void; } @@ -28,7 +26,6 @@ export function useGlobalShortcuts({ platform, onOpenCommandPalette, onNewSession, - onOpenGrid, onFocusSession, }: GlobalShortcutOptions): void { const shortcutPlatform = platform ?? detectShortcutPlatform(); @@ -63,12 +60,6 @@ export function useGlobalShortcuts({ return; } - if (event.shiftKey && key === "g" && onOpenGrid) { - event.preventDefault(); - onOpenGrid(); - return; - } - if (!event.shiftKey && onFocusSession) { const sessionNumber = getSessionShortcutNumber(event.key); if (sessionNumber) { @@ -85,7 +76,6 @@ export function useGlobalShortcuts({ onFocusSession, onNewSession, onOpenCommandPalette, - onOpenGrid, shortcutPlatform, ]); } diff --git a/apps/desktop/src/app/state/WorkspaceContext.tsx b/apps/desktop/src/app/state/WorkspaceContext.tsx index ec1b9d9..00129ce 100644 --- a/apps/desktop/src/app/state/WorkspaceContext.tsx +++ b/apps/desktop/src/app/state/WorkspaceContext.tsx @@ -56,8 +56,6 @@ import type { /** Main application destinations controlled independently from dialog state. */ export type WorkspaceView = | "canvas" - | "session" - | "grid" | "settings" | "diagnostics"; @@ -158,7 +156,7 @@ export interface WorkspaceProviderProps { readonly children: ReactNode; /** Production uses the shared Tauri client; tests inject a strict fake. */ readonly client?: IpcClient; - /** Allows legacy/detail surfaces to opt in while canvas remains the default. */ + /** Allows tests or launchers to select a canvas utility destination. */ readonly initialView?: WorkspaceView; } @@ -716,10 +714,15 @@ export function WorkspaceProvider({ projects.find((project) => project.id === state.selectedProjectId) ?? null; const selectedSession = sessions.find((session) => session.id === state.selectedSessionId) ?? null; - const selectedWorktree = - worktrees.find( - (worktree) => worktree.id === selectedSession?.worktreeId, - ) ?? null; + const selectedWorktree = selectedSession + ? (selectedSession.worktreeId + ? worktrees.find( + (worktree) => worktree.id === selectedSession.worktreeId, + ) + : undefined) ?? + worktrees.find((worktree) => worktree.sessionId === selectedSession.id) ?? + null + : null; const projectSessions = state.selectedProjectId === null ? EMPTY_SESSIONS @@ -943,7 +946,7 @@ function upsertProject( navigationRevision: shouldSelect ? state.navigationRevision + 1 : state.navigationRevision, - view: shouldSelect ? "session" : state.view, + view: shouldSelect ? "canvas" : state.view, }; } @@ -970,7 +973,7 @@ function upsertSession( navigationRevision: shouldSelect ? state.navigationRevision + 1 : state.navigationRevision, - view: shouldSelect ? "session" : state.view, + view: shouldSelect ? "canvas" : state.view, }; } @@ -1113,7 +1116,7 @@ function selectProjectInState( selectedProjectId: projectId, selectedSessionId, navigationRevision: state.navigationRevision + 1, - view: "session", + view: state.view, }; } @@ -1126,7 +1129,7 @@ function selectSessionInState( ...state, selectedSessionId: null, navigationRevision: state.navigationRevision + 1, - view: "session", + view: state.view, }; } const session = state.snapshot?.sessions.find( @@ -1140,7 +1143,7 @@ function selectSessionInState( selectedProjectId: session.projectId, selectedSessionId: session.id, navigationRevision: state.navigationRevision + 1, - view: "session", + view: state.view, }; } diff --git a/apps/desktop/src/styles/canvas.css b/apps/desktop/src/styles/canvas.css index a236177..f348839 100644 --- a/apps/desktop/src/styles/canvas.css +++ b/apps/desktop/src/styles/canvas.css @@ -1,33 +1,30 @@ -.app-shell--canvas { +.app-shell { --canvas-background: #fbfbfa; --canvas-grid-fine: rgb(80 88 96 / 7%); --canvas-grid-major: rgb(80 88 96 / 10%); --canvas-chrome: rgb(250 250 249 / 94%); - --canvas-card: #fff; + --canvas-card: #ffffff; --canvas-note: #fff6b7; --canvas-text: #26282b; --canvas-text-muted: #6d7075; --canvas-border: #d9dadc; --canvas-accent: #ef4b55; + --canvas-accent-ink: #9d242d; + --canvas-accent-fill: #c8323c; --canvas-accent-muted: #fff0f1; --canvas-connector: #8b8e93; + --canvas-control-border: #85878b; --canvas-focus: #1b63d9; color: var(--canvas-text); background: var(--canvas-background); } -.app-shell--canvas > .app-header, -.app-shell--canvas > .status-bar { - display: none; -} - -.app-shell--canvas .app-shell__body { +.app-shell__body { grid-template-columns: 15.5rem minmax(0, 1fr); background: var(--canvas-background); } -.app-shell--canvas .navigation-pane { - z-index: 8; +.app-shell .navigation-pane { width: 15.5rem; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); @@ -37,42 +34,26 @@ background: rgb(249 249 248 / 96%); box-shadow: 0.5rem 0 2rem rgb(35 37 39 / 6%); backdrop-filter: blur(18px); - transition: - width var(--duration-feedback) var(--ease-feedback), - opacity var(--duration-feedback) var(--ease-feedback); } -.app-shell--canvas-sidebar-collapsed .app-shell__body { - grid-template-columns: 0 minmax(0, 1fr); -} - -.app-shell--canvas-sidebar-collapsed .navigation-pane { - width: 0; - visibility: hidden; - border-right: 0; - opacity: 0; - pointer-events: none; -} +@media (min-width: 48rem) { + .app-shell .navigation-pane { + transition: + width var(--duration-feedback) var(--ease-feedback), + opacity var(--duration-feedback) var(--ease-feedback); + } -.app-shell--canvas-sidebar-collapsed .canvas-context { - left: 5rem; -} + .app-shell--canvas-sidebar-collapsed .app-shell__body { + grid-template-columns: 0 minmax(0, 1fr); + } -.canvas-sidebar-reveal { - position: absolute; - z-index: 12; - top: 1rem; - left: 1rem; - width: 3rem; - height: 3rem; - display: grid; - place-items: center; - border: 1px solid rgb(25 27 29 / 9%); - border-radius: 50%; - color: var(--canvas-text-muted); - background: var(--canvas-chrome); - box-shadow: 0 0.5rem 1.75rem rgb(30 33 36 / 10%); - backdrop-filter: blur(18px); + .app-shell--canvas-sidebar-collapsed .navigation-pane { + width: 0; + visibility: hidden; + border-right: 0; + opacity: 0; + pointer-events: none; + } } .canvas-sidebar { @@ -132,6 +113,11 @@ font: inherit; } +.canvas-sidebar__filter:has(input:focus-visible) { + outline: 3px solid var(--canvas-focus); + outline-offset: 2px; +} + .canvas-sidebar nav { min-height: 0; overflow: auto; @@ -140,7 +126,7 @@ .canvas-sidebar nav h2 { margin: 0 0 0.375rem; padding: 0 0.5rem; - color: #a1a2a5; + color: var(--canvas-text-muted); font-size: 0.6875rem; font-weight: 700; } @@ -151,7 +137,7 @@ list-style: none; } -.canvas-sidebar nav button, +.canvas-sidebar__project-select, .canvas-sidebar__workspace { width: 100%; min-width: 0; @@ -168,17 +154,19 @@ text-align: left; } -.canvas-sidebar nav button[aria-current="page"], +.canvas-sidebar__project-select[aria-current="page"], +.canvas-sidebar__workspace[aria-current="page"], .canvas-sidebar footer button[aria-current="page"] { background: #dfdfdd; + box-shadow: inset 3px 0 0 var(--canvas-accent-fill); } -.canvas-sidebar nav button > .icon, +.canvas-sidebar__project-select > .icon, .canvas-sidebar__workspace > .icon { color: var(--canvas-accent); } -.canvas-sidebar nav button > span, +.canvas-sidebar__project-select > span, .canvas-sidebar__workspace > span { overflow: hidden; font-size: 0.8125rem; @@ -187,7 +175,8 @@ white-space: nowrap; } -.canvas-sidebar nav small { +.canvas-sidebar__project-select small, +.canvas-sidebar__workspace small { display: inline-flex; align-items: center; gap: 0.25rem; @@ -196,11 +185,45 @@ font-size: 0.6875rem; } -.canvas-sidebar nav small .icon { +.canvas-sidebar__project-select small .icon, +.canvas-sidebar__workspace small .icon { width: 0.875rem; height: 0.875rem; } +.canvas-sidebar__project-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.canvas-sidebar__project-actions { + display: flex; + align-items: center; + gap: 0.125rem; + padding-right: 0.25rem; +} + +.canvas-sidebar__project-action { + width: 2rem; + min-width: 2rem; + height: 2rem; + min-height: 2rem; + display: grid; + place-items: center; + border: 0; + border-radius: 0.5rem; + padding: 0; + color: var(--canvas-text-muted); + background: transparent; +} + +.canvas-sidebar__project-action:disabled { + color: var(--color-text-disabled); + opacity: 0.64; +} + .canvas-sidebar__empty { margin: 0; padding: 0.75rem 0.5rem; @@ -225,17 +248,30 @@ } .canvas-sidebar footer button[aria-current="page"] { - color: var(--canvas-accent); + color: var(--canvas-accent-ink); } @media (hover: hover) { - .canvas-sidebar__topbar button:hover, - .canvas-sidebar-reveal:hover, - .canvas-sidebar nav button:hover, + .canvas-sidebar__topbar button:not(:disabled):hover, + .canvas-sidebar__project-select:hover, + .canvas-sidebar__workspace:hover, + .canvas-sidebar__project-action:not(:disabled):hover, .canvas-sidebar footer button:hover { - color: var(--canvas-accent); + color: var(--canvas-accent-ink); background: var(--canvas-accent-muted); } + + .canvas-sidebar__project-action--remove:not(:disabled):hover { + color: var(--color-danger); + background: var(--color-danger-muted); + } +} + +.canvas-sidebar__topbar button:disabled, +.canvas-terminal__body button:disabled, +.canvas-terminal__body button[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; } .canvas-settings { @@ -276,7 +312,7 @@ } .canvas-settings__back:hover { - color: var(--canvas-accent); + color: var(--canvas-accent-ink); background: var(--canvas-accent-muted); cursor: pointer; } @@ -298,7 +334,7 @@ .canvas-settings__header p { margin-bottom: 0.5rem; - color: var(--canvas-accent); + color: var(--canvas-accent-ink); font-size: 0.6875rem; font-weight: 750; letter-spacing: 0.1em; @@ -460,7 +496,7 @@ display: inline-flex; align-items: center; gap: 0.5rem; - border: 1px solid var(--canvas-border); + border: 1px solid var(--canvas-control-border); border-radius: 999px; padding: 0 0.875rem; color: var(--canvas-text-muted); @@ -471,7 +507,7 @@ .canvas-diagnostics__reconnect:hover { border-color: #b8b9bc; - color: var(--canvas-accent); + color: var(--canvas-accent-ink); background: #fff; cursor: pointer; } @@ -676,68 +712,6 @@ } } -.app-shell--canvas .project-rail, -.app-shell--canvas .session-pane { - color: var(--canvas-text); - background: transparent; -} - -.app-shell--canvas .project-rail { - border-right: 0; - border-bottom: 1px solid var(--canvas-border); -} - -.app-shell--canvas .pane-heading { - min-height: 3.5rem; - border-color: var(--canvas-border); - padding-inline: 1rem 0.75rem; -} - -.app-shell--canvas .pane-heading__label, -.app-shell--canvas .pane-heading__meta, -.app-shell--canvas .pane-heading__count, -.app-shell--canvas .navigation-row__meta, -.app-shell--canvas .session-row__agent, -.app-shell--canvas .session-row__details, -.app-shell--canvas .session-row__terminal-state, -.app-shell--canvas .empty-pane, -.app-shell--canvas .pane-footer__action { - color: var(--canvas-text-muted); -} - -.app-shell--canvas .pane-heading__count, -.app-shell--canvas .icon-button, -.app-shell--canvas .pane-footer__action { - border-color: var(--canvas-border); - color: var(--canvas-text-muted); - background: var(--canvas-card); -} - -.app-shell--canvas .navigation-row, -.app-shell--canvas .session-row { - margin: 0.125rem 0.5rem; - width: calc(100% - 1rem); - border-radius: 0.5rem; - color: var(--canvas-text-muted); -} - -.app-shell--canvas .navigation-row__title, -.app-shell--canvas .session-row__name { - color: var(--canvas-text); -} - -.app-shell--canvas .navigation-row[aria-current="page"], -.app-shell--canvas .session-row[aria-current="page"] { - color: var(--canvas-text); - background: #e9e9e7; - box-shadow: inset 3px 0 0 var(--canvas-accent); -} - -.app-shell--canvas .pane-footer { - border-color: var(--canvas-border); - background: rgb(249 249 248 / 98%); -} - .canvas-workspace { position: relative; min-width: 0; @@ -760,7 +734,7 @@ } .canvas-context__eyebrow { - color: var(--canvas-accent); + color: var(--canvas-accent-ink); font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.08em; @@ -864,10 +838,11 @@ .canvas-pill-button { border: 0; color: #fff; - background: var(--canvas-accent); + background: var(--canvas-accent-fill); } -.canvas-pill-button:disabled { +.canvas-pill-button:disabled, +.canvas-pill-button[aria-disabled="true"] { color: var(--canvas-text-muted); background: #ececeb; } @@ -1120,6 +1095,121 @@ background: transparent; } +.canvas-node__session-actions { + flex: 0 0 auto; +} + +.canvas-node__session-actions-trigger { + width: 1.875rem; + height: 1.875rem; + display: grid; + place-items: center; + border: 0; + border-radius: 0.4rem; + color: var(--canvas-text-muted); + background: transparent; + cursor: pointer; +} + +.canvas-node__session-actions-trigger:focus-visible { + outline: 3px solid var(--canvas-focus); + outline-offset: -3px; +} + +.canvas-node__session-actions-trigger[aria-expanded="true"] { + color: var(--canvas-accent-ink); + background: var(--canvas-accent-muted); +} + +.canvas-node__session-actions-panel { + position: absolute; + z-index: 7; + top: 2.625rem; + right: 0.375rem; + width: min(15rem, calc(100% - 0.75rem)); + max-height: calc(100% - 3rem); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.25rem; + overflow-y: auto; + border: 1px solid var(--canvas-border); + border-radius: 0.625rem; + padding: 0.375rem; + color: var(--canvas-text); + background: var(--canvas-card); + box-shadow: 0 0.75rem 2rem rgb(28 31 34 / 18%); + cursor: default; +} + +.canvas-node__session-actions-panel > strong { + min-width: 0; + align-self: center; + overflow: hidden; + padding-left: 0.375rem; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.canvas-node__session-actions-panel > button { + grid-column: 1 / -1; + width: 100%; + min-height: 2rem; + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + border: 0; + border-radius: 0.4rem; + padding: 0.375rem 0.5rem; + color: var(--canvas-text); + background: transparent; + font-size: 0.6875rem; + text-align: left; +} + +.canvas-node__session-actions-panel > button:disabled, +.canvas-node__session-actions-panel > button[aria-disabled="true"] { + color: var(--color-text-disabled); + opacity: 0.72; +} + +.canvas-node__session-action-error { + position: relative; + grid-column: 1 / -1; + min-width: 0; + display: grid; + gap: 0.125rem; + border-left: 3px solid var(--color-danger); + border-radius: 0.375rem; + padding: 0.5rem 2rem 0.5rem 0.5rem; + color: var(--color-danger); + background: var(--color-danger-muted); + font-size: 0.6875rem; + line-height: 1.4; +} + +.canvas-node__session-action-error strong, +.canvas-node__session-action-error span { + min-width: 0; + overflow-wrap: anywhere; +} + +.canvas-node__session-action-error button { + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 1.5rem; + height: 1.5rem; + display: grid; + place-items: center; + border: 0; + border-radius: 0.25rem; + padding: 0; + color: inherit; + background: transparent; +} + .canvas-node__resize-handle { position: absolute; right: 0; @@ -1209,6 +1299,19 @@ } .canvas-terminal__body--live { + color-scheme: dark; + --color-inset: #080b10; + --color-text: #f1f5f9; + --color-text-muted: #a8b3c2; + --color-border: #2b3543; + --color-border-strong: #465368; + --color-accent: #38bdf8; + --color-accent-strong: #7dd3fc; + --color-on-accent: #07131a; + --color-surface-active: #253243; + --color-focus-ring: #bae6fd; + --color-scrollbar: #465368; + --color-scrollbar-hover: #687386; display: flex; align-items: stretch; padding: 0; @@ -1271,7 +1374,7 @@ } .canvas-note textarea::placeholder { - color: #9a8a45; + color: #665a24; } .canvas-note textarea:focus-visible { @@ -1488,22 +1591,14 @@ font-size: 0.6875rem; } -.app-shell--canvas .dialog { - --color-surface: #fff; - --color-surface-raised: #fafaf9; - --color-inset: #f4f4f2; - --color-border: var(--canvas-border); - --color-border-strong: #c9cacc; - --color-text: var(--canvas-text); - --color-text-muted: var(--canvas-text-muted); - --color-text-disabled: #9a9ca0; +.app-shell .dialog { border-radius: 1.25rem; color: var(--canvas-text); background: #fff; box-shadow: 0 1.5rem 5rem rgb(25 27 29 / 24%); } -.app-shell--canvas .dialog::backdrop { +.app-shell .dialog::backdrop { background: rgb(34 36 38 / 32%); backdrop-filter: blur(2px); } @@ -1537,7 +1632,7 @@ grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 1rem; - border: 1px solid var(--color-border); + border: 1px solid var(--canvas-control-border); border-radius: 1rem; padding: 1.25rem; color: var(--color-text); @@ -1647,7 +1742,7 @@ padding-left: 0.75rem; } -.app-shell--canvas .project-directory-picker .text-input { +.app-shell .project-directory-picker .text-input { color: var(--canvas-text); background: #f4f4f2; } @@ -1685,7 +1780,7 @@ display: grid; justify-items: center; gap: 0.375rem; - border: 1px solid var(--canvas-border); + border: 1px solid var(--canvas-control-border); border-radius: 0.75rem; padding: 0.75rem 0.375rem; color: var(--canvas-text-muted); @@ -1746,7 +1841,7 @@ .canvas-terminal-form__fields input { min-width: 0; min-height: 2.5rem; - border: 1px solid var(--canvas-border); + border: 1px solid var(--canvas-control-border); border-radius: 0.625rem; padding: 0 0.75rem; color: var(--canvas-text); @@ -1778,7 +1873,7 @@ @media (hover: hover) { .canvas-tool:not(:disabled):hover, - .canvas-node__actions button:hover, + .canvas-node__actions > button:hover, .canvas-view-controls button:hover, .canvas-layers-panel button:hover, .canvas-connections-panel li button:hover, @@ -1787,45 +1882,72 @@ background: var(--canvas-accent-muted); } - .canvas-pill-button:not(:disabled):hover { - background: #d93f49; + .canvas-node__session-actions-trigger:hover, + .canvas-node__session-actions-panel > button:not(:disabled):not([aria-disabled="true"]):hover { + color: var(--canvas-accent-ink); + background: var(--canvas-accent-muted); + } + + .canvas-node__session-action-error button:hover { + color: var(--color-danger); + background: var(--color-danger-muted); + } + + .canvas-pill-button:not(:disabled):not([aria-disabled="true"]):hover { + background: var(--canvas-accent-ink); } } @media (max-width: 68rem) { - .app-shell--canvas .app-shell__body { + .app-shell__body { grid-template-columns: 15rem minmax(0, 1fr); } - .app-shell--canvas .navigation-pane { + .app-shell .navigation-pane { width: 15rem; } + .canvas-context { + top: 8.5rem; + } + .canvas-context__meta { display: none; } -} -@media (max-width: 47.99rem) { - .app-shell--canvas .app-shell__body { - display: block; + .canvas-toolbar { + top: 4.5rem; + left: 1rem; + transform: none; } - .app-shell--canvas .navigation-pane { + .canvas-connect-notice { + top: 12rem; + } +} + +@media (max-width: 47.99rem) { + .app-shell .navigation-pane { width: min(20rem, calc(100vw - 3rem)); + grid-template-rows: auto minmax(0, 1fr); } .canvas-context { - top: 4.5rem; + right: 1rem; + left: 1rem; } .canvas-toolbar { left: 0.75rem; + max-width: calc(100% - 1.5rem); + overflow-x: auto; transform: none; } .canvas-session-actions { + top: 0.75rem; right: 0.75rem; + max-width: calc(100% - 4.5rem); } .canvas-session-actions .canvas-pill-button { @@ -1839,13 +1961,51 @@ height: 1rem; } - .canvas-node--terminal { - width: min(27rem, calc(100vw - 3rem)); + .canvas-layers-panel { + right: 0.75rem; + width: min(17rem, calc(100% - 1.5rem)); + } + + .canvas-connect-notice { + max-width: calc(100% - 1.5rem); + } + + .app-shell .status-bar__sessions { + display: inline-flex; + } +} + +@media (max-width: 36rem) { + .app-shell .status-bar { + justify-content: space-between; + padding-left: var(--space-3); + } + + .app-shell .status-bar__group { + display: flex; + } + + .app-shell .status-bar__item:not(.status-bar__sessions) { + display: none; + } + + .app-shell .status-bar:has(.status-bar__connection) .status-bar__group { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .app-shell .navigation-pane, + .canvas-tool, + .canvas-node, + .project-directory-picker__browse, + .project-directory-picker__manual-toggle .icon { + transition: none; } } @supports not (backdrop-filter: blur(1px)) { - .app-shell--canvas .navigation-pane, + .app-shell .navigation-pane, .canvas-toolbar, .canvas-session-actions, .canvas-view-controls button, From 3ec922e5acbd57e2848b84f2f765f85074fc6b5b Mon Sep 17 00:00:00 2001 From: guicybercode Date: Wed, 2 Sep 2026 18:12:09 -0300 Subject: [PATCH 04/32] refactor(desktop): prune and relight shell styles --- apps/desktop/src/styles/shell.css | 1529 ++++------------------------ apps/desktop/src/styles/tokens.css | 64 +- 2 files changed, 207 insertions(+), 1386 deletions(-) diff --git a/apps/desktop/src/styles/shell.css b/apps/desktop/src/styles/shell.css index 6f22bd9..585f03c 100644 --- a/apps/desktop/src/styles/shell.css +++ b/apps/desktop/src/styles/shell.css @@ -57,11 +57,11 @@ } .navigation-pane { - width: clamp(25rem, 33vw, 30rem); + width: 15.5rem; min-width: 0; min-height: 0; display: grid; - grid-template-columns: minmax(10.5rem, 0.8fr) minmax(14.5rem, 1.2fr); + grid-template-columns: minmax(0, 1fr); overflow: hidden; border-right: 1px solid var(--color-border); background: var(--color-sidebar); @@ -75,95 +75,6 @@ display: none; } -.app-header { - min-width: 0; - min-height: var(--header-height); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - border-bottom: 1px solid var(--color-border); - padding: var(--space-2) var(--space-3); - background: var(--color-surface); -} - -.app-header__leading, -.app-header__brand, -.app-header__actions, -.app-header__context, -.app-header__branch { - min-width: 0; - display: flex; - align-items: center; -} - -.app-header__leading { - flex: 1 1 auto; - gap: var(--space-3); - overflow: hidden; -} - -.app-header__brand { - flex: 0 0 auto; - gap: var(--space-2); -} - -.app-header__mark { - width: 1.75rem; - height: 1.75rem; - display: grid; - flex: 0 0 auto; - place-items: center; - border: 1px solid var(--color-border-strong); - border-radius: var(--radius-small); - color: var(--color-accent-strong); - background: var(--color-inset); -} - -.app-header__name { - overflow-wrap: anywhere; - font-size: 0.9375rem; - font-weight: 650; - letter-spacing: 0.01em; -} - -.app-header__context { - flex: 1 1 auto; - gap: var(--space-2); - overflow: hidden; - border-left: 1px solid var(--color-border); - padding-left: var(--space-3); -} - -.app-header__project, -.app-header__branch { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.app-header__project { - max-width: 14rem; - font-weight: 600; -} - -.app-header__branch { - gap: var(--space-1); - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: 0.75rem; -} - -.app-header__actions { - flex: 0 0 auto; - gap: var(--space-2); -} - -.app-header__navigation-toggle { - display: none; -} - -.command-trigger, .icon-button, .dialog__close { min-width: var(--control-height); @@ -183,14 +94,6 @@ background-color var(--duration-feedback) var(--ease-feedback); } -.command-trigger { - font-size: 0.8125rem; -} - -.command-trigger kbd { - margin-left: var(--space-1); -} - .icon-button, .dialog__close { width: var(--control-height); @@ -205,16 +108,10 @@ } .icon-button:focus-visible, -.dialog__close:focus-visible, -.navigation-row:focus-visible, -.session-row:focus-visible, -.pane-footer__action:focus-visible, -.recent-project:focus-visible, -.session-grid__tile:focus-visible { +.dialog__close:focus-visible { outline-offset: -3px; } -.command-trigger:disabled, .icon-button:disabled, .dialog__close:disabled { cursor: not-allowed; @@ -223,7 +120,6 @@ } @media (hover: hover) { - .command-trigger:not(:disabled):hover, .icon-button:not(:disabled):hover, .dialog__close:not(:disabled):hover { border-color: var(--color-accent); @@ -232,1130 +128,245 @@ } } -.command-trigger:not(:disabled):active, .icon-button:not(:disabled):active, .dialog__close:not(:disabled):active { background: var(--color-surface-active); } -/* Project and session navigation */ -.project-rail, -.session-pane { - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--color-sidebar); -} - -.project-rail { - border-right: 1px solid var(--color-border); -} - -.app-shell__body > .project-rail { - width: 16rem; -} - -.pane-heading { +.status-badge { min-width: 0; - min-height: 2.75rem; - display: flex; + display: inline-flex; flex: 0 0 auto; align-items: center; - justify-content: space-between; - gap: var(--space-2); - border-bottom: 1px solid var(--color-border); - padding: var(--space-1) var(--space-2) var(--space-1) var(--space-3); -} - -.pane-heading > div { - min-width: 0; - display: flex; - align-items: baseline; - gap: var(--space-2); -} - -.pane-heading__label { - color: var(--color-text-muted); - font-size: 0.75rem; - font-weight: 650; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.pane-heading__count, -.pane-heading__meta { - flex: 0 0 auto; - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: 0.75rem; - font-variant-numeric: tabular-nums; -} - -.pane-heading__count { - min-width: 1.25rem; + gap: var(--space-1); border: 1px solid var(--color-border); - border-radius: var(--radius-small); - padding: 0 var(--space-1); + border-radius: 999px; + padding: 0.125rem var(--space-2); + color: var(--color-text-muted); background: var(--color-inset); + font-size: 0.75rem; + font-weight: 600; line-height: 1.25rem; - text-align: center; + white-space: nowrap; } -.pane-scroll { - min-width: 0; - min-height: 0; - flex: 1 1 auto; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; +.status-badge--compact { + padding-inline: var(--space-1); + font-size: 0.6875rem; + line-height: 1rem; } -.navigation-list, -.session-list, -.git-file-list { - min-width: 0; - margin: 0; - padding: 0; - list-style: none; +.status-badge__indicator { + width: 0.5rem; + height: 0.5rem; + flex: 0 0 auto; + border: 1px solid currentColor; + border-radius: 50%; + background: transparent; } -.navigation-list, -.session-list { - padding-block: var(--space-1); +.status-badge__label { + min-width: 0; } -.navigation-item { - min-width: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; +.status-badge--starting { + border-color: var(--color-accent); + color: var(--color-accent-strong); + background: var(--color-accent-muted); } -.navigation-row, -.session-row { - position: relative; - width: 100%; - min-width: 0; - border: 0; - border-radius: 0; - color: var(--color-text-muted); - background: transparent; - text-align: left; - transition: - color var(--duration-feedback) var(--ease-feedback), - background-color var(--duration-feedback) var(--ease-feedback), - box-shadow var(--duration-feedback) var(--ease-feedback); +.status-badge--starting .status-badge__indicator { + background: var(--color-accent); + animation: status-pulse 1.2s ease-in-out infinite; } -.navigation-row { - min-height: 3.25rem; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: start; - gap: var(--space-2); - padding: var(--space-2) var(--space-1) var(--space-2) var(--space-3); +.status-badge--running { + border-color: var(--color-success); + color: var(--color-success); + background: var(--color-success-muted); } -.navigation-row__icon { - width: 1.25rem; - height: 1.25rem; - display: grid; - place-items: center; - color: var(--color-text-disabled); +.status-badge--running .status-badge__indicator { + background: var(--color-success); } -.navigation-row__content, -.navigation-row__title, -.navigation-row__meta { - min-width: 0; - display: block; +.status-badge--idle { + border-color: var(--color-warning); + color: var(--color-warning); + background: var(--color-warning-muted); } -.navigation-row__title { - overflow-wrap: anywhere; - color: var(--color-text); - font-size: 0.8125rem; - font-weight: 600; - line-height: 1.35; +.status-badge--idle .status-badge__indicator { + border-radius: 1px; + background: var(--color-warning); } -.navigation-row__meta { - margin-top: 0.125rem; - overflow-wrap: anywhere; +.status-badge--exited { + border-color: var(--color-border-strong); color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: 0.6875rem; - line-height: 1.35; } -.navigation-item__actions { - min-width: 0; - display: flex; - gap: var(--space-1); - padding-right: var(--space-1); +.status-badge--failed { + border-color: var(--color-danger); + color: var(--color-danger); + background: var(--color-danger-muted); } -.navigation-row[aria-current="page"], -.session-row[aria-current="page"] { - color: var(--color-text); - background: var(--color-surface-raised); - box-shadow: inset 3px 0 0 var(--color-accent); +.status-badge--failed .status-badge__indicator { + border-radius: 1px; + background: var(--color-danger); + transform: rotate(45deg); } -.navigation-row[aria-current="page"] .navigation-row__icon { - color: var(--color-accent-strong); +.status-badge--unknown { + border-color: var(--color-unknown); + color: var(--color-unknown); + background: var(--color-unknown-muted); } -@media (hover: hover) { - .navigation-row:hover, - .session-row:hover { - color: var(--color-text); - background: var(--color-surface-hover); +@keyframes status-pulse { + 50% { + opacity: 0.35; } } -.session-row { - min-height: 5rem; - display: grid; - gap: var(--space-1); - padding: var(--space-2) var(--space-3); -} - -.session-row__heading, -.session-row__details, -.session-row__worktree, -.session-row__terminal-state { +/* Connection and loading workspaces */ +.workspace { min-width: 0; + min-height: 0; display: flex; - align-items: center; -} - -.session-row__heading { - justify-content: space-between; - gap: var(--space-2); -} - -.session-row__name { - min-width: 0; - overflow-wrap: anywhere; - color: var(--color-text); - font-size: 0.8125rem; - font-weight: 600; - line-height: 1.35; -} - -.session-row__agent { - min-width: 0; + flex-direction: column; overflow: hidden; - color: var(--color-text-muted); - font-size: 0.75rem; - text-overflow: ellipsis; - white-space: nowrap; + background: var(--color-background); + container: workspace / inline-size; } - -.session-row__details { - justify-content: space-between; - gap: var(--space-2); - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: 0.6875rem; - font-variant-numeric: tabular-nums; -} - -.session-row__details > span { - min-width: 0; - display: flex; - align-items: center; - gap: var(--space-1); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.session-row__details time { - flex: 0 0 auto; -} - -.session-row__worktree, -.session-row__terminal-state { - gap: var(--space-1); - overflow-wrap: anywhere; - color: var(--color-text-muted); - font-size: 0.6875rem; - line-height: 1.35; -} - -.session-row__terminal-state { - border-left: 2px solid var(--color-text-disabled); - padding-left: var(--space-2); -} - -.session-row__terminal-state--error { - border-left-color: var(--color-danger); - color: var(--color-danger); -} - -.retained-worktrees { - flex: 0 0 auto; - border-top: 1px solid var(--color-border); - padding: var(--space-3); - background: var(--color-surface-raised); -} - -.retained-worktrees h2 { - margin: 0 0 var(--space-2); - color: var(--color-text-muted); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.retained-worktrees ul { - display: grid; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; -} - -.retained-worktrees li { - min-width: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); -} - -.retained-worktrees li > span, -.retained-worktrees small { - min-width: 0; - display: block; -} - -.retained-worktrees strong, -.retained-worktrees small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.retained-worktrees strong { - color: var(--color-text); - font-size: 0.75rem; -} - -.retained-worktrees small { - margin-top: 0.125rem; - color: var(--color-text-muted); - font-size: 0.625rem; -} - -.retained-worktrees .button { - min-height: 2rem; - padding: var(--space-1) var(--space-2); - font-size: 0.6875rem; -} - -.status-badge { - min-width: 0; - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: var(--space-1); - border: 1px solid var(--color-border); - border-radius: 999px; - padding: 0.125rem var(--space-2); - color: var(--color-text-muted); - background: var(--color-inset); - font-size: 0.75rem; - font-weight: 600; - line-height: 1.25rem; - white-space: nowrap; -} - -.status-badge--compact { - padding-inline: var(--space-1); - font-size: 0.6875rem; - line-height: 1rem; -} - -.status-badge__indicator { - width: 0.5rem; - height: 0.5rem; - flex: 0 0 auto; - border: 1px solid currentColor; - border-radius: 50%; - background: transparent; -} - -.status-badge__label { - min-width: 0; -} - -.status-badge--starting { - border-color: var(--color-accent); - color: var(--color-accent-strong); - background: var(--color-accent-muted); -} - -.status-badge--starting .status-badge__indicator { - background: var(--color-accent); - animation: status-pulse 1.2s ease-in-out infinite; -} - -.status-badge--running { - border-color: var(--color-success); - color: var(--color-success); - background: var(--color-success-muted); -} - -.status-badge--running .status-badge__indicator { - background: var(--color-success); -} - -.status-badge--idle { - border-color: var(--color-warning); - color: var(--color-warning); - background: var(--color-warning-muted); -} - -.status-badge--idle .status-badge__indicator { - border-radius: 1px; - background: var(--color-warning); -} - -.status-badge--exited { - border-color: var(--color-border-strong); - color: var(--color-text-muted); -} - -.status-badge--failed { - border-color: var(--color-danger); - color: var(--color-danger); - background: var(--color-danger-muted); -} - -.status-badge--failed .status-badge__indicator { - border-radius: 1px; - background: var(--color-danger); - transform: rotate(45deg); -} - -.status-badge--unknown { - border-color: var(--color-unknown); - color: var(--color-unknown); - background: var(--color-unknown-muted); -} - -@keyframes status-pulse { - 50% { - opacity: 0.35; - } -} - -.nav-empty { - min-width: 0; - display: grid; - justify-items: start; - gap: var(--space-2); - padding: var(--space-4) var(--space-3); - color: var(--color-text-muted); -} - -.nav-empty--compact { - padding-block: var(--space-3); -} - -.nav-empty > .icon { - color: var(--color-text-disabled); -} - -.nav-empty p { - margin: 0; - overflow-wrap: anywhere; - font-size: 0.8125rem; -} - -.pane-footer { - min-width: 0; - display: grid; - flex: 0 0 auto; - grid-template-columns: repeat(2, minmax(0, 1fr)); - border-top: 1px solid var(--color-border); -} - -.pane-footer__action { - min-width: 0; - min-height: 2.25rem; - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-1); - border: 0; - color: var(--color-text-muted); - background: transparent; - font-size: 0.75rem; -} - -.pane-footer__action + .pane-footer__action { - border-left: 1px solid var(--color-border); -} - -@media (hover: hover) { - .pane-footer__action:hover { - color: var(--color-text); - background: var(--color-surface-hover); - } -} - -/* Workspace and dense session surface */ -.workspace { - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--color-background); - container: workspace / inline-size; -} - -.workspace-header, -.workspace__header { - min-width: 0; - min-height: 3.5rem; - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - border-bottom: 1px solid var(--color-border); - padding: var(--space-2) var(--space-4); - background: var(--color-background); -} - -.workspace-header > div, -.workspace-header__identity, -.workspace__header > div { - min-width: 0; -} - -.workspace-header__eyebrow, -.workspace__eyebrow { - margin: 0 0 0.125rem; - color: var(--color-text-muted); - font-size: 0.75rem; - line-height: 1.25; -} - -.workspace-header h1, -.workspace__header h1 { - margin: 0; - overflow-wrap: anywhere; - font-size: 1rem; - font-weight: 650; - line-height: 1.3; -} - -.workspace-header__path { - max-width: 52rem; - margin: var(--space-1) 0 0; - overflow-wrap: anywhere; - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: 0.6875rem; -} - -.workspace-header__title-row, -.workspace-header__actions { - min-width: 0; - display: flex; - align-items: center; -} - -.workspace-header__title-row { - gap: var(--space-2); -} - -.workspace-header__actions { - flex: 0 1 auto; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-2); -} - -.workspace-header__shortcut, -.workspace__mode, -.project-health { - flex: 0 0 auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-small); - padding: 0 var(--space-2); - color: var(--color-text-muted); - background: var(--color-surface); - font-family: var(--font-mono); - font-size: 0.75rem; - line-height: 1.5rem; -} - -.project-health { - border-left: 3px solid var(--color-success); -} - -.project-health--error { - border-color: var(--color-danger); - color: var(--color-danger); - background: var(--color-danger-muted); -} - -.workspace > .notice { - margin: var(--space-3) var(--space-4) 0; -} - -.session-workspace { - min-width: 0; - min-height: 0; - display: grid; - flex: 1 1 auto; - grid-template-rows: auto minmax(18rem, 1fr) auto; - gap: var(--space-3); - overflow: auto; - overscroll-behavior: contain; - padding: var(--space-3) var(--space-4) var(--space-4); -} - -.session-overview, -.terminal-host, -.danger-zone, -.document-section, -.session-grid__tile, -.recent-project { - min-width: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-surface); -} - -.session-overview { - padding: var(--space-3); -} - -.section-heading { - min-width: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); - margin-bottom: var(--space-3); -} - -.section-heading h2, -.danger-zone h2, -.document-section h2 { - margin: 0; - font-size: 0.8125rem; - font-weight: 650; - letter-spacing: 0.02em; -} - -.metadata-grid { - min-width: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1px; - margin: 0; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-small); - background: var(--color-border); -} - -.metadata-grid > div { - min-width: 0; - padding: var(--space-2) var(--space-3); - background: var(--color-inset); -} - -.metadata-grid__wide { - grid-column: 1 / -1; -} - -.metadata-grid dt, -.review-list dt, -.path-list dt, -.shortcut-list dt { - color: var(--color-text-muted); - font-size: 0.75rem; -} - -.metadata-grid dd, -.review-list dd, -.path-list dd, -.shortcut-list dd { - min-width: 0; - margin: 0.125rem 0 0; - overflow-wrap: anywhere; -} - -.metadata-grid dd { - font-size: 0.8125rem; -} - -.session-path-actions, -.danger-zone__actions { - min-width: 0; - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.session-path-actions { - margin-top: var(--space-3); -} - -.terminal-host { - min-height: 18rem; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - border-color: var(--color-border-strong); - background: var(--color-inset); - scroll-margin: var(--space-4); -} - -.terminal-host:focus-visible { - outline-width: 3px; -} - -.terminal-host__header { - min-width: 0; - min-height: 2rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - border-bottom: 1px solid var(--color-border); - padding: 0 var(--space-3); - color: var(--color-text-muted); - background: var(--color-surface); - font-family: var(--font-mono); - font-size: 0.75rem; -} - -.terminal-host__header > span { - min-width: 0; - display: inline-flex; - align-items: center; - gap: var(--space-1); -} - -.terminal-host__status { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.terminal-host__placeholder { - min-width: 0; - min-height: 0; - display: grid; - place-content: center; - justify-items: center; - gap: var(--space-2); - padding: var(--space-6); - color: var(--color-text-muted); - text-align: center; -} - -.terminal-host__placeholder > .icon { - width: 1.5rem; - height: 1.5rem; - color: var(--color-text-disabled); -} - -.terminal-host__placeholder p { - max-width: 30rem; - margin: 0; - overflow-wrap: anywhere; -} - -.terminal-host__surface, -.terminal-host__surface .terminal-surface { - min-width: 0; - min-height: 0; -} - -.terminal-host__surface .terminal-surface { - border: 0; - border-radius: 0; -} - -.danger-zone { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - border-color: var(--color-danger); - padding: var(--space-3); - background: var(--color-danger-muted); -} - -.danger-zone p { - margin: var(--space-1) 0 0; - color: var(--color-text-muted); - font-size: 0.75rem; -} - -/* Empty, loading, grid, settings, and diagnostics views */ -.empty-state { - width: min(calc(100% - (2 * var(--space-6))), 38rem); - min-width: 0; - display: grid; - flex: 0 1 auto; - align-self: center; - justify-items: center; - gap: var(--space-4); - margin: auto; - padding: var(--space-8) 0; - text-align: center; -} - -.empty-state__icon { - width: 3rem; - height: 3rem; - display: grid; - place-items: center; - border: 1px solid var(--color-border-strong); - border-radius: var(--radius); - color: var(--color-accent-strong); - background: var(--color-surface); -} - -.empty-state--error .empty-state__icon { - border-color: var(--color-danger); - color: var(--color-danger); - background: var(--color-danger-muted); -} - -.empty-state__icon .icon { - width: 1.5rem; - height: 1.5rem; -} - -.empty-state__copy { - min-width: 0; - max-width: 32rem; -} - -.empty-state__copy h1, -.empty-state__copy h2 { - margin: 0 0 var(--space-2); - overflow-wrap: anywhere; - font-size: 1.125rem; - font-weight: 650; - line-height: 1.3; - text-wrap: balance; -} - -.empty-state__copy p, -.empty-state > p { - margin: 0; - overflow-wrap: anywhere; - color: var(--color-text-muted); - line-height: 1.55; -} - -.empty-state__copy .empty-state__action-copy { - margin-top: var(--space-2); - color: var(--color-text); -} - -.empty-state__steps { - width: 100%; - min-width: 0; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-surface); -} - -.empty-state__step { - min-width: 0; - min-height: 4.5rem; - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3); - overflow-wrap: anywhere; - color: var(--color-text-muted); - text-align: left; -} - -.empty-state__step + .empty-state__step { - border-left: 1px solid var(--color-border); -} - -.empty-state__step-number { - width: 1.5rem; - height: 1.5rem; - display: grid; - flex: 0 0 auto; - place-items: center; - border: 1px solid var(--color-border-strong); - border-radius: 50%; - color: var(--color-text); - background: var(--color-inset); - font-family: var(--font-mono); - font-size: 0.75rem; - font-variant-numeric: tabular-nums; -} - -.loading-workspace, -.loading-state { - min-width: 0; - color: var(--color-text-muted); -} - -.loading-workspace { - display: flex; - flex: 1 1 auto; - align-items: center; - justify-content: center; - gap: var(--space-3); - padding: var(--space-6); -} - -.loading-workspace h1 { - margin: 0; - color: var(--color-text); - font-size: 1rem; -} - -.loading-workspace p { - margin: var(--space-1) 0 0; -} - -.loading-state { - display: flex; - align-items: center; - min-height: 2.5rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-small); - padding: var(--space-2) var(--space-3); - background: var(--color-inset); -} - -.spinner { - width: 1rem; - height: 1rem; - flex: 0 0 auto; - border: 2px solid var(--color-border-strong); - border-top-color: var(--color-accent); - border-radius: 50%; - animation: spinner-rotate 700ms linear infinite; -} - -@keyframes spinner-rotate { - to { - transform: rotate(360deg); - } -} - -.recent-projects, -.document-view, -.session-grid { - min-width: 0; - min-height: 0; - flex: 1 1 auto; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - padding: var(--space-4); -} - -.recent-projects { - display: grid; - align-content: start; - grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr)); - gap: var(--space-3); -} - -.recent-project { - min-height: 4rem; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-3); - padding: var(--space-3); - color: var(--color-text-muted); - text-align: left; -} - -.recent-project > span { - min-width: 0; - display: grid; - gap: var(--space-1); -} - -.recent-project strong, -.recent-project small { - min-width: 0; - overflow-wrap: anywhere; -} - -.recent-project strong { - color: var(--color-text); -} - -.recent-project small { - font-family: var(--font-mono); -} - -@media (hover: hover) { - .recent-project:hover, - .session-grid__tile:hover { - border-color: var(--color-accent); - background: var(--color-surface-hover); - } -} - -.session-grid { - display: grid; - align-content: start; - grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr)); - gap: var(--space-3); -} - -.session-grid__tile { - min-height: 12rem; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - padding: 0; - color: var(--color-text); - text-align: left; -} - -.session-grid__tile > span:first-child { - min-width: 0; - min-height: 2.75rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - border-bottom: 1px solid var(--color-border); - padding: var(--space-2) var(--space-3); -} - -.session-grid__tile strong { - min-width: 0; - overflow-wrap: anywhere; -} - -.terminal-grid-seam { - min-width: 0; - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-2); + +.workspace-header__eyebrow { + margin: 0 0 0.125rem; color: var(--color-text-muted); - background: var(--color-inset); - font-family: var(--font-mono); font-size: 0.75rem; + line-height: 1.25; } -.workspace--document { - background: var(--color-background); +/* Empty and loading connection views */ +.empty-state { + width: min(calc(100% - (2 * var(--space-6))), 38rem); + min-width: 0; + display: grid; + flex: 0 1 auto; + align-self: center; + justify-items: center; + gap: var(--space-4); + margin: auto; + padding: var(--space-8) 0; + text-align: center; } -.document-view { - width: 100%; - max-width: 62rem; - align-self: center; +.empty-state__icon { + width: 3rem; + height: 3rem; display: grid; - align-content: start; - gap: var(--space-4); + place-items: center; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + color: var(--color-accent-strong); + background: var(--color-surface); } -.document-section { - padding: var(--space-4); +.empty-state--error .empty-state__icon { + border-color: var(--color-danger); + color: var(--color-danger); + background: var(--color-danger-muted); +} + +.empty-state__icon .icon { + width: 1.5rem; + height: 1.5rem; +} + +.empty-state__copy { + min-width: 0; + max-width: 32rem; } -.document-section > h2 { - margin-bottom: var(--space-3); +.empty-state__copy h1, +.empty-state__copy h2 { + margin: 0 0 var(--space-2); + overflow-wrap: anywhere; + font-size: 1.125rem; + font-weight: 650; + line-height: 1.3; + text-wrap: balance; } -.document-section > p:not(.document-note) { - max-width: 70ch; +.empty-state__copy p, +.empty-state > p { margin: 0; + overflow-wrap: anywhere; color: var(--color-text-muted); + line-height: 1.55; } -.document-note { - margin: var(--space-3) 0 0; - color: var(--color-text-muted); - font-size: 0.75rem; +.empty-state__copy .empty-state__action-copy { + margin-top: var(--space-2); + color: var(--color-text); } -.shortcut-list, -.path-list { +.loading-workspace, +.loading-state { min-width: 0; - display: grid; - gap: 1px; - margin: 0; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-small); - background: var(--color-border); + color: var(--color-text-muted); } -.shortcut-list > div, -.path-list > div { - min-width: 0; - display: grid; - grid-template-columns: minmax(8rem, 0.4fr) minmax(0, 1fr); +.loading-workspace { + display: flex; + flex: 1 1 auto; align-items: center; + justify-content: center; gap: var(--space-3); - padding: var(--space-2) var(--space-3); - background: var(--color-inset); -} - -.shortcut-list dd { - justify-self: end; + padding: var(--space-6); } -.path-list dd { - font-family: var(--font-mono); - font-size: 0.75rem; +.loading-workspace h1 { + margin: 0; + color: var(--color-text); + font-size: 1rem; } -.diagnostic-issue-list { - display: grid; - gap: var(--space-2); - margin: 0; - padding: 0; - list-style: none; +.loading-workspace p { + margin: var(--space-1) 0 0; } -.diagnostic-issue-list li { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: var(--space-1) var(--space-3); +.loading-state { + display: flex; + align-items: center; + min-height: 2.5rem; border: 1px solid var(--color-border); border-radius: var(--radius-small); padding: var(--space-2) var(--space-3); background: var(--color-inset); } -.diagnostic-issue-list p { - grid-column: 1 / -1; - margin: 0; +.spinner { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + border: 2px solid var(--color-border-strong); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: spinner-rotate 700ms linear infinite; +} + +@keyframes spinner-rotate { + to { + transform: rotate(360deg); + } +} + +.document-note { + margin: var(--space-3) 0 0; color: var(--color-text-muted); + font-size: 0.75rem; } .fatal-screen { @@ -1393,8 +404,7 @@ .error-notice, .information-box, .destructive-copy, -.review-consequence, -.confirmation-check { +.review-consequence { min-width: 0; border: 1px solid var(--color-border); border-radius: var(--radius-small); @@ -1404,8 +414,7 @@ .notice, .information-box, .destructive-copy, -.review-consequence, -.confirmation-check { +.review-consequence { display: flex; align-items: flex-start; gap: var(--space-2); @@ -1440,12 +449,6 @@ color: var(--color-danger); } -.notice--success { - border-color: var(--color-success); - color: var(--color-success); - background: var(--color-success-muted); -} - .information-box--warning { border-color: var(--color-warning); background: var(--color-warning-muted); @@ -1461,23 +464,6 @@ background: var(--color-warning-muted); } -.confirmation-check { - cursor: pointer; -} - -.confirmation-check input { - width: 1rem; - height: 1rem; - flex: 0 0 auto; - margin: 0.125rem 0 0; - accent-color: var(--color-danger); -} - -.confirmation-check span { - min-width: 0; - overflow-wrap: anywhere; -} - .error-notice { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -1717,7 +703,7 @@ .text-input::placeholder, .text-area::placeholder, .command-palette__input::placeholder { - color: var(--color-text-disabled); + color: var(--color-text-muted); opacity: 1; } @@ -1796,8 +782,7 @@ box-shadow: inset 3px 0 0 var(--color-accent); } -.segmented-options > label:has(input:focus-visible), -.confirmation-check:has(input:focus-visible) { +.segmented-options > label:has(input:focus-visible) { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; } @@ -1898,9 +883,17 @@ background: var(--color-inset); } +.review-list dt { + color: var(--color-text-muted); + font-size: 0.75rem; +} + .review-list dd { + min-width: 0; display: grid; gap: var(--space-1); + margin: 0.125rem 0 0; + overflow-wrap: anywhere; font-size: 0.8125rem; } @@ -2047,12 +1040,16 @@ } .git-file-list { + min-width: 0; display: grid; gap: 1px; + margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-small); + padding: 0; background: var(--color-border); + list-style: none; } .git-file-list li { @@ -2198,14 +1195,6 @@ background: transparent; } -.status-bar__connection--connected { - color: var(--color-success); -} - -.status-bar__connection--connected .status-bar__indicator { - background: var(--color-success); -} - .status-bar__connection--connecting { color: var(--color-accent-strong); } @@ -2233,88 +1222,6 @@ } } -/* A wide workspace gives the terminal priority beside compact metadata. */ -@media (min-width: 75rem) { - .session-workspace { - grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); - grid-template-rows: auto auto minmax(0, 1fr); - } - - .session-overview { - grid-column: 1; - grid-row: 1; - } - - .danger-zone { - grid-column: 1; - grid-row: 2; - align-items: stretch; - flex-direction: column; - } - - .terminal-host { - grid-column: 2; - grid-row: 1 / -1; - } -} - -@media (max-width: 68rem) and (min-width: 48rem) { - .navigation-pane { - width: clamp(23rem, 38vw, 26rem); - grid-template-columns: minmax(9.5rem, 0.76fr) minmax(13.5rem, 1.24fr); - } - - .app-header__context { - gap: var(--space-1); - } - - .app-header__branch { - display: none; - } - - .navigation-item__actions { - flex-direction: column; - } - - .navigation-row { - padding-right: 0; - } - - .workspace-header__actions .button { - padding-inline: var(--space-2); - } -} - -@container workspace (max-width: 42rem) { - .workspace-header, - .workspace__header { - align-items: flex-start; - flex-direction: column; - } - - .workspace-header__actions { - width: 100%; - justify-content: flex-start; - } - - .danger-zone { - align-items: flex-start; - flex-direction: column; - } - - .metadata-grid { - grid-template-columns: minmax(0, 1fr); - } - - .metadata-grid__wide { - grid-column: auto; - } - - .workspace__panels { - grid-template-columns: minmax(0, 1fr); - } -} - /* At narrow widths navigation becomes an explicit, keyboard-operable drawer. */ @media (max-width: 47.99rem) { .app-shell__body { @@ -2327,7 +1234,7 @@ z-index: var(--layer-navigation); width: min(calc(100% - var(--space-8)), 32rem); max-width: 100%; - grid-template-columns: minmax(8.5rem, 0.82fr) minmax(0, 1.18fr); + grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); visibility: hidden; border-right-color: var(--color-border-strong); @@ -2381,34 +1288,6 @@ transition-delay: 0s; } - .app-header__navigation-toggle { - display: inline-flex; - } - - .app-header__leading { - gap: var(--space-2); - } - - .app-header__context { - display: none; - } - - .session-workspace, - .recent-projects, - .document-view, - .session-grid { - padding: var(--space-3); - } - - .workspace-header, - .workspace__header { - padding-inline: var(--space-3); - } - - .workspace > .notice { - margin-inline: var(--space-3); - } - .session-form-grid, .review-list { grid-template-columns: minmax(0, 1fr); @@ -2429,46 +1308,8 @@ } @media (max-width: 36rem) { - .app-header__name, - .command-trigger > span, - .command-trigger kbd, - .app-header__actions .button kbd { - display: none; - } - - .command-trigger { - width: var(--control-height); - padding: 0; - } - .navigation-pane { width: 100%; - grid-template-columns: minmax(8rem, 0.78fr) minmax(0, 1.22fr); - } - - .pane-footer { - grid-template-columns: minmax(0, 1fr); - } - - .pane-footer__action + .pane-footer__action { - border-top: 1px solid var(--color-border); - border-left: 0; - } - - .workspace-header__shortcut, - .project-health { - max-width: 100%; - overflow-wrap: anywhere; - white-space: normal; - } - - .workspace-header__title-row { - align-items: flex-start; - flex-direction: column; - } - - .workspace-header__actions .button { - flex: 1 1 auto; } .empty-state { @@ -2477,16 +1318,10 @@ padding-block: var(--space-6); } - .empty-state__steps, .segmented-options { grid-template-columns: minmax(0, 1fr); } - .empty-state__step + .empty-state__step { - border-top: 1px solid var(--color-border); - border-left: 0; - } - .dialog { width: calc(100vw - (2 * var(--space-2))); max-width: calc(100vw - (2 * var(--space-2))); @@ -2523,16 +1358,6 @@ grid-row: 2; } - .shortcut-list > div, - .path-list > div { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-1); - } - - .shortcut-list dd { - justify-self: start; - } - .status-bar__group { display: none; } @@ -2572,8 +1397,4 @@ animation: none; } - .status-bar__actions { - width: 100%; - justify-content: space-between; - } } diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css index 5024945..06d96fa 100644 --- a/apps/desktop/src/styles/tokens.css +++ b/apps/desktop/src/styles/tokens.css @@ -1,6 +1,6 @@ :root { - color-scheme: dark; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color-scheme: light; + font-family: var(--font-sans); font-size: 16px; font-synthesis: none; text-rendering: optimizeLegibility; @@ -8,37 +8,38 @@ -moz-osx-font-smoothing: grayscale; -webkit-text-size-adjust: 100%; - --color-background: #0b0f14; - --color-sidebar: #0f141b; - --color-surface: #151b23; - --color-surface-raised: #1b2430; - --color-inset: #080b10; - --color-text: #f1f5f9; - --color-text-muted: #a8b3c2; - --color-text-disabled: #687386; - --color-border: #2b3543; - --color-border-strong: #465368; - --color-accent: #38bdf8; - --color-accent-strong: #7dd3fc; - --color-on-accent: #07131a; - --color-success: #4ade80; - --color-warning: #fbbf24; - --color-danger: #fb7185; - --color-unknown: #c4b5fd; - --color-focus-ring: #bae6fd; + --color-background: #fbfbfa; + --color-sidebar: #f4f4f2; + --color-surface: #ffffff; + --color-surface-raised: #fafaf9; + --color-inset: #f4f4f2; + --color-text: #26282b; + --color-text-muted: #6d7075; + --color-text-disabled: #85878b; + --color-border: #d9dadc; + --color-border-strong: #85878b; + --color-accent: #c8323c; + --color-accent-strong: #9d242d; + --color-on-accent: #ffffff; + --color-success: #287a45; + --color-warning: #7b5b00; + --color-danger: #b42335; + --color-unknown: #6550a7; + --color-focus-ring: #1b63d9; /* Derived semantic surfaces keep component styles free of status hex values. */ - --color-surface-hover: #202a36; - --color-surface-active: #253243; - --color-accent-muted: #102b39; - --color-success-muted: #10291b; - --color-warning-muted: #30260e; - --color-danger-muted: #321820; - --color-unknown-muted: #25213b; - --color-scrim: rgb(2 6 10 / 78%); - --color-scrollbar: #465368; - --color-scrollbar-hover: #687386; + --color-surface-hover: #eeeeec; + --color-surface-active: #e4e4e1; + --color-accent-muted: #fff0f1; + --color-success-muted: #edf8f0; + --color-warning-muted: #fff7df; + --color-danger-muted: #fff0f2; + --color-unknown-muted: #f2effb; + --color-scrim: rgb(34 36 38 / 32%); + --color-scrollbar: #85878b; + --color-scrollbar-hover: #6d7075; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; --space-1: 0.25rem; @@ -56,10 +57,9 @@ --status-height: 2rem; --duration-feedback: 140ms; --ease-feedback: ease-out; - --shadow-dialog: 0 1.5rem 4rem rgb(0 0 0 / 48%); + --shadow-dialog: 0 1.5rem 5rem rgb(25 27 29 / 24%); --layer-navigation-backdrop: 20; --layer-navigation: 30; - --layer-dialog: 80; --layer-skip-link: 100; --layer-dialog: 200; } From 0ab3505f46e017f8b0d4ca30f8fbffd0b2ae2f91 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Wed, 2 Sep 2026 18:12:16 -0300 Subject: [PATCH 05/32] refactor(desktop): document the canvas-only design --- design-system/cli-master/MASTER.md | 103 +++++++++++++---------- design-system/cli-master/pages/canvas.md | 35 +++++--- 2 files changed, 85 insertions(+), 53 deletions(-) diff --git a/design-system/cli-master/MASTER.md b/design-system/cli-master/MASTER.md index 2e42d9a..1cfc5fe 100644 --- a/design-system/cli-master/MASTER.md +++ b/design-system/cli-master/MASTER.md @@ -7,8 +7,10 @@ overrides belong in `pages/.md` and must document why they differ. CLI Master is a dense, keyboard-first developer control center. It should feel closer to a terminal multiplexer or code editor than a consumer chat product. -The interface is dark-first, restrained, fast, and readable during long work -sessions. +The application shell is light-first, restrained, fast, and readable during +long work sessions. The xterm viewport is the only dark surface; dialogs, +navigation, status, settings, diagnostics, and connection states all use the +light shell palette. Design dials: @@ -23,28 +25,30 @@ values outside the token layer. | Role | Token | Value | |---|---|---| -| App background | `--color-background` | `#0b0f14` | -| Sidebar | `--color-sidebar` | `#0f141b` | -| Raised surface | `--color-surface` | `#151b23` | -| Elevated surface | `--color-surface-raised` | `#1b2430` | -| Input/terminal surface | `--color-inset` | `#080b10` | -| Primary text | `--color-text` | `#f1f5f9` | -| Secondary text | `--color-text-muted` | `#a8b3c2` | -| Disabled text | `--color-text-disabled` | `#687386` | -| Border | `--color-border` | `#2b3543` | -| Strong border | `--color-border-strong` | `#465368` | -| Action/focus | `--color-accent` | `#38bdf8` | -| Action hover | `--color-accent-strong` | `#7dd3fc` | -| Text on action | `--color-on-accent` | `#07131a` | -| Running/success | `--color-success` | `#4ade80` | -| Idle/warning | `--color-warning` | `#fbbf24` | -| Failed/destructive | `--color-danger` | `#fb7185` | -| Unknown | `--color-unknown` | `#c4b5fd` | -| Focus halo | `--color-focus-ring` | `#bae6fd` | - -Status always includes a visible label or icon in addition to color. Blue/cyan -belongs to actions and keyboard focus; green remains reserved for running or a -successful operation. +| App background | `--color-background` | `#fbfbfa` | +| Sidebar | `--color-sidebar` | `#f4f4f2` | +| Raised surface | `--color-surface` | `#ffffff` | +| Elevated surface | `--color-surface-raised` | `#fafaf9` | +| Input surface | `--color-inset` | `#f4f4f2` | +| Primary text | `--color-text` | `#26282b` | +| Secondary text | `--color-text-muted` | `#6d7075` | +| Disabled text | `--color-text-disabled` | `#85878b` | +| Border | `--color-border` | `#d9dadc` | +| Strong control border | `--color-border-strong` | `#85878b` | +| Action fill | `--color-accent` | `#c8323c` | +| Action hover/text | `--color-accent-strong` | `#9d242d` | +| Text on action | `--color-on-accent` | `#ffffff` | +| Running/success | `--color-success` | `#287a45` | +| Idle/warning | `--color-warning` | `#7b5b00` | +| Failed/destructive | `--color-danger` | `#b42335` | +| Unknown | `--color-unknown` | `#6550a7` | +| Focus halo | `--color-focus-ring` | `#1b63d9` | + +Status always includes a visible label or icon in addition to color. Coral is +the selection and action family, but `#ef4b55` is not used for small text or as +a fill behind white text; use the darker semantic action tokens for those +pairs. Blue belongs to keyboard focus, and green remains reserved for running +or a successful operation. ## Typography @@ -82,24 +86,26 @@ Use the four-pixel rhythm. ## Layout -The persistent hierarchy is: +The application has one persistent hierarchy: ```text -application header -├── project/session sidebar -└── workspace main - ├── session toolbar - ├── single terminal or terminal grid - └── optional Git panel -status bar +canvas-only application shell +├── canvas sidebar (modal drawer below 48rem) +├── primary workspace +│ ├── persistent workspace action chrome +│ └── canvas, settings, diagnostics, or connection state +│ └── terminal and note nodes (canvas only) +└── local status bar ``` -- The sidebar is navigation and metadata, not the home for primary actions. -- The workspace gives terminals the largest share of available area. -- One terminal fills the workspace; two use two columns when width permits; - three emphasize the active terminal; four use a 2×2 grid. -- At narrow widths, collapse secondary metadata before reducing terminal - usability. Never hide the active session or primary create action. +- There is no separate application header, session sidebar, single-session + view, or fixed terminal grid. +- The sidebar owns workspace navigation and project metadata actions. Global + navigation, palette, and create actions remain in workspace chrome. +- The spatial canvas gives terminal and note nodes the largest share of the + available area. +- At narrow widths, the sidebar becomes an explicit modal drawer. Never hide + the navigation trigger, command palette, or primary create action. - Keep project, session, and terminal selection when changing views. ## Components and states @@ -111,11 +117,16 @@ status bar border; destructive buttons use danger only in a confirmed destructive flow. - Hover and pressed feedback may change color, opacity, or border. It must not move surrounding layout. -- Disabled buttons use the native `disabled` attribute and remain legible. +- Disabled buttons normally use the native `disabled` attribute and remain + legible. Use guarded `aria-disabled` only when the control must stay + focusable so its unavailable reason can be announced. ### Navigation rows -- Use `ul > li > button` for project and session lists. +- Use list semantics for project navigation. Keep the project-selection button + separate from its sibling rename and remove buttons; never nest controls. +- Rename and remove controls stay visible, have descriptive accessible names, + and remain keyboard reachable without relying on hover. - Active rows use a left indicator plus contrast/weight, not color alone. - Preserve full names through wrapping or a keyboard-accessible tooltip when truncation is unavoidable. @@ -138,14 +149,17 @@ status bar ### Terminals - xterm owns terminal rendering and focus. PTY bytes never enter React state. +- A live terminal establishes an explicit dark token island (`#080b10` + background, `#f1f5f9` foreground, cyan cursor/focus) so light shell tokens do + not change xterm rendering. - The active tile has a visible border and title; focus is never color-only. - Resize only after measured dimensions change. Avoid remounting a live terminal - solely because single/grid mode changed. + when selecting, moving, or focusing its canvas node. - Do not intercept terminal control sequences such as Ctrl+C for app commands. ## Keyboard and focus -- Provide a skip link to the active terminal. +- Provide a skip link to the primary workspace. - Every action available by pointer is available by keyboard. - Use a 2px or larger focus outline with at least 3:1 state contrast. - Do not remove focus rings. Sticky toolbars and dialogs must not obscure the @@ -173,6 +187,8 @@ status bar ## Forbidden patterns - Consumer-chat bubbles or vendor-branded visual imitation. +- A second dark shell, header, project/session rail, or fixed terminal-grid + navigation alongside the canvas. - Remote font dependency, gradients, glassmorphism, glow-heavy decoration, or animated backgrounds. - Shell output simulated with styled text instead of xterm and a real PTY. @@ -190,4 +206,5 @@ status bar scrolling. - Confirm status has text/icon plus color and no terminal output is announced. - Confirm project removal never implies deleting its directory. -- Confirm active terminals keep buffers and focus across single/grid changes. +- Confirm active terminals keep buffers while canvas selection and navigation + change. diff --git a/design-system/cli-master/pages/canvas.md b/design-system/cli-master/pages/canvas.md index d9344cf..fa3291c 100644 --- a/design-system/cli-master/pages/canvas.md +++ b/design-system/cli-master/pages/canvas.md @@ -1,9 +1,9 @@ -# Canvas workspace override +# Canvas-only shell -This page intentionally departs from the dark-first application shell because -the requested spatial workspace is modeled after a light macOS canvas. The -underlying terminal surfaces remain dark and high contrast; dialogs and legacy -project/session views continue to use the master system until migrated. +This page specializes the light-first application system for the spatial +workspace. The canvas is the only shell: its sidebar, action chrome, dialogs, +palette, banners, settings, diagnostics, and status bar all use the light +palette. Only xterm terminal surfaces remain dark and high contrast. ## Product model @@ -19,6 +19,8 @@ project/session views continue to use the master system until migrated. - Canvas: warm white with a 16px fine grid and a 128px major grid. - Sidebar: translucent off-white surface with a solid fallback and a restrained shadow only where it separates from the canvas. +- Project rows: separate selection, rename, and remove buttons. Both project + actions are always visible and keyboard reachable rather than hover-revealed. - Nodes: white terminal cards and pale-yellow notes with 8px corners, one-pixel borders, compact macOS-style headers, and soft elevation. - Selection: coral-red outline plus a visible `Selected` label; never color @@ -41,10 +43,18 @@ project/session views continue to use the master system until migrated. | Primary text | `--canvas-text` | `#26282b` | | Secondary text | `--canvas-text-muted` | `#6d7075` | | Border | `--canvas-border` | `#d9dadc` | -| Selection/action | `--canvas-accent` | `#ef4b55` | +| Selection outline | `--canvas-accent` | `#ef4b55` | +| Action text/hover | `--canvas-accent-ink` | `#9d242d` | +| Action fill | `--canvas-accent-fill` | `#c8323c` | | Connection | `--canvas-connector` | `#8b8e93` | +| Control border | `--canvas-control-border` | `#85878b` | | Focus ring | `--canvas-focus` | `#1b63d9` | +The coral selection outline passes non-text contrast on white, but it does not +pass normal-text contrast with white text. Use `--canvas-accent-ink` for coral +text and `--canvas-accent-fill` behind white labels. Use the stronger control +border wherever a field or option needs its boundary to identify the control. + ## Interaction constraints - Node headers are drag handles; text areas and terminal surfaces never start a @@ -61,9 +71,14 @@ project/session views continue to use the master system until migrated. ## Responsive behavior -- At 1024px and above, the workspace sidebar is persistent. -- Below 1024px, the sidebar becomes an overlay opened from the toolbar. -- Below 768px, new nodes open near the viewport origin and node widths clamp to - the available canvas; the canvas itself remains pannable. +- At `48rem` and above, the workspace sidebar is persistent and may be + collapsed without losing the navigation trigger. +- Below `48rem`, the sidebar is a modal drawer with a backdrop, focus trap, + Escape dismissal, and focus restoration to its trigger. +- At `68rem` and below, navigation/palette/create actions occupy the first + chrome row; the canvas toolbar moves to a second row and the workspace context + follows it, so the groups never overlap as the workspace narrows. +- Below `48rem`, new nodes open near the viewport origin and terminal node width + clamps to the available canvas; the canvas itself remains pannable. - Zoom controls remain reachable in the lower-right corner and never obscure a focused node. From 9f3462ff852c4a6358053ee93686d334f9a2e917 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:26:25 -0300 Subject: [PATCH 06/32] feat(canvas): add safe group selection operations --- .../app/features/canvas/canvas-state.test.ts | 94 ++++++++++ .../src/app/features/canvas/canvas-state.ts | 169 ++++++++++++++---- 2 files changed, 226 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/app/features/canvas/canvas-state.test.ts b/apps/desktop/src/app/features/canvas/canvas-state.test.ts index d1f34a6..efc1282 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.test.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.test.ts @@ -7,11 +7,105 @@ import { createInitialCanvasState, createSessionTerminalCanvasNode, createTerminalCanvasNode, + duplicateCanvasSelection, parseCanvasDocument, serializeCanvasDocument, } from "./canvas-state"; describe("canvas state", () => { + it("toggles multi-selection, filters unknown IDs, and keeps selection transient", () => { + const initial = createInitialCanvasState(); + const first = canvasReducer(initial, { type: "node/select", nodeId: "note-first" }); + const multiple = canvasReducer(first, { + type: "node/select", nodeId: "terminal-primary", additive: true, + }); + expect(multiple.selectedNodeIds).toEqual(["note-first", "terminal-primary"]); + expect(multiple.selectedNodeId).toBe("terminal-primary"); + const toggled = canvasReducer(multiple, { + type: "node/select", nodeId: "terminal-primary", additive: true, + }); + expect(toggled.selectedNodeIds).toEqual(["note-first"]); + expect(toggled.selectedNodeId).toBe("note-first"); + const selected = canvasReducer(toggled, { + type: "nodes/select", nodeIds: ["terminal-primary", "missing", "terminal-primary", "note-first"], + }); + expect(selected.selectedNodeIds).toEqual(["terminal-primary", "note-first"]); + expect(JSON.parse(serializeCanvasDocument(selected))).not.toHaveProperty("selectedNodeIds"); + expect(createInitialCanvasState(parseCanvasDocument(serializeCanvasDocument(selected))).selectedNodeIds).toEqual([]); + expect(canvasReducer(selected, { type: "node/select", nodeId: null }).selectedNodeIds).toEqual([]); + }); + + it("moves a group by one bounded delta without distorting its layout", () => { + const initial = createInitialCanvasState({ + version: 1, + nodes: [ + createCanvasNode("note", { x: 7_990, y: -1_990 }, "edge"), + createCanvasNode("note", { x: 7_500, y: -1_500 }, "neighbor"), + createCanvasNode("note", { x: 0, y: 0 }, "unselected"), + ], + connections: [], zoom: 1, + }); + const moved = canvasReducer(initial, { + type: "nodes/move", nodeIds: ["edge", "neighbor"], delta: { x: 80, y: -80 }, + }); + expect(moved.nodes[0]).toMatchObject({ x: 8_000, y: -2_000 }); + expect(moved.nodes[1]).toMatchObject({ x: 7_510, y: -1_510 }); + expect(moved.nodes[2]).toBe(initial.nodes[2]); + expect(canvasReducer(moved, { + type: "nodes/move", nodeIds: ["edge"], delta: { x: NaN, y: 3 }, + })).toBe(moved); + }); + + it("duplicates notes, agent references, and internal connections without live sessions", () => { + const terminal = createSessionTerminalCanvasNode({ x: 0, y: 0 }, { + id: "live-session", agentId: "custom-agent", projectId: "project-one", name: "Review", cwd: "/repo", + }); + const note = { ...createCanvasNode("note", { x: 400, y: 0 }, "note"), title: "Release", text: "Ship it", projectId: "project-one" }; + const outside = createCanvasNode("note", { x: 0, y: 400 }, "outside"); + const initial = createInitialCanvasState({ + version: 1, nodes: [terminal, note, outside], zoom: 1, + connections: [ + { id: "internal", sourceNodeId: terminal.id, targetNodeId: note.id }, + { id: "external", sourceNodeId: terminal.id, targetNodeId: outside.id }, + ], + }); + const action = duplicateCanvasSelection(initial, [terminal.id, note.id]); + const copied = canvasReducer(initial, action); + expect(copied.nodes).toHaveLength(5); + const copies = copied.nodes.slice(3); + expect(copies[0]).toMatchObject({ kind: "terminal", title: "Review copy", agentId: "custom-agent", projectId: "project-one", x: 32, y: 32 }); + expect(copies[0]).not.toHaveProperty("sessionId", "live-session"); + expect(copies[1]).toMatchObject({ kind: "note", title: "Release copy", text: "Ship it", x: 432, y: 32 }); + expect(copied.connections).toHaveLength(3); + expect(copied.connections[2]).toMatchObject({ sourceNodeId: copies[0]?.id, targetNodeId: copies[1]?.id }); + expect(copied.selectedNodeIds).toEqual(copies.map((node) => node.id)); + expect(copied.nodes[0]).toBe(terminal); + expect(canvasReducer(copied, action)).toBe(copied); + expect(parseCanvasDocument(serializeCanvasDocument(copied)).nodes[3]).toMatchObject({ agentId: "custom-agent" }); + }); + + it("removes a selected group atomically, hiding its sessions and retaining other projects", () => { + const terminal = createSessionTerminalCanvasNode({ x: 0, y: 0 }, { + id: "live", projectId: "project-one", name: "Agent", cwd: "/repo", + }); + const note = createCanvasNode("note", { x: 400, y: 0 }, "note"); + const otherProject = { ...createCanvasNode("note", { x: 0, y: 0 }, "other"), projectId: "project-two" }; + const initial = createInitialCanvasState({ + version: 1, nodes: [terminal, note, otherProject], zoom: 1, + connections: [{ id: "edge", sourceNodeId: terminal.id, targetNodeId: note.id }], + }); + const selected = canvasReducer(initial, { type: "nodes/select", nodeIds: [terminal.id, note.id] }); + const deleted = canvasReducer(selected, { type: "nodes/delete", nodeIds: selected.selectedNodeIds }); + expect(deleted.nodes).toEqual([otherProject]); + expect(deleted.connections).toEqual([]); + expect(deleted.hiddenSessionIds).toEqual(["live"]); + expect(deleted.selectedNodeIds).toEqual([]); + expect(deleted.selectedNodeId).toBeNull(); + expect(canvasReducer(deleted, { + type: "sessions/reconcile", knownSessionIds: ["live"], sessionNodes: [terminal], + }).nodes).toEqual([otherProject]); + }); + it("provides the reference terminal and note composition on first launch", () => { const document = createInitialCanvasDocument(); diff --git a/apps/desktop/src/app/features/canvas/canvas-state.ts b/apps/desktop/src/app/features/canvas/canvas-state.ts index c671390..ffde2d7 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.ts @@ -24,6 +24,8 @@ interface CanvasNodeBase extends CanvasPoint { export interface TerminalCanvasNode extends CanvasNodeBase { readonly kind: "terminal"; readonly sessionId?: string; + /** Reference the persisted agent definition without copying its environment. */ + readonly agentId?: string; readonly preset: TerminalPreset; readonly executable?: string; readonly workingDirectory?: string; @@ -63,6 +65,7 @@ export interface CanvasDocument { export interface CanvasState extends CanvasDocument { readonly hiddenSessionIds: readonly string[]; readonly selectedNodeId: string | null; + readonly selectedNodeIds: readonly string[]; readonly connectionSourceId: string | null; } @@ -71,6 +74,7 @@ export interface CanvasSessionReference { readonly projectId: string; readonly name: string; readonly cwd: string; + readonly agentId?: string; } export type CanvasAction = @@ -117,7 +121,11 @@ export type CanvasAction = readonly node: TerminalCanvasNode; } | { readonly type: "node/delete"; readonly nodeId: string } - | { readonly type: "node/select"; readonly nodeId: string | null } + | { readonly type: "node/select"; readonly nodeId: string | null; readonly additive?: boolean } + | { readonly type: "nodes/select"; readonly nodeIds: readonly string[] } + | { readonly type: "nodes/move"; readonly nodeIds: readonly string[]; readonly delta: CanvasPoint } + | { readonly type: "nodes/delete"; readonly nodeIds: readonly string[] } + | { readonly type: "nodes/duplicate"; readonly nodes: readonly CanvasNode[]; readonly connections: readonly CanvasConnection[] } | { readonly type: "connection/start"; readonly nodeId: string } | { readonly type: "connection/complete"; readonly targetNodeId: string } | { readonly type: "connection/cancel" } @@ -178,6 +186,7 @@ export function createInitialCanvasState( ...document, hiddenSessionIds: document.hiddenSessionIds ?? [], selectedNodeId: null, + selectedNodeIds: [], connectionSourceId: null, }; } @@ -197,7 +206,32 @@ export function canvasReducer( ...state, nodes: [...state.nodes, normalizeNode(action.node)], selectedNodeId: action.node.id, + selectedNodeIds: [action.node.id], }; + case "nodes/select": + return selectNodes(state, action.nodeIds); + case "nodes/move": + return moveNodes(state, action.nodeIds, action.delta); + case "nodes/delete": + return deleteNodes(state, action.nodeIds); + case "nodes/duplicate": { + const nodes = action.nodes.map(normalizeNode); + if (nodes.length === 0 || new Set(nodes.map((node) => node.id)).size !== nodes.length || + nodes.some((node) => nodeExists(state.nodes, node.id))) { + return state; + } + const nodeIds = new Set(nodes.map((node) => node.id)); + const connections = action.connections.flatMap((connection) => { + const parsed = parseConnection(connection, nodeIds); + return parsed ? [parsed] : []; + }); + return selectNodes({ + ...state, + nodes: [...state.nodes, ...nodes], + connections: [...state.connections, ...connections], + connectionSourceId: null, + }, nodes.map((node) => node.id)); + } case "node/move": return updateNode(state, action.nodeId, (node) => ({ ...node, @@ -259,45 +293,24 @@ export function canvasReducer( ); case "session/reveal": return revealSessionNode(state, action.node); - case "node/delete": { - const deletedNode = state.nodes.find((node) => node.id === action.nodeId); - const hiddenSessionIds = - deletedNode?.kind === "terminal" && deletedNode.sessionId - ? addUnique(state.hiddenSessionIds, deletedNode.sessionId) - : state.hiddenSessionIds; - return { - ...state, - nodes: state.nodes.filter((node) => node.id !== action.nodeId), - connections: state.connections.filter( - (connection) => - connection.sourceNodeId !== action.nodeId && - connection.targetNodeId !== action.nodeId, - ), - selectedNodeId: - state.selectedNodeId === action.nodeId - ? null - : state.selectedNodeId, - connectionSourceId: - state.connectionSourceId === action.nodeId - ? null - : state.connectionSourceId, - hiddenSessionIds, - }; - } + case "node/delete": + return deleteNodes(state, [action.nodeId]); case "node/select": if (action.nodeId === null) { - return state.selectedNodeId === null - ? state - : { ...state, selectedNodeId: null }; + return selectNodes(state, []); } - return nodeExists(state.nodes, action.nodeId) - ? { ...state, selectedNodeId: action.nodeId } - : state; + if (!nodeExists(state.nodes, action.nodeId)) return state; + return selectNodes(state, action.additive + ? state.selectedNodeIds.includes(action.nodeId) + ? state.selectedNodeIds.filter((id) => id !== action.nodeId) + : [...state.selectedNodeIds, action.nodeId] + : [action.nodeId]); case "connection/start": return nodeExists(state.nodes, action.nodeId) ? { ...state, selectedNodeId: action.nodeId, + selectedNodeIds: [action.nodeId], connectionSourceId: action.nodeId, } : state; @@ -345,6 +358,31 @@ export function serializeCanvasDocument(state: CanvasState): string { return JSON.stringify(toCanvasDocument(state)); } +/** Copies graph metadata, never a running process or its terminal stream. */ +export function duplicateCanvasSelection( + state: CanvasState, + nodeIds: readonly string[], +): CanvasAction { + const selectedIds = new Set(nodeIds); + const originals = state.nodes.filter((node) => selectedIds.has(node.id)); + const idMap = new Map(originals.map((node) => [node.id, createId(node.kind)])); + const delta = boundedMoveDelta(originals, { x: 32, y: 32 }); + const nodes = originals.map((node): CanvasNode => ({ + ...node, + ...(node.kind === "terminal" ? { sessionId: undefined } : {}), + id: idMap.get(node.id)!, + title: `${node.title.slice(0, MAX_TITLE_LENGTH - 5)} copy`, + x: node.x + delta.x, + y: node.y + delta.y, + })); + const connections = state.connections.flatMap((connection) => { + const source = idMap.get(connection.sourceNodeId); + const target = idMap.get(connection.targetNodeId); + return source && target ? [createConnection(source, target)] : []; + }); + return { type: "nodes/duplicate", nodes, connections }; +} + export function createCanvasNode( kind: CanvasNodeKind, position: CanvasPoint, @@ -408,6 +446,7 @@ export function createSessionTerminalCanvasNode( `terminal-session-${session.id}`, ), sessionId: session.id, + agentId: session.agentId, projectId: session.projectId, }; } @@ -451,7 +490,8 @@ function reconcileSessionNodes( if ( existingNode?.kind === "terminal" && (existingNode.title !== sessionNode.title || - existingNode.projectId !== sessionNode.projectId) + existingNode.projectId !== sessionNode.projectId || + existingNode.agentId !== sessionNode.agentId) ) { nodes = nodes.map((node, index) => index === existingIndex @@ -459,6 +499,7 @@ function reconcileSessionNodes( ...existingNode, title: sessionNode.title, projectId: sessionNode.projectId, + agentId: sessionNode.agentId, } : node, ); @@ -496,6 +537,7 @@ function revealSessionNode( (sessionId) => sessionId !== sessionNode.sessionId, ), selectedNodeId: revealedNode.id, + selectedNodeIds: [revealedNode.id], connectionSourceId: null, }; } @@ -522,6 +564,7 @@ function completeConnection( return { ...state, selectedNodeId: targetNodeId, + selectedNodeIds: [targetNodeId], connectionSourceId: null, connections: duplicate ? state.connections @@ -540,6 +583,60 @@ function createConnection( }; } +function selectNodes(state: CanvasState, nodeIds: readonly string[]): CanvasState { + const selectedNodeIds = [...new Set(nodeIds)].filter((id) => nodeExists(state.nodes, id)); + if (stringArraysEqual(selectedNodeIds, state.selectedNodeIds)) return state; + return { + ...state, + selectedNodeIds, + selectedNodeId: selectedNodeIds[selectedNodeIds.length - 1] ?? null, + }; +} + +function boundedMoveDelta(nodes: readonly CanvasNode[], delta: CanvasPoint): CanvasPoint { + if (nodes.length === 0 || !Number.isFinite(delta.x) || !Number.isFinite(delta.y)) { + return { x: 0, y: 0 }; + } + // Clamp the entire group once so cards keep their relative positions at edges. + return nodes.reduce((bounded, node) => ({ + x: clamp(bounded.x, MIN_POSITION - node.x, MAX_POSITION - node.x), + y: clamp(bounded.y, MIN_POSITION - node.y, MAX_POSITION - node.y), + }), delta); +} + +function moveNodes(state: CanvasState, nodeIds: readonly string[], delta: CanvasPoint): CanvasState { + const ids = new Set(nodeIds); + const bounded = boundedMoveDelta(state.nodes.filter((node) => ids.has(node.id)), delta); + if (bounded.x === 0 && bounded.y === 0) return state; + return { + ...state, + nodes: state.nodes.map((node) => ids.has(node.id) + ? { ...node, x: node.x + bounded.x, y: node.y + bounded.y } + : node), + }; +} + +function deleteNodes(state: CanvasState, nodeIds: readonly string[]): CanvasState { + const ids = new Set(nodeIds); + const deletedNodes = state.nodes.filter((node) => ids.has(node.id)); + if (deletedNodes.length === 0) return state; + const selectedNodeIds = state.selectedNodeIds.filter((id) => !ids.has(id)); + return { + ...state, + nodes: state.nodes.filter((node) => !ids.has(node.id)), + connections: state.connections.filter((connection) => + !ids.has(connection.sourceNodeId) && !ids.has(connection.targetNodeId)), + selectedNodeIds, + selectedNodeId: selectedNodeIds[selectedNodeIds.length - 1] ?? null, + connectionSourceId: state.connectionSourceId && ids.has(state.connectionSourceId) + ? null : state.connectionSourceId, + hiddenSessionIds: [...new Set([ + ...state.hiddenSessionIds, + ...deletedNodes.flatMap((node) => node.kind === "terminal" && node.sessionId ? [node.sessionId] : []), + ])], + }; +} + function updateNode( state: CanvasState, nodeId: string, @@ -645,6 +742,8 @@ function parseNode(value: unknown): CanvasNode | null { ), sessionId: typeof value.sessionId === "string" ? value.sessionId : undefined, + agentId: + typeof value.agentId === "string" ? value.agentId : undefined, }; } @@ -780,10 +879,6 @@ function stringArraysEqual( ); } -function addUnique(values: readonly string[], value: string): readonly string[] { - return values.includes(value) ? values : [...values, value]; -} - function clamp(value: number, minimum: number, maximum: number): number { return Math.min(maximum, Math.max(minimum, value)); } From 0ac8dd7d49eefee16e5efbf389994f551bc584f5 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:30:11 -0300 Subject: [PATCH 07/32] fix(canvas): save only document changes and report storage failures --- .../features/canvas/useCanvasState.test.tsx | 39 ++++++++++++++++++- .../src/app/features/canvas/useCanvasState.ts | 29 +++++++++----- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx b/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx index 858c7de..6cfc5fd 100644 --- a/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx +++ b/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx @@ -1,5 +1,5 @@ import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CANVAS_DOCUMENT_UPDATED_EVENT, @@ -11,6 +11,43 @@ describe("useCanvasState", () => { beforeEach(() => { localStorage.clear(); }); + afterEach(() => vi.restoreAllMocks()); + + it("does not save or publish another document for transient selection changes", () => { + const write = vi.spyOn(Storage.prototype, "setItem"); + const publish = vi.spyOn(globalThis, "dispatchEvent"); + const { result } = renderHook(() => useCanvasState()); + write.mockClear(); + publish.mockClear(); + + act(() => result.current.dispatch({ + type: "nodes/select", nodeIds: ["terminal-primary", "note-first"], + })); + act(() => result.current.dispatch({ type: "connection/start", nodeId: "terminal-primary" })); + act(() => result.current.dispatch({ type: "connection/cancel" })); + + expect(write).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + act(() => result.current.dispatch({ type: "note/update", nodeId: "note-first", text: "Changed" })); + expect(write).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledTimes(1); + }); + + it("reports failed saves while retaining edits and recovers on the next successful save", () => { + const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage full", "QuotaExceededError"); + }); + const { result } = renderHook(() => useCanvasState()); + expect(result.current.persistenceAvailable).toBe(false); + act(() => result.current.dispatch({ type: "note/update", nodeId: "note-first", text: "Keep my draft" })); + expect(result.current.state.nodes.find((node) => node.id === "note-first")).toMatchObject({ text: "Keep my draft" }); + expect(result.current.persistenceAvailable).toBe(false); + + write.mockRestore(); + act(() => result.current.dispatch({ type: "zoom/set", zoom: 1.2 })); + expect(result.current.persistenceAvailable).toBe(true); + expect(localStorage.getItem(CANVAS_STORAGE_KEY)).toContain("Keep my draft"); + }); it("hydrates the first-launch graph and persists durable mutations", () => { const onDocumentUpdated = vi.fn(); diff --git a/apps/desktop/src/app/features/canvas/useCanvasState.ts b/apps/desktop/src/app/features/canvas/useCanvasState.ts index 9c6d00d..c261002 100644 --- a/apps/desktop/src/app/features/canvas/useCanvasState.ts +++ b/apps/desktop/src/app/features/canvas/useCanvasState.ts @@ -1,13 +1,12 @@ -import { useEffect, useReducer } from "react"; +import { useEffect, useMemo, useReducer, useState } from "react"; import { CANVAS_STORAGE_KEY, CANVAS_DOCUMENT_UPDATED_EVENT, + CANVAS_DOCUMENT_VERSION, canvasReducer, createInitialCanvasState, parseCanvasDocument, - serializeCanvasDocument, - toCanvasDocument, } from "./canvas-state"; import type { CanvasAction, CanvasState } from "./canvas-state"; @@ -30,20 +29,29 @@ export function useCanvasState(): CanvasStateController { ), ), ); + const [persistenceAvailable, setPersistenceAvailable] = useState(storage !== null); + const { nodes, connections, zoom, hiddenSessionIds } = state; + const document = useMemo(() => ({ + version: CANVAS_DOCUMENT_VERSION, + nodes, + connections, + zoom, + hiddenSessionIds, + }), [nodes, connections, zoom, hiddenSessionIds]); useEffect(() => { - safelyWrite(storage, CANVAS_STORAGE_KEY, serializeCanvasDocument(state)); + setPersistenceAvailable(safelyWrite(storage, CANVAS_STORAGE_KEY, JSON.stringify(document))); globalThis.dispatchEvent?.( new CustomEvent(CANVAS_DOCUMENT_UPDATED_EVENT, { - detail: toCanvasDocument(state), + detail: document, }), ); - }, [state, storage]); + }, [document, storage]); return { state, dispatch, - persistenceAvailable: storage !== null, + persistenceAvailable, }; } @@ -67,10 +75,13 @@ function safelyWrite( storage: Storage | null, key: string, value: string, -): void { +): boolean { try { - storage?.setItem(key, value); + if (!storage) return false; + storage.setItem(key, value); + return true; } catch { // Private browsing or a full quota must not make the canvas unusable. + return false; } } From a23587d412161912417642fc7b22e82ce3680a89 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:33:11 -0300 Subject: [PATCH 08/32] feat(daemon): expose Gemini and verify executable permissions --- crates/daemon/src/sessions.rs | 42 +++++++--- crates/daemon/src/sessions/tests.rs | 118 ++++++++++++++++++++++++++++ crates/daemon/tests/daemon_ipc.rs | 13 ++- 3 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 crates/daemon/src/sessions/tests.rs diff --git a/crates/daemon/src/sessions.rs b/crates/daemon/src/sessions.rs index 1192e5c..5643b9d 100644 --- a/crates/daemon/src/sessions.rs +++ b/crates/daemon/src/sessions.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; +use cli_master_agents::{AgentAdapter, DetectionResult, GeminiCliAdapter, LaunchEnvironment}; use cli_master_core::wire::{ AgentCommand, AgentCustomCreateRequest, AgentDetectRequest, AgentDetectResponse, AgentDetection, AgentListResponse, AgentRecord, EmptyResponse, SessionCreateRequest, @@ -13,6 +14,7 @@ use cli_master_core::wire::{ }; use cli_master_core::{ AgentId, AgentSource, ApiError, DaemonInstanceId, Project, Session, SessionId, SessionStatus, + builtin_agent_ids, }; use cli_master_session::{ SessionError, SessionManager, SessionSnapshot, SessionSubscription, TerminalSize, @@ -69,6 +71,14 @@ impl SessionRegistry { pub(super) fn detect_agents( &self, request: &AgentDetectRequest, + ) -> Result { + self.detect_agents_in_environment(request, &LaunchEnvironment::from_current_process_path()) + } + + fn detect_agents_in_environment( + &self, + request: &AgentDetectRequest, + environment: &LaunchEnvironment, ) -> Result { let requested = request.agent_ids(); let detections = self @@ -80,12 +90,18 @@ impl SessionRegistry { agent.enabled && (requested.is_empty() || requested.contains(&agent.id)) }) .map(|agent| { - let executable_path = resolve_executable(&agent.executable); + let (executable_path, error_code) = match environment.detect(&agent.executable) { + DetectionResult::Found { executable } => (Some(executable), None), + DetectionResult::NotFound => (None, Some("executable_not_found".to_owned())), + DetectionResult::NotExecutable { .. } => { + (None, Some("executable_not_executable".to_owned())) + } + }; AgentDetection { agent_id: agent.id, available: executable_path.is_some(), executable_path, - error_code: None, + error_code, } }) .collect(); @@ -335,6 +351,7 @@ impl SessionRegistry { fn seed_builtin_agents(&self) -> Result<(), ApiError> { let now = unix_timestamp_ms()?; let shell = login_shell(); + let gemini = GeminiCliAdapter; let builtins = [ (SHELL_AGENT_ID, "Shell", shell, vec!["-l".to_owned()]), (CODEX_AGENT_ID, "Codex", "codex".to_owned(), Vec::new()), @@ -345,6 +362,12 @@ impl SessionRegistry { "opencode".to_owned(), Vec::new(), ), + ( + builtin_agent_ids::gemini().as_uuid().as_u128(), + gemini.display_name(), + gemini.executable_name().to_owned(), + gemini.default_args().to_vec(), + ), ]; let storage = self.storage()?; for (raw_id, display_name, executable, args) in builtins { @@ -389,6 +412,7 @@ fn agent_record(agent: StoredAgent) -> Result { "codex" => Some("OpenAI Codex CLI".to_owned()), "claude" => Some("Claude Code CLI".to_owned()), "opencode" => Some("OpenCode CLI".to_owned()), + "gemini" => Some("Google Gemini CLI".to_owned()), _ if agent.source == AgentSource::BuiltIn => Some("Local login shell".to_owned()), _ => None, }, @@ -452,17 +476,6 @@ fn session_directory( }) } -fn resolve_executable(executable: &str) -> Option { - let path = Path::new(executable); - if path.is_absolute() { - return path.is_file().then(|| path.to_path_buf()); - } - env::split_paths(&env::var_os("PATH")?).find_map(|directory| { - let candidate = directory.join(executable); - candidate.is_file().then_some(candidate) - }) -} - fn login_shell() -> String { env::var("SHELL") .ok() @@ -616,3 +629,6 @@ pub(super) fn encode_base64(bytes: &[u8]) -> String { } encoded } + +#[cfg(test)] +mod tests; diff --git a/crates/daemon/src/sessions/tests.rs b/crates/daemon/src/sessions/tests.rs new file mode 100644 index 0000000..52547bc --- /dev/null +++ b/crates/daemon/src/sessions/tests.rs @@ -0,0 +1,118 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use tempfile::TempDir; + +use super::*; + +fn registry(directory: &Path) -> SessionRegistry { + let storage = Storage::open_migrated(directory.join("sessions.sqlite3")) + .expect("fixture storage should open"); + SessionRegistry::new(storage, DaemonInstanceId::new()) + .expect("session registry should initialize") +} + +#[test] +fn gemini_detection_checks_real_executable_permissions_without_running_it() { + let temporary = TempDir::new().expect("temporary directory should exist"); + let registry = registry(temporary.path()); + let environment = LaunchEnvironment::from_search_paths([temporary.path()]); + let request = AgentDetectRequest::try_new(vec![builtin_agent_ids::gemini()]) + .expect("Gemini detection request should validate"); + let executable = temporary.path().join("gemini"); + + let missing = registry + .detect_agents_in_environment(&request, &environment) + .expect("missing executable should produce a detection result"); + assert_eq!(missing.detections.len(), 1); + assert_eq!(missing.detections[0].agent_id, builtin_agent_ids::gemini()); + assert!(!missing.detections[0].available); + assert_eq!( + missing.detections[0].error_code.as_deref(), + Some("executable_not_found") + ); + + // This is deliberately not an executable program: discovery must inspect it, not launch it. + fs::write(&executable, "not a program\n").expect("fixture file should write"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o600)) + .expect("fixture permissions should apply"); + let denied = registry + .detect_agents_in_environment(&request, &environment) + .expect("non-executable file should produce a detection result"); + assert!(!denied.detections[0].available); + assert!(denied.detections[0].executable_path.is_none()); + assert_eq!( + denied.detections[0].error_code.as_deref(), + Some("executable_not_executable") + ); + + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)) + .expect("executable permissions should apply"); + let found = registry + .detect_agents_in_environment(&request, &environment) + .expect("executable fixture should be detected"); + assert!(found.detections[0].available); + assert_eq!( + found.detections[0].executable_path.as_deref(), + Some( + executable + .canonicalize() + .expect("fixture should canonicalize") + .as_path() + ) + ); + assert!(found.detections[0].error_code.is_none()); +} + +#[test] +fn seeding_gemini_upgrades_existing_catalog_and_preserves_disabled_agents() { + let temporary = TempDir::new().expect("temporary directory should exist"); + let first = registry(temporary.path()); + let legacy_agents = { + let storage = first.storage().expect("fixture storage should lock"); + storage + .remove_agent_metadata(builtin_agent_ids::gemini()) + .expect("fixture should represent the catalog before Gemini support"); + storage.list_agents().expect("legacy catalog should load") + }; + assert_eq!(legacy_agents.len(), 4); + drop(first); + + let upgraded = registry(temporary.path()); + { + let storage = upgraded.storage().expect("fixture storage should lock"); + for agent in &legacy_agents { + assert_eq!( + storage + .get_agent(agent.id) + .expect("legacy agent should load"), + Some(agent.clone()) + ); + } + storage + .set_agent_enabled(builtin_agent_ids::gemini(), false, 2) + .expect("Gemini should be present and configurable"); + } + drop(upgraded); + + let reopened = registry(temporary.path()); + let agents = reopened.agents().expect("reopened catalog should load"); + assert_eq!(agents.len(), 5); + let gemini = agents + .iter() + .find(|agent| agent.id == builtin_agent_ids::gemini()) + .expect("Gemini should retain its stable ID"); + assert!(!gemini.enabled); + let detections = reopened + .detect_agents_in_environment( + &AgentDetectRequest::default(), + &LaunchEnvironment::default(), + ) + .expect("disabled agent should be omitted from detection"); + assert!( + detections + .detections + .iter() + .all(|agent| agent.agent_id != gemini.id) + ); +} diff --git a/crates/daemon/tests/daemon_ipc.rs b/crates/daemon/tests/daemon_ipc.rs index 2a477a6..5f7bebe 100644 --- a/crates/daemon/tests/daemon_ipc.rs +++ b/crates/daemon/tests/daemon_ipc.rs @@ -162,7 +162,7 @@ async fn snapshot_reports_applied_migration_and_builtin_terminal_agents() { let snapshot: StateSnapshot = serde_json::from_value(data).expect("snapshot should decode"); assert_eq!(snapshot.schema_version, LATEST_SCHEMA_VERSION); assert!(snapshot.projects.is_empty()); - assert_eq!(snapshot.agents.len(), 4); + assert_eq!(snapshot.agents.len(), 5); assert!(snapshot.agents.iter().all(|agent| agent.enabled)); assert!( snapshot @@ -176,6 +176,17 @@ async fn snapshot_reports_applied_migration_and_builtin_terminal_agents() { .iter() .any(|agent| agent.display_name.as_str() == "Shell") ); + let gemini = snapshot + .agents + .iter() + .find(|agent| agent.id == cli_master_core::builtin_agent_ids::gemini()) + .expect("Gemini should be seeded in the daemon catalog"); + assert_eq!(gemini.id.as_uuid().get_version_num(), 7); + assert_eq!(gemini.display_name.as_str(), "Gemini CLI"); + assert_eq!(gemini.description.as_deref(), Some("Google Gemini CLI")); + assert_eq!(gemini.command.executable(), "gemini"); + assert!(gemini.command.args().is_empty()); + assert!(gemini.command.env().is_empty()); assert!(snapshot.sessions.is_empty()); assert!(snapshot.worktrees.is_empty()); From 472926cd0a5fc1ea77ad0b476dc3da165145a35f Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:33:25 -0300 Subject: [PATCH 09/32] feat(desktop): add Gemini terminal quick start --- .../canvas/NewCanvasTerminalDialog.test.tsx | 28 +++++++++++++++++++ .../canvas/NewCanvasTerminalDialog.tsx | 1 + .../app/features/canvas/canvas-state.test.ts | 8 ++++++ .../src/app/features/canvas/canvas-state.ts | 7 ++++- 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.test.tsx diff --git a/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.test.tsx b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.test.tsx new file mode 100644 index 0000000..1c0f12d --- /dev/null +++ b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { NewCanvasTerminalDialog } from "./NewCanvasTerminalDialog"; + +describe("NewCanvasTerminalDialog", () => { + it("creates a Gemini draft using the selected project directory", async () => { + const user = userEvent.setup(); + const onCreate = vi.fn(); + render(); + + await user.click(screen.getByRole("radio", { name: "Gemini" })); + expect(screen.getByLabelText("Command")).toHaveValue("gemini"); + await user.click(screen.getByRole("button", { name: "Create terminal" })); + + expect(onCreate).toHaveBeenCalledExactlyOnceWith({ + title: "Gemini", + preset: "gemini", + executable: "gemini", + workingDirectory: "/projects/demo", + }); + }); +}); diff --git a/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx index 36df807..109d6fb 100644 --- a/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx +++ b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx @@ -25,6 +25,7 @@ const TERMINAL_PRESETS: readonly TerminalPresetOption[] = [ { value: "shell", label: "Shell", shortLabel: ">_" }, { value: "codex", label: "Codex", shortLabel: "Cx", executable: "codex" }, { value: "claude", label: "Claude", shortLabel: "Cl", executable: "claude" }, + { value: "gemini", label: "Gemini", shortLabel: "Gm", executable: "gemini" }, { value: "opencode", label: "OpenCode", diff --git a/apps/desktop/src/app/features/canvas/canvas-state.test.ts b/apps/desktop/src/app/features/canvas/canvas-state.test.ts index efc1282..07b6a21 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.test.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.test.ts @@ -13,6 +13,14 @@ import { } from "./canvas-state"; describe("canvas state", () => { + it("persists the Gemini quick-start preset with its native executable", () => { + const node = createTerminalCanvasNode({ x: 0, y: 0 }, { preset: "gemini" }, "gemini"); + const state = createInitialCanvasState({ version: 1, nodes: [node], connections: [], zoom: 1 }); + expect(parseCanvasDocument(serializeCanvasDocument(state)).nodes[0]).toMatchObject({ + preset: "gemini", title: "Gemini", executable: "gemini", + }); + }); + it("toggles multi-selection, filters unknown IDs, and keeps selection transient", () => { const initial = createInitialCanvasState(); const first = canvasReducer(initial, { type: "node/select", nodeId: "note-first" }); diff --git a/apps/desktop/src/app/features/canvas/canvas-state.ts b/apps/desktop/src/app/features/canvas/canvas-state.ts index ffde2d7..3485511 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.ts @@ -3,7 +3,7 @@ export const CANVAS_DOCUMENT_VERSION = 1; export const CANVAS_DOCUMENT_UPDATED_EVENT = "cli-master:canvas-document-updated"; export type CanvasNodeKind = "terminal" | "note"; -export type TerminalPreset = "shell" | "codex" | "claude" | "opencode" | "custom"; +export type TerminalPreset = "shell" | "codex" | "claude" | "gemini" | "opencode" | "custom"; export const DEFAULT_TERMINAL_SIZE = { width: 432, height: 256 } as const; export const NOTE_SIZE = { width: 288, height: 288 } as const; @@ -770,6 +770,7 @@ function configureTerminalNode( function normalizeTerminalPreset(value: unknown): TerminalPreset { return value === "codex" || value === "claude" || + value === "gemini" || value === "opencode" || value === "custom" ? value @@ -782,6 +783,8 @@ function executableForPreset(preset: TerminalPreset): string | undefined { return "codex"; case "claude": return "claude"; + case "gemini": + return "gemini"; case "opencode": return "opencode"; case "shell": @@ -796,6 +799,8 @@ function titleForPreset(preset: TerminalPreset): string { return "Codex"; case "claude": return "Claude"; + case "gemini": + return "Gemini"; case "opencode": return "OpenCode"; case "shell": From 6774865d73a61fa21184ba18467af3921e268dce Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:35:13 -0300 Subject: [PATCH 10/32] docs: map Maestri parity and parallel implementation goals --- docs/codex/parallel-goals.md | 237 +++++++++++++++++++++++++++++++++ docs/maestri-xirp-parity.md | 250 +++++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 docs/codex/parallel-goals.md create mode 100644 docs/maestri-xirp-parity.md diff --git a/docs/codex/parallel-goals.md b/docs/codex/parallel-goals.md new file mode 100644 index 0000000..53a6db8 --- /dev/null +++ b/docs/codex/parallel-goals.md @@ -0,0 +1,237 @@ +# Goals para duas sessões adicionais + +Copie um dos blocos abaixo para cada nova sessão. A sessão atual (S1) mantém +o canvas, a aparência Maestri e a integração final. Consulte a +[matriz de paridade](../maestri-xirp-parity.md) para o inventário completo, +fontes, baseline e critérios de conclusão. Os goals não reduzem o objetivo ao +primeiro incremento entregue. + +## Organização antes de iniciar + +Cada sessão trabalha em **branch e worktree próprios**. A pasta +`/Users/eguimacs/cli-master` pertence à sessão principal; abrir três sessões +no mesmo diretório compartilha arquivos e índice Git e não isola commits. + +| Sessão | Branch proposta | Worktree proposta | Responsabilidade | +| --- | --- | --- | --- | +| S1 | `refactor/canvas-only-shell` | `/Users/eguimacs/cli-master` | Canvas, shell, UI final, bridge Tauri e integração serial | +| S2 | `feat/maestri-runtime` | `/Users/eguimacs/cli-master-runtime` | Domínio/IPC, SQLite, Git, sessões, automações, ambientes | +| S3 | `feat/xirp-local-workflows` | `/Users/eguimacs/cli-master-xirp-workflows` | Prompts, regras/skills, pin/arquivo/workflow e contexto | + +Os nomes são propostos, não evidência de worktrees já criados. A sessão que +iniciar verifica `git status`, `git branch --show-current`, +`git worktree list` e o remote. Se nome/diretório já existir, inspeciona e +reutiliza quando for dela; não apaga nem sobrescreve trabalho existente. +Após fetch, cria a worktree a partir do commit mais recente de +`origin/refactor/canvas-only-shell`. Não presume que `origin/main` contenha +o canvas atual. Integra esta documentação quando estiver publicada na origem. + +### Contratos e arquivos compartilhados + +S2 coordena as alterações de `crates/core/src/wire`, `protocol/catalog.json`, +`apps/desktop/src/ipc`, migrações/registro de migrações e dispatch do daemon. +S3 propõe os tipos/operações do seu domínio e recebe um commit de contrato +que pode integrar à sua branch. Métodos somente entram no catálogo público +junto de implementação funcional, ou por um mecanismo explícito de +capacidades documentado em ADR. Um handler fictício não fecha a integração. + +S1 coordena mudanças em `AppShell`, estado global, navegação, paleta, canvas, +estilos compartilhados, dependências frontend e Tauri. S3 entrega componentes +isolados com callbacks tipados; S1 os encaixa no canvas. Integração de UI real +continua sendo requisito de conclusão, mesmo quando pertence a outra sessão. + +Antes de cruzar uma fronteira, publicar no relatório da sessão: arquivo, +contrato proposto, erro/evento, persistência, dependência e commit. Arquivos +novos de S3 preferem os namespaces `workflows` e `knowledge`; +`crates/core/src/catalog.rs` já existe para outro propósito. +`lib.rs`, manifests e registros são compartilhados: combinar um +incremento curto com S2, em vez de dois conjuntos concorrentes de mudanças. + +### Commits, push e integração + +1. Fazer um commit pequeno por mudança coerente e verificada. Selecionar os + arquivos explicitamente; não incluir edits de outras sessões. +2. Executar testes proporcionais antes do commit e registrar o resultado. + Mudança de contrato inclui teste de espelhos e round-trip pelo daemon. +3. Fazer push da própria branch após cada commit pequeno validado. No primeiro + envio, usar `git push -u origin feat/maestri-runtime` ou + `git push -u origin feat/xirp-local-workflows`, conforme a sessão. +4. Usar a identidade Git já configurada pelo usuário. Não adicionar + `Co-authored-by` do Codex/OpenAI nem alterar autoria para a ferramenta. + Conferir `git show -s --format=full HEAD` antes do push. +5. S1 integra os commits publicados serialmente, testa o estado combinado e + faz push da branch de integração. Worktrees não compartilham índice, + mas ainda podem mudar os mesmos arquivos; resolver conflitos pelo contrato, + nunca escolhendo um lado inteiro sem análise. +6. Push rejeitado exige fetch e integração normal; não usar force push nem + reescrever commits publicados. Não remover worktrees automaticamente. + +O repo roda CI em push para `main` e em pull requests no baseline. Para uma +branch de trabalho receber a matriz de CI, abrir um draft PR quando possível; +push sozinho não prova CI executada. S1 coordena a integração dos PRs. + +## Goal da sessão 2 — backend Maestri + +```text +Trabalhe no projeto Jig/CLI Master para entregar o backend completo das +capacidades Maestri da matriz docs/maestri-xirp-parity.md, preservando Linux e +macOS e o frontend de canvas que a sessão 1 está construindo. O objetivo é +funcionamento real de ponta a ponta, não apenas tipos, endpoints ou mocks. +Mantenha este goal ativo até cumprir o escopo e verificar seus fluxos. + +Leia AGENTS.md, ARCHITECTURE.md, os ADRs e docs/codex/parallel-goals.md. +Confirme o estado atual: o inventário de 2026-09-05 é um ponto de partida. +Você não está sozinho no repositório: não reverta edições de outras sessões. +Use branch feat/maestri-runtime, criada do último commit de +origin/refactor/canvas-only-shell, e worktree +/Users/eguimacs/cli-master-runtime, após verificar se já existem. Não trabalhe +no índice Git da sessão principal e não apague worktrees de outras sessões. + +Seu domínio: crates/core (sem I/O), crates/storage, crates/agents, crates/git, +crates/session, crates/daemon, testes backend/fake-agent/e2e e documentação +backend nova. Coordene catálogo IPC, mirrors TypeScript e protocolo com S1/S3. +Reserve módulos novos workflows/knowledge e metadados de workflow para S3. +Tauri e componentes React são de +S1; entregue contratos/eventos e casos de aceitação para essa integração. + +Comece pelas falhas reais de integração: session.create hoje recusa +new_worktree; state.snapshot devolve worktrees vazios; existem métodos +anunciados sem dispatch. Ligue a saga existente respeitando create como +preparação de metadados e start como início do processo. A saga atual cria e +inicia, portanto não basta chamá-la diretamente do handler create. Preserve +relative_directory e rollback, recuperação e tokens de remoção. Consolide +o registry de agentes em vez de manter seeds divergentes no daemon. + +Depois execute o escopo backend de M04–M09, M12–M15, M17–M28, M29–M45, +M47–M48 e as dependências backend X01–X10. Implemente em incrementos verticais, +seguindo as prioridades da matriz, sem abandonar os itens posteriores: +- notas e documentos duráveis, grafo e permissões; +- arquivos e Git completos com segurança e estados de erro; +- floors/worktrees, landing, hooks e compartilhamento entre sessões; +- roles, entrega de prompts/anexos, comunicação CLI e recrutamento; +- rotinas e histórico, preferências, backup/restore e recuperação; +- retomada de conversa verificada por adaptador e persistência tmux; +- ambientes remotos, portais/dispositivos e controle remoto por contratos; +- contratos para prompts, regras/skills, arquivo e contexto da sessão 3. + +SessionManager é o único dono de PTY, spawn de agentes, transições e sinais. +Use CommandSpec com executável+argv+cwd+env; não use sh -c nem shell +interpolado. Hooks não podem criar um executor de agentes paralelo. +SQLite pertence a storage, tipos puros a core. Cada método nasce no contrato +Rust e atualiza protocol/catalog.json, methods.ts e domain.ts em sincronia. +Não adicione comandos Tauri de domínio. Timestamps continuam epoch-ms. + +Mantenha lifecycle do processo separado de workflow do trabalho e de atenção. +S3 é dona do workflow/arquivo/pin; combine os contratos de metadados com ela. +Um silêncio no terminal não é prova de pedido de aprovação. Não retome ou +sinalize PID persistido de outro daemon. Remover metadados não remove diretório +do projeto/worktree; remoção de worktree usa preparo+token vinculado ao estado. + +Decisões cross-cutting exigem ADR: persistência e revisão de documentos, +execução de hooks, floor com várias sessões, exposição remota e inferência +local opcional. Preserve o produto local-first e as capacidades Linux/macOS. +Documente limites reais de hardware/plataforma e alternativas; não declare +equivalência por desabilitar um recurso. Não proxyar tráfego dos agentes. + +Cada incremento inclui teste com diretórios temporários reais, SQLite real, +Git real e programas curtos. Prove o caminho pelo socket do daemon e o efeito +observável, incluindo falhas, concorrência e restart. Rode gates do README +antes da integração; confirme CI Linux/macOS e resultados do runtime. Registre +o que não pôde ser testado, sem chamar isso de aprovado. + +Faça commit e push de cada pequena alteração coerente e validada na sua +branch. Use o autor Git do usuário, sem Codex/OpenAI como Co-authored-by. +Não force push. Publique um draft PR para obter CI quando aplicável. Mantenha +docs/codex/maestri-runtime-report.md com IDs atendidos, contratos, commits, +testes e dependências de S1/S3. A conclusão exige integração consumida pela UI, +evidência de Linux/macOS e nenhum item obrigatório backend deixado como stub. +``` + +## Goal da sessão 3 — fluxos locais inspirados em XIRP + +```text +Trabalhe no projeto Jig/CLI Master para entregar os recursos locais do XIRP +selecionados em docs/maestri-xirp-parity.md, mantendo Linux/macOS e o canvas +Maestri como superfície principal. O escopo central é X06, X09, organização +de X01, regras de M05/M15 e prompts/arquivo/workflow de X07. Entregue +funcionalidades operantes e persistentes. Não conclua o goal com protótipos +ou dados estáticos. + +Leia AGENTS.md, ARCHITECTURE.md, ADRs e docs/codex/parallel-goals.md. +Confira as fontes oficiais citadas na matriz: Portal é opcional no XIRP. +Catálogo empresarial, Scaffolder e Insights do Portal são propostas opcionais +O01–O03, fora deste goal. Não os use para substituir os recursos locais pedidos. + +Você não está sozinho no repositório: não reverta o trabalho de outras +sessões. Use branch feat/xirp-local-workflows, criada do último commit de +origin/refactor/canvas-only-shell, e worktree +/Users/eguimacs/cli-master-xirp-workflows, verificando primeiro se já existem. +Não faça commits no índice Git da sessão principal. + +Seu ownership preferencial: +- novos módulos workflows/knowledge em crates/core/src/ (tipos puros); +- novos módulos workflows/knowledge em crates/storage/src/ (SQL); +- novos módulos workflows/knowledge em crates/daemon/src/ (handlers); +- novos testes workflows/knowledge em cada crate relevante; +- apps/desktop/src/app/features/workflows/ e knowledge/, com testes próprios; +- docs/codex/xirp-workflows-report.md e documentação do domínio novo. + +Não reutilize crates/core/src/catalog.rs para outro catálogo. S2 coordena +wire, mirrors, dispatch, lib.rs, manifests, migrações e executor de processos. +S1 coordena AppShell, estado global, navegação, canvas, estilos globais e Tauri. +Combine contratos pequenos primeiro e integre os commits correspondentes; +entregue componentes isolados e callbacks tipados para S1. Não crie um segundo +banco, catálogo IPC, cliente Tauri ou proprietário de sessão por conveniência. + +Entregue, nesta ordem, sem limitar o objetivo à primeira etapa: +1. Prompts salvos globais/de projeto: criar, editar, excluir e buscar; + selecionar insere rascunho no composer, sem envio oculto. Conteúdo persiste + no armazenamento oficial e sobrevive ao reinício. +2. Descoberta local de regras/skills e contexto de projeto: origem, escopo, + precedência, leitura segura, consulta e edição explícita com conflito. + Arquivos de credenciais ficam fora da indexação/edição. Trate symlinks, + limites, encoding e mudanças externas. Importar pasta pai não transforma + todas as pastas automaticamente em repositórios. +3. Organização persistida de projetos/sessões: pin, arquivo/restauração, + busca, filtros e agrupamento. Arquivar metadados não encerra processo nem + remove diretório. Registre workflow backlog/in_progress/in_review/done/ + blocked em campo próprio; não o derive nem misture com SessionStatus. +4. Coleções de contexto com documentos, referências, decisões e wiki local; + ligação a notas reais e projeto; busca e exportação explícita. Exponha ações + para abrir projeto ou iniciar sessão pela API oficial da S2. +5. Integre esses recursos ao canvas e à paleta com a S1. Componentes próprios + precisam de loading, vazio, erro e teclado. Não mexa no canvas, shell ou + estilos globais em paralelo; forneça callbacks e contratos pequenos. +6. Registre extensões posteriores documentadas no XIRP, como uso/custo e + contexto MCP. Métricas precisam de fonte nativa verificável; não infira + tokens/custo/atividade cognitiva de bytes PTY ou tempo de processo. + Não dependa de conta Portal para concluir os fluxos locais deste goal. + +Não ligue contas Spotify/GitHub, não publique transcripts, não instale serviços +pagos e não envie telemetria por padrão. Conectores externos são uma etapa +explícita separada, com contrato/conta/escopos disponíveis e preview do envio. +O frontend continua visualmente Maestri; não substitua o canvas por um portal +administrativo. Siga design tokens e componentes existentes. + +Escreva ADR para separação workflow/processo, escopos de prompts e persistência +do contexto. Teste parser/validação contra fixtures reais, SQLite/restart, +arquivamento/restauração, leitura insegura e conflitos de edição. Frontend +testa o cliente IPC do projeto. Não basta teste com mock para declarar fluxo +persistido; prove daemon+efeito no disco e UI. SessionManager continua o único +proprietário de processos; nenhum destes módulos pode abrir PTY ou dar spawn. + +Faça commits pequenos e push após cada alteração coerente validada na sua +branch, com autor Git do usuário e sem Codex/OpenAI como Co-authored-by. +Não force push. Abra draft PR para CI quando aplicável. Atualize +docs/codex/xirp-workflows-report.md com IDs, contratos, commits, testes, +dependências e limitações. Mantenha o goal até os recursos selecionados estarem +integrados ao canvas, persistirem e passarem pelas verificações Linux/macOS. +``` + +## Resultado esperado das três sessões + +Um produto integrado, com evolução registrada na matriz, commits pequenos +publicados e evidência de runtime. Os relatórios de S2/S3 devem permitir que S1 +integre sem adivinhar contratos ou reinterpretar sucesso. Documentação, +roadmap e branches publicadas são entregáveis intermediários; paridade +funcional e visual verificada continua sendo o objetivo final. diff --git a/docs/maestri-xirp-parity.md b/docs/maestri-xirp-parity.md new file mode 100644 index 0000000..b6a07ed --- /dev/null +++ b/docs/maestri-xirp-parity.md @@ -0,0 +1,250 @@ +# Paridade Maestri e referências XIRP + +Levantamento em **2026-09-05**, sobre o commit `0ab3505`. Este é um inventário +de trabalho, não uma declaração de paridade. O objetivo permanece: experiência +de canvas do Maestri, suas capacidades funcionais e recursos escolhidos do +XIRP, com Linux e macOS como plataformas de primeira classe. Commits posteriores +ao baseline devem ser auditados antes de atualizar os estados desta matriz. + +O [changelog do Maestri][m-changelog] foi percorrido de **0.13 +(2026-03-18) a 0.45.3 (2026-09-04)**, inclusive point releases. As tabelas +agrupam resultados funcionais por domínio; correções repetidas viram critérios +de regressão. As páginas oficiais por domínio complementam o changelog. +Documentação viva pode conter comportamento posterior a uma versão: não +inferir a versão de introdução quando ela não estiver publicada. + +O [changelog do XIRP][x-changelog] chega a **0.25.0 (2026-09-03)**. O overview +está menos atualizado sobre agentes que o changelog. Catálogo, Scaffolder e +Insights são capacidades do **Spotify Portal**, não recursos locais que se +possa atribuir automaticamente ao XIRP. Adaptações desses recursos aparecem +apenas como propostas opcionais, fora das entregas selecionadas. +[XIRP e contexto opcional][x-intro] + +## Como usar o inventário + +- **I — implementado no caminho indicado:** código executável e testes + relacionados foram localizados. Não equivale a validação visual ou do pacote. +- **P — parcial:** parte existe; a coluna de evidência explica a lacuna. +- **A — ausente:** sem implementação encontrada nos contratos, handlers, + modelos, migrações e telas examinados. +- **L — limite de plataforma:** exige alternativa ou capacidade condicional; + continua no inventário e não conta como concluído. +- **P0/P1/P2/P3** indicam ordem de entrega, não exclusão do escopo. +- **S1**: canvas/frontend e integração final; **S2**: domínio/backend Maestri; + **S3**: prompts, regras, skills, workflow, arquivo e contexto local do XIRP. + +Estados refletem o baseline acima. Antes de iniciar uma linha, conferir o +código atual e atualizar a evidência. Não promover uma linha por existir um +botão, tipo, mock, método anunciado ou biblioteca sem ligação ao fluxo real. +Esta inspeção não executou a suíte nem validou pacotes nas duas plataformas. + +## Evidências locais de referência + +| Código | Fonte no repositório | O que a inspeção permite afirmar | +| --- | --- | --- | +| E1 | [canvas-state.ts](../apps/desktop/src/app/features/canvas/canvas-state.ts), [CanvasWorkspace.tsx](../apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx), [useCanvasState.ts](../apps/desktop/src/app/features/canvas/useCanvasState.ts) | Canvas contém terminais e notas simples; documento em `localStorage`, seleção única, limites finitos de posição e zoom. | +| E2 | [CanvasConnections.tsx](../apps/desktop/src/app/features/canvas/CanvasConnections.tsx) | Curvas SVG persistidas no documento local; não autorizam comunicação entre agentes. | +| E3 | [sessions.rs](../crates/daemon/src/sessions.rs), [server.rs](../crates/daemon/src/server.rs), [LiveTerminal.tsx](../apps/desktop/src/app/features/terminal/LiveTerminal.tsx) | PTY local ligado ao daemon. `session.create` recusa isolamento diferente de `current`; snapshot devolve worktrees vazios. | +| E4 | [saga.rs](../crates/session/src/saga.rs), [create.rs](../crates/session/src/create.rs), [remove.rs](../crates/session/src/remove.rs), [testes da saga](../crates/session/tests/create_saga.rs) | Saga e segurança de worktrees existem, mas o caminho do daemon não usa a saga. A criação da saga também inicia processo, diferentemente do contrato `create` seguido de `start`. | +| E5 | [method.rs](../crates/core/src/wire/method.rs), [request.rs](../crates/core/src/wire/request.rs), [catalog.json](../protocol/catalog.json), [dispatcher](../crates/daemon/src/server.rs) | Catálogo v1 tem projetos, agentes, sessões, status/diff Git, remoção de worktree e diagnóstico. Nem todo nome anunciado tem handler. | +| E6 | [projects.rs](../crates/daemon/src/projects.rs), [model.rs](../crates/core/src/model.rs), [repository.rs](../crates/git/src/repository.rs) | Projetos locais e raiz Git opcional. Não há floors, pastas de workspaces, catálogo de software, roles ou rotinas. | +| E7 | [builtins.rs](../crates/agents/src/builtins.rs), [registry.rs](../crates/agents/src/registry.rs), [seed do daemon](../crates/daemon/src/sessions.rs) | Adaptadores incluem Gemini; daemon mantém seed próprio Shell/Codex/Claude/OpenCode. Não confundir adaptador disponível com opção funcional na aplicação. | +| E8 | [git_inspection.rs](../crates/daemon/src/git_inspection.rs), [Git](../crates/git/src/lib.rs) | Status/diff são operações de inspeção; não há fluxo completo de commit, push, branches, stash e landing. | +| E9 | [CommandPalette.tsx](../apps/desktop/src/app/features/commands/CommandPalette.tsx), [useGlobalShortcuts.ts](../apps/desktop/src/app/hooks/useGlobalShortcuts.ts), [CanvasSidebar.tsx](../apps/desktop/src/app/features/navigation/CanvasSidebar.tsx) | Navegação, paleta e rail existem; não têm o conjunto de indexadores e preferências das referências. | +| E10 | [TerminalSurface.tsx](../apps/desktop/src/app/features/terminal/TerminalSurface.tsx), [terminal-runtime.ts](../apps/desktop/src/app/features/terminal/terminal-runtime.ts), [replay.rs](../crates/session/src/replay.rs) | xterm recebe fluxo/replay sem guardar bytes em estado React. Busca, configuração e comportamento internacional precisam de verificação específica. | +| E11 | [migrações](../crates/storage/migrations), [recovery.rs](../crates/storage/src/recovery.rs), [KNOWN_ISSUES.md](KNOWN_ISSUES.md), [backup-and-recovery.md](backup-and-recovery.md) | SQLite e recuperação conservadora existem; reiniciar daemon perde handles PTY. Procedimento de backup não é gerenciador de snapshots na UI. | +| E12 | [CI](../.github/workflows/ci.yml), [packaging](../.github/workflows/packaging.yml), [Tauri](../apps/desktop/src-tauri), [aceitação runtime](../crates/e2e/tests/acceptance.rs) | Jobs Linux/macOS e empacotamento declarados. Estado verde da execução e smoke tests dos artefatos ainda precisam ser registrados. | + +## Maestri: canvas e conteúdo + +Os requisitos resumidos nesta tabela vêm das páginas oficiais indicadas. +Critérios de execução e prioridades são propostas para Jig. + +| ID | Capacidade / fonte | Estado e evidência | Próximo resultado verificável | Dono / prioridade | +| --- | --- | --- | --- | --- | +| M01 | Canvas espacial, inserir, mover, redimensionar, pan/zoom, minimapa. [Canvas][m-canvas] | P — E1, E10; espaço limitado e poucos tipos. | Navegar por uma composição grande com mouse/trackpad, sem misturar scroll do terminal e câmera. | S1 / P0 | +| M02 | Seleção múltipla, duplicação, copiar/colar, grupos, alinhamento, distribuição e tidy. [Canvas][m-canvas] | A/P — E1 só seleciona um nó. | Transformações mantêm relações e agrupamento, com undo/redo por workspace. | S1 / P1 | +| M03 | Lift, dock lateral, foco e snapping. [Canvas][m-canvas] | A — E1. | Painel destacado continua interativo enquanto a câmera se move; redimensionamento chega à PTY. | S1 / P1 | +| M04 | Workspaces com identidade, diretório, organização em pastas/grupos, rail, alternância e IDE. [Workspaces][m-workspaces] | P — E6, E9; registro de projetos e rail, sem modelo completo. | Configuração persiste; terminais herdados resolvem diretório correto e overrides permanecem explícitos. | S2 contrato + S1 / P1 | +| M05 | Regras do projeto, edição/sincronização de arquivos de instruções e importação de workspace. [Workspaces][m-workspaces] | A — E5/E6. | Revisão antes de importar/escrever; conflitos externos não apagam arquivos do usuário. | S3 regras; S2 importação; S1 / P1 | +| M06 | Paleta para elementos, ações, arquivos e atenção; atalhos configuráveis. [Batuta][m-batuta], [Atalhos][m-shortcuts] | P — E9. | Busca com escopo por projeto e global, navegação por teclado e remapeamento sem colisões silenciosas. | S1 / P1 | +| M07 | Notas Markdown com fonte/preview, imagens, nomes estáveis e arquivos externos. [Notas][m-notes] | P — E1: texto simples em armazenamento do navegador. | Arquivos duráveis, escrita atômica, detecção de edição externa e preview seguro; remover nó externo preserva arquivo. | S2 + S1 / P0 | +| M08 | Notas encadeadas e leitura/escrita por agente. [Notas][m-notes], [Conexões][m-connections] | P visual / A funcional — E1/E2. | Agente autorizado vê conteúdo atualizado; ciclos e limites de leitura tratados pelo daemon. | S2 + S1 / P1 | +| M09 | Fichários com páginas, reordenação, extração, identidade e acesso conjunto pelo agente. [Fichários][m-ficharios] | A — E1/E5. | Uma nota pertence a um fichário; mover páginas não perde conteúdo ou relações. | S2 + S1 / P2 | +| M10 | Texto, desenho, formas, pincel, reconhecimento de traços, preenchimento e cantos. [Changelog][m-changelog] | A — E1. | Ferramentas reais com seleção, edição e persistência; política de undo consistente. | S1 / P2 | +| M11 | Bloqueio de notas/páginas, blur, padrões de elementos, temas/fontes, materiais e fundo. [Changelog][m-changelog] | A/P — E1/E10; estilos fixos não são preferências. | Preferências propagam entre projetos; bloqueio é respeitado por UI e operações de agente. | S1 + S2 / P2 | +| M12 | Partituras: salvar composição, biblioteca, escopo, preview, import/export e conflitos de roles. [Partituras][m-partituras] | A — E1/E5. | Arquivo versionado recria composição; revisão mostra comandos; omite estado de execução e dados privados por padrão. | S2 + S1 / P2 | +| M13 | Arquivos/imagens/PDF/vídeo no canvas e clipboard entre aplicações. [Árvore de arquivos][m-files], [Changelog][m-changelog] | A — E1. | Preview funciona nos pacotes Linux/macOS; abrir/remover um nó não destrói o original. | S1 + S2 / P2 | + +## Maestri: agentes, terminal e execução + +| ID | Capacidade / fonte | Estado e evidência | Próximo resultado verificável | Dono / prioridade | +| --- | --- | --- | --- | --- | +| M14 | Terminais interativos, nomes, presets, comandos customizados e diretório por terminal. [Terminais][m-terminals] | I/P — E3/E7/E10; capacidade local implementada, configuração incompleta. | Executar todos os presets apresentados na UI e custom argv sem shell interpolado. | S2 + S1 / P0 | +| M15 | Roles reutilizáveis, instruções por agente, descoberta/importação e badges. [Terminais][m-terminals] | A — E5/E7. | Provisionar sem sobrescrever regras existentes; arquivo inseguro é rejeitado; role acompanha identidade e ambiente. | S2 + S3 descoberta + S1 / P1 | +| M16 | Temas de terminal, aparência automática, importação e preferências de fonte. [Terminais][m-terminals] | P — E10, sem biblioteca/preferências completas. | Tema muda em terminais ativos/inativos e permanece após reload; seleção continua legível. | S1 / P1 | +| M17 | Atenção, notificações e navegação até agente que aguarda resposta. [Terminais][m-terminals] | P — E3/E9: lifecycle não prova solicitação de autorização. | Estado de atenção vem de sinal verificável; clicar na notificação revela sessão correta. | S2 sinais + S1 / P1 | +| M18 | Busca, Smart Copy, scroll lock, anexos, IME, RTL e teclado internacional. [Atalhos][m-shortcuts], [Changelog][m-changelog] | P — E10; PTY básica, sem evidência do conjunto. | Testar streaming com seleção ativa, busca, paste multilinha e composição sem envio acidental. | S1 + S2 anexos / P1 | +| M19 | Composer com drafts por terminal, menções, imagens/arquivos e passagem de teclas. [Composer][m-composer] | A — E1/E5. | Enviar uma vez; destino vê bytes/arquivos corretos; rascunho sobrevive à alternância de projeto/floor. | S1 + S2 / P1 | +| M20 | Conexões com estilos, inspeção, navegação entre floors e cable ties. [Conexões][m-connections] | P — E2: desenho e relações locais apenas. | Relação funcional sobrevive a reload; edição visual não altera permissões implicitamente. | S1 + S2 / P1 | +| M21 | Pedidos entre agentes, respostas, notas/portais encadeados e ferramentas de CLI. [Conexões][m-connections] | A — E5. | Dois agentes diferentes trocam pedido/resposta com correlação, prazo e cancelamento; negar recurso não conectado. | S2 / P1 | +| M22 | Maestro: recrutar, trocar role, conectar, dispensar e criar workspaces/floors. [Maestro][m-maestro] | A — E5. | Comandos de um agente autorizado usam o mesmo domínio e SessionManager; agente comum não ganha acesso gerencial. | S2 + S1 / P2 | +| M23 | Rotinas agendadas, sequência de prompts, pause/resume, execução manual e histórico. [Rotinas][m-routines] | A — E5/E11. | Scheduler durável trata reinício, sobreposição e alvo indisponível; cada disparo tem resultado rastreável. | S2 + S1 / P2 | +| M24 | Rotinas com preparação, skip ocupado, execução única e CLI. [Changelog][m-changelog] | A — E5. | Separar execução de programa de envio de prompt; registrar skip/erro sem forjar conclusão. | S2 + S1 / P2 | +| M25 | Sessões persistentes local/tmux e ambientes remotos; reconectar sem duplicar. [Ambientes][m-environments] | P — E3/E11: fechar janela preserva daemon; reiniciar daemon não reanexa PTY. | Distinguir reconexão do cliente, reinício do daemon e retomada nativa de conversa; testar cada caso. | S2 / P1 | +| M26 | Restaurar conversa de agentes que suportam resume; unload/reload e recuperação. [Changelog][m-changelog] | A/P — E11. | Persistir identificador nativo verificado por adaptador; reinício não cria conversa inesperada nem sinaliza PID antigo. | S2 / P1 | +| M27 | SSH, Docker, Sandbox e runtime custom; herança/override e arquivos remotos. [Ambientes][m-environments] | A — E3/E5. | Executável+argv, resolução de cwd no host correto, uploads e ferramentas no mesmo ambiente. | S2 + S1 / P3 | +| M28 | Descoberta de hosts/conexões, tmux remoto e provisionamento de roles/skills. [Ambientes][m-environments] | A — E5/E7. | Preservar configuração SSH do usuário; falhas de conexão/reload têm estados explícitos e cleanup limitado ao que Jig criou. | S2 + S1 / P3 | + +## Maestri: arquivos, Git e floors + +| ID | Capacidade / fonte | Estado e evidência | Próximo resultado verificável | Dono / prioridade | +| --- | --- | --- | --- | --- | +| M29 | File tree: lista/ícones, navegação, pesquisa, preview e gerenciamento de arquivos. [Arquivos][m-files] | A — E1/E5. | Árvore real com operações limitadas à raiz autorizada, incluindo symlinks e nomes não UTF-8. | S2 + S1 / P1 | +| M30 | Editor: sintaxe, find/replace, multicursor, indentação, linhas e envio de trecho. [Arquivos][m-files] | A — E1/E5. | Editar/salvar no disco com detecção de conflito, limite de arquivo e proteção de alterações não salvas. | S2 I/O + S1 / P1 | +| M31 | Tabs, definições, busca em conteúdo, pinning, Markdown e configuração do editor. [Changelog][m-changelog] | A — E1/E5. | Busca cancelável e paginada, resultados abrem linha correta; defaults e tabs restaurados. | S1 + S2 / P2 | +| M32 | Status/diff, contexto Git, stage/unstage/discard e commit. [Arquivos][m-files] | P — E8: status/diff backend; ações de escrita ausentes. | UI usa handlers reais; validar escopo do nó e confirmar descarte com estado atualizado. | S2 + S1 / P0 | +| M33 | Histórico/gráfico, fetch/pull/push, branches, merge e stash. [Arquivos][m-files] | A — E8. | Git real com erro acionável, cancelamento e exclusão mútua; credenciais seguem Git do usuário. | S2 + S1 / P1 | +| M34 | Floors: canvas isolado, branch, clonar layout, visão geral e diretórios corretos. [Floors][m-floors] | P infraestrutura / A produto — E4/E6. | Primeiro ligar worktree ao daemon sem iniciar processo em `session.create`; depois modelo floor para várias sessões. | S2 + S1 / P0/P1 | +| M35 | Landing com preview, conflitos, destino e remoção segura. [Floors][m-floors] | P infraestrutura — E4; landing ausente. | Provar branch/CWD reais; dirty e divergência impedem perda; token de remoção invalida se o estado muda. | S2 + S1 / P1 | +| M36 | Hooks setup/run/teardown ordenados e contexto do floor. [Floors][m-floors] | A — E5. | Cada comando estruturado passa pelo proprietário da execução, com timeout, resultado e rollback documentado. | S2 + S1 / P2 | + +## Maestri: portais, compartilhamento e plataforma + +| ID | Capacidade / fonte | Estado e evidência | Próximo resultado verificável | Dono / prioridade | +| --- | --- | --- | --- | --- | +| M37 | Navegador embutido, navegação, armazenamento e automação via conexão. [Portais][m-portals] | A — E1/E5. | Nó contém navegador real; app e conteúdo web mantêm fronteira de privilégios; UI e agente veem a mesma página. | S1 bridge + S2 autorização / P3 | +| M38 | Navegador: screenshots, DOM, console, JavaScript, cliques e formulários. [Portais][m-portals] | A — E5. | Contrato de automação com erros e alvo explícitos; revogar conexão revoga ferramentas. | S2 + S1 bridge / P3 | +| M39 | Portais: viewport/user-agent, devtools, downloads, mídia, mute, retry e certificados locais. [Changelog][m-changelog] | A — E5. | Fluxos nativos testados nos dois sistemas; autorização de mídia/certificado por origem, nunca global. | S1 bridge + S2 / P3 | +| M40 | Android/emulador, iOS Simulator, gestos, árvore de elementos e lifecycle de app. [Portais][m-portals] | A/L — E5/E12. | Android local nos dois sistemas; iOS condicionado a host macOS/Xcode. Provar interação além de imagem estática. | S2 runtime + S1 / P3 | +| M41 | Wire: pareamento, funções, revogação, capacidades, feed e fluxo de terminal. [Wire][m-wire] | A — E5: Unix IPC local não implementa Wire. | ADR de transporte separado, TLS e validação de origem; leitura/escrita autorizadas; eventos e reconexão reais. | S2 + S1 / P3 | +| M42 | Controle remoto de canvas, notas, floors/hooks, rotinas, arquivos e Git. [Wire][m-wire] | A — E5. | Adaptador reutiliza domínio e anuncia somente capacidades implementadas; mapear timestamps sem alterar IPC epoch-ms. | S2 + S1 / P3 | +| M43 | Ombro: janela auxiliar, resumos e notas usando modelo local. [Ombro][m-ombro] | A/L — E5/E12. | ADR antes de introduzir inferência: manter Jig coordenador. Alternativa local equivalente no Linux ainda precisa de desenho e medição. | S2 + S1 / P3 | +| M44 | Recuperação de workspace e diagnóstico exportável. [Troubleshooting][m-troubleshooting] | P — E11/E12; diagnóstico sanitizado existe. | Backup restaurável da composição, notas/anexos e metadados; recuperação de dados corrompidos não inicia vazia silenciosamente. | S2 + S1 / P1 | +| M45 | Prevent sleep, safe launch, backups automáticos e restauração. [Changelog][m-changelog] | A/P — E11. | Ciclo dormir/acordar e crash testado; inibição de suspensão por API nativa com liberação garantida. | S2 + S1 bridge / P2 | +| M46 | Aparência e interação fiéis ao canvas de referência. [Introdução][m-intro], [Canvas][m-canvas] | P — E1/E2/E9. | Comparação visual em tamanho equivalente, incluindo seleção, menus, terminal real, nó focado, zoom e estado vazio. | S1 / P0 contínuo | +| M47 | Compatibilidade: Gemini, Hermes, Antigravity, Pi, Copilot e Cursor. [Changelog][m-changelog] | A/P — E7; suporte customizado não comprova roles/resume/menções. | Registrar capacidades por CLI e testar provisionamento, envio e retomada individualmente. | S2 / P2 | +| M48 | Atualização da aplicação e preferências associadas. [Changelog][m-changelog] | A/P — E12 prova configuração de build, não atualização. | Atualização verificável por plataforma, sem perder sessões ou dados e com falha recuperável. | S1 bridge + S2 release / P3 | + +### Equivalentes Linux/macOS e limites reais + +Estas são escolhas propostas de implementação; não são afirmações sobre uma +versão Linux do Maestri. A presença de um link Linux no site não prova entrega +de todas as capacidades. O [overview do XIRP][x-intro] declara beta macOS. + +| Área | macOS | Linux | Regra para considerar entregue | +| --- | --- | --- | --- | +| Terminal/canvas | Tauri + xterm; otimização acelerada onde houver suporte | Mesma UI; testar WebKitGTK, compositor e escala | Comportamento medido nas duas plataformas; Metal não é requisito de portabilidade. | +| Floors | Worktree Git como base; clone otimizado pode ser opcional | Worktree Git como base; cópia/reflink apenas se suportado | Isolamento funcional não depende de APFS; documentar que worktree não copia mudanças sujas ou arquivos ignorados. [Floors][m-floors] | +| iOS | Detectar Xcode/runtime disponível | Simulator local não disponível | Expor indisponibilidade local; uma futura conexão com host Mac é outra capacidade, não simulação de suporte local. [Portais][m-portals] | +| Android | SDK/ADB/emulador compatíveis | SDK/ADB/emulador compatíveis | Instalação e disponibilidade verificadas; nenhum pacote presume hardware presente. | +| Ombro | Foundation Models é específico de versões/hardware Apple | Exige alternativa de inferência local ou cliente de agente local | Não chamar um badge de status de equivalente a resumo. [Ombro][m-ombro] | +| Busca do sistema | Spotlight pode ser integração opcional | Launcher/search-provider conforme desktop, além da busca interna | Busca interna completa nos dois sistemas; não alegar Spotlight no Linux. [Workspaces][m-workspaces] | +| Portais | WebKit nativo | WebKitGTK | Suporte real a armazenamento, downloads e automação; diferenças devem aparecer nas capacidades. | +| Atalhos | Modificadores adaptados a macOS | Evitar conflitos com terminal/gerenciador de janelas | Ação lógica comum; atalhos visíveis, remapeáveis e verificados por teclado. | +| Distribuição | `.app`/`.dmg`; assinatura/notarização pendentes | AppImage; dependências WebKitGTK documentadas | Artefato instalado e smoke test, além de CI declarada. E12. | + +WSL, Win32 e ConPTY não entram: o usuário mantém Linux/macOS. Marca, cobrança, +licenciamento e serviços operados pelo fornecedor não são capacidades a +duplicar; recursos equivalentes de uso continuam mapeados. Compatibilidade +com o app Maestri Remote ou seus formatos não pode ser anunciada antes de +testes contra contratos/arquivos reais. Arquivo exportado próprio deve ter +identificação e versão próprias enquanto essa compatibilidade não existir. + +## XIRP e adaptações locais selecionadas + +O canvas continua sendo a superfície principal. As referências abaixo entram +por nós, painéis, paleta e ações contextuais; não exigem trocar o produto por +uma home administrativa. Não há conexão automática com Spotify Portal, +telemetria externa ou envio de transcript decorrente desta proposta. + +| ID | Capacidade / origem | Estado e evidência | Resultado proposto | Dono / prioridade | +| --- | --- | --- | --- | --- | +| X01 | Projetos locais, pastas não Git, diretórios com vários repos, pin e importação de filhos. [Projetos][x-projects] | P — E6/E9. | Distinguir pasta de repositório; desabilitar apenas operações Git inaplicáveis. | S2 base + S3 descoberta + S1 / P1 | +| X02 | Sessões gerais, objetivo inicial, anexos e opções do agente. [Sessões][x-sessions] | P — E3: projeto obrigatório, PTY disponível. | Sessão geral sem projeto fictício; objetivo entregue ao agente; controles específicos e verificáveis. | S2 + S1 / P1 | +| X03 | Troca de agente, fork de conversa, shell vinculado e retomada. [Sessões][x-sessions] | A/P — E3/E7/E11. | Continuar conversa só quando adaptador suporta; shell compartilha worktree e tem lifecycle próprio. | S2 + S1 / P2 | +| X04 | Status de atividade/espera, grade de terminais, minimapa e busca. [Sessões][x-sessions] | P — E9/E10. | Agrupamento/tiling dentro do canvas, sem encerrar sessão ao ocultar painel. | S2 status + S1 / P1 | +| X05 | Preferências locais, atalhos, hooks, notificações, configurações nativas e diagnóstico. [Settings][x-settings] | P — E7/E9/E10. | Painel mostra somente opções implementadas; arquivos de credenciais ficam fora do editor. | S2 + S3 regras + S1 / P1 | +| X06 | Skills e regras globais/de projeto descobertas e consultáveis. [Projetos][x-projects] | A — E5. | Mostrar origem, escopo, precedência e conteúdo; edição explícita com proteção contra conflitos/symlinks. | S3 + S2 contrato / P1 | +| X07 | Status de workflow, arquivo de sessões, prompts salvos, uso/custo, revisão de PR, Doctor e cleanup. [Changelog][x-changelog] | A/P — E3: só lifecycle; E8 inspeção Git. | Workflow separado de processo; métricas medidas ou indisponíveis; cleanup mantém confirmação por estado. | S3 workflow/arquivo/prompts; S2 cleanup/métricas + S1 / P1/P2 | +| X08 | Cursor e controles específicos de agentes. [Changelog][x-changelog] | A/P — E7. | Consolidar registry real antes de novos presets; testar versão/flags instalados e comportamento no daemon. | S2 + S1 / P1 | +| X09 | Contexto de Workspace: entidades, links, documentos, decisões, wiki e sessões. [Workspaces][x-workspaces] | A — E5/E11. | Coleção local ligada a projetos e notas; exportação explícita e rastreabilidade de origem. | S3 + S2 contrato + S1 / P1 | +| X10 | Exposição de contexto via MCP e compartilhamento manual de sessão. [Workspaces][x-workspaces] | A — E5. | Primeiro contexto local autorizado; conector Portal opcional permanece separado e depende de conta/API reais. | S3 + S2 / P3 | + +### Propostas opcionais do Portal — fora dos goals atuais + +Os itens O01–O03 não foram selecionados para entrega e não entram no critério +de conclusão do objetivo. São referências para uma futura decisão de produto. + +| ID | Referência | Estado | Possível adaptação futura | +| --- | --- | --- | --- | +| O01 | Catálogo de software com identidade, ownership, relações, tags e busca. [Portal Catalog][p-catalog] | A — E6 não modela entidades. | Descritores locais vinculados a repositórios/documentação. | +| O02 | Templates com formulário, passos, dry-run, logs e resultados. [Portal Scaffolder][p-scaffolder] | A — E5. | Geração local revisável e versionada, separada dos presets de canvas. | +| O03 | Indicadores de uso e adoção. [Portal Insights][p-insights] | A — E11. | Métricas locais com definições explícitas; não confundir com uso/custo nativo de X07. | + +O01–O03 são adaptações propostas, não compatibilidade com plugins do Backstage. +Instalar o Portal inteiro, comprar serviços, ligar contas ou copiar dashboards +empresariais não é pré-requisito para estas entregas locais. Uma integração +real deverá declarar credenciais, escopos e quais dados saem da máquina. + +## Ordem e critérios de aceitação + +1. **P0: realidade do produto.** Fechar a ligação daemon/worktree e manter o + canvas fiel e operável. Exercitar `create → start → subscribe → stop`, + reconexão do desktop e remoção em duas etapas através do socket real. +2. **P1: trabalho completo local.** Notas duráveis, arquivos/editor/Git, + roles/conexões/composer, projetos e preferências; prompts e regras locais + acrescentam contexto sem competir com o canvas. +3. **P2: automação e composição.** Floors completos/hooks, rotinas, fichários, + partituras, desenho e continuidade das sessões. Cada fluxo deve ter persistência, + cancelamento e comportamento de erro demonstrados. +4. **P3: ambientes e controle externo.** Remotos, portais, dispositivos, + Wire e assistente local exigem ADRs e testes de capacidade por plataforma. + A prioridade posterior não remove essas linhas do objetivo. + +Para cada entrega, registrar no PR/relatório: IDs desta matriz, commit, +comando de teste e resultado, captura visual quando relevante, evidência +Linux/macOS e lacunas restantes. Teste de Rust isolado não prova integração +Tauri; browser com IPC mockado não prova execução do daemon. + +Regressões transversais derivadas da inspeção das referências: preservar +rascunhos/seleção/foco ao alternar contexto; não duplicar prompts ou processos; +não perder arquivos em autosave/recovery; validar escopo de arquivo/Git/role; +tratar output volumoso, clone lento, suspensão, DPI e arquivo corrompido. +Os casos exatos entram na suíte do recurso quando ele for implementado. + +O objetivo só pode terminar quando todas as linhas obrigatórias tiverem +evidência do fluxo real e cada limite de plataforma tiver resultado explícito. +Não reduzir o inventário ao que ficou pronto em uma rodada. Os dois goals +para execução paralela estão em [parallel-goals.md](codex/parallel-goals.md). + +[m-changelog]: https://www.themaestri.app/en/changelog +[m-intro]: https://www.themaestri.app/en/docs/intro +[m-workspaces]: https://www.themaestri.app/en/docs/workspaces +[m-canvas]: https://www.themaestri.app/en/docs/canvas +[m-shortcuts]: https://www.themaestri.app/en/docs/shortcuts +[m-batuta]: https://www.themaestri.app/en/docs/batuta-search +[m-terminals]: https://www.themaestri.app/en/docs/terminals +[m-composer]: https://www.themaestri.app/en/docs/prompt-composer +[m-notes]: https://www.themaestri.app/en/docs/notes +[m-ficharios]: https://www.themaestri.app/en/docs/ficharios +[m-connections]: https://www.themaestri.app/en/docs/connections +[m-maestro]: https://www.themaestri.app/en/docs/maestro +[m-partituras]: https://www.themaestri.app/en/docs/partituras +[m-files]: https://www.themaestri.app/en/docs/file-tree +[m-floors]: https://www.themaestri.app/en/docs/floors +[m-portals]: https://www.themaestri.app/en/docs/portals +[m-routines]: https://www.themaestri.app/en/docs/routines +[m-environments]: https://www.themaestri.app/en/docs/environments +[m-ombro]: https://www.themaestri.app/en/docs/ombro +[m-wire]: https://www.themaestri.app/en/docs/wire +[m-troubleshooting]: https://www.themaestri.app/en/docs/troubleshooting +[x-intro]: https://backstage.spotify.com/docs/xirp +[x-projects]: https://backstage.spotify.com/docs/xirp/projects +[x-sessions]: https://backstage.spotify.com/docs/xirp/sessions +[x-settings]: https://backstage.spotify.com/docs/xirp/settings +[x-workspaces]: https://backstage.spotify.com/docs/xirp/workspaces +[x-changelog]: https://backstage.spotify.com/docs/xirp/changelog +[p-catalog]: https://backstage.spotify.com/docs/portal/core-features-and-plugins/catalog +[p-scaffolder]: https://backstage.spotify.com/docs/portal/core-features-and-plugins/scaffolder +[p-insights]: https://backstage.spotify.com/docs/portal/core-features-and-plugins/insights From e2da3c2d4bd0978700426049d52ac8ac39a0ca47 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:37:17 -0300 Subject: [PATCH 11/32] feat(canvas): add group actions and searchable canvas items --- .../canvas/CanvasElementSearch.test.tsx | 73 +++++ .../features/canvas/CanvasElementSearch.tsx | 124 +++++++ .../features/canvas/CanvasWorkspace.test.tsx | 187 +++++++++++ .../app/features/canvas/CanvasWorkspace.tsx | 307 ++++++++++++++---- apps/desktop/src/styles/canvas.css | 99 +++++- 5 files changed, 717 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/app/features/canvas/CanvasElementSearch.test.tsx create mode 100644 apps/desktop/src/app/features/canvas/CanvasElementSearch.tsx diff --git a/apps/desktop/src/app/features/canvas/CanvasElementSearch.test.tsx b/apps/desktop/src/app/features/canvas/CanvasElementSearch.test.tsx new file mode 100644 index 0000000..e7f4527 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/CanvasElementSearch.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { CanvasElementSearch } from "./CanvasElementSearch"; +import type { CanvasNode } from "./canvas-state"; + +const NODES: readonly CanvasNode[] = [ + { id: "note", kind: "note", title: "Release checklist", text: "Validate Linux and macOS packaging", x: 0, y: 0 }, + { id: "terminal", kind: "terminal", title: "Build", preset: "custom", executable: "/opt/bin/checker", workingDirectory: "/workspace/release", x: 100, y: 100, width: 432, height: 256 }, +]; + +describe("CanvasElementSearch", () => { + it("matches titles, note content, executables and directories with all query terms", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole("searchbox", { name: "Search canvas items" }); + expect(input).toHaveFocus(); + for (const query of ["release checklist", "LINUX packaging"]) { + await user.clear(input); + await user.type(input, query); + const results = screen.getByRole("list", { name: "Canvas search results" }); + expect(within(results).getAllByRole("button")).toHaveLength(1); + expect(within(results).getByRole("button", { name: /Release checklist/ })).toBeVisible(); + } + for (const query of ["checker", "/workspace/release"]) { + await user.clear(input); + await user.type(input, query); + expect(screen.getByRole("button", { name: /Build/ })).toBeVisible(); + expect(screen.queryByRole("button", { name: /Release checklist/ })).not.toBeInTheDocument(); + } + await user.clear(input); + await user.type(input, "checker packaging"); + expect(screen.getByText(/No matching items/)).toBeVisible(); + }); + + it("supports arrow navigation, returning to search, and focusing the chosen item", async () => { + const user = userEvent.setup(); + const onFocusNode = vi.fn(); + const onClose = vi.fn(); + render(); + await user.keyboard("{ArrowDown}"); + expect(screen.getByRole("button", { name: /Release checklist/ })).toHaveFocus(); + await user.keyboard("{ArrowUp}"); + expect(screen.getByRole("searchbox")).toHaveFocus(); + await user.keyboard("{ArrowDown}{ArrowDown}{Enter}"); + expect(onFocusNode).toHaveBeenCalledWith(NODES[1]); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("searches the saved agent display name and executable without exposing its environment", async () => { + const user = userEvent.setup(); + const node = NODES[1]; + if (!node || node.kind !== "terminal") throw new Error("Missing terminal fixture"); + render(); + const input = screen.getByRole("searchbox"); + await user.type(input, "security reviewer"); + expect(screen.getByRole("button", { name: /Build/ })).toBeVisible(); + await user.clear(input); + await user.type(input, "security-agent"); + expect(screen.getByRole("button", { name: /Build/ })).toBeVisible(); + await user.clear(input); + await user.type(input, "private-value"); + expect(screen.queryByRole("button", { name: /Build/ })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/desktop/src/app/features/canvas/CanvasElementSearch.tsx b/apps/desktop/src/app/features/canvas/CanvasElementSearch.tsx new file mode 100644 index 0000000..30653d0 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/CanvasElementSearch.tsx @@ -0,0 +1,124 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import type { AgentRecord, Session } from "../../../ipc/types"; +import { Icon } from "../../components/Icon"; +import type { CanvasNode } from "./canvas-state"; + +interface CanvasElementSearchProps { + readonly nodes: readonly CanvasNode[]; + readonly sessions: ReadonlyMap; + readonly agents: readonly AgentRecord[]; + readonly onFocusNode: (node: CanvasNode) => void; + readonly onClose: () => void; +} + +/** Searches canvas metadata while the live terminal cards remain mounted. */ +export function CanvasElementSearch({ + nodes, + sessions, + agents, + onFocusNode, + onClose, +}: CanvasElementSearchProps) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const resultsRef = useRef(new Map()); + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + const entries = nodes.map((node) => { + const session = node.kind === "terminal" && node.sessionId + ? sessions.get(node.sessionId) + : undefined; + const agentId = session?.agentId ?? (node.kind === "terminal" ? node.agentId : undefined); + const agent = agents.find((candidate) => candidate.id === agentId); + const details = node.kind === "note" + ? node.text + : [agent?.displayName, agent?.command.executable ?? node.executable, session?.branch, session?.cwd ?? node.workingDirectory] + .filter(Boolean).join(" · "); + return { node, details, searchable: `${node.title} ${node.kind} ${details}`.toLocaleLowerCase() }; + }).filter((entry) => terms.every((term) => entry.searchable.includes(term))); + + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( +
    { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + > +
    +
    + Workspace +

    Canvas items

    +
    + +
    + +

    + {entries.length} of {nodes.length} items +

    + {entries.length === 0 ? ( +

    No matching items. Try a title, note, agent, branch or path.

    + ) : ( +
      + {entries.map(({ node, details }, index) => ( +
    • + +
    • + ))} +
    + )} +
    + ); +} diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx index c7e6bb9..17e8cf2 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx @@ -279,6 +279,193 @@ describe("CanvasWorkspace", () => { expect(container.querySelector(".canvas-node--selected")).toBe(terminal); }); + it("toggles group selection with Shift+click and Shift+Space without child focus collapsing it", async () => { + const user = userEvent.setup(); + renderCanvas(); + const terminal = screen.getByRole("article", { name: "Terminal 1, terminal canvas item" }); + const other = screen.getByRole("article", { name: "Terminal 2, terminal canvas item" }); + const note = screen.getByRole("article", { name: "Notes, note canvas item" }); + await user.click(terminal); + await user.keyboard("{Shift>}"); + await user.click(within(other).getByLabelText("Move Terminal 2")); + await user.keyboard("{/Shift}"); + expect(terminal).toHaveAttribute("data-selected", "true"); + expect(other).toHaveAttribute("data-selected", "true"); + expect(screen.getByText(/2 selected/)).toBeVisible(); + await user.click(within(terminal).getByRole("region", { name: "Terminal surface for Terminal 1" })); + expect(screen.getByText(/2 selected/)).toBeVisible(); + await user.click(screen.getByRole("textbox", { name: "Notes content" })); + expect(note).not.toHaveAttribute("data-selected"); + expect(screen.getByText(/2 selected/)).toBeVisible(); + note.focus(); + await user.keyboard("{Shift>} {/Shift}"); + expect(screen.getByText(/3 selected/)).toBeVisible(); + await user.keyboard("{Shift>}"); + await user.click(other); + await user.keyboard("{/Shift}"); + expect(other).not.toHaveAttribute("data-selected"); + expect(screen.getByText(/2 selected/)).toBeVisible(); + }); + + it("moves the selected group by keyboard and drag while preserving relative spacing", async () => { + const user = userEvent.setup(); + renderCanvas(); + const terminal = screen.getByRole("article", { name: "Terminal 1, terminal canvas item" }); + const other = screen.getByRole("article", { name: "Terminal 2, terminal canvas item" }); + await user.click(terminal); + await user.keyboard("{Shift>}"); + await user.click(other); + await user.keyboard("{/Shift}{ArrowRight}"); + await waitFor(() => { + expect(readNodePosition("terminal-primary")).toEqual({ x: 178, y: 210 }); + expect(readNodePosition("terminal-secondary")).toEqual({ x: 568, y: 90 }); + }); + const header = within(terminal).getByLabelText("Move Terminal 1"); + fireEvent.pointerDown(header, { button: 0, pointerId: 7, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(header, { pointerId: 7, clientX: 120, clientY: 130 }); + fireEvent.pointerMove(header, { pointerId: 7, clientX: 132, clientY: 140 }); + fireEvent.pointerUp(header, { pointerId: 7 }); + await waitFor(() => { + expect(readNodePosition("terminal-primary")).toEqual({ x: 210, y: 250 }); + expect(readNodePosition("terminal-secondary")).toEqual({ x: 600, y: 130 }); + expect(readNodePosition("note-first")).toEqual({ x: 600, y: 390 }); + }); + expect(screen.getByText(/2 selected/)).toBeVisible(); + }); + + it("selects, duplicates, and removes groups with shortcuts without daemon mutations", async () => { + const user = userEvent.setup(); + const { props } = renderCanvas(); + const viewport = screen.getByLabelText("Pannable canvas"); + viewport.focus(); + await user.keyboard("{Control>}a{/Control}{Control>}d{/Control}"); + expect(screen.getAllByRole("article")).toHaveLength(6); + expect(screen.getByText(/3 selected/)).toBeVisible(); + expect(screen.getByRole("article", { name: "Notes copy, note canvas item" })).toHaveFocus(); + await waitFor(() => expect(readCanvasDocument()?.connections).toHaveLength(4)); + await user.keyboard("{Delete}"); + expect(screen.getAllByRole("article")).toHaveLength(3); + expect(viewport).toHaveFocus(); + expect(props.onCreateCustomAgent).not.toHaveBeenCalled(); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + expect(props.onStopSession).not.toHaveBeenCalled(); + expect(props.onDeleteSession).not.toHaveBeenCalled(); + expect(props.onRemoveWorktree).not.toHaveBeenCalled(); + }); + + it("leaves group shortcuts inside editors, terminal surfaces, and buttons to those controls", async () => { + const user = userEvent.setup(); + renderCanvas(); + await user.click(screen.getByRole("button", { name: "Select all canvas items" })); + const note = screen.getByRole("textbox", { name: "Notes content" }); + const terminal = screen.getByRole("region", { name: "Terminal surface for Terminal 1" }); + const button = screen.getByRole("button", { name: "Duplicate selected canvas items" }); + for (const target of [note, terminal, button]) { + expect(fireEvent.keyDown(target, { key: "d", ctrlKey: true })).toBe(true); + expect(fireEvent.keyDown(target, { key: "a", metaKey: true })).toBe(true); + expect(fireEvent.keyDown(target, { key: "Backspace" })).toBe(true); + } + expect(screen.getAllByRole("article")).toHaveLength(3); + expect(screen.getByText(/3 selected/)).toBeVisible(); + }); + + it("copies the exact attached agent and starts it only when explicitly requested", async () => { + const user = userEvent.setup(); + const agent = { + ...SHELL_AGENT, + displayName: "Review Codex", + command: { executable: "/opt/bin/codex", args: ["--model", "review"], env: { CUSTOM_TOKEN: "must-not-persist" } }, + }; + const { props } = renderProjectCanvas({ agents: [agent], sessions: [STOPPED_SESSION], worktrees: [MANAGED_WORKTREE] }); + await user.click(screen.getByRole("article", { name: "Review agent, terminal canvas item" })); + await user.click(screen.getByRole("button", { name: "Duplicate selected canvas items" })); + const copied = screen.getByRole("article", { name: "Review agent copy, terminal canvas item" }); + expect(within(copied).getByText("Review Codex draft")).toBeVisible(); + expect(copied).not.toHaveAttribute("data-canvas-session-id"); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + await waitFor(() => { + expect(readCanvasDocument()?.nodes).toContainEqual(expect.objectContaining({ title: "Review agent copy", agentId: agent.id })); + expect(localStorage.getItem(CANVAS_STORAGE_KEY)).not.toContain("must-not-persist"); + }); + await user.click(within(copied).getByRole("button", { name: "Start terminal" })); + expect(props.onCreateSession).toHaveBeenCalledWith({ + projectId: PROJECT.id, + name: "Review agent copy", + agentId: agent.id, + isolation: "current", + relativeDirectory: ".worktrees/review", + }); + expect(props.onCreateCustomAgent).not.toHaveBeenCalled(); + expect(props.onStartSession).toHaveBeenCalledWith(STOPPED_SESSION.id); + }); + + it.each(["missing", "disabled"])("refuses to replace a %s saved agent with a shell", async (availability) => { + const user = userEvent.setup(); + const { props } = renderProjectCanvas({ + agents: availability === "missing" ? [] : [{ ...SHELL_AGENT, enabled: false }], + sessions: [STOPPED_SESSION], + }); + await user.click(screen.getByRole("article", { name: "Review agent, terminal canvas item" })); + await user.click(screen.getByRole("button", { name: "Duplicate selected canvas items" })); + const copied = screen.getByRole("article", { name: "Review agent copy, terminal canvas item" }); + await user.click(within(copied).getByRole("button", { name: "Start terminal" })); + expect(within(copied).getByText(/The original agent is (unavailable|disabled)/)).toBeVisible(); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onCreateCustomAgent).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + }); + + it("scopes group actions to the current project and prunes selection after switching", async () => { + const user = userEvent.setup(); + const otherSession = { ...STOPPED_SESSION, id: "other-session", projectId: OTHER_PROJECT.id, name: "Other agent" }; + const { props, rerender } = renderProjectCanvas({ sessions: [STOPPED_SESSION, otherSession], projects: [PROJECT, OTHER_PROJECT] }); + await user.click(screen.getByRole("article", { name: "Review agent, terminal canvas item" })); + rerender(); + expect(screen.queryByText(/1 selected/)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Select all canvas items" })); + await user.click(screen.getByRole("button", { name: "Remove selected items from canvas" })); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole("article", { name: "Review agent, terminal canvas item" })).toBeVisible(); + await waitFor(() => { + expect(readCanvasDocument()?.hiddenSessionIds).toContain(otherSession.id); + expect(readCanvasDocument()?.hiddenSessionIds).not.toContain(STOPPED_SESSION.id); + }); + expect(props.onStopSession).not.toHaveBeenCalled(); + expect(props.onDeleteSession).not.toHaveBeenCalled(); + }); + + it("searches metadata, focuses results, and keeps live terminals mounted while filtering", async () => { + const user = userEvent.setup(); + const { props } = renderProjectCanvas({ sessions: [{ ...STOPPED_SESSION, status: "running" }] }); + const terminal = screen.getByTestId(`live-terminal-${STOPPED_SESSION.id}`); + const viewport = screen.getByLabelText("Pannable canvas"); + const scrollTo = vi.fn(); + Object.defineProperty(viewport, "scrollTo", { configurable: true, value: scrollTo }); + viewport.focus(); + await user.keyboard("{Meta>}f{/Meta}"); + const search = screen.getByRole("searchbox", { name: "Search canvas items" }); + expect(search).toHaveFocus(); + await user.type(search, "agent/review"); + const panel = screen.getByRole("region", { name: "Canvas items" }); + expect(within(panel).getByText("1 of 4 items")).toBeVisible(); + expect(screen.getByTestId(`live-terminal-${STOPPED_SESSION.id}`)).toBe(terminal); + await user.keyboard("{ArrowDown}{Enter}"); + expect(screen.queryByRole("region", { name: "Canvas items" })).not.toBeInTheDocument(); + expect(screen.getByRole("article", { name: "Review agent, terminal canvas item" })).toHaveFocus(); + expect(scrollTo).toHaveBeenCalledOnce(); + expect(props.onSelectSession).toHaveBeenCalledWith(STOPPED_SESSION.id); + await user.click(screen.getByRole("button", { name: "Show canvas items" })); + await user.type(screen.getByRole("searchbox"), "no-such-item"); + expect(screen.getByText(/No matching items/)).toBeVisible(); + expect(screen.getByTestId(`live-terminal-${STOPPED_SESSION.id}`)).toBe(terminal); + await user.keyboard("{Escape}"); + expect(screen.getByRole("button", { name: "Show canvas items" })).toHaveFocus(); + expect(props.onStopSession).not.toHaveBeenCalled(); + }); + it("resizes a terminal by keyboard and pointer", async () => { const user = userEvent.setup(); renderCanvas(); diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx index d6b4ff1..c6485a4 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx @@ -25,6 +25,7 @@ import { createInitialCanvasDocument, createSessionTerminalCanvasNode, createTerminalCanvasNode, + duplicateCanvasSelection, getCanvasNodeSize, type CanvasTerminalConfiguration, type CanvasNode, @@ -32,6 +33,7 @@ import { type TerminalCanvasNode, } from "./canvas-state"; import { CanvasConnections } from "./CanvasConnections"; +import { CanvasElementSearch } from "./CanvasElementSearch"; import { CANVAS_ORIGIN_X, CANVAS_ORIGIN_Y, @@ -111,6 +113,8 @@ export function CanvasWorkspace({ const viewportInitializedRef = useRef(false); const nodeElementsRef = useRef(new Map()); const handledFocusRequestRef = useRef(null); + const focusSelectionRef = useRef(false); + const layersTriggerRef = useRef(null); const panRef = useRef<{ readonly pointerId: number; readonly clientX: number; @@ -194,6 +198,9 @@ export function CanvasWorkspace({ const selectedNode = visibleNodes.find( (node) => node.id === state.selectedNodeId, ); + const selectedNodeIds = state.selectedNodeIds.filter((id) => + visibleNodeIds.has(id), + ); const selectedConnections = selectedNode ? visibleConnections.flatMap((connection) => { const otherNodeId = @@ -261,11 +268,11 @@ export function CanvasWorkspace({ }, [dispatch, projectSessions, sessionCanvasTopologyKey, sessions]); useLayoutEffect(() => { - if ( - state.selectedNodeId !== null && - !visibleNodeIds.has(state.selectedNodeId) - ) { - dispatch({ type: "node/select", nodeId: null }); + const remainingSelection = state.selectedNodeIds.filter((id) => + visibleNodeIds.has(id), + ); + if (remainingSelection.length !== state.selectedNodeIds.length) { + dispatch({ type: "nodes/select", nodeIds: remainingSelection }); } if ( state.connectionSourceId !== null && @@ -276,10 +283,19 @@ export function CanvasWorkspace({ }, [ dispatch, state.connectionSourceId, - state.selectedNodeId, + state.selectedNodeIds, visibleNodeIds, ]); + useLayoutEffect(() => { + if (focusSelectionRef.current && state.selectedNodeId) { + focusSelectionRef.current = false; + nodeElementsRef.current + .get(state.selectedNodeId) + ?.focus({ preventScroll: true }); + } + }, [state.selectedNodeId]); + useLayoutEffect(() => { if (!selectedSessionId) { handledFocusRequestRef.current = null; @@ -455,6 +471,52 @@ export function CanvasWorkspace({ dispatch({ type: "zoom/set", zoom: Number(zoom.toFixed(2)) }); } + function selectAllNodes() { + onSelectSession(null); + dispatch({ + type: "nodes/select", + nodeIds: visibleNodes.map((node) => node.id), + }); + } + + function duplicateSelectedNodes() { + if (selectedNodeIds.length === 0) return; + onSelectSession(null); + focusSelectionRef.current = true; + dispatch(duplicateCanvasSelection(state, selectedNodeIds)); + } + + function removeSelectedNodes() { + if (selectedNodeIds.length === 0) return; + onSelectSession(null); + dispatch({ type: "nodes/delete", nodeIds: selectedNodeIds }); + viewportRef.current?.focus({ preventScroll: true }); + } + + function selectNode(node: CanvasNode, additive = false, fromFocus = false) { + if (fromFocus && state.selectedNodeIds.length > 0) return; + const sessionId = node.kind === "terminal" ? node.sessionId : undefined; + // A selection made here is already focused; do not treat its echo from + // AppShell as a sidebar navigation request that would collapse the group. + handledFocusRequestRef.current = sessionId + ? { sessionId, revision: sessionFocusRevision } + : null; + if (additive || !state.selectedNodeIds.includes(node.id)) { + dispatch({ type: "node/select", nodeId: node.id, additive }); + } else if (state.selectedNodeId !== node.id) { + dispatch({ + type: "nodes/select", + nodeIds: [...state.selectedNodeIds.filter((id) => id !== node.id), node.id], + }); + } + onSelectSession(sessionId ?? null); + } + + function closeLayers() { + setLayersOpen(false); + layersTriggerRef.current?.focus(); + } + function fitCanvasToItems() { const viewport = viewportRef.current; if (!viewport || storedVisibleNodes.length === 0) { @@ -545,6 +607,27 @@ export function CanvasWorkspace({ className="canvas-workspace" tabIndex={-1} aria-labelledby="canvas-workspace-title" + onKeyDown={(event) => { + if (event.defaultPrevented || isCanvasEditingTarget(event.target)) { + return; + } + const command = event.metaKey || event.ctrlKey; + if (command && event.key.toLowerCase() === "f") { + event.preventDefault(); + setLayersOpen(true); + } else if (command && event.key.toLowerCase() === "a") { + event.preventDefault(); + selectAllNodes(); + } else if (command && event.key.toLowerCase() === "d") { + event.preventDefault(); + duplicateSelectedNodes(); + } else if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + removeSelectedNodes(); + } else if (event.key === "Escape") { + dispatch({ type: "node/select", nodeId: null }); + } + }} >
    Workspace @@ -604,15 +687,34 @@ export function CanvasWorkspace({ + + @@ -629,6 +731,12 @@ export function CanvasWorkspace({
    +

    + {selectedNodeIds.length > 0 + ? `${selectedNodeIds.length} selected · Shift+click to add or remove` + : "Shift+click to select multiple items"} +

    + {visibleConnectionSourceId ? (
    @@ -647,7 +755,7 @@ export function CanvasWorkspace({ className="canvas-viewport" tabIndex={0} aria-label="Pannable canvas" - aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight" + aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight Control+a Meta+a Control+d Meta+d Control+f Meta+f Delete Backspace" onKeyDown={(event) => { if (event.defaultPrevented || event.currentTarget !== event.target) { return; @@ -656,8 +764,16 @@ export function CanvasWorkspace({ const movement = keyboardMovement(event.key, step); if (movement) { event.preventDefault(); - event.currentTarget.scrollLeft += movement.x; - event.currentTarget.scrollTop += movement.y; + if (selectedNodeIds.length > 0) { + dispatch({ + type: "nodes/move", + nodeIds: selectedNodeIds, + delta: keyboardMovement(event.key, event.altKey ? 1 : 8) ?? movement, + }); + } else { + event.currentTarget.scrollLeft += movement.x; + event.currentTarget.scrollTop += movement.y; + } } }} onPointerDown={(event) => { @@ -739,19 +855,22 @@ export function CanvasWorkspace({ : undefined } session={session} + agent={agents.find( + (agent) => agent.id === (session?.agentId ?? + (node.kind === "terminal" ? node.agentId : undefined)), + )} worktree={worktree} isConnected={isConnected} - selected={state.selectedNodeId === node.id} + selected={selectedNodeIds.includes(node.id)} connectionSource={visibleConnectionSourceId} connectionCount={visibleConnections.filter( (connection) => connection.sourceNodeId === node.id || connection.targetNodeId === node.id, ).length} - onSelect={() => { - dispatch({ type: "node/select", nodeId: node.id }); - onSelectSession(session?.id ?? null); - }} + onSelect={(additive, fromFocus) => + selectNode(node, additive, fromFocus) + } onConnect={() => { if ( visibleConnectionSourceId && @@ -775,8 +894,14 @@ export function CanvasWorkspace({ dispatch({ type: "node/delete", nodeId: node.id }); }} zoom={state.zoom} - onMove={(position) => - dispatch({ type: "node/move", nodeId: node.id, position }) + onMove={(delta) => + dispatch({ + type: "nodes/move", + nodeIds: selectedNodeIds.includes(node.id) + ? selectedNodeIds + : [node.id], + delta, + }) } onResize={(size) => dispatch({ type: "terminal/resize", nodeId: node.id, size }) @@ -816,36 +941,14 @@ export function CanvasWorkspace({
    {layersOpen ? ( -
    -
    -
    - Workspace -

    Canvas items

    -
    - {visibleNodes.length} -
    -
      - {visibleNodes.map((node) => ( -
    • - -
    • - ))} -
    -
    + ) : null} {selectedNode && selectedConnections.length > 0 && !layersOpen ? ( @@ -889,6 +992,7 @@ export function CanvasWorkspace({ aria-label="Canvas view controls" >
@@ -1774,7 +1918,7 @@ function TerminalNodeBody({ startDisabledReason ?? (session ? "This terminal is stopped. Start it to attach a fresh live PTY." - : `${node.executable ?? "A login shell"} is ready to start in this project.`)} + : `${agent?.displayName ?? node.executable ?? (node.agentId ? "The saved agent" : "A login shell")} is ready to start in this project.`)}

+ {workspace.connection.status === "disconnected" ? ( +
+ + Daemon disconnected.{" "} + {workspace.snapshot + ? "Existing metadata may be stale." + : "Notes and terminal drafts are available offline."} + +
) : null} {workspace.operationError ? ( diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx index 17e8cf2..e241fd0 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx @@ -840,6 +840,41 @@ describe("CanvasWorkspace", () => { expect(onDeleteSession).not.toHaveBeenCalled(); }); + it("preserves dismissed sessions during offline edits and connected reconciliation", async () => { + localStorage.setItem(CANVAS_STORAGE_KEY, JSON.stringify({ + version: 1, + nodes: [], + connections: [], + zoom: 1, + hiddenSessionIds: [STOPPED_SESSION.id], + })); + const user = userEvent.setup(); + const { props, rerender } = renderCanvas({ isConnected: false }); + + await user.click(screen.getByRole("button", { name: "Add note" })); + await waitFor(() => { + expect(readCanvasDocument().nodes).toHaveLength(1); + expect(readCanvasDocument().hiddenSessionIds).toContain(STOPPED_SESSION.id); + }); + + rerender(); + + expect(screen.queryByRole("article", { + name: "Review agent, terminal canvas item", + })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Notes content" })).toBeVisible(); + expect(readCanvasDocument().hiddenSessionIds).toContain(STOPPED_SESSION.id); + expect(props.onDeleteSession).not.toHaveBeenCalled(); + expect(props.onStopSession).not.toHaveBeenCalled(); + }); + it("reconciles project sessions again after resetting the canvas document", async () => { const user = userEvent.setup(); renderProjectCanvas({ sessions: [STOPPED_SESSION] }); diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx index c6485a4..bf7a5bb 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx @@ -255,6 +255,8 @@ export function CanvasWorkspace({ ); useLayoutEffect(() => { + // An offline empty session list is not evidence that saved sessions vanished. + if (!isConnected) return; dispatch({ type: "sessions/reconcile", knownSessionIds: sessions.map((session) => session.id), @@ -265,7 +267,7 @@ export function CanvasWorkspace({ ), ), }); - }, [dispatch, projectSessions, sessionCanvasTopologyKey, sessions]); + }, [dispatch, isConnected, projectSessions, sessionCanvasTopologyKey, sessions]); useLayoutEffect(() => { const remainingSelection = state.selectedNodeIds.filter((id) => diff --git a/apps/desktop/src/app/features/workspace/Workspace.tsx b/apps/desktop/src/app/features/workspace/Workspace.tsx index 8628ab7..c748adc 100644 --- a/apps/desktop/src/app/features/workspace/Workspace.tsx +++ b/apps/desktop/src/app/features/workspace/Workspace.tsx @@ -57,9 +57,6 @@ export function Workspace(props: WorkspaceProps) { if (props.connectionStatus === "fatal") { return ; } - if (props.connectionStatus === "disconnected" && !props.snapshot) { - return ; - } if (props.view === "settings") { return ; } From 180513fdec6bc622519dc5e308e536010afc95e0 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Wed, 2 Sep 2026 17:09:30 -0300 Subject: [PATCH 13/32] test(frontend): provide storage fallback on Node 25 --- .../features/canvas/useCanvasState.test.tsx | 12 +++++-- apps/desktop/src/test/setup.ts | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx b/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx index 6cfc5fd..2719471 100644 --- a/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx +++ b/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx @@ -14,7 +14,7 @@ describe("useCanvasState", () => { afterEach(() => vi.restoreAllMocks()); it("does not save or publish another document for transient selection changes", () => { - const write = vi.spyOn(Storage.prototype, "setItem"); + const write = spyOnStorageWrites(); const publish = vi.spyOn(globalThis, "dispatchEvent"); const { result } = renderHook(() => useCanvasState()); write.mockClear(); @@ -34,7 +34,7 @@ describe("useCanvasState", () => { }); it("reports failed saves while retaining edits and recovers on the next successful save", () => { - const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + const write = spyOnStorageWrites().mockImplementation(() => { throw new DOMException("Storage full", "QuotaExceededError"); }); const { result } = renderHook(() => useCanvasState()); @@ -103,3 +103,11 @@ describe("useCanvasState", () => { ]); }); }); + +function spyOnStorageWrites() { + // Node 25's fallback owns its methods; jsdom Storage exposes them on its prototype. + const owner = Object.prototype.hasOwnProperty.call(localStorage, "setItem") + ? localStorage + : Storage.prototype; + return vi.spyOn(owner, "setItem"); +} diff --git a/apps/desktop/src/test/setup.ts b/apps/desktop/src/test/setup.ts index c938037..27f84ba 100644 --- a/apps/desktop/src/test/setup.ts +++ b/apps/desktop/src/test/setup.ts @@ -3,4 +3,35 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; +if (typeof globalThis.localStorage?.clear !== "function") { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMemoryStorage(), + }); +} + afterEach(cleanup); + +function createMemoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key) { + return values.get(String(key)) ?? null; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + removeItem(key) { + values.delete(String(key)); + }, + setItem(key, value) { + values.set(String(key), String(value)); + }, + }; +} From ace4e8ff3c196d8e8d35da3631f9e6d02b57a41b Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:44:34 -0300 Subject: [PATCH 14/32] fix(canvas): fill compact windows and verify canvas workflows --- apps/desktop/e2e/canvas-interactions.spec.ts | 186 +++++++++++++++++++ apps/desktop/src/styles/canvas.css | 15 +- 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/e2e/canvas-interactions.spec.ts diff --git a/apps/desktop/e2e/canvas-interactions.spec.ts b/apps/desktop/e2e/canvas-interactions.spec.ts new file mode 100644 index 0000000..f57c9f0 --- /dev/null +++ b/apps/desktop/e2e/canvas-interactions.spec.ts @@ -0,0 +1,186 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +test.use({ + viewport: { width: 1440, height: 900 }, + contextOptions: { reducedMotion: "reduce" }, +}); + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("article")).toHaveCount(3); +}); + +test("duplicates and removes a pointer-selected group while preserving notes across reloads", async ({ page }) => { + const terminal = canvasCard(page, "Terminal 1", "terminal"); + const note = canvasCard(page, "Notes", "note"); + await note.getByRole("textbox", { name: "Notes content" }).fill("Release checklist: test Linux and macOS."); + await page.getByLabel("Move Terminal 1", { exact: true }).click(); + await page.getByLabel("Move Notes", { exact: true }).click({ modifiers: ["Shift"] }); + await expect(terminal).toHaveAccessibleDescription("Selected canvas item"); + await expect(note).toHaveAccessibleDescription("Selected canvas item"); + await expect(page.getByRole("status").filter({ hasText: /^2 selected/ })).toBeVisible(); + + await page.getByRole("button", { name: "Duplicate selected canvas items" }).click(); + await expect(page.getByRole("article")).toHaveCount(5); + await expect(canvasCard(page, "Terminal 1 copy", "terminal")).toHaveAccessibleDescription("Selected canvas item"); + await expect(canvasCard(page, "Notes copy", "note").getByRole("textbox")).toHaveValue("Release checklist: test Linux and macOS."); + await page.reload(); + await expect(page.getByRole("article")).toHaveCount(5); + await expect(canvasCard(page, "Notes copy", "note").getByRole("textbox")).toHaveValue("Release checklist: test Linux and macOS."); + + await canvasCard(page, "Terminal 1 copy", "terminal").focus(); + await canvasCard(page, "Notes copy", "note").focus(); + await page.keyboard.press("Shift+Space"); + await expect(page.getByRole("status").filter({ hasText: /^2 selected/ })).toBeVisible(); + await page.getByRole("button", { name: "Remove selected items from canvas", exact: true }).click(); + await expect(page.getByRole("article")).toHaveCount(3); + await page.reload(); + await expect(canvasCard(page, "Terminal 1 copy", "terminal")).toHaveCount(0); + await expect(canvasCard(page, "Notes copy", "note")).toHaveCount(0); + await expect(note.getByRole("textbox")).toHaveValue("Release checklist: test Linux and macOS."); + await expect(terminal).toBeVisible(); +}); + +test("moves a keyboard-selected group with arrows and pointer drag without changing its spacing", async ({ page }) => { + const first = canvasCard(page, "Terminal 1", "terminal"); + const second = canvasCard(page, "Terminal 2", "terminal"); + const note = canvasCard(page, "Notes", "note"); + await first.focus(); + await second.focus(); + await page.keyboard.press("Shift+Space"); + await expect(first).toHaveAccessibleDescription("Selected canvas item"); + await expect(second).toHaveAccessibleDescription("Selected canvas item"); + + const initial = await positions([first, second, note]); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Alt+ArrowDown"); + await expect.poll(() => positions([first, second, note])).toEqual([ + { x: initial[0]!.x + 8, y: initial[0]!.y + 1 }, + { x: initial[1]!.x + 8, y: initial[1]!.y + 1 }, + initial[2], + ]); + + const header = page.getByLabel("Move Terminal 2", { exact: true }); + await header.scrollIntoViewIfNeeded(); + const bounds = await header.boundingBox(); + expect(bounds).not.toBeNull(); + const start = { x: bounds!.x + 100, y: bounds!.y + bounds!.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 64, start.y + 40, { steps: 4 }); + await page.mouse.up(); + await expect.poll(() => positions([first, second, note])).toEqual([ + { x: initial[0]!.x + 72, y: initial[0]!.y + 41 }, + { x: initial[1]!.x + 72, y: initial[1]!.y + 41 }, + initial[2], + ]); + await expect(page.getByRole("status").filter({ hasText: /^2 selected/ })).toBeVisible(); + + await page.keyboard.press("Shift+Space"); + await expect(first).toHaveAccessibleDescription("Selected canvas item"); + await expect(second).not.toHaveAccessibleDescription("Selected canvas item"); +}); + +test("search filters results without replacing terminal cards and focuses the chosen note", async ({ page }, testInfo) => { + const terminal = canvasCard(page, "Terminal 1", "terminal"); + const note = canvasCard(page, "Notes", "note"); + await note.getByRole("textbox").fill("Investigate the orange release checklist"); + const terminalElement = await terminal.elementHandle(); + expect(terminalElement).not.toBeNull(); + await page.getByRole("main").focus(); + await page.keyboard.press("ControlOrMeta+f"); + const search = page.getByRole("searchbox", { name: "Search canvas items" }); + const panel = page.getByRole("region", { name: "Canvas items", exact: true }); + await expect(search).toBeFocused(); + await search.fill("ORANGE checklist"); + await expect(panel.getByRole("status")).toHaveText("1 of 3 items"); + await expect(panel.getByRole("list", { name: "Canvas search results" }).getByRole("button")).toHaveCount(1); + await expect(page.getByRole("article")).toHaveCount(3); + expect(await terminalElement!.evaluate((element) => element.isConnected)).toBe(true); + await page.screenshot({ path: testInfo.outputPath("canvas-desktop-search.png") }); + + await search.press("ArrowDown"); + await expect(panel.getByRole("button", { name: /Notes Investigate/ })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(panel).toHaveCount(0); + await expect(note).toBeFocused(); + await expect(note).toBeInViewport(); + + await page.getByRole("button", { name: "Show canvas items", exact: true }).click(); + await search.fill("no-such-canvas-item"); + await expect(panel.getByRole("status")).toHaveText("0 of 3 items"); + await expect(panel.getByText(/No matching items/)).toBeVisible(); + await search.press("Escape"); + await expect(page.getByRole("button", { name: "Show canvas items", exact: true })).toBeFocused(); + expect(await terminalElement!.evaluate((element) => element.isConnected)).toBe(true); +}); + +test("editing a note keeps selection and delete shortcuts inside its text field", async ({ page }) => { + await page.getByRole("button", { name: "Select all canvas items" }).click(); + const note = canvasCard(page, "Notes", "note"); + const editor = note.getByRole("textbox"); + const initial = await positions([note]); + await editor.fill("Replace this note"); + await editor.press("ControlOrMeta+a"); + await editor.press("Backspace"); + await expect(editor).toHaveValue(""); + await expect(page.getByRole("article")).toHaveCount(3); + await expect(page.getByRole("status").filter({ hasText: /^3 selected/ })).toBeVisible(); + await editor.fill("abc"); + await editor.press("ArrowLeft"); + await editor.press("Delete"); + await expect(editor).toHaveValue("ab"); + await expect.poll(() => positions([note])).toEqual(initial); + await expect(page.getByRole("article")).toHaveCount(3); +}); + +test("creates and finds a Gemini draft in a compact window", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 640, height: 800 }); + await expect.poll(async () => (await page.getByRole("main").boundingBox())?.width).toBe(640); + await page.getByRole("button", { name: "Add terminal card" }).click(); + const dialog = page.getByRole("dialog", { name: "New Terminal", exact: true }); + await dialog.getByRole("radio", { name: "Gemini", exact: true }).check(); + await expect(dialog.getByRole("textbox", { name: "Terminal name", exact: true })).toHaveValue("Gemini"); + await expect(dialog.getByRole("textbox", { name: "Command", exact: true })).toHaveValue("gemini"); + await dialog.getByRole("textbox", { name: "Working directory", exact: true }).fill("/workspace/gemini-review"); + await dialog.getByRole("button", { name: "Create terminal", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("article")).toHaveCount(4); + + for (const width of [360, 640]) { + await page.setViewportSize({ width, height: 800 }); + await expect.poll(async () => (await page.getByRole("main").boundingBox())?.width).toBe(width); + await expect.poll(async () => { + const status = await page.getByRole("status").filter({ hasText: /^1 selected/ }).boundingBox(); + const context = await page.getByRole("main").getByText("Workspace", { exact: true }).boundingBox(); + return status !== null && context !== null && status.y + status.height <= context.y; + }).toBe(true); + } + + await page.getByRole("button", { name: "Show canvas items", exact: true }).click(); + const search = page.getByRole("searchbox", { name: "Search canvas items" }); + await search.fill("gemini-review"); + const panel = page.getByRole("region", { name: "Canvas items", exact: true }); + await expect(panel.getByRole("status")).toHaveText("1 of 4 items"); + await expect(panel).toBeInViewport({ ratio: 1 }); + await page.screenshot({ path: testInfo.outputPath("canvas-compact-search.png") }); + await search.press("Enter"); + const gemini = canvasCard(page, "Gemini", "terminal"); + await expect(gemini).toBeFocused(); + await expect(gemini).toBeInViewport({ ratio: 1 }); + await page.reload(); + await expect(gemini).toHaveCount(1); +}); + +/** Resolves a canvas card through its user-facing accessible name. */ +function canvasCard(page: Page, title: string, kind: "terminal" | "note") { + return page.getByRole("article", { name: `${title}, ${kind} canvas item`, exact: true }); +} + +/** Reads rendered positions independently of canvas scrolling and zoom. */ +async function positions(cards: readonly Locator[]) { + return Promise.all(cards.map((card) => card.evaluate((element) => { + const matrix = new DOMMatrix(getComputedStyle(element).transform); + return { x: matrix.m41, y: matrix.m42 }; + }))); +} diff --git a/apps/desktop/src/styles/canvas.css b/apps/desktop/src/styles/canvas.css index e0c81cc..8fa20a3 100644 --- a/apps/desktop/src/styles/canvas.css +++ b/apps/desktop/src/styles/canvas.css @@ -1998,7 +1998,14 @@ } .canvas-context { - top: 8.5rem; + top: 10rem; + right: 1rem; + } + + .canvas-context h1 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .canvas-context__meta { @@ -2019,11 +2026,15 @@ } .canvas-connect-notice { - top: 12rem; + top: 13rem; } } @media (max-width: 47.99rem) { + .app-shell__body { + grid-template-columns: minmax(0, 1fr); + } + .app-shell .navigation-pane { width: min(20rem, calc(100vw - 3rem)); grid-template-rows: auto minmax(0, 1fr); From 6f6af262658528023b6bf3455c9ae70727208eed Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:47:14 -0300 Subject: [PATCH 15/32] docs: record canvas validation and XIRP integration contract --- docs/codex/maestri-canvas-report.md | 76 ++++++++++++++++++++++++++ docs/codex/xirp-integration-request.md | 52 ++++++++++++++++++ docs/maestri-xirp-parity.md | 6 ++ docs/playwright-testing.md | 25 +++++---- 4 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 docs/codex/maestri-canvas-report.md create mode 100644 docs/codex/xirp-integration-request.md diff --git a/docs/codex/maestri-canvas-report.md b/docs/codex/maestri-canvas-report.md new file mode 100644 index 0000000..60a990b --- /dev/null +++ b/docs/codex/maestri-canvas-report.md @@ -0,0 +1,76 @@ +# S1 — canvas Maestri e integração XIRP + +Atualizado em 2026-09-05. A meta completa continua ativa; esta entrega não +constitui paridade integral com Maestri nem integração com Spotify Portal. +O inventário completo está em [maestri-xirp-parity.md](../maestri-xirp-parity.md). + +## Entrega verificada + +| Commit | Resultado | +| --- | --- | +| `9f3462f` | Seleção múltipla transitória; movimento em grupo preservando distâncias nos limites; duplicação do subgrafo sem copiar sessão viva; remoção oculta cartões de sessões. | +| `0ac8dd7` | Mudanças de seleção não gravam/publicam documento; falha de armazenamento é indicada e edições permanecem na memória. | +| `a23587d` | Gemini no cadastro público do daemon com ID UUIDv7 estável; detecção reutiliza o adapter e verifica permissão de execução. | +| `472926c` | Preset Gemini no diálogo de terminal e na persistência do canvas. | +| `6774865` | Inventário de paridade e dois goals com ownership para sessões paralelas. | +| `e2da3c2` | Shift+click/Shift+Space, seleção total, duplicação/remoção/arraste em grupo; busca por título, nota, agente, branch e caminho. Cópia mantém referência ao agente original, incluindo sua configuração, sem persistir seu ambiente no canvas. | +| `e75e6cb` | Notas e drafts disponíveis sem daemon; operações de sessão continuam bloqueadas. Reconciliação offline não apaga sessões ocultadas. | +| `180513f` | Reutilizada a compatibilidade de testes Node 25 já existente na main, com spies de storage compatíveis. | +| `ace4e8f` | Canvas ocupa toda a janela compacta; título e seleção não se sobrepõem; cinco testes de navegador novos. | + +Os commits foram enviados individualmente para +`origin/refactor/canvas-only-shell`, com autoria Git do usuário e sem trailers +de coautoria Codex/OpenAI. Nenhuma sessão foi iniciada ao duplicar cartões e +nenhuma operação de stop/delete do daemon é chamada ao remover uma seleção. + +## Validação e limites + +- `pnpm --filter @cli-master/desktop check`: TypeScript, **118 testes** e + build de produção passaram no macOS com Node 25.9. O build ainda informa + bundle principal maior que 500 kB; isso não foi corrigido nesta entrega. +- `pnpm --filter @cli-master/desktop exec playwright test --workers=2`: + **9 testes** passaram no Chromium/macOS, com build novo. Incluem ponteiro, + teclado, persistência após reload e dimensões de 360/640/1440 px. +- `cargo test -p cli-master-daemon`: **37 testes** passaram; Clippy do daemon + com todos os targets e `-D warnings` e formatação passaram. Os testes usam + arquivos, permissões Unix e SQLite reais; não exigem login no Gemini. +- A build Rust usou `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 + CARGO_PROFILE_TEST_DEBUG=0` por espaço limitado. Apenas artefatos gerados + desta worktree foram removidos quando o disco esgotou; fontes e dados do + usuário foram preservados. Os artefatos são regeneráveis. +- Capturas desktop/compacta foram inspecionadas. Asserções geométricas + reproduziram e depois verificaram a correção da largura compacta. +- E2E no navegador verifica cartões locais/drafts. Testes de componente + verificam seleção de terminais com IPC do projeto simulado. Isso **não** + prova PTY em janela Tauri, empacotamento nativo, login no Gemini ou Linux. + A matriz Linux/macOS de CI e os smoke tests nativos continuam necessários. + +M02 e M06 avançaram, mas ainda faltam copiar/colar, grupos persistidos, +alinhamento, undo/redo, busca global e atalhos configuráveis. M46 continua +parcial; esta revisão visual não prova fidelidade de todas as telas/estados. + +## Próximas integrações reais + +1. Integrar o navegador já entregue em `origin/main` pelo PR 40. O baseline + desta sessão não o contém. A implementação inclui host WKWebView/WebKitGTK, + navegação, foco, redaction de URL, proteção contra obstruções e smoke + harness nativo. Reutilizar esse trabalho antes de implementar M37 do zero. + A análise de merge encontrou conflitos em AppShell, CanvasWorkspace e + canvas-state com seus testes. Manter shell claro/canvas-only, isolamento + por projeto, IDs ocultados, referência de agente, seleção múltipla e modo + offline. A migração v2 deve manter v1, e somente o nó primário ativa uma + superfície nativa; busca/menus/movimentos em grupo precisam ocultá-la. +2. S2: integração real daemon/worktree, preservando `create` seguido de + `start`. A saga interna ainda não está conectada ao caminho público. + Contratos de arquivos/notas/floors seguem o inventário. +3. S3: prompts/contexto, regras/skills e organização em módulos próprios. + [Coordenação com S3](xirp-integration-request.md) define inserção explícita + em draft editável; o composer e sua montagem real ainda serão entregues. +4. Continuar as linhas obrigatórias da matriz, incluindo lift/dock, conteúdo, + portais/dispositivos, automações, continuidade, ambientes e capacidades + de plataforma. A entrega atual não reduz esse escopo. + +As worktrees das sessões adicionais já existem. Os paths efetivamente +observados são `/Users/eguimacs/cli-master-runtime` e +`/Users/eguimacs/cli-master-xirp`; os nomes propostos no documento de goals +não autorizam recriá-las nem apagar seu trabalho. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md new file mode 100644 index 0000000..1594ea6 --- /dev/null +++ b/docs/codex/xirp-integration-request.md @@ -0,0 +1,52 @@ +# XIRP S3 integration request — 2026-09-05 + +S3 is active in `/Users/eguimacs/cli-master-xirp`, branch `feat/xirp-local-workflows`, based on latest `origin/refactor/canvas-only-shell` (`0ac8dd7`). The user explicitly requested coordination before shared changes. This file is a coordination message, not part of your commit unless useful. + +## S2 runtime / shared-contract owner + +Please reply in `/Users/eguimacs/cli-master-xirp/docs/codex/xirp-coordination-reply.md` or append below in this copy. I will inspect both main and runtime copies. + +Proposed S3 ownership: new `knowledge/` modules under core/storage/daemon; isolated React components under `app/features/knowledge/`; domain tests and XIRP report. No parallel process executor, file service, database, or Tauri client. + +First vertical increment: global/project saved prompts (UUIDv7, title/body/projectId nullable, created/updated epoch-ms, integer revision) and reusable local context documents (same scope/revision plus kind). Explicit insert creates composer draft, never hidden send. Suggest `knowledge.list`, `knowledge.save`, `knowledge.delete` with tagged kind `prompt|context`; optimistic revision on update/delete; `knowledge.updated` event for refresh. Stable existing `invalid_input`/not_found + agreed conflict error. Storage tables in current SQLite with project FK; no runtime side effects. + +Next: `knowledge.discover`/`knowledge.read` for explicitly known rule/skill paths, bounded UTF-8/no credentials/no symlink traversal; edit via your file service once available (no duplicate generic file service). Please advise your file contract/reusable safe reader. + +Organization metadata: propose separate project/session organization records for pinned/archived, session workflow (backlog/in_progress/in_review/blocked/done) separate from process status, initial objective + explicit local context references. Do you own these fields as planned? Please reserve/implement contracts or delegate an agreed narrow patch. Initial objective execution must use SessionManager/adapters; S3 can own drafting/context composition. + +Please confirm migration allocation, shared lib.rs registration, wire/mirrors/dispatch ownership. Can you delegate a small additive S3 patch to these files after review of this proposal, or supply a commit? No shared files have been changed by S3. + +## S1 canvas owner + +Will deliver isolated components with typed callbacks for saved prompts/context library, rules/skills inspector, and organization controls. Please identify preferred integration surface (canvas contextual panel/toolbar/palette) and coordinate real mounting/composer insertion. Your AppShell/canvas/global styles remain yours. S3 will publish commits and integration API in `docs/codex/xirp-context-report.md`; main canvas remains Maestri. + +## Responses + +### S1 acknowledgment — 2026-09-05 + +S1 confirms the canvas remains the primary workspace. Deliver the saved +prompt/context library and instruction inspector as isolated panels under +`app/features/knowledge/`, using the existing semantic CSS tokens. S1 will +mount them from canvas controls and the command palette. A panel receives +the current project/session as optional context; global items must remain +usable without selecting a project. + +For insertion, expose a typed callback carrying the selected item's stable +ID, revision, title and body. S1 will place this content in an editable prompt +draft for the explicitly selected terminal. Insertion must never write to a +PTY, press Return, start a session or silently change the selected terminal. +Keep the source reference alongside the draft so the user can inspect its +origin. The composer is part of S1's remaining implementation; a callback +alone will not count as completed integration. + +S3 owns organization metadata (pin/archive/workflow) as described in the +published parallel goals; S2 coordinates shared model/migration/IPC changes. +S1 does not allocate migrations or authorize conflicting edits on S2's +behalf. The proposed optimistic revision model fits the UI; coordinate the +exact errors and events with S2 before publishing shared contracts. + +New S1 commits after your base include Gemini, safe group actions, canvas +search and offline editing. Integrate the current +`origin/refactor/canvas-only-shell` before final frontend integration; preserve +your own work. S1 is also reviewing the native-browser work already in +`origin/main` for reuse, so avoid changing CanvasNode or CanvasWorkspace. diff --git a/docs/maestri-xirp-parity.md b/docs/maestri-xirp-parity.md index b6a07ed..917e0c4 100644 --- a/docs/maestri-xirp-parity.md +++ b/docs/maestri-xirp-parity.md @@ -38,6 +38,12 @@ código atual e atualizar a evidência. Não promover uma linha por existir um botão, tipo, mock, método anunciado ou biblioteca sem ligação ao fluxo real. Esta inspeção não executou a suíte nem validou pacotes nas duas plataformas. +Entregas posteriores ao baseline e suas verificações estão no +[relatório S1](codex/maestri-canvas-report.md). A `origin/main` já contém +implementação de navegador nativo ausente desta branch inicial; M37 deve +reaproveitá-la antes de criar outro host. Não confundir ausência nesta +worktree com ausência em todas as branches. + ## Evidências locais de referência | Código | Fonte no repositório | O que a inspeção permite afirmar | diff --git a/docs/playwright-testing.md b/docs/playwright-testing.md index f320225..f1bdccd 100644 --- a/docs/playwright-testing.md +++ b/docs/playwright-testing.md @@ -1,19 +1,21 @@ # Playwright testing -Playwright covers the disconnected desktop state that the browser preview can -actually exercise. It does not inject fake project/session state into the -existing terminal grid or add production hooks so a browser test can pretend -the daemon domain API is implemented. +Playwright covers the disconnected desktop and local canvas that the browser +preview can exercise. Notes, drafts and layout remain available without the +daemon. It does not inject fake project/session state or add production hooks +to pretend the daemon domain API is implemented. ## What runs in CI -`apps/desktop/e2e/empty-shell.spec.ts` starts the Vite production preview and -drives the current `AppShell` with role locators: +The specs start the Vite production preview and drive `AppShell` with role +locators: -- New Session stays disabled until a project exists -- Add Project explains that the local daemon is required -- the empty workspace copy is honest -- the skip link is first in tab order +- `empty-shell.spec.ts` checks disabled project management, offline notice, + sidebar navigation and the first keyboard skip link. +- `canvas-interactions.spec.ts` checks group selection/movement/duplication/ + removal, note persistence after reload, metadata search preserving draft + card DOM identity, shortcuts inside note editors, Gemini draft creation + and compact canvas geometry at 360 and 640 px. Those tests wait on Playwright's auto-waiting assertions. They do not use `page.waitForTimeout`. @@ -32,7 +34,8 @@ test a path users never run. The runtime portions are implemented in `crates/e2e` against the production `SessionWorktreeSaga`, Git, SQLite, and `Daemon::bind`. Dropping and recreating PTY subscriptions verifies replay semantics, but it is not -described as a real window test. There are no placeholder or unconditionally +described as a real window test. Browser tests use drafts and do not prove +PTY continuity, Gemini authentication or native packaging. There are no placeholder or unconditionally skipped Playwright cases: every test listed by Playwright executes. Add a separate Tauri-driver suite after the daemon domain API can populate the From 839e319deefa78e61d7c224ad94d9fa905579337 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:06:48 -0300 Subject: [PATCH 16/32] feat(canvas): persist revision-safe terminal prompt drafts --- .../app/features/canvas/canvas-state.test.ts | 53 +++++++++++++++++++ .../src/app/features/canvas/canvas-state.ts | 50 +++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/features/canvas/canvas-state.test.ts b/apps/desktop/src/app/features/canvas/canvas-state.test.ts index 3a3dce4..a15fb23 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.test.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.test.ts @@ -16,6 +16,59 @@ import { } from "./canvas-state"; describe("canvas state", () => { + it("persists each terminal's draft without changing its session or other cards", () => { + const initial = createInitialCanvasState(); + const drafted = canvasReducer(initial, { + type: "terminal/draft", nodeId: "terminal-primary", text: "Review this\nthen explain.", + }); + const saved = parseCanvasDocument(serializeCanvasDocument(drafted)); + expect(saved.nodes.find((node) => node.id === "terminal-primary")).toMatchObject({ + promptDraft: "Review this\nthen explain.", promptDraftRevision: 1, + }); + expect(drafted.nodes.find((node) => node.id === "terminal-secondary")).toBe(initial.nodes.find((node) => node.id === "terminal-secondary")); + expect(canvasReducer(drafted, { + type: "terminal/draft", nodeId: "terminal-primary", text: "Review this\nthen explain.", + })).toBe(drafted); + expect(canvasReducer(drafted, { + type: "terminal/draft", nodeId: "note-first", text: "wrong target", + })).toBe(drafted); + }); + + it("clears only an acknowledged draft revision, including edit-away-and-back races", () => { + const drafted = canvasReducer(createInitialCanvasState(), { + type: "terminal/draft", nodeId: "terminal-primary", text: "A", + }); + const acknowledgment = { + type: "terminal/draft_sent", nodeId: "terminal-primary", text: "A", revision: 1, + } as const; + const cleared = canvasReducer(drafted, acknowledgment); + expect(cleared.nodes.find((node) => node.id === "terminal-primary")).toMatchObject({ + promptDraft: "", promptDraftRevision: 2, + }); + expect(canvasReducer(cleared, acknowledgment)).toBe(cleared); + const edited = canvasReducer(drafted, { + type: "terminal/draft", nodeId: "terminal-primary", text: "B", + }); + const editedBack = canvasReducer(edited, { + type: "terminal/draft", nodeId: "terminal-primary", text: "A", + }); + expect(canvasReducer(editedBack, acknowledgment)).toBe(editedBack); + expect(canvasReducer(edited, acknowledgment)).toBe(edited); + const removed = canvasReducer(drafted, { type: "node/delete", nodeId: "terminal-primary" }); + expect(canvasReducer(removed, acknowledgment)).toBe(removed); + }); + + it("migrates absent or malformed draft metadata without treating it as input", () => { + const document = createInitialCanvasDocument(); + const parsed = parseCanvasDocument(JSON.stringify({ + ...document, version: 1, + nodes: document.nodes.map((node) => ({ ...node, promptDraft: 123, promptDraftRevision: -1 })), + })); + expect(parsed.nodes.find((node) => node.id === "terminal-primary")).toMatchObject({ + promptDraft: undefined, promptDraftRevision: undefined, + }); + }); + it("persists the Gemini quick-start preset with its native executable", () => { const node = createTerminalCanvasNode({ x: 0, y: 0 }, { preset: "gemini" }, "gemini"); const state = createInitialCanvasState({ version: 2, nodes: [node], connections: [], zoom: 1 }); diff --git a/apps/desktop/src/app/features/canvas/canvas-state.ts b/apps/desktop/src/app/features/canvas/canvas-state.ts index 447c232..2b7ab25 100644 --- a/apps/desktop/src/app/features/canvas/canvas-state.ts +++ b/apps/desktop/src/app/features/canvas/canvas-state.ts @@ -27,6 +27,9 @@ export interface TerminalCanvasNode extends CanvasNodeBase { readonly sessionId?: string; /** Reference the persisted agent definition without copying its environment. */ readonly agentId?: string; + /** User-authored composer text, never PTY output or an auto-send instruction. */ + readonly promptDraft?: string; + readonly promptDraftRevision?: number; readonly preset: TerminalPreset; readonly executable?: string; readonly workingDirectory?: string; @@ -103,6 +106,17 @@ export type CanvasAction = readonly nodeId: string; readonly text: string; } + | { + readonly type: "terminal/draft"; + readonly nodeId: string; + readonly text: string; + } + | { + readonly type: "terminal/draft_sent"; + readonly nodeId: string; + readonly text: string; + readonly revision: number; + } | { readonly type: "terminal/configure"; readonly nodeId: string; @@ -292,6 +306,16 @@ export function canvasReducer( ? { ...node, text: action.text.slice(0, MAX_NOTE_LENGTH) } : node, ); + case "terminal/draft": + return updateNode(state, action.nodeId, (node) => + node.kind === "terminal" ? updatePromptDraft(node, action.text) : node, + ); + case "terminal/draft_sent": + return updateNode(state, action.nodeId, (node) => + node.kind === "terminal" && (node.promptDraft ?? "") === action.text + && (node.promptDraftRevision ?? 0) === action.revision + ? updatePromptDraft(node, "") : node, + ); case "terminal/configure": return updateNode(state, action.nodeId, (node) => node.kind === "terminal" @@ -765,13 +789,14 @@ function updateNode( nodeId: string, update: (node: CanvasNode) => CanvasNode, ): CanvasState { - if (!state.nodes.some((node) => node.id === nodeId)) { - return state; - } + const current = state.nodes.find((node) => node.id === nodeId); + if (!current) return state; + const updated = update(current); + if (updated === current) return state; return { ...state, nodes: state.nodes.map((node) => - node.id === nodeId ? update(node) : node, + node === current ? updated : node, ), }; } @@ -897,6 +922,23 @@ function parseNode(value: unknown): CanvasNode | null { typeof value.sessionId === "string" ? value.sessionId : undefined, agentId: typeof value.agentId === "string" ? value.agentId : undefined, + promptDraft: typeof value.promptDraft === "string" ? value.promptDraft : undefined, + promptDraftRevision: typeof value.promptDraft === "string" + ? normalizePromptDraftRevision(value.promptDraftRevision) : undefined, + }; +} + +function normalizePromptDraftRevision(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) + && value >= 0 && value < Number.MAX_SAFE_INTEGER ? value : 0; +} + +function updatePromptDraft(node: TerminalCanvasNode, text: string): TerminalCanvasNode { + if ((node.promptDraft ?? "") === text) return node; + return { + ...node, + promptDraft: text, + promptDraftRevision: normalizePromptDraftRevision(node.promptDraftRevision) + 1, }; } From b46dee36a7e26b3f521a7f372119c8256ad75b40 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:07:00 -0300 Subject: [PATCH 17/32] docs: coordinate native browser and shared knowledge integration --- docs/codex/xirp-integration-request.md | 97 ++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md index 1594ea6..485fefc 100644 --- a/docs/codex/xirp-integration-request.md +++ b/docs/codex/xirp-integration-request.md @@ -50,3 +50,100 @@ search and offline editing. Integrate the current `origin/refactor/canvas-only-shell` before final frontend integration; preserve your own work. S1 is also reviewing the native-browser work already in `origin/main` for reuse, so avoid changing CanvasNode or CanvasWorkspace. + +### S3 live integration update + +Saved prompt/context contract implemented on S3 worktree; first real socket +tests passed (scope, conflicts, daemon restart, escaped large pages, no session +side effects). S2 authorized migration0004 and additive registrations. Public +methods knowledge.list/save/delete, existing IpcClient methods listKnowledge/ +saveKnowledge/deleteKnowledge. Isolated KnowledgePanel in features/knowledge +accepts {client,currentProject,onInsert,insertDisabledReason}; onInsert gets +{sourceId,kind,title,body}. Please identify mounting/composer hook for S1; root +S3 is finishing tests and will publish hashes shortly. If you want S3 to supply +an additive canvas adapter on its branch, state the exact files/delegation in +S3 docs/codex/xirp-coordination-reply.md. S3 will not touch canvas without that +agreement. Latest details are in S3 docs/codex/xirp-context-report.md. + +### S3 backend published + +`2f3b7c8` pushed to origin/feat/xirp-local-workflows. This is the coherent +knowledge contract+migration0004+storage+daemon dispatch+typed client+tests +commit approved by S2. Please cherry-pick/integrate by name preserving S2's +worktree.list and runtime additions. Full daemon suite37, new SQLite9, core +contract9+catalog1, clippy and format passed locally. UI component commit +follows shortly. Report with exact APIs: S3 docs/codex/xirp-context-report.md. + +### S3 UI published and CI + +`a757f05` pushed after `2f3b7c8`: isolated KnowledgePanel +12 behavior tests, +responsive screenshots and integration API. Full frontend121 tests/typecheck/ +build passed with Node25 webstorage workaround; draft PR45 is open against +refactor/canvas-only-shell for Linux/macOS CI: +https://github.com/guicybercode/Jig/pull/45 +S3 now preparing narrow rule/skill discovery per S2 approval, with official +format provenance in S3 docs/codex/xirp-rule-sources.md. Canvas mounting remains +S1-owned and S2 organization/workflow/initial-objective contracts still awaited. + +### S3 second method proposal awaiting S2 review + +Please review S3 docs/codex/xirp-discovery-contract.md: knowledge.discover +{projectId?}->{scanId,entries,truncated,issues}; knowledge.read +{scanId,entryId}->{entry,content}. Narrow bounded known-rule/skill reader only, +opaque IDs, safe symlinked skill-directory targets, no arbitrary paths, config +files, scripts or second editor. No migration. Can S3 add these names +shared +registration after implementation/tests, under the same additive workflow? +Reply in S3 xirp-coordination-reply.md. + +S1 integration inspection found no editable prompt composer yet. Minimal host +patch is WorkspaceOperations forwarding knowledge client methods +AppShell +toolbar/Dialog +host-owned {targetSessionId,text} draft. S3 can provide isolated +textarea/append helper; initial objective launch still needs S2 contract. We +will leave S1's active merges untouched. + +## S3 integration progress and organization ownership — 2026-09-05 + +S3 has merged current S1 canvas through `6f6af26` in `cfc79ba`; doc agreements +are preserved. Draft PR #45 contains the saved library. S1 callback revision +request is being implemented; source ID/revision identify the draft's base +snapshot, while title/body may include unsaved edits. Actual composer mounting +is still S1 work. + +S2's second-increment acknowledgment received. Rule/skill scanner work is now +underway in isolated knowledge modules; S3 will publish both approved methods +with bounded safe handlers and socket tests. Existing spawn_blocking knowledge +dispatch from S2 will be preserved in the resulting integration. + +Organization needs one ownership clarification between the existing replies: +the original user task and S1 acknowledgment assign S3 the new pin/archive/ +workflow modules, while S2's first acknowledgment retained these contracts. +To avoid parallel implementation, S3 proposes owning isolated organization +core/storage/daemon modules and UI, with S2 reviewing the wire contract and +allocating the next migration after file/workspace work. Please delegate that +bounded patch or confirm an implementation already underway with its contract. +Workflow stays separate from process status, archive does not stop sessions, +metadata deletion never removes working directories. S2 retains initial +objective delivery through SessionManager/adapters; S3 owns drafting/context. + +## S1 composer and integration checkpoint — 2026-09-05 + +Native browser is integrated and pushed in `1e75081`; keep its project/group +behavior and browser obstructions when rebasing. Both packaging jobs passed; +the integrated Rust CI is still running. Native interactive smoke remains open. + +S1 is now mounting a per-terminal PromptComposer, with retained draft/revision +on each canvas terminal node. Explicit send goes through the same live input +queue as typing and observes the current terminal paste/cursor modes. No +library insert starts a session or sends terminal input. S3's KnowledgePanel +will be mounted by S1 after the S2 reconciled backend (`1a74bbb`) and S3 UI +(`a757f05`) are integrated. S1 will retain the library editor while hidden. + +S1 found CI E0061 in S2's earlier integrated Gemini test: SessionRegistry::new +now needs managed_root/Git as well as storage/instance. Preserve Gemini +coverage while updating this helper, not by removing the test. + +Organization ownership: S1 supports the proposed bounded S3 organization +modules, subject to S2 confirming that its implementation has not already +started and reserving a migration. S2 should retain file/workspace/floor and +initial-objective delivery. Do not start duplicate organization implementations; +record the final agreement here and in the S3 coordination reply first. From 1f1209fdebedfbaed9090e29562ade2e5e247d3f Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:47:04 -0300 Subject: [PATCH 18/32] refactor(session): separate worktree preparation from process launch --- crates/session/src/create.rs | 227 ++++++++++++---- crates/session/src/lib.rs | 5 +- crates/session/src/remove.rs | 20 ++ crates/session/src/saga.rs | 115 ++++++-- crates/session/tests/prepare_saga.rs | 328 +++++++++++++++++++++++ crates/storage/src/sessions.rs | 65 +++-- crates/storage/src/worktrees.rs | 45 +++- crates/storage/tests/agents_worktrees.rs | 78 ++++++ 8 files changed, 773 insertions(+), 110 deletions(-) create mode 100644 crates/session/tests/prepare_saga.rs diff --git a/crates/session/src/create.rs b/crates/session/src/create.rs index c64f9d9..2504449 100644 --- a/crates/session/src/create.rs +++ b/crates/session/src/create.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use cli_master_core::wire::SessionIsolation; +use cli_master_core::wire::{RelativeDirectory, SessionIsolation}; use cli_master_core::{ AgentId, ProjectId, Session, SessionId, SessionStatus, Worktree, WorktreeId, }; @@ -18,6 +18,25 @@ use crate::token::now_ms; const DEFAULT_PTY_COLS: u16 = 80; const DEFAULT_PTY_ROWS: u16 = 24; +#[derive(Clone, Copy)] +pub(crate) enum Launch<'a> { + Prepare(Option<&'a RelativeDirectory>), + Start, +} + +impl<'a> Launch<'a> { + fn relative_directory(self) -> Option<&'a RelativeDirectory> { + match self { + Self::Prepare(relative) => relative, + Self::Start => None, + } + } + + fn starts_process(self) -> bool { + matches!(self, Self::Start) + } +} + /// Named saga effect after which tests may inject a failure. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CreateStep { @@ -82,7 +101,7 @@ pub struct CreateSession { /// Durable result of a completed create saga. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CreatedSession { - /// Persisted session, including daemon-observed pid after spawn. + /// Persisted session; preparing metadata alone never assigns a pid. pub session: Session, /// Managed worktree when isolation requested one. pub worktree: Option, @@ -94,10 +113,11 @@ pub(crate) fn create( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { match request.isolation { - SessionIsolation::Current => create_current(saga, request, faults), - SessionIsolation::NewWorktree => create_worktree(saga, request, faults), + SessionIsolation::Current => create_current(saga, request, faults, launch), + SessionIsolation::NewWorktree => create_worktree(saga, request, faults, launch), } } @@ -105,21 +125,31 @@ fn create_current( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { let now = now_ms(); let project = require_project(saga, request.project_id)?; let agent = require_agent(saga, request.agent_id)?; + let cwd = resolve_session_directory(&project.path, launch.relative_directory())?; + let command = agent + .command_for_cwd(cwd.clone()) + .map_err(SagaError::from)?; maybe_fail(faults, CreateStep::Plan)?; maybe_fail(faults, CreateStep::PersistCreating)?; maybe_fail(faults, CreateStep::GitAdd)?; let session_id = SessionId::new(); - insert_starting_session(saga, session_id, request, project.path.clone(), now)?; + let session = session_row(saga, session_id, request, cwd, now, launch); + saga.storage().insert_session(&session)?; maybe_fail(faults, CreateStep::PersistActive).inspect_err(|_| { discard_session(saga, session_id); })?; - let command = agent - .command_for_cwd(project.path.clone()) - .map_err(SagaError::from)?; + if !launch.starts_process() { + return Ok(CreatedSession { + session: session_dto(session, None, None), + worktree: None, + plan: None, + }); + } let spawned = saga .spawner .spawn(SpawnRequest { @@ -159,10 +189,11 @@ fn create_worktree( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { let now = now_ms(); let project = require_project(saga, request.project_id)?; - let agent = require_agent(saga, request.agent_id)?; + require_agent(saga, request.agent_id)?; let worktree_id = WorktreeId::new(); let session_id = SessionId::new(); let short_id = request @@ -170,11 +201,22 @@ fn create_worktree( .clone() .unwrap_or_else(|| short_id_for(worktree_id)); let plan = saga.git.plan_worktree( - &project.path, + project.repository_root.as_ref().unwrap_or(&project.path), &request.managed_root, &request.name, &short_id, )?; + let selected_directory = canonical_directory(&project.path)?; + let project_relative = selected_directory + .strip_prefix(plan.repository_root()) + .map_err(|_| { + SagaError::new( + SagaErrorKind::InvalidInput, + "selected project directory is outside its Git repository root", + "Register a directory inside the repository and try again", + ) + .with_path(&project.path) + })?; if let Some(hook) = &faults.after_plan { hook(&plan); } @@ -208,10 +250,10 @@ fn create_worktree( saga, request, faults, - &agent, &plan, - (worktree_id, session_id), + (worktree_id, session_id, project_relative), now, + launch, ) } @@ -219,33 +261,50 @@ fn persist_spawn_and_run( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, - agent: &cli_master_storage::StoredAgent, plan: &WorktreePlan, - ids: (WorktreeId, SessionId), + ids: (WorktreeId, SessionId, &Path), now: i64, + launch: Launch<'_>, ) -> Result { - let (worktree_id, session_id) = ids; - if let Err(error) = insert_starting_session( - saga, - session_id, - request, - plan.destination().to_path_buf(), - now, - ) { - return Err(compensate(saga, plan, worktree_id, None, error)); - } - if let Err(error) = activate_worktree(saga, worktree_id, session_id, now) { - discard_session(saga, session_id); - return Err(compensate(saga, plan, worktree_id, None, error)); + let (worktree_id, session_id, project_relative) = ids; + let selected_root = resolve_directory( + plan.destination(), + &plan.destination().join(project_relative), + ) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let cwd = resolve_session_directory(&selected_root, launch.relative_directory()) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let agent = require_agent(saga, request.agent_id) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let command = agent + .command_for_cwd(cwd.clone()) + .map_err(|error| compensate(saga, plan, worktree_id, None, SagaError::from(error)))?; + let session = session_row(saga, session_id, request, cwd, now, launch); + let persisted = + saga.storage() + .insert_prepared_session_with_worktree(&session, worktree_id, now); + if let Err(error) = persisted { + return Err(compensate( + saga, + plan, + worktree_id, + None, + SagaError::from(error), + )); } if let Err(error) = maybe_fail(faults, CreateStep::PersistActive) { - discard_session(saga, session_id); return Err(compensate(saga, plan, worktree_id, Some(session_id), error)); } - let command = agent - .command_for_cwd(plan.destination().to_path_buf()) - .map_err(SagaError::from)?; + if !launch.starts_process() { + let worktree = require_worktree(saga, worktree_id) + .map_err(|error| compensate(saga, plan, worktree_id, Some(session_id), error))?; + return Ok(CreatedSession { + session: session_dto(session, Some(&worktree), None), + worktree: Some(worktree_dto(worktree)), + plan: Some(plan.clone()), + }); + } let spawned = match saga.spawner.spawn(SpawnRequest { session_id, project_id: request.project_id, @@ -311,46 +370,35 @@ fn persist_creating( .map_err(SagaError::from) } -fn insert_starting_session( +fn session_row( saga: &SessionWorktreeSaga, session_id: SessionId, request: &CreateSession, cwd: PathBuf, now: i64, -) -> Result<(), SagaError> { - let row = StoredSession { + launch: Launch<'_>, +) -> StoredSession { + StoredSession { id: session_id, project_id: request.project_id, agent_id: request.agent_id, name: request.name.clone(), cwd, - status: SessionStatus::Starting, + status: if launch.starts_process() { + SessionStatus::Starting + } else { + SessionStatus::Unknown + }, runtime_pid: None, - daemon_instance_id: Some(saga.daemon_instance_id.clone()), + daemon_instance_id: launch + .starts_process() + .then(|| saga.daemon_instance_id.clone()), exit_code: None, error_code: None, created_at_ms: now, updated_at_ms: now, - last_activity_at_ms: Some(now), - }; - saga.storage().insert_session(&row).map_err(SagaError::from) -} - -fn activate_worktree( - saga: &SessionWorktreeSaga, - worktree_id: WorktreeId, - session_id: SessionId, - now: i64, -) -> Result<(), SagaError> { - saga.storage() - .update_worktree_state( - worktree_id, - WorktreeState::Active, - false, - Some(session_id), - now, - ) - .map_err(SagaError::from) + last_activity_at_ms: launch.starts_process().then_some(now), + } } fn persist_running( @@ -424,7 +472,15 @@ fn compensate( original } Err(error) => { - mark_orphaned(saga, worktree_id, session_id); + // Successful session deletion clears the foreign-key association; + // do not try to reattach an identifier that no longer exists. + let remaining_session = saga + .storage() + .get_worktree(worktree_id) + .ok() + .flatten() + .and_then(|worktree| worktree.session_id); + mark_orphaned(saga, worktree_id, remaining_session); if error.kind() == cli_master_git::GitErrorKind::PartialWorktree { return SagaError::from(error).with_worktree_id(worktree_id); } @@ -510,3 +566,58 @@ pub(crate) fn require_worktree( .with_worktree_id(worktree_id) }) } + +/// Resolves an existing session directory inside its daemon-selected root. +/// +/// Both paths are canonicalized so a child symlink cannot escape the project +/// or managed worktree. The typed relative directory has already passed wire +/// validation for parent traversal and absolute paths. +/// +/// # Errors +/// +/// Returns an error when either directory is unavailable or the resolved child +/// is outside the canonical root. +pub fn resolve_session_directory( + root: &Path, + relative_directory: Option<&RelativeDirectory>, +) -> Result { + let directory = relative_directory.map_or_else( + || root.to_path_buf(), + |relative| root.join(relative.as_str()), + ); + resolve_directory(root, &directory) +} + +fn resolve_directory(root: &Path, directory: &Path) -> Result { + let canonical_root = canonical_directory(root)?; + let canonical_directory = canonical_directory(directory)?; + if !canonical_directory.starts_with(&canonical_root) { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session directory resolves outside its project or worktree root", + "Choose an existing directory inside the session root", + ) + .with_path(directory)); + } + Ok(canonical_directory) +} + +fn canonical_directory(directory: &Path) -> Result { + let resolved = directory.canonicalize().map_err(|error| { + SagaError::new( + SagaErrorKind::InvalidInput, + format!("session directory could not be opened: {error}"), + "Choose an existing directory inside the session root and check its permissions", + ) + .with_path(directory) + })?; + if !resolved.is_dir() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session working directory is not a directory", + "Choose an existing directory inside the session root", + ) + .with_path(directory)); + } + Ok(resolved) +} diff --git a/crates/session/src/lib.rs b/crates/session/src/lib.rs index cc56796..3d57e91 100644 --- a/crates/session/src/lib.rs +++ b/crates/session/src/lib.rs @@ -30,7 +30,10 @@ pub use config::{ DEFAULT_REPLAY_MAX_BYTES, DEFAULT_REPLAY_MAX_CHUNKS, MAX_EVENT_CAPACITY, MAX_TRACKED_PROCESSES, SessionManagerConfig, }; -pub use create::{CreateFaults, CreateSession, CreateStep, CreatedSession, LockHook, PlanHook}; +pub use create::{ + CreateFaults, CreateSession, CreateStep, CreatedSession, LockHook, PlanHook, + resolve_session_directory, +}; pub use error::{SagaError, SagaErrorKind, SessionError}; pub use event::{ IoOperation, OutputChunk, ReconnectSnapshot, SessionEvent, SessionHandle, SessionSnapshot, diff --git a/crates/session/src/remove.rs b/crates/session/src/remove.rs index 8d9a54e..ae309f0 100644 --- a/crates/session/src/remove.rs +++ b/crates/session/src/remove.rs @@ -15,6 +15,26 @@ use crate::saga::{SessionWorktreeSaga, require_project}; use crate::spawn::SessionSpawner; use crate::token::now_ms; +pub(crate) fn cancel_pending_removal( + saga: &SessionWorktreeSaga, + worktree_id: WorktreeId, +) -> Result<(), SagaError> { + let _mutation = lock_mutation(&saga.mutations, worktree_id)?; + let stored = require_worktree(saga, worktree_id)?; + let _destination = lock_destination(&saga.destinations, stored.path.clone())?; + saga.tokens.discard_for(worktree_id); + if stored.state == WorktreeState::RemovePending { + saga.storage().update_worktree_state( + worktree_id, + WorktreeState::Active, + stored.is_dirty, + stored.session_id, + now_ms(), + )?; + } + Ok(()) +} + pub(crate) fn prepare_remove( saga: &SessionWorktreeSaga, worktree_id: WorktreeId, diff --git a/crates/session/src/saga.rs b/crates/session/src/saga.rs index 3be54aa..3b4cab2 100644 --- a/crates/session/src/saga.rs +++ b/crates/session/src/saga.rs @@ -1,6 +1,8 @@ -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use cli_master_core::wire::{ConfirmationToken, SessionIsolation, WorktreePrepareRemoveResponse}; +use cli_master_core::wire::{ + ConfirmationToken, RelativeDirectory, SessionIsolation, WorktreePrepareRemoveResponse, +}; use cli_master_core::{AgentId, Project, ProjectId, SessionId, WorktreeId}; use cli_master_git::Git; use cli_master_storage::{Storage, StoredAgent}; @@ -15,7 +17,7 @@ use crate::{ /// Orchestrates recoverable worktree-backed session creation and removal. pub struct SessionWorktreeSaga { pub(crate) git: Git, - storage: Mutex, + storage: Arc>, pub(crate) spawner: S, pub(crate) daemon_instance_id: String, pub(crate) destinations: DestinationLocks, @@ -34,6 +36,25 @@ impl SessionWorktreeSaga { storage: Storage, spawner: S, daemon_instance_id: impl Into, + ) -> Result { + Self::new_with_shared_storage( + git, + Arc::new(Mutex::new(storage)), + spawner, + daemon_instance_id, + ) + } + + /// Builds a saga sharing the daemon's single metadata owner. + /// + /// # Errors + /// + /// Returns an error when `daemon_instance_id` is blank. + pub fn new_with_shared_storage( + git: Git, + storage: Arc>, + spawner: S, + daemon_instance_id: impl Into, ) -> Result { let daemon_instance_id = daemon_instance_id.into(); if daemon_instance_id.trim().is_empty() { @@ -45,7 +66,7 @@ impl SessionWorktreeSaga { } Ok(Self { git, - storage: Mutex::new(storage), + storage, spawner, daemon_instance_id, destinations: DestinationLocks::default(), @@ -73,23 +94,44 @@ impl SessionWorktreeSaga { request: &CreateSession, faults: &CreateFaults, ) -> Result { - if request.name.trim().is_empty() { - return Err(SagaError::new( - SagaErrorKind::InvalidInput, - "session name must not be blank", - "Provide a user-facing session name", - )); - } - if request.isolation == SessionIsolation::NewWorktree && !request.managed_root.is_absolute() - { - return Err(SagaError::new( - SagaErrorKind::InvalidInput, - "managed worktree root must be an absolute path", - "Pass the daemon-owned managed worktree root", - ) - .with_path(&request.managed_root)); - } - crate::create::create(self, request, faults) + validate_create(request)?; + crate::create::create(self, request, faults, crate::create::Launch::Start) + } + + /// Prepares durable session metadata and optional Git isolation without launching a process. + /// + /// The returned session has `unknown` status and no runtime owner until the + /// daemon starts it through `SessionManager`. + /// + /// # Errors + /// + /// Returns an error when directory validation, persistence, or Git preparation fails. + pub fn prepare_session( + &self, + request: &CreateSession, + relative_directory: Option<&RelativeDirectory>, + ) -> Result { + self.prepare_session_injected(request, relative_directory, &CreateFaults::default()) + } + + /// Prepares session metadata with deterministic fault hooks for compensation tests. + /// + /// # Errors + /// + /// Returns the same failures as [`Self::prepare_session`] plus injected faults. + pub fn prepare_session_injected( + &self, + request: &CreateSession, + relative_directory: Option<&RelativeDirectory>, + faults: &CreateFaults, + ) -> Result { + validate_create(request)?; + crate::create::create( + self, + request, + faults, + crate::create::Launch::Prepare(relative_directory), + ) } /// Inspects whether a managed worktree can be removed and issues a token when safe. @@ -104,6 +146,18 @@ impl SessionWorktreeSaga { crate::remove::prepare_remove(self, worktree_id) } + /// Invalidates removal confirmation before a daemon-owned session starts again. + /// + /// The daemon serializes this operation with session launch and removal. + /// + /// # Errors + /// + /// Returns an error when the worktree is missing, a mutation is already in + /// progress, or the pending state cannot be restored. + pub fn cancel_pending_removal(&self, worktree_id: WorktreeId) -> Result<(), SagaError> { + crate::remove::cancel_pending_removal(self, worktree_id) + } + /// Removes a clean unused worktree using a matching confirmation token. /// /// # Errors @@ -195,3 +249,22 @@ pub(crate) fn require_agent( } Ok(agent) } + +fn validate_create(request: &CreateSession) -> Result<(), SagaError> { + if request.name.trim().is_empty() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session name must not be blank", + "Provide a user-facing session name", + )); + } + if request.isolation == SessionIsolation::NewWorktree && !request.managed_root.is_absolute() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "managed worktree root must be an absolute path", + "Pass the daemon-owned managed worktree root", + ) + .with_path(&request.managed_root)); + } + Ok(()) +} diff --git a/crates/session/tests/prepare_saga.rs b/crates/session/tests/prepare_saga.rs new file mode 100644 index 0000000..0bf8522 --- /dev/null +++ b/crates/session/tests/prepare_saga.rs @@ -0,0 +1,328 @@ +mod support; + +use std::fs; +use std::os::unix::fs::symlink; +use std::sync::{Arc, Mutex}; + +use cli_master_core::{ + SessionStatus, + wire::{RelativeDirectory, SessionIsolation}, +}; +use cli_master_git::Git; +use cli_master_session::{ + CreateFaults, CreateStep, FakeSpawner, SagaErrorKind, SessionWorktreeSaga, +}; +use cli_master_storage::{Storage, WorktreeState}; +use support::{Fixture, git}; + +#[test] +fn prepared_worktree_has_no_runtime_and_survives_recovery() { + let fixture = Fixture::new(); + let shared = Arc::new(Mutex::new( + Storage::open_migrated(&fixture.database).unwrap(), + )); + let saga = SessionWorktreeSaga::new_with_shared_storage( + Git::discover().unwrap(), + Arc::clone(&shared), + FakeSpawner::failing(), + support::DAEMON_ID, + ) + .unwrap(); + let prepared = saga + .prepare_session(&fixture.request("Prepare", None), None) + .unwrap(); + let worktree = prepared.worktree.unwrap(); + assert_eq!(prepared.session.status, SessionStatus::Unknown); + assert!(prepared.session.pid.is_none()); + assert!(prepared.session.pty_id.is_none()); + assert!(worktree.path.is_dir()); + assert_eq!(worktree.session_id, Some(prepared.session.id)); + assert_eq!(worktree.state, cli_master_core::WorktreeState::Active); + let storage = shared.lock().unwrap(); + let stored = storage.get_session(prepared.session.id).unwrap().unwrap(); + assert!(stored.daemon_instance_id.is_none()); + assert!(stored.last_activity_at_ms.is_none()); + assert_eq!( + storage + .recover_stale_sessions_for_daemon("next-daemon", stored.updated_at_ms + 1) + .unwrap(), + 0 + ); + drop(storage); + assert_eq!(saga.recover().unwrap(), Default::default()); + saga.delete_session(prepared.session.id).unwrap(); + assert!( + worktree.path.is_dir(), + "deleting metadata must preserve the worktree" + ); + assert!( + shared + .lock() + .unwrap() + .get_worktree(worktree.id) + .unwrap() + .unwrap() + .session_id + .is_none() + ); +} + +#[test] +fn relative_directory_is_resolved_in_the_new_checkout() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.repository.join("apps/api")).unwrap(); + fs::write(fixture.repository.join("apps/api/README.md"), "api\n").unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "api directory"]); + let saga = fixture.saga(FakeSpawner::failing()); + let relative = RelativeDirectory::try_new("apps/api").unwrap(); + let prepared = saga + .prepare_session(&fixture.request("API", None), Some(&relative)) + .unwrap(); + let root = prepared.worktree.unwrap().path; + assert_eq!( + prepared.session.cwd, + root.join("apps/api").canonicalize().unwrap() + ); + assert!( + !prepared + .session + .cwd + .starts_with(fixture.repository.canonicalize().unwrap()) + ); +} + +#[test] +fn project_subdirectory_is_preserved_for_current_and_worktree_sessions() { + for isolation in [SessionIsolation::Current, SessionIsolation::NewWorktree] { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.repository.join("apps/api")).unwrap(); + fs::write(fixture.repository.join("apps/api/README.md"), "api\n").unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "api directory"]); + let storage = Storage::open(&fixture.database).unwrap(); + let mut project = storage.get_project(fixture.project_id).unwrap().unwrap(); + storage.remove_project_metadata(project.id).unwrap(); + project.path = fixture.repository.join("apps"); + project.repository_root = Some(fixture.repository.canonicalize().unwrap()); + storage.insert_project(&project).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("API", None); + request.isolation = isolation; + let prepared = saga + .prepare_session(&request, Some(&RelativeDirectory::try_new("api").unwrap())) + .unwrap(); + let root = prepared + .worktree + .map_or(fixture.repository, |worktree| worktree.path); + assert_eq!( + prepared.session.cwd, + root.join("apps/api").canonicalize().unwrap() + ); + } +} + +#[test] +fn cancelled_removal_invalidates_the_old_token_and_restores_active_metadata() { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let prepared = saga + .prepare_session(&fixture.request("Cancel removal", None), None) + .unwrap(); + let worktree = prepared.worktree.unwrap(); + let cli_master_core::wire::WorktreePrepareRemoveResponse::Ready { + confirmation_token, .. + } = saga.prepare_remove(worktree.id).unwrap() + else { + panic!("prepared clean worktree should be removable"); + }; + saga.cancel_pending_removal(worktree.id).unwrap(); + let stored = Storage::open(&fixture.database) + .unwrap() + .get_worktree(worktree.id) + .unwrap() + .unwrap(); + assert_eq!(stored.state, WorktreeState::Active); + assert_eq!(stored.session_id, Some(prepared.session.id)); + assert_eq!( + saga.remove_worktree(worktree.id, &confirmation_token) + .unwrap_err() + .kind(), + SagaErrorKind::InvalidToken + ); + assert!(worktree.path.is_dir()); + let cli_master_core::wire::WorktreePrepareRemoveResponse::Ready { + confirmation_token: new_token, + .. + } = saga.prepare_remove(worktree.id).unwrap() + else { + panic!("a fresh confirmation should still be available"); + }; + saga.remove_worktree(worktree.id, &new_token).unwrap(); + assert!(!worktree.path.exists()); +} + +#[test] +fn current_directory_can_be_prepared_without_a_git_repository() { + let fixture = Fixture::new(); + fs::remove_dir_all(fixture.repository.join(".git")).unwrap(); + fs::create_dir(fixture.repository.join("child")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("Current", None); + request.isolation = SessionIsolation::Current; + let prepared = saga + .prepare_session( + &request, + Some(&RelativeDirectory::try_new("child").unwrap()), + ) + .unwrap(); + assert_eq!( + prepared.session.cwd, + fixture.repository.join("child").canonicalize().unwrap() + ); + assert!(prepared.worktree.is_none()); + assert!(!fixture.managed.exists()); +} + +#[test] +fn current_directory_rejects_symlink_escape_without_metadata() { + let fixture = Fixture::new(); + symlink(fixture.temp.path(), fixture.repository.join("outside")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("Escape", None); + request.isolation = SessionIsolation::Current; + let error = saga + .prepare_session( + &request, + Some(&RelativeDirectory::try_new("outside").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + assert!( + Storage::open(&fixture.database) + .unwrap() + .list_sessions() + .unwrap() + .is_empty() + ); +} + +#[test] +fn worktree_symlink_escape_is_compensated_without_deleting_the_target() { + let fixture = Fixture::new(); + let outside = fixture.temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep.txt"), "keep\n").unwrap(); + symlink(&outside, fixture.repository.join("outside")).unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "linked directory"]); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session( + &fixture.request("Escape", None), + Some(&RelativeDirectory::try_new("outside").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + assert_eq!( + fs::read_to_string(outside.join("keep.txt")).unwrap(), + "keep\n" + ); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + assert!(storage.list_worktrees().unwrap().is_empty()); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn missing_directory_in_checkout_rolls_back_git_and_metadata() { + let fixture = Fixture::new(); + fs::create_dir(fixture.repository.join("untracked-directory")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session( + &fixture.request("Missing", None), + Some(&RelativeDirectory::try_new("untracked-directory").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + assert!(storage.list_worktrees().unwrap().is_empty()); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn metadata_only_faults_compensate_each_durable_effect() { + for step in [ + CreateStep::Plan, + CreateStep::PersistCreating, + CreateStep::GitAdd, + CreateStep::PersistActive, + ] { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session_injected( + &fixture.request("Fault", None), + None, + &CreateFaults { + fail_after: Some(step), + ..CreateFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InjectedFailure); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty(), "{step:?}"); + assert!(storage.list_worktrees().unwrap().is_empty(), "{step:?}"); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn compensation_preserves_dirty_user_data_after_metadata_preparation() { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session_injected( + &fixture.request("Dirty", None), + None, + &CreateFaults { + fail_after: Some(CreateStep::PersistActive), + after_git_add: Some(Arc::new(|plan| { + fs::write(plan.destination().join("keep.txt"), "keep\n").unwrap(); + })), + ..CreateFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::PartialWorktree); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + let worktrees = storage.list_worktrees().unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].state, WorktreeState::Orphaned); + assert!(worktrees[0].path.join("keep.txt").is_file()); +} diff --git a/crates/storage/src/sessions.rs b/crates/storage/src/sessions.rs index ad3633d..0a3c1f2 100644 --- a/crates/storage/src/sessions.rs +++ b/crates/storage/src/sessions.rs @@ -25,36 +25,8 @@ impl Storage { /// Returns an error for invalid metadata, missing project/agent references, /// duplicate IDs, or database failures. pub fn insert_session(&self, session: &StoredSession) -> Result<(), StorageError> { - session.validate()?; - let cwd = path_to_sql_value(&session.cwd, "session cwd")?; self.with_connection("insert session", |connection| { - connection - .execute( - "INSERT INTO sessions ( - id, project_id, agent_id, name, cwd, status, runtime_pid, - daemon_instance_id, exit_code, error_code, created_at, - updated_at, last_activity_at - ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 - )", - params![ - session.id.to_string(), - session.project_id.to_string(), - session.agent_id.to_string(), - session.name, - cwd, - session_status_to_database(session.status), - session.runtime_pid.map(i64::from), - session.daemon_instance_id, - session.exit_code, - session.error_code, - session.created_at_ms, - session.updated_at_ms, - session.last_activity_at_ms, - ], - ) - .map_err(|error| map_write_error(error, "session"))?; - Ok(()) + insert_session_on_connection(connection, session) }) } @@ -269,3 +241,38 @@ fn require_changed(changed: usize, id: SessionId) -> Result<(), StorageError> { Ok(()) } } + +pub(crate) fn insert_session_on_connection( + connection: &Connection, + session: &StoredSession, +) -> Result<(), StorageError> { + session.validate()?; + let cwd = path_to_sql_value(&session.cwd, "session cwd")?; + connection + .execute( + "INSERT INTO sessions ( + id, project_id, agent_id, name, cwd, status, runtime_pid, + daemon_instance_id, exit_code, error_code, created_at, + updated_at, last_activity_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 + )", + params![ + session.id.to_string(), + session.project_id.to_string(), + session.agent_id.to_string(), + session.name, + cwd, + session_status_to_database(session.status), + session.runtime_pid.map(i64::from), + session.daemon_instance_id, + session.exit_code, + session.error_code, + session.created_at_ms, + session.updated_at_ms, + session.last_activity_at_ms, + ], + ) + .map_err(|error| map_write_error(error, "session"))?; + Ok(()) +} diff --git a/crates/storage/src/worktrees.rs b/crates/storage/src/worktrees.rs index 913299f..bcd1fa7 100644 --- a/crates/storage/src/worktrees.rs +++ b/crates/storage/src/worktrees.rs @@ -7,7 +7,7 @@ use rusqlite::{Row, params}; use crate::Storage; use crate::error::{StorageError, corrupt_data, map_write_error, persisted_validation}; -use crate::models::{StoredWorktree, WorktreeState, validate_timestamp}; +use crate::models::{StoredSession, StoredWorktree, WorktreeState, validate_timestamp}; use crate::paths::{path_from_sql_value, path_to_sql_value}; use crate::values::timestamp_from_sql_value; @@ -15,6 +15,49 @@ const WORKTREE_COLUMNS: &str = "id, project_id, session_id, path, branch, state, is_dirty, created_at, updated_at"; impl Storage { + /// Inserts a session and activates its previously prepared worktree atomically. + /// + /// Git has already created the working copy. A failed association must leave + /// no unassociated session row, while the `creating` worktree remains available + /// for compensation or crash recovery. + /// + /// # Errors + /// + /// Returns an error for invalid session metadata, missing or non-creating + /// worktrees, cross-project associations, or database failures. + pub fn insert_prepared_session_with_worktree( + &self, + session: &StoredSession, + worktree_id: WorktreeId, + updated_at_ms: i64, + ) -> Result<(), StorageError> { + validate_timestamp("worktree updated_at_ms", updated_at_ms)?; + self.transaction(|transaction| { + crate::sessions::insert_session_on_connection(transaction, session)?; + let changed = transaction + .execute( + "UPDATE worktrees SET state = 'active', session_id = ?1, updated_at = ?2 + WHERE id = ?3 AND project_id = ?4 AND state = 'creating' AND session_id IS NULL", + params![ + session.id.to_string(), + updated_at_ms, + worktree_id.to_string(), + session.project_id.to_string(), + ], + ) + .map_err(|error| map_write_error(error, "worktree"))?; + if changed == 0 { + return Err(StorageError::InvalidInput { + field: "worktree association", + reason: + "worktree must be unassociated, creating, and belong to the session project" + .to_owned(), + }); + } + Ok(()) + }) + } + /// Inserts worktree metadata without running Git or creating directories. /// /// # Errors diff --git a/crates/storage/tests/agents_worktrees.rs b/crates/storage/tests/agents_worktrees.rs index 575ed6c..9a9e501 100644 --- a/crates/storage/tests/agents_worktrees.rs +++ b/crates/storage/tests/agents_worktrees.rs @@ -336,6 +336,84 @@ struct WorktreeScenario { worktree: StoredWorktree, } +#[test] +fn prepared_session_and_worktree_commit_together_or_roll_back() { + let scenario = seeded_worktree_scenario(); + let candidate = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + let mut prepared = worktree( + scenario.first_project_id, + None, + std::path::Path::new("/tmp/prepared-worktree"), + "agent/prepared", + ); + scenario.storage.insert_worktree(&prepared).unwrap(); + scenario + .storage + .insert_prepared_session_with_worktree(&candidate, prepared.id, CREATED_AT_MS + 1) + .unwrap(); + assert_eq!( + scenario.storage.get_session(candidate.id).unwrap(), + Some(candidate.clone()) + ); + prepared.state = WorktreeState::Active; + prepared.session_id = Some(candidate.id); + prepared.updated_at_ms = CREATED_AT_MS + 1; + assert_eq!( + scenario.storage.get_worktree(prepared.id).unwrap(), + Some(prepared.clone()) + ); + + let replacement = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + assert!( + scenario + .storage + .insert_prepared_session_with_worktree(&replacement, prepared.id, CREATED_AT_MS + 2) + .is_err() + ); + assert!( + scenario + .storage + .get_session(replacement.id) + .unwrap() + .is_none() + ); + assert_eq!( + scenario.storage.get_worktree(prepared.id).unwrap(), + Some(prepared) + ); + + let invalid = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + assert!( + scenario + .storage + .insert_prepared_session_with_worktree( + &invalid, + cli_master_core::WorktreeId::new(), + CREATED_AT_MS + 2 + ) + .is_err() + ); + assert!(scenario.storage.get_session(invalid.id).unwrap().is_none()); +} + fn seeded_worktree_scenario() -> WorktreeScenario { let storage = Storage::open_in_memory().expect("database should open"); storage.migrate().expect("database should migrate"); From 7263d10c3a39d6a2c25a133ba75434e2460313e8 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:49:11 -0300 Subject: [PATCH 19/32] feat(daemon): expose isolated session and safe worktree lifecycle over IPC --- apps/desktop/src/ipc/domain.ts | 12 + apps/desktop/src/ipc/methods.ts | 34 + crates/core/src/protocol.rs | 19 + crates/core/src/wire/method.rs | 3 + crates/core/src/wire/mod.rs | 4 +- crates/core/src/wire/request.rs | 9 + crates/core/src/wire/response.rs | 8 + crates/daemon/src/server.rs | 94 ++- crates/daemon/src/sessions.rs | 155 ++++- crates/daemon/src/sessions/tests.rs | 9 +- crates/daemon/src/sessions/worktrees.rs | 267 ++++++++ crates/daemon/tests/worktree_ipc.rs | 831 ++++++++++++++++++++++++ protocol/catalog.json | 1 + 13 files changed, 1396 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/src/ipc/domain.ts create mode 100644 apps/desktop/src/ipc/methods.ts create mode 100644 crates/daemon/src/sessions/worktrees.rs create mode 100644 crates/daemon/tests/worktree_ipc.rs diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts new file mode 100644 index 0000000..7cb4aa6 --- /dev/null +++ b/apps/desktop/src/ipc/domain.ts @@ -0,0 +1,12 @@ +/** Additive daemon contracts. Existing UI DTOs remain owned by types.ts. */ +export type * from "./types"; +import type { Worktree } from "./types"; + +/** List managed worktrees; omission includes every registered project. */ +export interface WorktreeListRequest { + readonly projectId?: string; +} + +export interface WorktreeListResponse { + readonly worktrees: readonly Worktree[]; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts new file mode 100644 index 0000000..7b00a06 --- /dev/null +++ b/apps/desktop/src/ipc/methods.ts @@ -0,0 +1,34 @@ +/** Mirror of cli_master_core::wire::method; protocol/catalog.json is checked in tests. */ +export const IPC_METHODS = [ + "system.hello", + "state.snapshot", + "project.add", + "project.list", + "project.rename", + "project.remove", + "agent.list", + "agent.detect", + "agent.set_enabled", + "agent.custom.create", + "agent.custom.update", + "agent.custom.remove", + "session.create", + "session.list", + "session.rename", + "session.start", + "session.restart", + "session.stop", + "session.delete", + "session.write", + "session.resize", + "session.subscribe", + "session.unsubscribe", + "git.status", + "git.diff", + "worktree.list", + "worktree.prepare_remove", + "worktree.remove", + "diagnostics.get" +] as const; + +export type IpcMethod = (typeof IPC_METHODS)[number]; diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 254ee50..49b3cd4 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -278,6 +278,25 @@ mod tests { assert_eq!(catalog["protocolVersion"], PROTOCOL_V1); assert_eq!(catalog["applicationVersion"], APPLICATION_VERSION); assert_eq!(APPLICATION_VERSION, env!("CARGO_PKG_VERSION")); + assert_eq!( + catalog["methods"], + serde_json::json!(crate::wire::method::ALL) + ); + assert_eq!( + catalog["events"], + serde_json::json!(crate::wire::event_name::ALL) + ); + } + + #[test] + fn typescript_method_mirror_matches_the_rust_catalog() { + let mirror = include_str!("../../../apps/desktop/src/ipc/methods.ts"); + let (_, array) = mirror.split_once("= ").expect("method array assignment"); + let (array, _) = array + .split_once(" as const;") + .expect("readonly method array"); + let methods: Vec = serde_json::from_str(array).expect("JSON method literals"); + assert_eq!(methods, crate::wire::method::ALL); } #[test] diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index d4633ea..96bffd5 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -55,6 +55,8 @@ pub const GIT_STATUS: &str = "git.status"; /// Read a bounded textual diff for a registered project, session, or worktree. pub const GIT_DIFF: &str = "git.diff"; +/// List managed worktrees, optionally constrained to a project. +pub const WORKTREE_LIST: &str = "worktree.list"; /// Inspect whether a managed worktree can be safely removed. pub const WORKTREE_PREPARE_REMOVE: &str = "worktree.prepare_remove"; /// Remove a managed worktree after token-bound state confirmation. @@ -90,6 +92,7 @@ pub const ALL: &[&str] = &[ SESSION_UNSUBSCRIBE, GIT_STATUS, GIT_DIFF, + WORKTREE_LIST, WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, DIAGNOSTICS_GET, diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index cc73091..71737a2 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -26,7 +26,7 @@ pub use request::{ GitTarget, ProjectAddRequest, ProjectRemoveRequest, ProjectRenameRequest, SessionCreateRequest, SessionDeleteRequest, SessionIsolation, SessionListRequest, SessionRenameRequest, SessionResizeRequest, SessionRestartRequest, SessionStartRequest, SessionStopRequest, - SessionSubscribeRequest, SessionUnsubscribeRequest, SessionWriteRequest, + SessionSubscribeRequest, SessionUnsubscribeRequest, SessionWriteRequest, WorktreeListRequest, WorktreePrepareRemoveRequest, WorktreeRemoveRequest, validate_agent_command, }; pub use response::{ @@ -37,7 +37,7 @@ pub use response::{ ProjectListResponse, ProjectRemoveResponse, ProjectRenameResponse, SessionCreateResponse, SessionDeleteResponse, SessionListResponse, SessionRenameResponse, SessionResizeResponse, SessionRestartResponse, SessionStartResponse, SessionStopResponse, SessionSubscribeResponse, - SessionUnsubscribeResponse, SessionWriteResponse, StateSnapshotResponse, + SessionUnsubscribeResponse, SessionWriteResponse, StateSnapshotResponse, WorktreeListResponse, WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, WorktreeRemoveResponse, }; pub use value::{ diff --git a/crates/core/src/wire/request.rs b/crates/core/src/wire/request.rs index 34632b1..2fbff93 100644 --- a/crates/core/src/wire/request.rs +++ b/crates/core/src/wire/request.rs @@ -464,6 +464,15 @@ pub struct GitDiffRequest { pub path: Option, } +/// Request to list managed worktrees, optionally for one project. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WorktreeListRequest { + /// Project filter; omitted to return all managed worktrees. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, +} + /// Request to prepare safe managed-worktree removal. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] diff --git a/crates/core/src/wire/response.rs b/crates/core/src/wire/response.rs index 20afb29..1a059a9 100644 --- a/crates/core/src/wire/response.rs +++ b/crates/core/src/wire/response.rs @@ -235,6 +235,14 @@ pub struct GitDiffResponse { pub binary: bool, } +/// Durable managed worktrees visible to the local client. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeListResponse { + /// Managed worktrees matching the requested project filter. + pub worktrees: Vec, +} + /// Safe reason a worktree cannot currently be removed. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 43ce49d..855d60a 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -17,7 +17,8 @@ use cli_master_core::{ SessionOutputEvent, SessionOutputGapEvent, SessionRenameRequest, SessionReplayCompleteEvent, SessionResizeRequest, SessionRestartRequest, SessionStartRequest, SessionStatusChangedEvent, SessionStopRequest, - SessionSubscribeRequest, SessionWriteRequest, event_name, method, + SessionSubscribeRequest, SessionWriteRequest, WorktreeListRequest, + WorktreePrepareRemoveRequest, WorktreeRemoveRequest, event_name, method, }, }; use cli_master_git::Git; @@ -59,13 +60,13 @@ pub struct HelloResponse { pub struct StateSnapshot { /// Applied `SQLite` schema migration version. pub schema_version: u32, - /// Registered projects. Empty until project persistence is wired in. + /// Registered projects. pub projects: Vec, - /// Available agent definitions. Empty until registry persistence is wired in. + /// Available agent definitions. pub agents: Vec, - /// Known sessions. Empty until the session manager is wired in. + /// Known sessions, including managed worktree associations. pub sessions: Vec, - /// Managed worktrees. Empty until Git orchestration is wired in. + /// Durable managed worktrees, including recoverable partial operations. pub worktrees: Vec, } @@ -146,6 +147,8 @@ impl Daemon { sessions: SessionRegistry::new( session_storage, DaemonInstanceId::from_uuid(instance_id), + config.data_directory().join("worktrees"), + git.clone(), ) .map_err(|error| { DaemonError::initialization( @@ -525,7 +528,10 @@ fn validate_peer(_stream: &UnixStream) -> Result<(), io::Error> { clippy::too_many_lines, reason = "the versioned IPC method table is intentionally kept in one auditable dispatcher" )] -async fn dispatch(request: RequestEnvelope, state: &ServerState) -> ResponseEnvelope { +async fn dispatch( + request: RequestEnvelope, + state: &Arc, +) -> ResponseEnvelope { if request.kind != EnvelopeKind::Request { return ResponseEnvelope::failure( request.request_id, @@ -560,7 +566,7 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo projects, agents, sessions, - worktrees: Vec::new(), + worktrees: state.sessions.worktrees()?, }) }), method::PROJECT_ADD => decode_payload(request.payload) @@ -582,27 +588,34 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo state.sessions.create_custom_agent(payload) }) .and_then(encode_response), - method::SESSION_CREATE => decode_payload(request.payload) - .and_then(|payload: SessionCreateRequest| state.sessions.create(payload)) - .and_then(encode_response), + method::SESSION_CREATE => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.create(payload)).await, + Err(error) => Err(error), + }, method::SESSION_LIST => decode_payload(request.payload) .and_then(|payload: SessionListRequest| state.sessions.list_sessions(payload)) .and_then(encode_response), method::SESSION_RENAME => decode_payload(request.payload) .and_then(|payload: SessionRenameRequest| state.sessions.rename(&payload)) .and_then(encode_response), - method::SESSION_START => decode_payload(request.payload) - .and_then(|payload: SessionStartRequest| state.sessions.start(payload)) - .and_then(encode_response), - method::SESSION_RESTART => decode_payload(request.payload) - .and_then(|payload: SessionRestartRequest| state.sessions.restart(payload)) - .and_then(encode_response), - method::SESSION_STOP => decode_payload(request.payload) - .and_then(|payload: SessionStopRequest| state.sessions.stop(payload)) - .and_then(encode_response), - method::SESSION_DELETE => decode_payload(request.payload) - .and_then(|payload: SessionDeleteRequest| state.sessions.delete(payload)) - .and_then(encode_response), + method::SESSION_START => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.start(payload)).await, + Err(error) => Err(error), + }, + method::SESSION_RESTART => match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| sessions.restart(payload)).await + } + Err(error) => Err(error), + }, + method::SESSION_STOP => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.stop(payload)).await, + Err(error) => Err(error), + }, + method::SESSION_DELETE => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.delete(payload)).await, + Err(error) => Err(error), + }, method::SESSION_WRITE => decode_payload(request.payload) .and_then(|payload: SessionWriteRequest| state.sessions.write(&payload)) .and_then(encode_response), @@ -610,6 +623,26 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo .and_then(|payload: SessionResizeRequest| state.sessions.resize(payload)) .and_then(encode_response), method::SESSION_UNSUBSCRIBE => encode_response(EmptyResponse::default()), + method::WORKTREE_LIST => decode_payload(request.payload) + .and_then(|payload: WorktreeListRequest| state.sessions.list_worktrees(payload)) + .and_then(encode_response), + method::WORKTREE_PREPARE_REMOVE => { + match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| { + sessions.prepare_worktree_removal(payload) + }) + .await + } + Err(error) => Err(error), + } + } + method::WORKTREE_REMOVE => match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| sessions.remove_worktree(&payload)).await + } + Err(error) => Err(error), + }, method::DIAGNOSTICS_GET => encode_response(&state.diagnostics), method::GIT_STATUS => match decode_payload::(request.payload) { Ok(payload) => { @@ -642,6 +675,23 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo } } +/// Git and PTY lifecycle calls may block; keep client I/O and output delivery responsive. +async fn session_operation(state: &Arc, operation: F) -> Result +where + T: Serialize, + F: FnOnce(&SessionRegistry) -> Result + Send + 'static, +{ + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || operation(&state.sessions).and_then(encode_response)) + .await + .map_err(|_| { + ApiError::new( + "session_operation_failed", + "The session operation could not complete.", + ) + })? +} + fn decode_payload(payload: Value) -> Result where T: for<'de> Deserialize<'de>, diff --git a/crates/daemon/src/sessions.rs b/crates/daemon/src/sessions.rs index 5643b9d..7813266 100644 --- a/crates/daemon/src/sessions.rs +++ b/crates/daemon/src/sessions.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::env; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::{SystemTime, UNIX_EPOCH}; use cli_master_agents::{AgentAdapter, DetectionResult, GeminiCliAdapter, LaunchEnvironment}; @@ -16,12 +16,16 @@ use cli_master_core::{ AgentId, AgentSource, ApiError, DaemonInstanceId, Project, Session, SessionId, SessionStatus, builtin_agent_ids, }; +use cli_master_git::Git; use cli_master_session::{ - SessionError, SessionManager, SessionSnapshot, SessionSubscription, TerminalSize, + SessionError, SessionManager, SessionSnapshot, SessionSubscription, SessionWorktreeSaga, + TerminalSize, }; use cli_master_storage::{SessionRuntimeUpdate, Storage, StorageError, StoredAgent, StoredSession}; use uuid::Uuid; +mod worktrees; + const INITIAL_COLUMNS: u16 = 100; const INITIAL_ROWS: u16 = 30; const SHELL_AGENT_ID: u128 = 0x018f_0000_0000_7000_8000_0000_0000_0001; @@ -34,17 +38,44 @@ pub(super) struct SessionRegistry { storage: Arc>, manager: SessionManager, daemon_instance_id: DaemonInstanceId, + worktree_saga: Option>, + git: Option, + managed_root: PathBuf, + /// Serializes start/delete/removal so Git cannot remove a directory during spawn. + lifecycle: Mutex<()>, + session_operations: Mutex>>>, } impl SessionRegistry { pub(super) fn new( storage: Storage, daemon_instance_id: DaemonInstanceId, + managed_root: PathBuf, + git: Option, ) -> Result { + let storage = Arc::new(Mutex::new(storage)); + let manager = SessionManager::default(); + let worktree_saga = git + .clone() + .map(|git| { + SessionWorktreeSaga::new_with_shared_storage( + git, + Arc::clone(&storage), + manager.clone(), + daemon_instance_id.to_string(), + ) + }) + .transpose() + .map_err(worktrees::saga_error)?; let registry = Self { - storage: Arc::new(Mutex::new(storage)), - manager: SessionManager::default(), + storage, + manager, daemon_instance_id, + worktree_saga, + git, + managed_root, + lifecycle: Mutex::new(()), + session_operations: Mutex::new(BTreeMap::new()), }; registry.seed_builtin_agents()?; let now = unix_timestamp_ms()?; @@ -52,6 +83,9 @@ impl SessionRegistry { .storage()? .recover_stale_sessions_for_daemon(&daemon_instance_id.to_string(), now) .map_err(storage_error)?; + if let Some(saga) = ®istry.worktree_saga { + saga.recover().map_err(worktrees::saga_error)?; + } Ok(registry) } @@ -131,10 +165,8 @@ impl SessionRegistry { } pub(super) fn sessions(&self) -> Result, ApiError> { - self.storage()? - .list_sessions() - .map_err(storage_error) - .map(|sessions| sessions.into_iter().map(stored_session).collect()) + self.list_sessions(SessionListRequest { project_id: None }) + .map(|response| response.sessions) } pub(super) fn list_sessions( @@ -148,18 +180,18 @@ impl SessionRegistry { } .map_err(storage_error)? .into_iter() - .map(stored_session) - .collect(); + .map(|session| worktrees::session_with_worktree(&storage, session)) + .collect::, _>>()?; Ok(SessionListResponse { sessions }) } pub(super) fn create(&self, request: SessionCreateRequest) -> Result { - if request.isolation != SessionIsolation::Current { - return Err(ApiError::new( - "worktree_sessions_unavailable", - "Isolated worktree sessions are not available in this canvas yet.", - ) - .with_action("Choose the current project directory and try again.")); + let _lifecycle = self.lifecycle()?; + if let Some(saga) = &self.worktree_saga { + return self.prepare_with_saga(saga, request); + } + if request.isolation == SessionIsolation::NewWorktree { + return Err(worktrees::git_unavailable()); } let storage = self.storage()?; let project = storage @@ -208,10 +240,16 @@ impl SessionRegistry { } pub(super) fn start(&self, request: SessionStartRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; self.start_id(request.session_id) } pub(super) fn restart(&self, request: SessionRestartRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; if let Ok(snapshot) = self.manager.snapshot(request.session_id) { if is_live(snapshot.status) { self.manager @@ -226,6 +264,9 @@ impl SessionRegistry { } pub(super) fn stop(&self, request: SessionStopRequest) -> Result { + // Stopping only reduces worktree use. Do not queue it behind a slow checkout. + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; let snapshot = self .manager .stop(request.session_id) @@ -235,6 +276,9 @@ impl SessionRegistry { } pub(super) fn delete(&self, request: SessionDeleteRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; if let Ok(snapshot) = self.manager.snapshot(request.session_id) { if is_live(snapshot.status) { return Err(ApiError::new( @@ -243,10 +287,17 @@ impl SessionRegistry { ) .with_action("Stop the session, then try deleting it again.")); } + // A process may have exited while no client was subscribed to its events. + self.persist_snapshot(&snapshot)?; self.manager .remove(request.session_id) .map_err(session_error)?; } + if let Some(saga) = &self.worktree_saga { + saga.delete_session(request.session_id) + .map_err(worktrees::saga_error)?; + return Ok(EmptyResponse::default()); + } self.storage()? .remove_session_metadata(request.session_id) .map_err(storage_error)?; @@ -327,7 +378,7 @@ impl SessionRegistry { .ok_or_else(|| not_found("session", session_id))?; if let Ok(snapshot) = self.manager.snapshot(session_id) { if is_live(snapshot.status) { - return Ok(stored_session(stored)); + return worktrees::session_with_worktree(&storage, stored); } self.manager.remove(session_id).map_err(session_error)?; } @@ -335,8 +386,19 @@ impl SessionRegistry { .get_agent(stored.agent_id) .map_err(storage_error)? .ok_or_else(|| not_found("agent", stored.agent_id))?; + if !agent.enabled { + return Err(ApiError::new( + "agent_disabled", + "The selected terminal command is disabled.", + )); + } let command = agent.command_for_cwd(&stored.cwd).map_err(storage_error)?; drop(storage); + let worktree_id = self.validate_start_directory(&stored)?; + if let (Some(saga), Some(worktree_id)) = (&self.worktree_saga, worktree_id) { + saga.cancel_pending_removal(worktree_id) + .map_err(worktrees::saga_error)?; + } let size = TerminalSize::new(INITIAL_ROWS, INITIAL_COLUMNS).map_err(session_error)?; let handle = self .manager @@ -344,7 +406,11 @@ impl SessionRegistry { .map_err(session_error)?; let snapshot = self.manager.snapshot(session_id).map_err(session_error)?; debug_assert_eq!(handle.id, session_id); - self.persist_snapshot(&snapshot)?; + if let Err(error) = self.persist_snapshot(&snapshot) { + cli_master_session::SessionSpawner::rollback(&self.manager, session_id) + .map_err(worktrees::saga_error)?; + return Err(error); + } self.get(session_id) } @@ -401,6 +467,34 @@ impl SessionRegistry { .with_action("Restart Jig and try again.") }) } + + fn lifecycle(&self) -> Result, ApiError> { + self.lifecycle + .lock() + .map_err(|_| session_state_unavailable()) + } + + fn session_operation(&self, session_id: SessionId) -> Result>, ApiError> { + let mut operations = self + .session_operations + .lock() + .map_err(|_| session_state_unavailable())?; + operations.retain(|_, operation| operation.strong_count() > 0); + if let Some(operation) = operations.get(&session_id).and_then(Weak::upgrade) { + return Ok(operation); + } + let operation = Arc::new(Mutex::new(())); + operations.insert(session_id, Arc::downgrade(&operation)); + Ok(operation) + } +} + +fn session_state_unavailable() -> ApiError { + ApiError::new( + "session_state_unavailable", + "Session operations are unavailable.", + ) + .with_action("Restart the daemon and try again.") } fn agent_record(agent: StoredAgent) -> Result { @@ -445,18 +539,18 @@ fn stored_session(session: StoredSession) -> Session { } fn load_session(storage: &Storage, session_id: SessionId) -> Result { - storage + let session = storage .get_session(session_id) .map_err(storage_error)? - .map(stored_session) - .ok_or_else(|| not_found("session", session_id)) + .ok_or_else(|| not_found("session", session_id))?; + worktrees::session_with_worktree(storage, session) } fn session_directory( project: &Project, relative_directory: Option<&cli_master_core::wire::RelativeDirectory>, ) -> Result { - let root = project.repository_root.as_ref().unwrap_or(&project.path); + let root = &project.path; let directory = relative_directory.map_or_else(|| root.clone(), |relative| root.join(relative.as_str())); if !directory.is_dir() { @@ -466,14 +560,27 @@ fn session_directory( ) .with_action("Choose an existing directory inside the project.")); } - directory.canonicalize().map_err(|error| { + let canonical = directory.canonicalize().map_err(|error| { ApiError::new( "session_directory_unavailable", "The terminal working directory could not be opened.", ) .with_action("Check the directory permissions and try again.") .with_detail("reason", error.to_string()) - }) + })?; + let root = root.canonicalize().map_err(|_| { + ApiError::new( + "session_directory_unavailable", + "The project directory could not be opened.", + ) + })?; + if !canonical.starts_with(&root) { + return Err(ApiError::new( + "session_invalid_input", + "The terminal directory escapes the project.", + )); + } + Ok(canonical) } fn login_shell() -> String { diff --git a/crates/daemon/src/sessions/tests.rs b/crates/daemon/src/sessions/tests.rs index 52547bc..28ce2a0 100644 --- a/crates/daemon/src/sessions/tests.rs +++ b/crates/daemon/src/sessions/tests.rs @@ -8,8 +8,13 @@ use super::*; fn registry(directory: &Path) -> SessionRegistry { let storage = Storage::open_migrated(directory.join("sessions.sqlite3")) .expect("fixture storage should open"); - SessionRegistry::new(storage, DaemonInstanceId::new()) - .expect("session registry should initialize") + SessionRegistry::new( + storage, + DaemonInstanceId::new(), + directory.join("worktrees"), + None, + ) + .expect("session registry should initialize") } #[test] diff --git a/crates/daemon/src/sessions/worktrees.rs b/crates/daemon/src/sessions/worktrees.rs new file mode 100644 index 0000000..9c8f4d0 --- /dev/null +++ b/crates/daemon/src/sessions/worktrees.rs @@ -0,0 +1,267 @@ +use cli_master_core::wire::{ + SessionCreateRequest, WorktreeListRequest, WorktreeListResponse, WorktreePrepareRemoveRequest, + WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, WorktreeRemoveRequest, +}; +use cli_master_core::{ApiError, Session, Worktree, WorktreeState}; +use cli_master_session::{CreateSession, SagaError, SessionManager, SessionWorktreeSaga}; +use cli_master_storage::{Storage, StoredSession, StoredWorktree}; + +use super::{SessionRegistry, not_found, storage_error, stored_session}; + +impl SessionRegistry { + pub(super) fn prepare_with_saga( + &self, + saga: &SessionWorktreeSaga, + request: SessionCreateRequest, + ) -> Result { + // The root belongs to the daemon; callers cannot supply paths or branches. + let creation = CreateSession { + project_id: request.project_id, + agent_id: request.agent_id, + name: request.name.into_inner(), + isolation: request.isolation, + managed_root: self.managed_root.join(request.project_id.to_string()), + short_id: None, + }; + saga.prepare_session(&creation, request.relative_directory.as_ref()) + .map(|created| created.session) + .map_err(saga_error) + } + + pub(crate) fn worktrees(&self) -> Result, ApiError> { + self.list_worktrees(WorktreeListRequest::default()) + .map(|response| response.worktrees) + } + + pub(crate) fn list_worktrees( + &self, + request: WorktreeListRequest, + ) -> Result { + let storage = self.storage()?; + let worktrees = match request.project_id { + Some(project_id) => storage.list_worktrees_for_project(project_id), + None => storage.list_worktrees(), + } + .map_err(storage_error)?; + Ok(WorktreeListResponse { + worktrees: worktrees.into_iter().map(worktree_dto).collect(), + }) + } + + pub(crate) fn prepare_worktree_removal( + &self, + request: WorktreePrepareRemoveRequest, + ) -> Result { + let _lifecycle = self.lifecycle()?; + let saga = self.worktree_saga.as_ref().ok_or_else(git_unavailable)?; + if self.has_live_worktree_user(request.worktree_id)? { + return Ok(WorktreePrepareRemoveResponse::Blocked { + worktree_id: request.worktree_id, + is_dirty: self + .storage()? + .get_worktree(request.worktree_id) + .map_err(storage_error)? + .is_some_and(|worktree| worktree.is_dirty), + blockers: vec![ + WorktreeRemovalBlocker::Running, + WorktreeRemovalBlocker::InUse, + ], + }); + } + saga.prepare_remove(request.worktree_id).map_err(saga_error) + } + + pub(crate) fn remove_worktree( + &self, + request: &WorktreeRemoveRequest, + ) -> Result { + let _lifecycle = self.lifecycle()?; + let saga = self.worktree_saga.as_ref().ok_or_else(git_unavailable)?; + if self.has_live_worktree_user(request.worktree_id)? { + return Err(ApiError::new( + "worktree_confirmation_invalid", + "A live session now uses this worktree.", + ) + .with_action("Stop its sessions and prepare removal again.")); + } + saga.remove_worktree(request.worktree_id, &request.confirmation_token) + .map_err(saga_error)?; + Ok(cli_master_core::wire::EmptyResponse::default()) + } + + fn has_live_worktree_user( + &self, + worktree_id: cli_master_core::WorktreeId, + ) -> Result { + let (worktree, sessions) = { + let storage = self.storage()?; + let worktree = storage + .get_worktree(worktree_id) + .map_err(storage_error)? + .ok_or_else(|| not_found("worktree", worktree_id))?; + let sessions = storage.list_sessions().map_err(storage_error)?; + (worktree, sessions) + }; + let root = worktree.path.canonicalize().unwrap_or(worktree.path); + let mut live = false; + for session in sessions { + // Also cover sessions registered directly at this worktree or a child folder. + let cwd = session.cwd.canonicalize().unwrap_or(session.cwd); + if worktree.session_id == Some(session.id) || cwd.starts_with(&root) { + if let Ok(snapshot) = self.manager.snapshot(session.id) { + self.persist_snapshot(&snapshot)?; + live |= snapshot.status.is_live(); + } else { + live |= session.status.is_live(); + } + } + } + Ok(live) + } +} + +pub(super) fn session_with_worktree( + storage: &Storage, + stored: StoredSession, +) -> Result { + let worktree = storage + .list_worktrees_for_project(stored.project_id) + .map_err(storage_error)? + .into_iter() + .find(|worktree| worktree.session_id == Some(stored.id)); + let mut session = stored_session(stored); + if let Some(worktree) = worktree { + session.branch = Some(worktree.branch); + session.worktree_id = Some(worktree.id); + session.worktree_path = Some(worktree.path); + } + Ok(session) +} + +impl SessionRegistry { + pub(super) fn validate_start_directory( + &self, + stored: &StoredSession, + ) -> Result, ApiError> { + let (project, worktree) = { + let storage = self.storage()?; + let project = storage + .get_project(stored.project_id) + .map_err(storage_error)? + .ok_or_else(|| not_found("project", stored.project_id))?; + let worktree = storage + .list_worktrees_for_project(stored.project_id) + .map_err(storage_error)? + .into_iter() + .find(|worktree| worktree.session_id == Some(stored.id)); + (project, worktree) + }; + let worktree_id = worktree.as_ref().map(|worktree| worktree.id); + let root = if let Some(worktree) = worktree { + if !matches!( + worktree.state, + cli_master_storage::WorktreeState::Active + | cli_master_storage::WorktreeState::RemovePending + ) { + return Err( + ApiError::new("worktree_not_active", "This worktree needs recovery.") + .with_action("Resolve the incomplete operation before starting."), + ); + } + if worktree + .path + .canonicalize() + .map_err(|_| directory_unavailable())? + != worktree.path + { + return Err(directory_unavailable()); + } + let git = self.git.as_ref().ok_or_else(git_unavailable)?; + let registered = git + .list_worktrees(&project.path) + .map_err(|error| saga_error(error.into()))?; + let inspection = git + .inspect_repository(&worktree.path) + .map_err(|error| saga_error(error.into()))?; + if inspection.repository_root.as_ref() != Some(&worktree.path) + || inspection.branch.as_deref() != Some(worktree.branch.as_str()) + || !registered.iter().any(|entry| { + entry.path == worktree.path + && entry.branch.as_deref() == Some(worktree.branch.as_str()) + && !entry.prunable + }) + { + return Err(ApiError::new( + "worktree_identity_changed", + "The managed Git worktree identity changed.", + ) + .with_action("Restore the original worktree or create a new isolated session.")); + } + worktree.path + } else { + project.path + }; + let canonical_root = root.canonicalize().map_err(|_| directory_unavailable())?; + let cwd = stored + .cwd + .canonicalize() + .map_err(|_| directory_unavailable())?; + if canonical_root != root || cwd != stored.cwd || !cwd.is_dir() || !cwd.starts_with(root) { + return Err(directory_unavailable()); + } + Ok(worktree_id) + } +} + +fn directory_unavailable() -> ApiError { + ApiError::new( + "session_directory_unavailable", + "The session directory is missing or outside its project/worktree.", + ) + .with_action("Restore the directory or create a session in an existing project folder.") +} + +fn worktree_dto(stored: StoredWorktree) -> Worktree { + Worktree { + id: stored.id, + project_id: stored.project_id, + session_id: stored.session_id, + path: stored.path, + branch: stored.branch, + is_dirty: stored.is_dirty, + state: match stored.state { + cli_master_storage::WorktreeState::Creating => WorktreeState::Creating, + cli_master_storage::WorktreeState::Active => WorktreeState::Active, + cli_master_storage::WorktreeState::RemovePending => WorktreeState::RemovePending, + cli_master_storage::WorktreeState::Orphaned => WorktreeState::Orphaned, + }, + created_at_ms: stored.created_at_ms, + updated_at_ms: stored.updated_at_ms, + } +} + +pub(super) fn git_unavailable() -> ApiError { + ApiError::new( + "git_unavailable", + "Git is required to manage isolated worktrees.", + ) + .with_action("Install Git and restart the daemon.") +} + +#[allow( + clippy::needless_pass_by_value, + reason = "owned adapter for Result::map_err" +)] +pub(super) fn saga_error(error: SagaError) -> ApiError { + let mut api = ApiError::new(error.code(), error.message()).with_action(error.action()); + if let Some(path) = error.path() { + api = api.with_detail("path", path.to_string_lossy().into_owned()); + } + if let Some(id) = error.worktree_id() { + api = api.with_detail("worktreeId", id.to_string()); + } + if let Some(id) = error.session_id() { + api = api.with_detail("sessionId", id.to_string()); + } + api +} diff --git a/crates/daemon/tests/worktree_ipc.rs b/crates/daemon/tests/worktree_ipc.rs new file mode 100644 index 0000000..98f36fe --- /dev/null +++ b/crates/daemon/tests/worktree_ipc.rs @@ -0,0 +1,831 @@ +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use cli_master_core::wire::{ + AgentRecord, ConfirmationToken, StateSnapshotResponse, WorktreeListResponse, + WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, method, +}; +use cli_master_core::{ + AgentId, CommandSpec, Project, ProjectId, RequestEnvelope, ResponseEnvelope, ResponsePayload, + Session, SessionStatus, Worktree, WorktreeId, WorktreeState, +}; +use cli_master_daemon::{Daemon, DaemonConfig, DaemonError, MAX_FRAME_LENGTH}; +use cli_master_session::{SessionManager, SessionManagerConfig, TerminalSize}; +use cli_master_storage::{SessionRuntimeUpdate, Storage}; +use futures_util::{SinkExt, StreamExt}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio::task::JoinHandle; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +type Client = Framed; + +struct RunningDaemon { + config: DaemonConfig, + cancellation: CancellationToken, + task: JoinHandle>, +} + +impl RunningDaemon { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).expect("daemon should bind"); + let cancellation = CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(task_cancellation).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + let stream = UnixStream::connect(self.config.socket_path()) + .await + .expect("client should connect"); + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed(stream) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(10), self.task) + .await + .expect("daemon should stop before timeout") + .expect("daemon task should join") + .expect("daemon should stop cleanly"); + } +} + +struct Fixture { + root: TempDir, + repository: PathBuf, + observed_cwd: PathBuf, + project_id: ProjectId, + agent_id: AgentId, +} + +impl Fixture { + async fn new(project_directory: &str) -> (Self, RunningDaemon, Client) { + let root = TempDir::new().expect("temporary directory should exist"); + let repository = root.path().join("repository"); + fs::create_dir_all(repository.join("apps/api")).unwrap(); + fs::write(repository.join("apps/api/tracked.txt"), "original\n").unwrap(); + git(&repository, &["init", "-b", "main"]); + git( + &repository, + &["config", "user.email", "tests@example.invalid"], + ); + git(&repository, &["config", "user.name", "CLI Master Tests"]); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "initial"]); + let script = root.path().join("observe-cwd.sh"); + fs::write(&script, "pwd -P > \"$1\"\nexec /bin/cat\n").unwrap(); + let observed_cwd = root.path().join("observed-cwd"); + let daemon = RunningDaemon::start(root.path()); + let mut client = daemon.connect().await; + let project: Project = call( + &mut client, + method::PROJECT_ADD, + json!({"path": repository.join(project_directory)}), + ) + .await; + let agent: AgentRecord = call( + &mut client, + method::AGENT_CUSTOM_CREATE, + json!({ + "displayName": "Worktree fixture", + "command": { + "executable": "/bin/sh", + "args": [script, observed_cwd], + "env": {} + } + }), + ) + .await; + ( + Self { + root, + repository, + observed_cwd, + project_id: project.id, + agent_id: agent.id, + }, + daemon, + client, + ) + } + + async fn create(&self, client: &mut Client, relative_directory: Option<&str>) -> Session { + let mut payload = json!({ + "projectId": self.project_id, + "agentId": self.agent_id, + "name": "Isolated task", + "isolation": "new_worktree" + }); + if let Some(relative_directory) = relative_directory { + payload["relativeDirectory"] = json!(relative_directory); + } + call(client, method::SESSION_CREATE, payload).await + } +} + +fn git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .expect("git fixture should start"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + client + .send(serde_json::to_vec(&request).unwrap().into()) + .await + .expect("request should send"); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let bytes = client + .next() + .await + .expect("response frame should arrive") + .expect("frame should be valid"); + let envelope: Value = serde_json::from_slice(&bytes).unwrap(); + if envelope["kind"] == "event" { + continue; + } + let response: ResponseEnvelope = serde_json::from_value(envelope).unwrap(); + assert_eq!(response.request_id, request.request_id); + return response; + } + }) + .await + .unwrap_or_else(|_| panic!("{method} should respond before timeout")) +} + +async fn call(client: &mut Client, method: &str, payload: Value) -> T { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => { + serde_json::from_value(data).expect("response should match its wire DTO") + } + ResponsePayload::Error { error } => panic!("{method} failed: {error:?}"), + } +} + +async fn failure(client: &mut Client, method: &str, payload: Value) -> String { + match exchange(client, method, payload).await.payload { + ResponsePayload::Error { error } => error.code, + ResponsePayload::Success { data } => panic!("{method} unexpectedly succeeded: {data}"), + } +} + +async fn worktrees(client: &mut Client, project_id: Option) -> Vec { + let payload = project_id.map_or_else(|| json!({}), |id| json!({"projectId": id})); + let response: WorktreeListResponse = call(client, method::WORKTREE_LIST, payload).await; + response.worktrees +} + +async fn prepare(client: &mut Client, worktree_id: WorktreeId) -> WorktreePrepareRemoveResponse { + call( + client, + method::WORKTREE_PREPARE_REMOVE, + json!({"worktreeId": worktree_id}), + ) + .await +} + +async fn ready_token(client: &mut Client, worktree_id: WorktreeId) -> ConfirmationToken { + let response = prepare(client, worktree_id).await; + let WorktreePrepareRemoveResponse::Ready { + worktree_id: prepared_id, + confirmation_token, + expires_at_ms, + } = response + else { + panic!("clean unused worktree should be removable: {response:?}"); + }; + assert_eq!(prepared_id, worktree_id); + assert!(expires_at_ms > 1_700_000_000_000); + confirmation_token +} + +async fn remove(client: &mut Client, worktree_id: WorktreeId, token: ConfirmationToken) { + let _: Value = call( + client, + method::WORKTREE_REMOVE, + json!({"worktreeId": worktree_id, "confirmationToken": token}), + ) + .await; +} + +async fn wait_for_file(path: &Path) -> String { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(contents) = fs::read_to_string(path) { + if !contents.is_empty() { + return contents; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child should report before timeout") +} + +#[tokio::test] +#[allow( + clippy::too_many_lines, + reason = "one socket lifecycle proves preparation, launch and independent metadata deletion" +)] +async fn create_prepares_without_spawning_then_start_uses_the_isolated_subdirectory() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, Some("apps/api")).await; + let worktree_id = created + .worktree_id + .expect("session should identify its worktree"); + let path = created.worktree_path.as_ref().expect("worktree path"); + assert_eq!(created.status, SessionStatus::Unknown); + assert_eq!(created.pid, None); + assert_eq!(created.pty_id, None); + assert_eq!( + failure( + &mut client, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "eA=="}) + ) + .await, + "session_not_found", + "prepared metadata must not have a writable PTY" + ); + assert_eq!(created.cwd, path.join("apps/api")); + assert!( + !fixture.observed_cwd.exists(), + "create must not run the command" + ); + assert_eq!( + fs::read_to_string(created.cwd.join("tracked.txt")).unwrap(), + "original\n" + ); + assert_ne!(path, &fixture.repository); + + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions, std::slice::from_ref(&created)); + let listed = worktrees(&mut client, Some(fixture.project_id)).await; + assert_eq!(snapshot.worktrees, listed); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, worktree_id); + assert_eq!(listed[0].session_id, Some(created.id)); + assert_eq!(listed[0].path, *path); + assert_eq!(Some(&listed[0].branch), created.branch.as_ref()); + assert_eq!(listed[0].state, WorktreeState::Active); + assert!( + worktrees(&mut client, Some(ProjectId::new())) + .await + .is_empty() + ); + + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert!(started.status.is_live()); + assert!(started.pid.is_some()); + assert!(started.pty_id.is_some()); + assert_eq!(started.worktree_id, Some(worktree_id)); + assert_eq!(started.cwd, created.cwd); + assert_eq!( + PathBuf::from(wait_for_file(&fixture.observed_cwd).await.trim()), + created.cwd + ); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + let _: Value = call( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await; + assert!( + path.join("apps/api/tracked.txt").is_file(), + "deleting metadata must preserve the checkout" + ); + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert!(snapshot.sessions.is_empty()); + assert_eq!(snapshot.worktrees.len(), 1); + assert_eq!(snapshot.worktrees[0].session_id, None); + + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + assert!(fixture.repository.join("apps/api/tracked.txt").is_file()); + assert!(worktrees(&mut client, None).await.is_empty()); + daemon.stop().await; +} + +#[tokio::test] +async fn start_rejects_a_worktree_replaced_by_a_symlink_to_the_original_project() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, Some("apps/api")).await; + let path = created.worktree_path.unwrap(); + let moved = fixture.root.path().join("moved-worktree"); + fs::rename(&path, &moved).unwrap(); + symlink(&fixture.repository, &path).unwrap(); + + let response = exchange( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + // Restore Git's registered path before checking the result so the fixture + // stays recoverable even if a regression unexpectedly starts the command. + fs::remove_file(&path).unwrap(); + fs::rename(&moved, &path).unwrap(); + + let ResponsePayload::Error { error } = response.payload else { + panic!("a substituted worktree path must not launch an agent"); + }; + assert_eq!(error.code, "session_directory_unavailable"); + assert!(!fixture.observed_cwd.exists()); + for root in [&path, &fixture.repository] { + assert_eq!( + fs::read_to_string(root.join("apps/api/tracked.txt")).unwrap(), + "original\n" + ); + } + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert_eq!(snapshot.sessions[0].pid, None); + assert_eq!(snapshot.worktrees[0].path, path); + daemon.stop().await; +} + +#[tokio::test] +async fn concurrent_stop_and_restart_keep_the_same_session_metadata_consistent_with_its_runtime() { + let (fixture, daemon, mut control) = Fixture::new("").await; + let created = fixture.create(&mut control, None).await; + let mut stopping = daemon.connect().await; + let mut restarting = daemon.connect().await; + + for _ in 0..3 { + let _: Session = call( + &mut control, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + let (stopped, restarted) = tokio::join!( + call::( + &mut stopping, + method::SESSION_STOP, + json!({"sessionId": created.id}) + ), + call::( + &mut restarting, + method::SESSION_RESTART, + json!({"sessionId": created.id}) + ), + ); + assert!(!stopped.status.is_live()); + assert_eq!(stopped.pid, None); + assert!(restarted.status.is_live()); + assert!(restarted.pid.is_some()); + assert_eq!(restarted.worktree_id, created.worktree_id); + + let snapshot: StateSnapshotResponse = + call(&mut control, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions.len(), 1); + let durable = &snapshot.sessions[0]; + assert_eq!(durable.id, created.id); + let write = exchange( + &mut control, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "cHJvYmUK"}), + ) + .await; + match write.payload { + ResponsePayload::Success { .. } => { + assert!( + durable.status.is_live(), + "a writable runtime must be recorded live" + ); + assert_eq!(durable.pid, restarted.pid); + } + ResponsePayload::Error { error } => { + assert_eq!(error.code, "session_not_running"); + assert!(!durable.status.is_live()); + assert_eq!(durable.pid, None); + } + } + } + + let stopped: Session = call( + &mut control, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + assert!(!stopped.status.is_live()); + assert_eq!(stopped.pid, None); + let token = ready_token(&mut control, created.worktree_id.unwrap()).await; + remove(&mut control, created.worktree_id.unwrap(), token).await; + daemon.stop().await; +} + +#[tokio::test] +async fn dirty_worktrees_block_removal_and_changes_after_confirmation_invalidate_the_token() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + fs::write(path.join("user-notes.txt"), "preserve this\n").unwrap(); + + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + assert_eq!( + fs::read_to_string(path.join("user-notes.txt")).unwrap(), + "preserve this\n" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { + is_dirty, blockers, .. + } = blocked + else { + panic!("untracked content must block removal: {blocked:?}"); + }; + assert!(is_dirty); + assert!(blockers.contains(&WorktreeRemovalBlocker::UntrackedFiles)); + assert_eq!( + worktrees(&mut client, None).await[0].state, + WorktreeState::Active + ); + + fs::remove_file(path.join("user-notes.txt")).unwrap(); + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + daemon.stop().await; +} + +#[tokio::test] +async fn a_live_session_blocks_removal_and_old_tokens_cannot_override_changed_ownership() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + let _: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + wait_for_file(&fixture.observed_cwd).await; + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { + is_dirty, blockers, .. + } = blocked + else { + panic!("the live session must block removal: {blocked:?}"); + }; + assert!(!is_dirty); + assert!( + blockers.contains(&WorktreeRemovalBlocker::Running) + || blockers.contains(&WorktreeRemovalBlocker::InUse) + ); + assert_eq!( + failure( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}) + ) + .await, + "session_still_running" + ); + assert!(path.is_dir()); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + let token = ready_token(&mut client, worktree_id).await; + let _: Value = call( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await; + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + assert!(path.is_dir()); + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + daemon.stop().await; +} + +#[tokio::test] +async fn an_unsubscribed_session_can_be_deleted_after_its_process_exits_on_its_own() { + let (fixture, daemon, mut client) = Fixture::new("").await; + fs::write( + fixture.root.path().join("observe-cwd.sh"), + "pwd -P > \"$1\"\nIFS= read -r line\n", + ) + .unwrap(); + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert!(started.status.is_live()); + wait_for_file(&fixture.observed_cwd).await; + + // Let the command return normally, with no subscription driving durable + // status updates and no explicit stop operation that could hide the bug. + let _: Value = call( + &mut client, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "ZG9uZQo="}), + ) + .await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match exchange( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await + .payload + { + ResponsePayload::Success { .. } => break, + ResponsePayload::Error { error } => { + assert_eq!(error.code, "session_still_running"); + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("metadata should become deletable after the child exits"); + + assert!(path.join("apps/api/tracked.txt").is_file()); + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert!(snapshot.sessions.is_empty()); + assert_eq!(snapshot.worktrees[0].id, worktree_id); + assert_eq!(snapshot.worktrees[0].session_id, None); + daemon.stop().await; +} + +#[tokio::test] +async fn another_session_using_the_checkout_blocks_removal_without_a_worktree_association() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let isolated = fixture.create(&mut client, None).await; + let worktree_id = isolated.worktree_id.unwrap(); + let path = isolated.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + let project: Project = call( + &mut client, + method::PROJECT_ADD, + json!({"path": path.join("apps/api")}), + ) + .await; + let other: Session = call( + &mut client, + method::SESSION_CREATE, + json!({ + "projectId": project.id, + "agentId": fixture.agent_id, + "name": "Independent terminal", + "isolation": "current" + }), + ) + .await; + assert_eq!(other.worktree_id, None); + let _: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": other.id}), + ) + .await; + wait_for_file(&fixture.observed_cwd).await; + let error = failure( + &mut client, + method::WORKTREE_REMOVE, + json!({"worktreeId": worktree_id, "confirmationToken": token}), + ) + .await; + assert!( + ["worktree_confirmation_invalid", "worktree_in_use"].contains(&error.as_str()), + "removal should reject changed usage: {error}" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { blockers, .. } = blocked else { + panic!("another session using this checkout must block removal: {blocked:?}"); + }; + assert!(blockers.contains(&WorktreeRemovalBlocker::InUse)); + assert!(path.is_dir()); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": other.id}), + ) + .await; + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + daemon.stop().await; +} + +#[tokio::test] +async fn prepared_worktrees_survive_restart_with_the_project_subdirectory_preserved() { + let (fixture, first, mut client) = Fixture::new("apps").await; + let created = fixture.create(&mut client, Some("api")).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.as_ref().unwrap(); + assert_eq!(created.cwd, path.join("apps/api")); + drop(client); + first.stop().await; + + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].id, created.id); + assert_eq!(snapshot.sessions[0].cwd, created.cwd); + assert_eq!(snapshot.sessions[0].worktree_id, Some(worktree_id)); + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert_eq!(snapshot.sessions[0].pid, None); + assert_eq!(snapshot.worktrees.len(), 1); + assert_eq!(snapshot.worktrees[0].state, WorktreeState::Active); + assert_eq!(snapshot.worktrees[0].path, *path); + assert_eq!(snapshot.worktrees, worktrees(&mut client, None).await); + assert!(!fixture.observed_cwd.exists()); + + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert_eq!(started.cwd, created.cwd); + assert_eq!( + PathBuf::from(wait_for_file(&fixture.observed_cwd).await.trim()), + created.cwd + ); + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + second.stop().await; +} + +#[tokio::test] +async fn restart_reconciles_a_worktree_session_without_signalling_an_unowned_stale_pid() { + let (fixture, first, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let database = first.config.database_path().to_path_buf(); + drop(client); + first.stop().await; + + // This separate manager owns the canary process. Its PID simulates an old + // persisted PID that now belongs to an unrelated process after a crash. + let canary_script = fixture.root.path().join("canary.sh"); + let canary_reply = fixture.root.path().join("canary-reply"); + fs::write( + &canary_script, + "IFS= read -r line\nprintf '%s' \"$line\" > \"$1\"\n", + ) + .unwrap(); + let canary_manager = SessionManager::new(SessionManagerConfig::default()).unwrap(); + let command = CommandSpec::try_from_parts( + "/bin/sh", + [ + canary_script.to_string_lossy().into_owned(), + canary_reply.to_string_lossy().into_owned(), + ], + fixture.root.path(), + BTreeMap::new(), + ) + .unwrap(); + let canary = canary_manager + .spawn(&command, TerminalSize::default()) + .unwrap(); + let canary_pid = canary_manager.snapshot(canary.id).unwrap().pid.unwrap(); + Storage::open(&database) + .unwrap() + .update_session_runtime( + created.id, + &SessionRuntimeUpdate { + status: SessionStatus::Running, + runtime_pid: Some(canary_pid), + daemon_instance_id: Some("crashed-daemon".to_owned()), + exit_code: None, + error_code: None, + last_activity_at_ms: None, + updated_at_ms: created.updated_at_ms, + }, + ) + .unwrap(); + + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + let recovered = &snapshot.sessions[0]; + assert_eq!(recovered.id, created.id); + assert_eq!(recovered.status, SessionStatus::Unknown); + assert_eq!(recovered.pid, None); + assert_eq!(recovered.pty_id, None); + assert_eq!(recovered.worktree_id, created.worktree_id); + assert_eq!(recovered.worktree_path, created.worktree_path); + assert_eq!(snapshot.worktrees[0].state, WorktreeState::Active); + assert_eq!( + failure( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}) + ) + .await, + "session_not_found" + ); + canary_manager.write(canary.id, b"still-alive\n").unwrap(); + assert_eq!(wait_for_file(&canary_reply).await, "still-alive"); + let durable = Storage::open(&database) + .unwrap() + .get_session(created.id) + .unwrap() + .unwrap(); + assert_eq!(durable.status, SessionStatus::Unknown); + assert_eq!(durable.runtime_pid, None); + assert_eq!(durable.daemon_instance_id, None); + canary_manager.shutdown().unwrap(); + second.stop().await; +} diff --git a/protocol/catalog.json b/protocol/catalog.json index fff6729..0efc949 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -27,6 +27,7 @@ "session.unsubscribe", "git.status", "git.diff", + "worktree.list", "worktree.prepare_remove", "worktree.remove", "diagnostics.get" From a5a8cf656fd41f5edf95aacecab3ce5c820fc806 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:11:47 -0300 Subject: [PATCH 20/32] feat(terminal): share mode-aware input queue with prompt composer --- .../app/features/canvas/prompt-input.test.ts | 45 +++++ .../src/app/features/canvas/prompt-input.ts | 47 +++++ .../features/terminal/LiveTerminal.test.tsx | 188 ++++++++++++++++++ .../app/features/terminal/LiveTerminal.tsx | 87 +++++++- .../terminal/TerminalSurface.test.tsx | 23 +++ .../app/features/terminal/TerminalSurface.tsx | 4 + .../app/features/terminal/terminal-runtime.ts | 14 ++ 7 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/app/features/canvas/prompt-input.test.ts create mode 100644 apps/desktop/src/app/features/canvas/prompt-input.ts create mode 100644 apps/desktop/src/app/features/terminal/LiveTerminal.test.tsx diff --git a/apps/desktop/src/app/features/canvas/prompt-input.test.ts b/apps/desktop/src/app/features/canvas/prompt-input.test.ts new file mode 100644 index 0000000..a03ed00 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/prompt-input.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { encodePromptInput, encodePromptTerminalKey, getPromptInputError, MAX_PROMPT_INPUT_BYTES } from "./prompt-input"; + +const plain = { bracketedPasteMode: false, applicationCursorKeysMode: false }; +const application = { bracketedPasteMode: true, applicationCursorKeysMode: true }; +const decode = (value: Uint8Array) => new TextDecoder().decode(value); + +describe("prompt input encoding", () => { + it("uses live paste mode and one trailing Return without shell interpolation", () => { + const prompt = "printf '$HOME'\r\nreview `file`\n$(literal) — ação"; + const normalized = "printf '$HOME'\rreview `file`\r$(literal) — ação"; + expect(decode(encodePromptInput(prompt, plain))).toBe(`${normalized}\r`); + expect(decode(encodePromptInput(prompt, application))).toBe(`\u001b[200~${normalized}\u001b[201~\r`); + }); + + it.each(["\u001b[201~run", "stop\u0003", "null\u0000", "delete\u007f", "csi\u009b"])( + "rejects embedded terminal control sequences without echoing user input", + (text) => { + expect(() => encodePromptInput(text, application)).toThrow("Remove terminal control characters"); + expect(getPromptInputError(text)).not.toContain(text); + }, + ); + + it("preserves tabs and whitespace but rejects an empty submission", () => { + expect(decode(encodePromptInput(" line\t ", plain))).toBe(" line\t \r"); + expect(() => encodePromptInput(" \n\t", plain)).toThrow("Write a prompt"); + }); + + it("bounds UTF-8 input including framing without truncating the prompt", () => { + expect(encodePromptInput("x".repeat(MAX_PROMPT_INPUT_BYTES - 13), application)).toHaveLength(MAX_PROMPT_INPUT_BYTES); + expect(() => encodePromptInput("x".repeat(MAX_PROMPT_INPUT_BYTES - 12), application)).toThrow("64 KiB"); + expect(encodePromptInput("x".repeat(MAX_PROMPT_INPUT_BYTES - 1), plain)).toHaveLength(MAX_PROMPT_INPUT_BYTES); + expect(() => encodePromptInput("🦉".repeat(16_384), application)).toThrow("64 KiB"); + }); + + it("uses normal or application cursor sequences for the same logical key", () => { + for (const [key, suffix] of [["ArrowUp", "A"], ["ArrowDown", "B"], ["ArrowRight", "C"], ["ArrowLeft", "D"]] as const) { + expect(decode(encodePromptTerminalKey(key, plain))).toBe(`\u001b[${suffix}`); + expect(decode(encodePromptTerminalKey(key, application))).toBe(`\u001bO${suffix}`); + } + expect(decode(encodePromptTerminalKey("Enter", application))).toBe("\r"); + expect(decode(encodePromptTerminalKey("Tab", plain))).toBe("\t"); + }); +}); diff --git a/apps/desktop/src/app/features/canvas/prompt-input.ts b/apps/desktop/src/app/features/canvas/prompt-input.ts new file mode 100644 index 0000000..4994e30 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/prompt-input.ts @@ -0,0 +1,47 @@ +import type { TerminalInputModes } from "../terminal/terminal-runtime"; + +/** Mirrors core wire MAX_PTY_INPUT_BYTES, including paste framing and Return. */ +export const MAX_PROMPT_INPUT_BYTES = 64 * 1024; + +export type PromptInputKey = "Enter" | "Tab" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + +const BRACKETED_PASTE_START = "\u001b[200~"; +const BRACKETED_PASTE_END = "\u001b[201~"; +const encoder = new TextEncoder(); + +/** Matches xterm's paste newline normalization without interpreting shell syntax. */ +function promptPayload(text: string, bracketedPasteMode: boolean): string { + const normalized = text.replace(/\r\n|\n/g, "\r"); + return bracketedPasteMode + ? `${BRACKETED_PASTE_START}${normalized}${BRACKETED_PASTE_END}\r` + : `${normalized}\r`; +} + +/** Static errors never include the prompt, paths, or other user input. */ +export function getPromptInputError(text: string, modes?: TerminalInputModes): string | undefined { + if (!text.trim()) return "Write a prompt before sending."; + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(text)) { + return "Remove terminal control characters from the prompt before sending."; + } + // Before a terminal is available, reserve the largest supported paste framing. + const payload = promptPayload(text, modes?.bracketedPasteMode ?? true); + if (encoder.encode(payload).byteLength > MAX_PROMPT_INPUT_BYTES) { + return "This prompt exceeds the terminal's 64 KiB input limit. Shorten it before sending."; + } + return undefined; +} + +/** Encodes one explicitly submitted prompt using the live terminal's modes. */ +export function encodePromptInput(text: string, modes: TerminalInputModes): Uint8Array { + const error = getPromptInputError(text, modes); + if (error) throw new Error(error); + return encoder.encode(promptPayload(text, modes.bracketedPasteMode)); +} + +/** Empty-composer navigation honors the program's application-cursor mode. */ +export function encodePromptTerminalKey(key: PromptInputKey, modes: TerminalInputModes): Uint8Array { + if (key === "Enter") return encoder.encode("\r"); + if (key === "Tab") return encoder.encode("\t"); + const suffix = { ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D" }[key]; + return encoder.encode(`\u001b${modes.applicationCursorKeysMode ? "O" : "["}${suffix}`); +} diff --git a/apps/desktop/src/app/features/terminal/LiveTerminal.test.tsx b/apps/desktop/src/app/features/terminal/LiveTerminal.test.tsx new file mode 100644 index 0000000..181aa85 --- /dev/null +++ b/apps/desktop/src/app/features/terminal/LiveTerminal.test.tsx @@ -0,0 +1,188 @@ +import { createRef, useImperativeHandle, type ComponentProps } from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IpcError } from "../../../ipc/client"; +import type { Session } from "../../../ipc/types"; +import { LiveTerminal, type LiveTerminalInputHandle, type LiveTerminalTransport } from "./LiveTerminal"; +import type { TerminalSurfaceProps } from "./TerminalSurface"; +import type { TerminalInputModes } from "./terminal-runtime"; + +const surface = vi.hoisted((): { modes: TerminalInputModes | null } => ({ + modes: { bracketedPasteMode: false, applicationCursorKeysMode: false }, +})); + +vi.mock("./TerminalSurface", () => ({ + TerminalSurface({ ref, accessibleLabel, readOnly, onInput }: TerminalSurfaceProps) { + useImperativeHandle(ref, () => ({ + write: () => true, + writeOutput: () => "queued", + markOutputGap: () => true, + markReplayComplete: () => true, + reset: () => true, + focus: () => true, + getCursor: () => 0, + getInputModes: () => surface.modes, + })); + return ( +
+ + +
+ ); + }, +})); + +const SESSION: Session = { + id: "0198f000-0000-7000-8000-000000000001", + projectId: "0198f000-0000-7000-8000-000000000002", + agentId: "0198f000-0000-7000-8000-000000000003", + name: "Review agent", + cwd: "/workspace/project", + status: "running", + ptyId: "pty-first", + createdAtMs: 1, + updatedAtMs: 1, +}; + +describe("LiveTerminal input handle", () => { + beforeEach(() => { + surface.modes = { bracketedPasteMode: false, applicationCursorKeysMode: false }; + }); + + it("serializes keyboard, composed, and binary writes and reads modes at delivery", async () => { + const pending = deferred(); + const writeTerminal = vi.fn(async () => undefined).mockImplementationOnce(() => pending.promise); + const { inputRef } = renderTerminal({ writeTerminal }); + const handle = requireHandle(inputRef.current); + const encode = vi.fn((modes: TerminalInputModes) => Uint8Array.of( + Number(modes.bracketedPasteMode), Number(modes.applicationCursorKeysMode), + )); + + fireEvent.click(screen.getByRole("button", { name: "Type into terminal" })); + await waitFor(() => expect(writeTerminal).toHaveBeenCalledOnce()); + const composed = handle.writeInput(encode); + fireEvent.click(screen.getByRole("button", { name: "Send binary input" })); + expect(encode).not.toHaveBeenCalled(); + expect(writeTerminal).toHaveBeenCalledOnce(); + surface.modes = { bracketedPasteMode: true, applicationCursorKeysMode: true }; + + await act(async () => { + pending.resolve(); + await composed; + }); + await waitFor(() => expect(writeTerminal).toHaveBeenCalledTimes(3)); + expect(encode).toHaveBeenCalledWith(surface.modes); + expect(writeTerminal.mock.calls).toEqual([ + [SESSION.id, new TextEncoder().encode("keyboard")], + [SESSION.id, Uint8Array.of(1, 1)], + [SESSION.id, Uint8Array.of(0, 255)], + ]); + }); + + it("rejects composer failures while preserving keyboard error feedback and queue recovery", async () => { + const failure = new IpcError({ code: "write_failed", message: "Input temporarily unavailable" }); + const writeTerminal = vi.fn(async () => undefined) + .mockRejectedValueOnce(failure) + .mockRejectedValueOnce(failure); + const { inputRef } = renderTerminal({ writeTerminal }); + const handle = requireHandle(inputRef.current); + + await expect(handle.writeInput(() => Uint8Array.of(1))).rejects.toBe(failure); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Type into terminal" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Input temporarily unavailable"); + await expect(handle.writeInput(() => Uint8Array.of(2))).resolves.toBeUndefined(); + expect(writeTerminal).toHaveBeenLastCalledWith(SESSION.id, Uint8Array.of(2)); + }); + + it.each([ + { description: "switching session", next: { ...SESSION, id: "0198f000-0000-7000-8000-000000000004" } }, + { description: "replacing the PTY", next: { ...SESSION, ptyId: "pty-restarted" } }, + ])("discards queued input after $description and gives the new target its own queue", async ({ next }) => { + const pending = deferred(); + const writeTerminal = vi.fn(async () => undefined).mockImplementationOnce(() => pending.promise); + const { inputRef, props, rerender } = renderTerminal({ writeTerminal }); + const handle = requireHandle(inputRef.current); + const first = handle.writeInput(() => Uint8Array.of(1)); + await waitFor(() => expect(writeTerminal).toHaveBeenCalledOnce()); + const encodeStale = vi.fn(() => Uint8Array.of(2)); + const stale = handle.writeInput(encodeStale); + const staleRejection = expect(stale).rejects.toThrow("session changed"); + fireEvent.click(screen.getByRole("button", { name: "Type into terminal" })); + + rerender(); + await expect(handle.writeInput(encodeStale)).rejects.toThrow("no longer available"); + await expect(requireHandle(inputRef.current).writeInput(() => Uint8Array.of(3))).resolves.toBeUndefined(); + expect(writeTerminal).toHaveBeenLastCalledWith(next.id, Uint8Array.of(3)); + await act(async () => { + pending.resolve(); + await first; + await staleRejection; + }); + expect(encodeStale).not.toHaveBeenCalled(); + expect(writeTerminal).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("invalidates retained and queued input when the terminal is unmounted", async () => { + const pending = deferred(); + const writeTerminal = vi.fn(async () => undefined).mockImplementationOnce(() => pending.promise); + const { inputRef, unmount } = renderTerminal({ writeTerminal }); + const handle = requireHandle(inputRef.current); + const first = handle.writeInput(() => Uint8Array.of(1)); + await waitFor(() => expect(writeTerminal).toHaveBeenCalledOnce()); + const encodeStale = vi.fn(() => Uint8Array.of(2)); + const queued = expect(handle.writeInput(encodeStale)).rejects.toThrow("session changed"); + + unmount(); + expect(inputRef.current).toBeNull(); + await expect(handle.writeInput(encodeStale)).rejects.toThrow("no longer available"); + pending.resolve(); + await first; + await queued; + expect(encodeStale).not.toHaveBeenCalled(); + expect(writeTerminal).toHaveBeenCalledOnce(); + }); + + it("rejects input when the session stops or its surface cannot report modes", async () => { + const writeTerminal = vi.fn(async () => undefined); + const { inputRef, props, rerender } = renderTerminal({ writeTerminal }); + surface.modes = null; + const encode = vi.fn(() => Uint8Array.of(1)); + await expect(requireHandle(inputRef.current).writeInput(encode)).rejects.toThrow("not ready"); + surface.modes = { bracketedPasteMode: false, applicationCursorKeysMode: false }; + rerender(); + await expect(requireHandle(inputRef.current).writeInput(encode)).rejects.toThrow("no longer available"); + expect(encode).not.toHaveBeenCalled(); + expect(writeTerminal).not.toHaveBeenCalled(); + }); +}); + +function renderTerminal(overrides: Partial> = {}) { + const inputRef = createRef(); + const props: ComponentProps = { + session: SESSION, + inputRef, + subscribeTerminal: vi.fn(async () => vi.fn()), + writeTerminal: vi.fn(async () => undefined), + resizeTerminal: vi.fn(async () => undefined), + ...overrides, + }; + return { ...render(), inputRef, props }; +} + +function requireHandle(handle: LiveTerminalInputHandle | null): LiveTerminalInputHandle { + if (!handle) throw new Error("Expected a mounted terminal input handle."); + return handle; +} + +function deferred() { + let resolve: () => void = () => undefined; + const promise = new Promise((complete) => { resolve = complete; }); + return { promise, resolve }; +} diff --git a/apps/desktop/src/app/features/terminal/LiveTerminal.tsx b/apps/desktop/src/app/features/terminal/LiveTerminal.tsx index 4ce218a..eee7885 100644 --- a/apps/desktop/src/app/features/terminal/LiveTerminal.tsx +++ b/apps/desktop/src/app/features/terminal/LiveTerminal.tsx @@ -1,4 +1,12 @@ -import { useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type Ref, +} from "react"; import type { IpcEventErrorHandler, @@ -11,7 +19,13 @@ import { requireBoolean, requireNumber, requireRecord, requireString } from "../ import type { ApiErrorData, EventEnvelope, Session } from "../../../ipc/types"; import { errorData, isLiveStatus } from "../../utils"; import { TerminalSurface, type TerminalSurfaceHandle } from "./TerminalSurface"; -import type { TerminalInput } from "./terminal-runtime"; +import type { TerminalInput, TerminalInputModes } from "./terminal-runtime"; + +/** Shares the terminal input queue without retaining PTY bytes in React state. */ +export interface LiveTerminalInputHandle { + /** Encodes against the modes at delivery and rejects if the target changed. */ + writeInput(encode: (modes: TerminalInputModes) => Uint8Array): Promise; +} export interface LiveTerminalTransport { readonly subscribeTerminal: ( @@ -27,6 +41,15 @@ interface LiveTerminalProps extends LiveTerminalTransport { readonly session: Session; readonly className?: string; readonly autoFocus?: boolean; + /** Imperative input access for a composer associated with this exact session. */ + readonly inputRef?: Ref; +} + +interface TerminalInputTarget { + readonly sessionId: string; + readonly ptyId: string | undefined; + readonly live: boolean; + writeQueue: Promise; } /** Connects one mounted xterm directly to one daemon-owned PTY session. */ @@ -34,15 +57,59 @@ export function LiveTerminal({ session, className, autoFocus = false, + inputRef, subscribeTerminal, writeTerminal, resizeTerminal, }: LiveTerminalProps) { const terminalRef = useRef(null); - const writeQueueRef = useRef>(Promise.resolve()); + const inputTargetRef = useRef(null); + const writeTerminalRef = useRef(writeTerminal); const [terminalError, setTerminalError] = useState(); const live = isLiveStatus(session.status); + useLayoutEffect(() => { + writeTerminalRef.current = writeTerminal; + }, [writeTerminal]); + + useLayoutEffect(() => { + const target: TerminalInputTarget = { + sessionId: session.id, + ptyId: session.ptyId, + live, + writeQueue: Promise.resolve(), + }; + inputTargetRef.current = target; + return () => { + if (inputTargetRef.current === target) inputTargetRef.current = null; + }; + }, [live, session.id, session.ptyId]); + + const enqueueInput = useCallback(( + target: TerminalInputTarget | null, + encode: (modes: TerminalInputModes) => Uint8Array, + ): Promise => { + if (!target || inputTargetRef.current !== target || !target.live) { + return Promise.reject(new Error("The terminal is no longer available for input.")); + } + const write = target.writeQueue.then(() => { + if (inputTargetRef.current !== target || !target.live) { + throw new Error("The terminal session changed before input could be sent."); + } + const modes = terminalRef.current?.getInputModes(); + if (!modes) throw new Error("The terminal is not ready for input."); + return writeTerminalRef.current(target.sessionId, encode(modes)); + }); + // A failed input must reject its caller without poisoning subsequent writes. + target.writeQueue = write.catch(() => undefined); + return write; + }, []); + + useImperativeHandle(inputRef, () => { + const target = inputTargetRef.current; + return { writeInput: (encode) => enqueueInput(target, encode) }; + }, [enqueueInput, live, session.id, session.ptyId]); + useEffect(() => { let active = true; let unsubscribe: Unsubscribe | undefined; @@ -83,20 +150,22 @@ export function LiveTerminal({ active = false; unsubscribe?.(); }; - }, [autoFocus, live, session.id, subscribeTerminal]); + }, [autoFocus, live, session.id, session.ptyId, subscribeTerminal]); function handleInput(input: TerminalInput) { - const bytes = terminalInputBytes(input); - writeQueueRef.current = writeQueueRef.current - .then(() => writeTerminal(session.id, bytes)) - .catch((error: unknown) => { + const target = inputTargetRef.current; + if (target?.sessionId !== session.id || target?.ptyId !== session.ptyId) return; + void enqueueInput(target, () => terminalInputBytes(input)).catch((error: unknown) => { + if (inputTargetRef.current === target) { setTerminalError(errorData(error)); - }); + } + }); } return (
{ Listener<{ cols: number; rows: number }> >(); readonly addons: Addon[] = []; + readonly modes = { + bracketedPasteMode: false, + applicationCursorKeysMode: false, + }; readonly options: { disableStdin?: boolean; screenReaderMode?: boolean; @@ -334,6 +338,25 @@ describe("TerminalSurface", () => { expect(terminal.options.screenReaderMode).toBe(false); }); + it("exposes current input modes and makes retained handles unavailable after unmount", () => { + const terminalRef = createRef(); + const { unmount } = render( + , + ); + const handle = requireValue(terminalRef.current); + const terminal = requireItem(xtermMocks.terminalInstances, 0); + const initialModes = handle.getInputModes(); + expect(initialModes).toEqual({ bracketedPasteMode: false, applicationCursorKeysMode: false }); + + terminal.modes.bracketedPasteMode = true; + terminal.modes.applicationCursorKeysMode = true; + expect(handle.getInputModes()).toEqual({ bracketedPasteMode: true, applicationCursorKeysMode: true }); + expect(initialModes).toEqual({ bracketedPasteMode: false, applicationCursorKeysMode: false }); + + unmount(); + expect(handle.getInputModes()).toBeNull(); + }); + it("coalesces ResizeObserver work and reports only changed dimensions", () => { const onResize = vi.fn(); render( diff --git a/apps/desktop/src/app/features/terminal/TerminalSurface.tsx b/apps/desktop/src/app/features/terminal/TerminalSurface.tsx index 46ca2cd..0025b5b 100644 --- a/apps/desktop/src/app/features/terminal/TerminalSurface.tsx +++ b/apps/desktop/src/app/features/terminal/TerminalSurface.tsx @@ -14,6 +14,7 @@ import { createTerminalRuntime, type TerminalDimensions, type TerminalInput, + type TerminalInputModes, type TerminalRuntime, type TerminalRuntimeBindings, } from "./terminal-runtime"; @@ -40,6 +41,8 @@ export interface TerminalSurfaceHandle { focus(): boolean; /** Returns the applied cursor, or null before mount/after unmount. */ getCursor(): number | null; + /** Returns current input modes, or null before mount/after unmount. */ + getInputModes(): TerminalInputModes | null; } /** Props for the isolated, imperative xterm surface. */ @@ -180,6 +183,7 @@ export function TerminalSurface({ return true; }, getCursor: () => runtimeRef.current?.getCursor() ?? null, + getInputModes: () => runtimeRef.current?.getInputModes() ?? null, }), [], ); diff --git a/apps/desktop/src/app/features/terminal/terminal-runtime.ts b/apps/desktop/src/app/features/terminal/terminal-runtime.ts index 498001b..a2806b3 100644 --- a/apps/desktop/src/app/features/terminal/terminal-runtime.ts +++ b/apps/desktop/src/app/features/terminal/terminal-runtime.ts @@ -21,6 +21,12 @@ export type TerminalInput = | { readonly kind: "text"; readonly data: string } | { readonly kind: "binary"; readonly data: string }; +/** Current terminal modes that change how composed input must be encoded. */ +export interface TerminalInputModes { + readonly bracketedPasteMode: boolean; + readonly applicationCursorKeysMode: boolean; +} + /** Latest callbacks and mutable presentation options supplied by React. */ export interface TerminalRuntimeBindings { readonly accessibleLabel: string; @@ -48,6 +54,7 @@ export interface TerminalRuntime { reset(cursor?: number): void; focus(): void; getCursor(): number; + getInputModes(): TerminalInputModes; setAccessibleLabel(label: string): void; setReadOnly(readOnly: boolean): void; setScreenReaderMode(enabled: boolean): void; @@ -244,6 +251,13 @@ export function createTerminalRuntime({ reset, focus: () => requireTerminal().focus(), getCursor: outputWriter.getCursor, + getInputModes() { + const modes = requireTerminal().modes; + return { + bracketedPasteMode: modes.bracketedPasteMode, + applicationCursorKeysMode: modes.applicationCursorKeysMode, + }; + }, setAccessibleLabel, setReadOnly, setScreenReaderMode, From b5185130c217e78efb893be6406bddecb6815d2e Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:12:08 -0300 Subject: [PATCH 21/32] feat(canvas): integrate persistent per-terminal prompt composer --- apps/desktop/e2e/canvas-interactions.spec.ts | 55 +++ .../features/canvas/CanvasWorkspace.test.tsx | 320 +++++++++++++++++- .../app/features/canvas/CanvasWorkspace.tsx | 174 +++++++++- .../features/canvas/PromptComposer.test.tsx | 269 +++++++++++++++ .../app/features/canvas/PromptComposer.tsx | 287 ++++++++++++++++ apps/desktop/src/styles/prompt-composer.css | 245 ++++++++++++++ docs/desktop/prompt-composer.md | 49 +++ 7 files changed, 1390 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/app/features/canvas/PromptComposer.test.tsx create mode 100644 apps/desktop/src/app/features/canvas/PromptComposer.tsx create mode 100644 apps/desktop/src/styles/prompt-composer.css create mode 100644 docs/desktop/prompt-composer.md diff --git a/apps/desktop/e2e/canvas-interactions.spec.ts b/apps/desktop/e2e/canvas-interactions.spec.ts index f57c9f0..756ac7e 100644 --- a/apps/desktop/e2e/canvas-interactions.spec.ts +++ b/apps/desktop/e2e/canvas-interactions.spec.ts @@ -172,6 +172,61 @@ test("creates and finds a Gemini draft in a compact window", async ({ page }, te await expect(gemini).toHaveCount(1); }); +test("keeps separate composer drafts across terminal switches, Escape and reload while offline", async ({ page }, testInfo) => { + const first = canvasCard(page, "Terminal 1", "terminal"); + const second = canvasCard(page, "Terminal 2", "terminal"); + const note = canvasCard(page, "Notes", "note"); + await note.getByRole("textbox").fill("Verify Linux and macOS before release."); + await first.getByRole("button", { name: "Open Prompt Composer for Terminal 1" }).click(); + const editor = page.getByRole("textbox", { name: "Prompt for Terminal 1", exact: true }); + await expect(editor).toBeFocused(); + await editor.fill("Review the changes."); + await page.getByText("Insert context", { exact: false }).click(); + await page.getByRole("button", { name: "Insert context from Notes", exact: true }).click(); + const draft = "Review the changes.\n\nContext snapshot: Notes\nVerify Linux and macOS before release.\n"; + await expect(editor).toHaveValue(draft); + await expect(page.getByRole("button", { name: "Send prompt", exact: true })).toBeDisabled(); + await editor.press("Escape"); + await expect(editor).toHaveCount(0); + + await second.getByRole("button", { name: "Open Prompt Composer for Terminal 2" }).click(); + const secondEditor = page.getByRole("textbox", { name: "Prompt for Terminal 2", exact: true }); + await expect(secondEditor).toHaveValue(""); + await secondEditor.fill("A different investigation."); + await secondEditor.press("Escape"); + await page.reload(); + + await first.getByRole("button", { name: "Open Prompt Composer for Terminal 1" }).click(); + await expect(editor).toHaveValue(draft); + await page.screenshot({ path: testInfo.outputPath("canvas-prompt-composer-desktop.png") }); + await editor.press("Escape"); + await second.getByRole("button", { name: "Open Prompt Composer for Terminal 2" }).click(); + await expect(secondEditor).toHaveValue("A different investigation."); + await expect(page.getByRole("article")).toHaveCount(3); +}); + +test("keeps the compact composer visible and text-editing shortcuts inside its draft", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 640, height: 800 }); + await canvasCard(page, "Terminal 1", "terminal").getByRole("button", { name: "Open Prompt Composer for Terminal 1" }).click(); + const editor = page.getByRole("textbox", { name: "Prompt for Terminal 1", exact: true }); + await editor.fill("Replace this prompt"); + await editor.press("ControlOrMeta+a"); + await editor.press("Backspace"); + await expect(editor).toHaveValue(""); + await expect(page.getByRole("article")).toHaveCount(3); + await editor.fill("First line"); + await editor.press("Shift+Enter"); + await editor.pressSequentially("Second line"); + await expect(editor).toHaveValue("First line\nSecond line"); + await expect(editor).toBeInViewport({ ratio: 1 }); + await expect(page.getByRole("button", { name: "Close Prompt Composer", exact: true })).toBeInViewport({ ratio: 1 }); + await page.screenshot({ path: testInfo.outputPath("canvas-prompt-composer-compact.png") }); + await editor.press("ControlOrMeta+Shift+p"); + await expect(editor).toHaveCount(0); + await page.getByRole("button", { name: "Toggle Prompt Composer", exact: true }).click(); + await expect(editor).toHaveValue("First line\nSecond line"); +}); + /** Resolves a canvas card through its user-facing accessible name. */ function canvasCard(page: Page, title: string, kind: "terminal" | "note") { return page.getByRole("article", { name: `${title}, ${kind} canvas item`, exact: true }); diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx index df561f0..577db97 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx @@ -1,4 +1,5 @@ -import type { ComponentProps } from "react"; +import { useImperativeHandle, useState } from "react"; +import type { ComponentProps, Ref } from "react"; import { act, fireEvent, @@ -13,7 +14,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IpcError } from "../../../ipc/client"; import type { Session, Worktree } from "../../../ipc/types"; import type { BrowserRuntime } from "../browser/browser-runtime"; -import type { LiveTerminalTransport } from "../terminal/LiveTerminal"; +import type { + LiveTerminalInputHandle, + LiveTerminalTransport, +} from "../terminal/LiveTerminal"; import { CANVAS_STORAGE_KEY, parseCanvasDocument, @@ -25,9 +29,21 @@ import { import { CanvasWorkspace } from "./CanvasWorkspace"; vi.mock("../terminal/LiveTerminal", () => ({ - LiveTerminal: ({ session }: { readonly session: Session }) => ( -
- ), + LiveTerminal: ({ session, inputRef, writeTerminal }: { + readonly session: Session; + readonly inputRef?: Ref; + readonly writeTerminal: LiveTerminalTransport["writeTerminal"]; + }) => { + useImperativeHandle(inputRef, () => ({ + writeInput: async (encode) => { + await writeTerminal(session.id, encode({ + bracketedPasteMode: true, + applicationCursorKeysMode: false, + })); + }, + }), [session.id, writeTerminal]); + return
; + }, })); const PROJECT = { @@ -138,6 +154,278 @@ describe("CanvasWorkspace", () => { vi.restoreAllMocks(); }); + describe("Prompt Composer", () => { + it("delivers a multiline draft through the attached terminal transport", async () => { + const user = userEvent.setup(); + const writeTerminal = vi.fn().mockResolvedValue(undefined); + seedCanvasDocument([TERMINAL_NODE]); + const { props } = renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal }); + const trigger = screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + const composer = screen.getByRole("region", { name: "Prompt Composer" }); + const editor = within(composer).getByRole("textbox", { name: "Prompt for Terminal 1" }); + expect(editor).toHaveFocus(); + await user.type(editor, " Review Linux{Shift>}{Enter}{/Shift}and macOS "); + await user.click(within(composer).getByRole("button", { name: "Send prompt" })); + + expect(writeTerminal).toHaveBeenCalledExactlyOnceWith( + LIVE_SESSION.id, + new TextEncoder().encode("\x1b[200~ Review Linux\rand macOS \x1b[201~\r"), + ); + expect(editor).toHaveValue(""); + expect(readPromptDraft(TERMINAL_NODE.id)).toBe(""); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + }); + + it.each(["disconnected", "stopped", "unattached"] as const)( + "allows drafting but never starts or writes a %s terminal", + async (availability) => { + const user = userEvent.setup(); + seedCanvasDocument([{ + ...TERMINAL_NODE, + sessionId: availability === "unattached" ? undefined : LIVE_SESSION.id, + }]); + const { props } = renderProjectCanvas({ + isConnected: availability !== "disconnected", + sessions: availability === "unattached" ? [] : [{ + ...LIVE_SESSION, + status: availability === "stopped" ? "exited" : "running", + }], + }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + const editor = screen.getByRole("textbox", { name: "Prompt for Terminal 1" }); + await user.keyboard("{Enter}{ArrowUp}"); + await user.type(editor, "Continue when ready{Enter}"); + + expect(editor).toHaveValue("Continue when ready"); + expect(screen.getByRole("button", { name: "Send prompt" })).toBeDisabled(); + expect(readPromptDraft(TERMINAL_NODE.id)).toBe("Continue when ready"); + expect(props.writeTerminal).not.toHaveBeenCalled(); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + }, + ); + + it("restores the same terminal's offline draft after workspace reload", async () => { + const user = userEvent.setup(); + seedCanvasDocument([TERMINAL_NODE]); + const first = renderProjectCanvas({ isConnected: false }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + await user.type(screen.getByRole("textbox", { name: "Prompt for Terminal 1" }), "Revisar a implantação"); + await waitFor(() => expect(readPromptDraft(TERMINAL_NODE.id)).toBe("Revisar a implantação")); + first.unmount(); + + const second = renderProjectCanvas({ isConnected: false }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + expect(screen.getByRole("textbox", { name: "Prompt for Terminal 1" })).toHaveValue("Revisar a implantação"); + expect(first.props.writeTerminal).not.toHaveBeenCalled(); + expect(second.props.writeTerminal).not.toHaveBeenCalled(); + expect(second.props.onStartSession).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "keeps pending delivery isolated across close/reopen (revised draft: %s)", + async (reviseDraft) => { + const user = userEvent.setup(); + const delivery = deferredPromptDelivery(); + const writeTerminal = vi.fn().mockReturnValue(delivery.promise); + seedCanvasDocument([TERMINAL_NODE]); + renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal }); + const trigger = screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" }); + await user.click(trigger); + await user.type(screen.getByRole("textbox", { name: "Prompt for Terminal 1" }), "Repeat{Enter}"); + await user.click(screen.getByRole("button", { name: "Close Prompt Composer" })); + await user.click(trigger); + const editor = screen.getByRole("textbox", { name: "Prompt for Terminal 1" }); + expect(editor).toHaveValue("Repeat"); + await user.keyboard("{Enter}"); + expect(writeTerminal).toHaveBeenCalledTimes(1); + if (reviseDraft) { + await user.clear(editor); + await user.type(editor, "Repeat"); + } + await user.click(screen.getByRole("button", { name: "Close Prompt Composer" })); + await act(async () => delivery.complete()); + expect(screen.queryByRole("region", { name: "Prompt Composer" })).not.toBeInTheDocument(); + await user.click(trigger); + + expect(screen.getByRole("textbox", { name: "Prompt for Terminal 1" })).toHaveValue(reviseDraft ? "Repeat" : ""); + expect(readPromptDraft(TERMINAL_NODE.id)).toBe(reviseDraft ? "Repeat" : ""); + expect(writeTerminal).toHaveBeenCalledTimes(1); + }, + ); + + it("follows the primary terminal without letting the previous delivery erase its draft", async () => { + const user = userEvent.setup(); + const delivery = deferredPromptDelivery(); + const secondSession = { ...LIVE_SESSION, id: "0198f000-0000-7000-8000-000000000011", name: "Terminal 2" }; + const secondNode = { ...TERMINAL_NODE, id: "terminal-second", title: "Terminal 2", sessionId: secondSession.id }; + const writeTerminal = vi.fn() + .mockReturnValueOnce(delivery.promise).mockResolvedValue(undefined); + seedCanvasDocument([TERMINAL_NODE, secondNode, NOTE_NODE]); + renderProjectCanvas({ sessions: [LIVE_SESSION, secondSession], writeTerminal }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + await user.type(screen.getByRole("textbox", { name: "Prompt for Terminal 1" }), "First request{Enter}"); + await user.click(screen.getByRole("article", { name: "Terminal 2, terminal canvas item" })); + const secondEditor = screen.getByRole("textbox", { name: "Prompt for Terminal 2" }); + await user.type(secondEditor, "Second request"); + await act(async () => delivery.complete()); + expect(secondEditor).toHaveValue("Second request"); + expect(readPromptDraft(TERMINAL_NODE.id)).toBe(""); + await user.keyboard("{Enter}"); + + expect(writeTerminal).toHaveBeenNthCalledWith(1, LIVE_SESSION.id, new TextEncoder().encode("\x1b[200~First request\x1b[201~\r")); + expect(writeTerminal).toHaveBeenNthCalledWith(2, secondSession.id, new TextEncoder().encode("\x1b[200~Second request\x1b[201~\r")); + await user.click(screen.getByRole("article", { name: "Notes, note canvas item" })); + expect(screen.queryByRole("region", { name: "Prompt Composer" })).not.toBeInTheDocument(); + }); + + it("hides the composer on project changes and completes only the original project's draft", async () => { + const user = userEvent.setup(); + const delivery = deferredPromptDelivery(); + const otherSession = { + ...LIVE_SESSION, + id: "0198f000-0000-7000-8000-000000000011", + projectId: OTHER_PROJECT.id, + name: "Other terminal", + cwd: OTHER_PROJECT.path, + }; + const otherNode = { + ...TERMINAL_NODE, id: "terminal-other", title: "Other terminal", + projectId: OTHER_PROJECT.id, sessionId: otherSession.id, + }; + const writeTerminal = vi.fn().mockReturnValue(delivery.promise); + seedCanvasDocument([{ ...TERMINAL_NODE, projectId: PROJECT.id }, otherNode]); + const props = createProjectCanvasProps({ + projects: [PROJECT, OTHER_PROJECT], sessions: [LIVE_SESSION, otherSession], writeTerminal, + }); + function ProjectSwitcher() { + const [project, setProject] = useState(PROJECT); + return <> + + + ; + } + render(); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + await user.type(screen.getByRole("textbox", { name: "Prompt for Terminal 1" }), "For Jig{Enter}"); + await user.click(screen.getByRole("button", { name: "Switch project" })); + expect(screen.queryByRole("region", { name: "Prompt Composer" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Other terminal" })); + const editor = screen.getByRole("textbox", { name: "Prompt for Other terminal" }); + await user.type(editor, "Private draft"); + await act(async () => delivery.complete()); + + expect(editor).toHaveValue("Private draft"); + expect(readPromptDraft(otherNode.id)).toBe("Private draft"); + expect(readPromptDraft(TERMINAL_NODE.id)).toBe(""); + expect(writeTerminal).toHaveBeenCalledExactlyOnceWith(LIVE_SESSION.id, new TextEncoder().encode("\x1b[200~For Jig\x1b[201~\r")); + }); + + it.each(["ctrlKey", "metaKey"] as const)("guards the %s shortcut and Enter against repeat and IME confirmation", async (modifier) => { + const user = userEvent.setup(); + const writeTerminal = vi.fn().mockResolvedValue(undefined); + seedCanvasDocument([TERMINAL_NODE]); + renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal }); + const terminal = screen.getByRole("article", { name: "Terminal 1, terminal canvas item" }); + await user.click(terminal); + const shortcut = { key: "P", shiftKey: true, [modifier]: true }; + fireEvent.keyDown(terminal, { ...shortcut, repeat: true }); + fireEvent.keyDown(terminal, { ...shortcut, isComposing: true }); + expect(screen.queryByRole("region", { name: "Prompt Composer" })).not.toBeInTheDocument(); + fireEvent.keyDown(terminal, shortcut); + const editor = screen.getByRole("textbox", { name: "Prompt for Terminal 1" }); + await user.type(editor, "Intentional request"); + fireEvent.compositionStart(editor); + fireEvent.keyDown(editor, shortcut); + expect(editor).toBeVisible(); + fireEvent.compositionEnd(editor); + fireEvent.keyDown(editor, { key: "Enter", repeat: true }); + fireEvent.keyDown(editor, { key: "Enter", isComposing: true }); + fireEvent.keyDown(editor, { key: "Enter", keyCode: 229 }); + expect(writeTerminal).not.toHaveBeenCalled(); + expect(editor).toHaveValue("Intentional request"); + await user.keyboard("{Enter}"); + expect(writeTerminal).toHaveBeenCalledExactlyOnceWith(LIVE_SESSION.id, new TextEncoder().encode("\x1b[200~Intentional request\x1b[201~\r")); + fireEvent.keyDown(editor, shortcut); + expect(screen.queryByRole("region", { name: "Prompt Composer" })).not.toBeInTheDocument(); + const toolbarTrigger = screen.getByRole("button", { name: "Toggle Prompt Composer" }); + await user.click(toolbarTrigger); + expect(screen.getByRole("textbox", { name: "Prompt for Terminal 1" })).toHaveValue(""); + await user.keyboard("{Escape}"); + expect(toolbarTrigger).toHaveFocus(); + fireEvent.keyDown(toolbarTrigger, shortcut); + expect(screen.getByRole("textbox", { name: "Prompt for Terminal 1" })).toHaveFocus(); + }); + + it("forwards intentional empty-editor keys as terminal input", async () => { + const user = userEvent.setup(); + const writeTerminal = vi.fn().mockResolvedValue(undefined); + seedCanvasDocument([TERMINAL_NODE]); + renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + for (const key of ["Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]) { + await user.keyboard(`{${key}}`); + } + expect(writeTerminal.mock.calls.map(([sessionId, bytes]) => [sessionId, new TextDecoder().decode(bytes)])).toEqual( + ["\r", "\t", "\x1b[A", "\x1b[B", "\x1b[D", "\x1b[C"].map((text) => [LIVE_SESSION.id, text]), + ); + expect(screen.getByRole("textbox", { name: "Prompt for Terminal 1" })).toHaveValue(""); + }); + + it("inserts only connected context snapshots and sends them only on explicit submission", async () => { + const user = userEvent.setup(); + const writeTerminal = vi.fn().mockResolvedValue(undefined); + seedCanvasDocument([ + TERMINAL_NODE, NOTE_NODE, { ...BROWSER_NODE, url: "docs.example.com/guide" }, + { ...NOTE_NODE, id: "unconnected-note", title: "Unconnected", text: "Not selected as context" }, + ], [ + { id: "terminal-note", sourceNodeId: TERMINAL_NODE.id, targetNodeId: NOTE_NODE.id }, + { id: "browser-terminal", sourceNodeId: BROWSER_NODE.id, targetNodeId: TERMINAL_NODE.id }, + ]); + const { props } = renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + const composer = screen.getByRole("region", { name: "Prompt Composer" }); + await user.click(within(composer).getByText(/Insert context/)); + expect(within(composer).queryByRole("button", { name: "Insert context from Unconnected" })).not.toBeInTheDocument(); + await user.click(within(composer).getByRole("button", { name: "Insert context from Notes" })); + await user.click(within(composer).getByRole("button", { name: "Insert context from Browser URL" })); + const editor = within(composer).getByRole("textbox", { name: "Prompt for Terminal 1" }); + const draft = (editor as HTMLTextAreaElement).value; + expect(draft).toContain("Context snapshot: Notes\nReview the integration\n"); + expect(draft).toContain("Context snapshot: Browser URL\nhttps://docs.example.com/guide\n"); + fireEvent.change(within(screen.getByRole("article", { name: "Notes, note canvas item" })).getByRole("textbox"), { + target: { value: "Changed after insertion" }, + }); + expect(editor).toHaveValue(draft); + expect(writeTerminal).not.toHaveBeenCalled(); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + await user.click(within(composer).getByRole("button", { name: "Send prompt" })); + expect(writeTerminal).toHaveBeenCalledExactlyOnceWith(LIVE_SESSION.id, new TextEncoder().encode(`\x1b[200~${draft.replace(/\n/g, "\r")}\x1b[201~\r`)); + }); + + it("preserves a rejected transport write for an explicit retry", async () => { + const user = userEvent.setup(); + const writeTerminal = vi.fn() + .mockRejectedValueOnce(new Error("Socket disconnected")).mockResolvedValue(undefined); + seedCanvasDocument([TERMINAL_NODE]); + renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal }); + await user.click(screen.getByRole("button", { name: "Open Prompt Composer for Terminal 1" })); + const editor = screen.getByRole("textbox", { name: "Prompt for Terminal 1" }); + await user.type(editor, "Keep for retry{Enter}"); + expect(screen.getByRole("alert")).toHaveTextContent("Could not send the prompt"); + expect(editor).toHaveValue("Keep for retry"); + expect(readPromptDraft(TERMINAL_NODE.id)).toBe("Keep for retry"); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(writeTerminal).toHaveBeenCalledTimes(2); + expect(writeTerminal).toHaveBeenLastCalledWith(LIVE_SESSION.id, new TextEncoder().encode("\x1b[200~Keep for retry\x1b[201~\r")); + expect(editor).toHaveValue(""); + }); + }); + it("renders the first-launch terminal and note composition", () => { const { container } = renderCanvas(); @@ -1914,7 +2202,14 @@ function renderCanvas( function renderProjectCanvas( overrides: Partial> = {}, ) { - const props: ComponentProps = { + const props = createProjectCanvasProps(overrides); + return { ...render(), props }; +} + +function createProjectCanvasProps( + overrides: Partial> = {}, +): ComponentProps { + return { isConnected: true, projects: [PROJECT], project: PROJECT, @@ -1938,7 +2233,6 @@ function renderProjectCanvas( resizeTerminal: vi.fn(), ...overrides, }; - return { ...render(), props }; } function readCanvasDocument(): CanvasDocument { @@ -1947,6 +2241,18 @@ function readCanvasDocument(): CanvasDocument { return document; } +function readPromptDraft(nodeId: string): string { + const node = readCanvasDocument().nodes.find((candidate) => candidate.id === nodeId); + if (node?.kind !== "terminal") throw new Error(`Expected terminal ${nodeId}.`); + return node.promptDraft ?? ""; +} + +function deferredPromptDelivery() { + let complete: () => void = () => {}; + const promise = new Promise((resolve) => { complete = resolve; }); + return { promise, complete }; +} + function connectionEndpointX(container: HTMLElement): number { const path = container.querySelector("[data-connection-id] path"); const coordinates = path?.getAttribute("d")?.match(/-?\d+(?:\.\d+)?/g); diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx index 19a6840..2139e3e 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx @@ -8,6 +8,7 @@ import { useState, useSyncExternalStore, } from "react"; +import type { Ref } from "react"; import type { AgentRecord, @@ -32,6 +33,7 @@ import { createTerminalCanvasNode, duplicateCanvasSelection, getCanvasNodeSize, + normalizeBrowserUrl, type BrowserCanvasNode, type CanvasTerminalConfiguration, type CanvasNode, @@ -51,11 +53,16 @@ import { toStagePoint, } from "./canvas-geometry"; import { NewCanvasTerminalDialog } from "./NewCanvasTerminalDialog"; +import { PromptComposer } from "./PromptComposer"; +import type { PromptContextItem, PromptTerminalKey } from "./PromptComposer"; +import { encodePromptInput, encodePromptTerminalKey, getPromptInputError } from "./prompt-input"; import { useCanvasState } from "./useCanvasState"; import { LiveTerminal, + type LiveTerminalInputHandle, type LiveTerminalTransport, } from "../terminal/LiveTerminal"; +import type { TerminalInputModes } from "../terminal/terminal-runtime"; import { errorData, isLiveStatus } from "../../utils"; export interface CanvasSessionFocusRequest { @@ -128,6 +135,9 @@ export function CanvasWorkspace({ const handledFocusRequestRef = useRef(null); const focusSelectionRef = useRef(false); const layersTriggerRef = useRef(null); + const terminalInputsRef = useRef(new Map()); + const pendingComposerInputRef = useRef(new Set()); + const canvasComposingRef = useRef(false); const panRef = useRef<{ readonly pointerId: number; readonly clientX: number; @@ -139,6 +149,10 @@ export function CanvasWorkspace({ null, ); const [layersOpen, setLayersOpen] = useState(false); + const [composerOpen, setComposerOpen] = useState(false); + const [pendingComposerNodes, setPendingComposerNodes] = useState>( + () => new Set(), + ); const [terminalDialogOpen, setTerminalDialogOpen] = useState(false); const [pendingTerminals, setPendingTerminals] = useState>( () => new Set(), @@ -219,6 +233,25 @@ export function CanvasWorkspace({ const selectedNode = visibleNodes.find( (node) => node.id === state.selectedNodeId, ); + const composerNode = composerOpen && selectedNode?.kind === "terminal" + ? selectedNode + : undefined; + const composerSession = composerNode?.sessionId + ? terminalSessions.get(composerNode.sessionId) + : undefined; + const composerDisabledReason = composerNode + ? !isConnected + ? "Reconnect to the daemon to send input. You can keep drafting." + : !composerSession + ? "Attach a running session to send input. You can keep drafting." + : !isLiveStatus(composerSession.status) + ? "This terminal is stopped. Start it to send input." + : pendingComposerNodes.has(composerNode.id) + ? "Input is being delivered to this terminal. You can keep drafting." + : composerNode.promptDraft?.trim() + ? getPromptInputError(composerNode.promptDraft) + : undefined + : undefined; const selectedNodeIds = state.selectedNodeIds.filter((id) => visibleNodeIds.has(id), ); @@ -234,6 +267,22 @@ export function CanvasWorkspace({ return otherNode ? [{ connection, otherNode }] : []; }) : []; + const composerContextItems: readonly PromptContextItem[] = composerNode + ? selectedConnections.flatMap(({ otherNode }) => { + if (otherNode.kind === "note") { + return [{ id: otherNode.id, title: otherNode.title, text: otherNode.text }]; + } + if (otherNode.kind === "browser") { + const url = normalizeBrowserUrl(otherNode.url); + return url ? [{ id: otherNode.id, title: `${otherNode.title} URL`, text: url }] : []; + } + return []; + }) + : []; + const registerTerminalInput = useCallback((nodeId: string, handle: LiveTerminalInputHandle | null) => { + if (handle) terminalInputsRef.current.set(nodeId, handle); + else terminalInputsRef.current.delete(nodeId); + }, []); const sessionCanvasTopologyKey = useMemo( () => JSON.stringify({ @@ -560,6 +609,44 @@ export function CanvasWorkspace({ } } + async function deliverComposerInput( + node: TerminalCanvasNode, + encode: (modes: TerminalInputModes) => Uint8Array, + ) { + const session = terminalSessions.get(node.sessionId ?? ""); + const input = terminalInputsRef.current.get(node.id); + if (!isConnected || !session || !isLiveStatus(session.status) || !input) { + throw new Error("This terminal is not available for input."); + } + if (pendingComposerInputRef.current.has(node.id)) { + throw new Error("Input is already being delivered to this terminal."); + } + pendingComposerInputRef.current.add(node.id); + setPendingComposerNodes(new Set(pendingComposerInputRef.current)); + try { + await input.writeInput(encode); + } finally { + pendingComposerInputRef.current.delete(node.id); + setPendingComposerNodes(new Set(pendingComposerInputRef.current)); + } + } + + async function sendComposerPrompt(node: TerminalCanvasNode, text: string) { + const revision = node.promptDraftRevision ?? 0; + await deliverComposerInput(node, (modes) => encodePromptInput(text, modes)); + dispatch({ type: "terminal/draft_sent", nodeId: node.id, text, revision }); + } + + function sendComposerKey(node: TerminalCanvasNode, key: PromptTerminalKey) { + return deliverComposerInput(node, (modes) => encodePromptTerminalKey(key, modes)); + } + + function toggleComposer(node: TerminalCanvasNode) { + selectNode(node); + setLayersOpen(false); + setComposerOpen((open) => !(open && selectedNode?.id === node.id)); + } + function setZoom(zoom: number) { markCanvasScrolling(); dispatch({ type: "zoom/set", zoom: Number(zoom.toFixed(2)) }); @@ -702,6 +789,28 @@ export function CanvasWorkspace({ className="canvas-workspace" tabIndex={-1} aria-labelledby="canvas-workspace-title" + onCompositionStartCapture={() => { canvasComposingRef.current = true; }} + onCompositionEndCapture={() => { canvasComposingRef.current = false; }} + onKeyDownCapture={(event) => { + if ( + event.defaultPrevented || event.repeat || canvasComposingRef.current || event.nativeEvent.isComposing + || event.nativeEvent.keyCode === 229 || !event.shiftKey || event.altKey + || event.metaKey === event.ctrlKey || event.key.toLowerCase() !== "p" + ) return; + const target = event.target instanceof Element ? event.target : null; + const targetId = target?.closest("[data-canvas-node-id]")?.getAttribute("data-canvas-node-id"); + const targetNode = visibleNodes.find((node) => node.id === targetId); + if (targetNode && targetNode.kind !== "terminal") return; + const terminal = targetNode?.kind === "terminal" ? targetNode : selectedNode; + if (terminal?.kind !== "terminal") return; + if ( + target?.closest("input, textarea, select, [contenteditable]:not([contenteditable='false']), [role='textbox'], [role='dialog']") + && !target?.closest("[data-terminal-root], .prompt-composer") + ) return; + event.preventDefault(); + event.stopPropagation(); + toggleComposer(terminal); + }} onKeyDown={(event) => { if (event.defaultPrevented || isCanvasEditingTarget(event.target)) { return; @@ -774,6 +883,18 @@ export function CanvasWorkspace({ > +
+ {node.kind === "terminal" ? ( + + ) : null} {selected ? ( Selected ) : null} @@ -1566,6 +1733,7 @@ function CanvasNodeCard({ isConnected, )} transport={terminalTransport} + inputRef={registerInput} /> ) : node.kind === "note" ? ( @@ -2139,6 +2307,7 @@ function TerminalNodeBody({ error, startDisabledReason, transport, + inputRef, }: { readonly node: TerminalCanvasNode; readonly agent?: AgentRecord; @@ -2148,6 +2317,7 @@ function TerminalNodeBody({ readonly error?: string; readonly startDisabledReason?: string; readonly transport: LiveTerminalTransport; + readonly inputRef: Ref; }) { const live = session ? isLiveStatus(session.status) : false; const startDisabled = pending || startDisabledReason !== undefined; @@ -2172,7 +2342,7 @@ function TerminalNodeBody({
{session && live ? (
- +
) : (
diff --git a/apps/desktop/src/app/features/canvas/PromptComposer.test.tsx b/apps/desktop/src/app/features/canvas/PromptComposer.test.tsx new file mode 100644 index 0000000..36d54c1 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/PromptComposer.test.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { PromptComposer } from "./PromptComposer"; +import type { PromptComposerProps, PromptTerminalKey } from "./PromptComposer"; + +interface FixtureProps extends Omit { + readonly initialValue?: string; +} + +function Fixture({ initialValue = "", ...props }: FixtureProps) { + const [value, setValue] = useState(initialValue); + return ; +} + +function deferred() { + let complete: () => void = () => {}; + const promise = new Promise((resolve) => { complete = resolve; }); + return { promise, complete }; +} + +function setup(overrides: Partial = {}) { + const onSend = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const props = { nodeId: "terminal-a", title: "Codex API", onSend, onClose, ...overrides }; + const view = render(); + return { ...view, onSend, onClose, user: userEvent.setup() }; +} + +describe("PromptComposer", () => { + it("labels the target, focuses its draft and sends the exact text", async () => { + const { user, onSend } = setup({ initialValue: " Explain this function\nthen add tests " }); + const editor = screen.getByRole("textbox", { name: "Prompt for Codex API" }); + expect(screen.getByRole("region", { name: "Prompt Composer" })).toBeVisible(); + expect(screen.getByText("Send to")).toBeVisible(); + expect(editor).toHaveFocus(); + + await user.click(screen.getByRole("button", { name: "Send prompt" })); + + expect(onSend).toHaveBeenCalledExactlyOnceWith(" Explain this function\nthen add tests "); + expect(editor).toHaveValue(""); + expect(screen.getByRole("status")).toHaveTextContent("Prompt sent to Codex API."); + }); + + it("sends on Enter and inserts a newline with Shift+Enter", async () => { + const { user, onSend } = setup(); + const editor = screen.getByRole("textbox"); + await user.type(editor, "First line"); + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(editor).toHaveValue("First line\n"); + expect(onSend).not.toHaveBeenCalled(); + + await user.type(editor, "Second line{Enter}"); + + expect(onSend).toHaveBeenCalledExactlyOnceWith("First line\nSecond line"); + expect(editor).toHaveValue(""); + }); + + it("ignores repeated Enter, IME confirmation and modified shortcuts", async () => { + const { onSend, onClose, user } = setup({ initialValue: "Draft" }); + const editor = screen.getByRole("textbox"); + fireEvent.keyDown(editor, { key: "Enter", repeat: true }); + fireEvent.keyDown(editor, { key: "Enter", isComposing: true }); + fireEvent.keyDown(editor, { key: "Enter", keyCode: 229 }); + fireEvent.compositionStart(editor); + fireEvent.keyDown(editor, { key: "Enter" }); + fireEvent.keyDown(editor, { key: "Escape" }); + fireEvent.compositionEnd(editor); + fireEvent.keyDown(editor, { key: "Enter", ctrlKey: true }); + fireEvent.keyDown(editor, { key: "Enter", metaKey: true }); + fireEvent.keyDown(editor, { key: "Enter", altKey: true }); + expect(onSend).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(editor).toHaveValue("Draft"); + + await user.keyboard("{Enter}"); + expect(onSend).toHaveBeenCalledExactlyOnceWith("Draft"); + }); + + it.each(["", " \n\t"])("does not submit an empty or whitespace-only draft %j", async (initialValue) => { + const { user, onSend } = setup({ initialValue }); + expect(screen.getByRole("button", { name: "Send prompt" })).toBeDisabled(); + await user.keyboard("{Enter}"); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox")).toHaveValue(initialValue); + }); + + it("explains an unavailable terminal while allowing draft edits", async () => { + const onTerminalKey = vi.fn>().mockResolvedValue(undefined); + const { user, onSend } = setup({ disabledReason: "Attach a running session to send input.", onTerminalKey }); + const editor = screen.getByRole("textbox"); + expect(editor).toHaveAccessibleDescription(expect.stringContaining("Attach a running session")); + await user.keyboard("{Enter}{ArrowUp}"); + await user.type(editor, "Saved for later{Enter}"); + + expect(editor).toHaveValue("Saved for later"); + expect(screen.getByRole("button", { name: "Send prompt" })).toBeDisabled(); + expect(onSend).not.toHaveBeenCalled(); + expect(onTerminalKey).not.toHaveBeenCalled(); + }); + + it("retains the draft after a rejected send and offers an explicit retry", async () => { + const onSend = vi.fn() + .mockRejectedValueOnce(new Error("Transport failed")) + .mockResolvedValueOnce(undefined); + const { user } = setup({ initialValue: "Keep this request", onSend }); + await user.click(screen.getByRole("button", { name: "Send prompt" })); + + expect(screen.getByRole("alert")).toHaveTextContent("Your draft is still here"); + expect(screen.getByRole("textbox")).toHaveValue("Keep this request"); + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect(onSend).toHaveBeenCalledTimes(2); + expect(onSend).toHaveBeenLastCalledWith("Keep this request"); + expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("does not duplicate pending sends or clear a draft edited while sending", async () => { + const delivery = deferred(); + const onSend = vi.fn().mockReturnValue(delivery.promise); + const { user } = setup({ initialValue: "Submitted", onSend }); + const editor = screen.getByRole("textbox"); + await user.keyboard("{Enter}{Enter}"); + expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled(); + expect(screen.getByRole("status")).toHaveTextContent("Sending prompt…"); + await user.clear(editor); + await user.type(editor, "Next request"); + await act(async () => delivery.complete()); + + expect(onSend).toHaveBeenCalledExactlyOnceWith("Submitted"); + expect(editor).toHaveValue("Next request"); + expect(screen.getByRole("button", { name: "Send prompt" })).toBeEnabled(); + }); + + it("preserves a revised draft even when it equals the submitted text again", async () => { + const delivery = deferred(); + const { user } = setup({ initialValue: "Repeat", onSend: () => delivery.promise }); + const editor = screen.getByRole("textbox"); + await user.keyboard("{Enter}"); + await user.clear(editor); + await user.type(editor, "Repeat"); + await act(async () => delivery.complete()); + expect(editor).toHaveValue("Repeat"); + }); + + it("ignores a previous terminal's pending completion after switching targets", async () => { + const delivery = deferred(); + const onChange = vi.fn(); + const onClose = vi.fn(); + const { rerender } = render( delivery.promise} onClose={onClose} + />); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + rerender(); + await act(async () => delivery.complete()); + + expect(screen.getByRole("textbox", { name: "Prompt for Terminal B" })).toHaveValue("B draft"); + expect(screen.getByRole("textbox")).toHaveFocus(); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("inserts named context as an explicit text snapshot without sending it", async () => { + const { user, onSend, rerender } = setup({ + initialValue: "Review this plan.", + contextItems: [{ id: "note-a", title: "Release plan", text: "Keep Linux and macOS support." }], + }); + await user.click(screen.getByText(/Insert context/)); + expect(screen.getByText("Copies the source text into this draft as a snapshot.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Insert context from Release plan" })); + + const expected = "Review this plan.\n\nContext snapshot: Release plan\nKeep Linux and macOS support.\n"; + expect(screen.getByRole("textbox")).toHaveValue(expected); + expect(screen.getByRole("textbox")).toHaveFocus(); + expect(onSend).not.toHaveBeenCalled(); + rerender(); + expect(screen.getByRole("textbox")).toHaveValue(expected); + }); + + it.each(["Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"])( + "forwards %s only when the editor is empty and the keystroke is intentional", + async (key) => { + const onTerminalKey = vi.fn>().mockResolvedValue(undefined); + const { onSend, user } = setup({ onTerminalKey }); + const editor = screen.getByRole("textbox"); + fireEvent.keyDown(editor, { key, repeat: true }); + fireEvent.keyDown(editor, { key, isComposing: true }); + fireEvent.keyDown(editor, { key, ctrlKey: true }); + expect(onTerminalKey).not.toHaveBeenCalled(); + await act(async () => { fireEvent.keyDown(editor, { key }); }); + expect(onTerminalKey).toHaveBeenCalledExactlyOnceWith(key); + expect(onSend).not.toHaveBeenCalled(); + await user.type(editor, "New draft"); + await act(async () => { fireEvent.keyDown(editor, { key }); }); + expect(onTerminalKey).toHaveBeenCalledTimes(1); + }, + ); + + it("allows Shift+Tab to leave the empty editor", async () => { + const onTerminalKey = vi.fn>().mockResolvedValue(undefined); + const { user } = setup({ onTerminalKey }); + await user.tab({ shift: true }); + expect(screen.getByRole("button", { name: "Close Prompt Composer" })).toHaveFocus(); + expect(onTerminalKey).not.toHaveBeenCalled(); + }); + + it("keeps forward tab navigation available when terminal input is disabled", async () => { + const onTerminalKey = vi.fn>().mockResolvedValue(undefined); + const { user } = setup({ + onTerminalKey, + disabledReason: "Attach a running session to send input.", + contextItems: [{ id: "note-a", title: "Plan", text: "Useful context" }], + }); + await user.tab(); + expect(screen.getByText(/Insert context/)).toHaveFocus(); + expect(onTerminalKey).not.toHaveBeenCalled(); + }); + + it("offers a retry for a key that could not be delivered", async () => { + const onTerminalKey = vi.fn>() + .mockRejectedValueOnce(new Error("Disconnected")) + .mockResolvedValueOnce(undefined); + const { user, onSend } = setup({ onTerminalKey }); + await user.keyboard("{ArrowUp}"); + expect(screen.getByRole("alert")).toHaveTextContent("Could not deliver the key"); + await user.click(screen.getByRole("button", { name: "Retry key" })); + expect(onTerminalKey).toHaveBeenCalledTimes(2); + expect(onTerminalKey).toHaveBeenLastCalledWith("ArrowUp"); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent("Key sent to Codex API."); + }); + + it("closes with Escape or the close button without discarding the draft", async () => { + const { user, onClose, onSend } = setup({ initialValue: "Return to this later" }); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.getByRole("textbox")).toHaveValue("Return to this later"); + await user.click(screen.getByRole("button", { name: "Close Prompt Composer" })); + expect(onClose).toHaveBeenCalledTimes(2); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("restores focus to the trigger when closed from inside the composer", async () => { + const user = userEvent.setup(); + function Host() { + const [open, setOpen] = useState(false); + return <> + + {open ? setOpen(false)} /> : null} + ; + } + render(); + const trigger = screen.getByRole("button", { name: "Compose" }); + await user.click(trigger); + expect(screen.getByRole("textbox")).toHaveFocus(); + await user.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); + }); +}); diff --git a/apps/desktop/src/app/features/canvas/PromptComposer.tsx b/apps/desktop/src/app/features/canvas/PromptComposer.tsx new file mode 100644 index 0000000..b419299 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/PromptComposer.tsx @@ -0,0 +1,287 @@ +import { useId, useLayoutEffect, useRef, useState } from "react"; +import type { FormEvent, KeyboardEvent } from "react"; + +import { Icon } from "../../components/Icon"; +import "../../../styles/prompt-composer.css"; + +/** A source whose current text can be explicitly copied into the draft. */ +export interface PromptContextItem { + readonly id: string; + readonly title: string; + readonly text: string; +} + +/** Keys an empty composer can forward to its attached terminal. */ +export type PromptTerminalKey = + | "Enter" + | "Tab" + | "ArrowUp" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight"; + +/** Controlled terminal input; the owner persists drafts and delivers input. */ +export interface PromptComposerProps { + readonly nodeId: string; + readonly title: string; + readonly value: string; + readonly onChange: (value: string) => void; + readonly onSend: (text: string) => Promise; + readonly onClose: () => void; + /** Explains why input cannot be sent; drafting remains available. */ + readonly disabledReason?: string; + readonly contextItems?: readonly PromptContextItem[]; + readonly onTerminalKey?: (key: PromptTerminalKey) => Promise; + /** False when the owner acknowledges sends with its own persistent revision check. */ + readonly clearOnSend?: boolean; +} + +type PendingInput = { readonly kind: "prompt" } | { + readonly kind: "key"; + readonly key: PromptTerminalKey; +}; + +/** Keeps each terminal's focus and in-flight input isolated when targets change. */ +export function PromptComposer(props: PromptComposerProps) { + return ; +} + +function TerminalPromptComposer({ + nodeId, + title, + value, + onChange, + onSend, + onClose, + disabledReason, + contextItems = [], + onTerminalKey, + clearOnSend = true, +}: PromptComposerProps) { + const id = useId(); + const panelRef = useRef(null); + const editorRef = useRef(null); + const mountedRef = useRef(false); + const composingRef = useRef(false); + const busyRef = useRef(false); + const draftRef = useRef({ value, onChange, revision: 0 }); + const [pending, setPending] = useState(); + const [failed, setFailed] = useState(); + const [status, setStatus] = useState(""); + + useLayoutEffect(() => { + const previous = draftRef.current; + draftRef.current = { + value, + onChange, + revision: previous.revision + (previous.value === value ? 0 : 1), + }; + }, [value, onChange]); + + useLayoutEffect(() => { + mountedRef.current = true; + const panel = panelRef.current; + const previousFocus = document.activeElement; + editorRef.current?.focus(); + return () => { + mountedRef.current = false; + if ( + panel?.contains(document.activeElement) + && previousFocus instanceof HTMLElement + && previousFocus.isConnected + ) { + previousFocus.focus(); + } + }; + }, []); + + function changeDraft(nextValue: string) { + draftRef.current = { + value: nextValue, + onChange, + revision: draftRef.current.revision + 1, + }; + onChange(nextValue); + setFailed(undefined); + setStatus(""); + } + + async function sendPrompt() { + if (disabledReason || busyRef.current || !value.trim()) return; + const submitted = draftRef.current; + busyRef.current = true; + setPending({ kind: "prompt" }); + setFailed(undefined); + setStatus(""); + try { + await onSend(value); + if (!mountedRef.current) return; + const latest = draftRef.current; + if (clearOnSend && latest.value === submitted.value && latest.revision === submitted.revision) { + latest.onChange(""); + } + setStatus(`Prompt sent to ${title}.`); + } catch { + if (mountedRef.current) setFailed({ kind: "prompt" }); + } finally { + busyRef.current = false; + if (mountedRef.current) setPending(undefined); + } + } + + async function sendTerminalKey(key: PromptTerminalKey) { + if (disabledReason || busyRef.current || !onTerminalKey) return; + busyRef.current = true; + setPending({ kind: "key", key }); + setFailed(undefined); + setStatus(""); + try { + await onTerminalKey(key); + if (mountedRef.current) setStatus(`Key sent to ${title}.`); + } catch { + if (mountedRef.current) setFailed({ kind: "key", key }); + } finally { + busyRef.current = false; + if (mountedRef.current) setPending(undefined); + } + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + void sendPrompt(); + } + + function handleEditorKey(event: KeyboardEvent) { + if ( + composingRef.current || event.nativeEvent.isComposing + || event.nativeEvent.keyCode === 229 + || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey + ) return; + + if ( + value === "" && onTerminalKey && !disabledReason && !busyRef.current + && isTerminalKey(event.key) + ) { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) void sendTerminalKey(event.key); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) void sendPrompt(); + } + } + + function insertContext(item: PromptContextItem) { + const separator = value && !value.endsWith("\n\n") ? "\n\n" : ""; + changeDraft(`${value}${separator}Context snapshot: ${item.title}\n${item.text}\n`); + setStatus(`Inserted a text snapshot from ${item.title}.`); + editorRef.current?.focus(); + } + + const feedbackId = `${id}-feedback`; + const disabledId = `${id}-disabled`; + const helpId = `${id}-help`; + + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if ( + event.key === "Escape" && !event.repeat && !composingRef.current + && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229 + ) { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + > +
+
+

Prompt Composer

+

Send to {title}

+
+ +
+
+ +