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/e2e/canvas-interactions.spec.ts b/apps/desktop/e2e/canvas-interactions.spec.ts new file mode 100644 index 0000000..3377152 --- /dev/null +++ b/apps/desktop/e2e/canvas-interactions.spec.ts @@ -0,0 +1,289 @@ +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); +}); + +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"); +}); + +test("uses the local knowledge editor as an explicit draft source while offline", 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(); + await page.getByRole("textbox", { name: "Prompt for Terminal 1", exact: true }).fill("Start with this draft."); + await page.getByRole("button", { name: "Close Prompt Composer", exact: true }).click(); + await page.getByRole("button", { name: "Open prompts and context", exact: true }).click(); + const library = page.getByRole("region", { name: "Knowledge library", exact: true }); + await expect(library).toBeVisible(); + await library.getByRole("textbox", { name: "Title", exact: true }).fill("Review checklist"); + await library.getByRole("textbox", { name: "Content", exact: true }).fill("Inspect the Linux and macOS acceptance evidence."); + await library.getByRole("button", { name: "Close knowledge library", exact: true }).click(); + await page.getByRole("button", { name: "Open prompts and context", exact: true }).click(); + await expect(library.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Review checklist"); + await library.getByRole("button", { name: "Insert into draft", exact: true }).scrollIntoViewIfNeeded(); + await page.screenshot({ path: testInfo.outputPath("canvas-knowledge-compact.png") }); + await library.getByRole("button", { name: "Insert into draft", exact: true }).click(); + const editor = page.getByRole("textbox", { name: "Prompt for Terminal 1", exact: true }); + const expected = "Start with this draft.\n\nKnowledge snapshot: Review checklist\nInspect the Linux and macOS acceptance evidence.\n"; + await expect(editor).toHaveValue(expected); + await expect(page.getByRole("button", { name: "Send prompt", exact: true })).toBeDisabled(); + await page.reload(); + await canvasCard(page, "Terminal 1", "terminal").getByRole("button", { name: "Open Prompt Composer for Terminal 1" }).click(); + await expect(editor).toHaveValue(expected); +}); + +test("opens the compact source inspector without losing unsaved library text while offline", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.getByRole("button", { name: "Open prompts and context", exact: true }).click(); + const library = page.getByRole("region", { name: "Knowledge library", exact: true }); + await library.getByRole("textbox", { name: "Title", exact: true }).fill("Keep this local draft"); + await library.getByRole("textbox", { name: "Content", exact: true }).fill("Do not send or overwrite this text."); + await library.getByRole("button", { name: "Rules & skills", exact: true }).click(); + const inspector = library.getByRole("region", { name: "Rules & skills", exact: true }); + await expect(inspector).toBeVisible(); + await expect(inspector.getByRole("alert")).toHaveText(/The local daemon could not be reached/); + await expect(inspector.getByText(/Native CLI loading remains unverified/)).toBeVisible(); + await expect(library).toBeInViewport({ ratio: 1 }); + expect(await library.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath("canvas-source-inspector-compact.png") }); + await library.getByRole("button", { name: "Close knowledge library", exact: true }).click(); + await page.getByRole("button", { name: "Open prompts and context", exact: true }).click(); + await expect(inspector.getByRole("searchbox")).toBeFocused(); + await library.getByRole("button", { name: "Saved prompts & context", exact: true }).click(); + await expect(library.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Keep this local draft"); + await expect(library.getByRole("textbox", { name: "Content", exact: true })).toHaveValue("Do not send or overwrite this text."); + await expect(page.getByRole("article")).toHaveCount(3); +}); + +/** 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/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 d481a52..e26be08 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/apps/desktop/src/app/AppShell.test.tsx b/apps/desktop/src/app/AppShell.test.tsx index 89b0e41..209d24b 100644 --- a/apps/desktop/src/app/AppShell.test.tsx +++ b/apps/desktop/src/app/AppShell.test.tsx @@ -1,13 +1,13 @@ 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 { KnowledgeSourceEntry } from "../ipc/domain"; import type { - AgentRecord, AgentDetection, + AgentRecord, BootstrapResult, Project, Session, @@ -19,18 +19,187 @@ 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("opens the project knowledge library from the command palette and keeps its IPC scope current", async () => { + const project = createProject({ lastOpenedAtMs: TEST_TIME + 1 }); + const otherProject = createProject({ id: "project-other", name: "Other repository" }); + const client = createMockIpcClient({ + bootstrap: createBootstrap({ projects: [project, otherProject] }), + handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) }, + }); + const user = await renderApp(client); + await user.click(screen.getByRole("button", { name: "Settings" })); + expect(client.listKnowledge).not.toHaveBeenCalled(); + const palette = await openCommandPalette(user); + await selectPaletteCommand(user, palette, "Open prompts and context"); + + const library = await screen.findByRole("region", { name: "Prompts & context" }); + expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: project.name, level: 1 })).toBeVisible(); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledExactlyOnceWith({ projectId: project.id })); + await user.type(within(library).getByLabelText("Title"), "Current project draft"); + await user.type(within(library).getByLabelText("Content"), "Keep project scope"); + expect(client.listKnowledge).toHaveBeenCalledTimes(1); + await user.click(within(screen.getByRole("navigation", { name: "Workspaces" })).getByRole("button", { name: /^Other repository/ })); + if (!screen.queryByRole("region", { name: "Knowledge library" })) { + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + } + + await waitFor(() => expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: otherProject.id })); + expect(screen.getByLabelText("Title")).toHaveValue(""); + expect(screen.getByLabelText("Scope")).toHaveValue(otherProject.id); + expect(client.saveKnowledge).not.toHaveBeenCalled(); + expect(client.writeTerminal).not.toHaveBeenCalled(); + expect(client.createSession).not.toHaveBeenCalled(); + expect(client.startSession).not.toHaveBeenCalled(); + }); + + it("routes the command palette to the global knowledge library without a project", async () => { + const client = createMockIpcClient({ + bootstrap: EMPTY_BOOTSTRAP, + handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) }, + }); + const user = await renderApp(client); + const palette = await openCommandPalette(user); + expect(client.listKnowledge).not.toHaveBeenCalled(); + await selectPaletteCommand(user, palette, "Open prompts and context"); + + const library = await screen.findByRole("region", { name: "Prompts & context" }); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledExactlyOnceWith({ projectId: null })); + expect(within(library).getByText("Global library")).toBeVisible(); + expect(within(library).getByLabelText("Scope")).toHaveValue(""); + expect(client.writeTerminal).not.toHaveBeenCalled(); + }); + + it.each([true, false])("discovers rules and skills only in their section and reads an explicitly selected source (project: %s)", async (hasProject) => { + const project = createProject(); + const entry = createKnowledgeSource({ scope: hasProject ? "project" : "global" }); + const content = "# Local instructions\n\n"; + const client = createMockIpcClient({ + bootstrap: createBootstrap({ projects: hasProject ? [project] : [] }), + handlers: { + listKnowledge: async () => ({ entries: [], nextCursor: null }), + discoverKnowledge: async () => ({ scanId: "opaque-scan", entries: [entry], truncated: false, issues: [] }), + readKnowledge: async () => ({ entry, content }), + }, + }); + const user = await renderApp(client); + const palette = await openCommandPalette(user); + await selectPaletteCommand(user, palette, "Open prompts and context"); + const library = await screen.findByRole("region", { name: "Knowledge library" }); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledOnce()); + expect(client.discoverKnowledge).not.toHaveBeenCalled(); + expect(client.readKnowledge).not.toHaveBeenCalled(); + await user.click(within(library).getByRole("button", { name: "Rules & skills" })); + const sourceButton = await within(library).findByRole("button", { name: entry.name }); + expect(client.discoverKnowledge).toHaveBeenCalledExactlyOnceWith({ projectId: hasProject ? project.id : null }); + expect(client.readKnowledge).not.toHaveBeenCalled(); + await user.click(sourceButton); + + expect(client.readKnowledge).toHaveBeenCalledExactlyOnceWith({ scanId: "opaque-scan", entryId: entry.entryId }); + expect((await within(library).findByLabelText("Source content")).textContent).toBe(content); + expect(document.querySelector("script")).not.toBeInTheDocument(); + expect(client.writeTerminal).not.toHaveBeenCalled(); + expect(client.startSession).not.toHaveBeenCalled(); + expect(client.createSession).not.toHaveBeenCalled(); + expect(client.saveKnowledge).not.toHaveBeenCalled(); + expect(client.openPath).not.toHaveBeenCalled(); + }); + + it("invalidates a source preview when the same client reconnects to a new daemon", async () => { + const project = createProject(); + const entry = createKnowledgeSource(); + const bootstrap = createBootstrap({ projects: [project] }); + const client = createMockIpcClient({ + bootstrap, + handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) }, + }); + client.discoverKnowledge.mockResolvedValueOnce({ scanId: "scan-before", entries: [entry], truncated: false, issues: [] }); + client.discoverKnowledge.mockResolvedValue({ scanId: "scan-after", entries: [entry], truncated: false, issues: [] }); + client.readKnowledge.mockResolvedValueOnce({ entry, content: "Preview from the old daemon" }); + client.readKnowledge.mockResolvedValue({ entry, content: "Revalidated instructions" }); + const user = await renderApp(client); + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + await user.click(screen.getByRole("button", { name: "Rules & skills" })); + await user.click(await screen.findByRole("button", { name: entry.name })); + expect(await screen.findByLabelText("Source content")).toHaveTextContent("Preview from the old daemon"); + expect(client.readKnowledge).toHaveBeenCalledExactlyOnceWith({ scanId: "scan-before", entryId: entry.entryId }); + client.initialize.mockResolvedValue({ ...bootstrap, hello: { ...bootstrap.hello, instanceId: "daemon-after-reconnect" } }); + await act(async () => { + client.emit("daemon.shutting_down", { reasonCode: "restart_requested", activeSessionCount: 0 }); + }); + expect(screen.queryByLabelText("Source content")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Reconnect" })); + await waitFor(() => { + expect(client.initialize).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("button", { name: "Reconnect" })).not.toBeInTheDocument(); + }); + expect(screen.queryByLabelText("Source content")).not.toBeInTheDocument(); + expect(client.readKnowledge).toHaveBeenCalledTimes(1); + await user.click(await screen.findByRole("button", { name: entry.name })); + + expect(client.readKnowledge).toHaveBeenLastCalledWith({ scanId: "scan-after", entryId: entry.entryId }); + expect(await screen.findByLabelText("Source content")).toHaveTextContent("Revalidated instructions"); + expect(client.saveKnowledge).not.toHaveBeenCalled(); + expect(client.writeTerminal).not.toHaveBeenCalled(); + expect(client.startSession).not.toHaveBeenCalled(); + }); + + it("keeps local notes available when first connection fails and retries without losing them", async () => { + const client = createMockIpcClient({ + initialize: async () => { throw new Error("Local daemon is unavailable"); }, + }); + const user = userEvent.setup(); + render(); + const offline = await screen.findByRole("region", { name: "Daemon disconnected" }); + expect(screen.getByRole("main")).toHaveClass("canvas-workspace"); + expect(screen.getByRole("button", { name: "Add workspace project" })).toBeDisabled(); + for (const button of screen.getAllByRole("button", { name: "Start terminal" })) { + expect(button).toHaveAttribute("aria-disabled", "true"); + await user.click(button); + } + + const note = screen.getByRole("textbox", { name: "Notes content" }); + await user.clear(note); + await user.type(note, "Keep this plan while offline"); + expect(localStorage.getItem(CANVAS_STORAGE_KEY)).toContain("Keep this plan while offline"); + expect(client.createSession).not.toHaveBeenCalled(); + expect(client.startSession).not.toHaveBeenCalled(); + + client.initialize.mockResolvedValue(EMPTY_BOOTSTRAP); + await user.click(within(offline).getByRole("button", { name: "Retry Connection" })); + await waitFor(() => expect(screen.queryByRole("region", { name: "Daemon disconnected" })).not.toBeInTheDocument()); + expect(screen.getByRole("textbox", { name: "Notes content" })).toHaveValue("Keep this plan while offline"); + }); + + it("starts a terminal inside the canvas without changing workspace", async () => { const project = createProject(); const shellAgent: AgentRecord = { ...createAgent(), @@ -60,16 +229,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 +248,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 +283,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 +302,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 +313,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 +321,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 +365,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 +388,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 +423,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 +515,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 +549,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 +588,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 +620,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 +644,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 +660,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 +795,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 +853,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 +994,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 +1040,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 +1067,246 @@ 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 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: { + listWorktrees: async () => [worktree], + 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" }); + const session = createSession({ + name: "Offline metadata", + status: "running", + }); const client = createMockIpcClient({ bootstrap: createBootstrap({ projects: [project], @@ -817,34 +1326,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 +1387,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 +1409,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", @@ -942,6 +1438,7 @@ describe("AppShell project and session workflows", () => { worktrees: [worktree], }), handlers: { + listWorktrees: async () => [], stopSession: async () => ({ ...runningSession, status: "exited", @@ -958,27 +1455,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 +1484,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 +1497,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 +1509,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 +1532,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 +1572,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 +1583,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 +1727,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 +1791,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, })), }; } @@ -1183,6 +1810,15 @@ function createProject(overrides: Partial = {}): Project { }; } +function createKnowledgeSource(overrides: Partial = {}): KnowledgeSourceEntry { + return { + entryId: "opaque-entry", kind: "rule", provider: "codex", scope: "project", + sourcePath: "/repos/project/AGENTS.md", name: "AGENTS.md", scopeDirectory: ".", + precedenceHint: "Native loading depends on the CLI.", + viaSymlink: false, availability: "available", ...overrides, + }; +} + function createAgent(): AgentRecord { return { id: "agent-codex", @@ -1239,5 +1875,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 cb3175f..4c1ed71 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,15 @@ export function AppShell() { const [canvasSidebarCollapsed, setCanvasSidebarCollapsed] = useState( readCanvasSidebarCollapsed, ); + const [sessionFocusRevision, setSessionFocusRevision] = useState(0); + const [knowledgeOpenRevision, setKnowledgeOpenRevision] = 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 +43,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 +85,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 +148,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 +159,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 +196,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 +223,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 +260,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 +317,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 +329,90 @@ 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: "knowledge.open", + label: "Open prompts and context", + description: "Browse saved content and insert a snapshot into a terminal draft.", + keywords: ["library", "knowledge", "templates", "XIRP"], + onSelect: () => { + workspace.setView("canvas"); + setKnowledgeOpenRevision((revision) => revision + 1); + }, + }, { 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,46 +431,81 @@ 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. - + {workspace.connection.status === "disconnected" ? ( +
+ + Daemon disconnected.{" "} + {workspace.snapshot + ? "Existing metadata may be stale." + : "Notes and terminal drafts are available offline."} + +
) : null} {workspace.operationError ? ( @@ -372,20 +526,16 @@ export function AppShell() { ) : null}
Navigation @@ -393,76 +543,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 }) @@ -494,10 +667,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} @@ -528,6 +700,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 5e5ba95..dc66c35 100644 --- a/apps/desktop/src/app/components/Icon.tsx +++ b/apps/desktop/src/app/components/Icon.tsx @@ -15,7 +15,6 @@ export type IconName = | "diagnostics" | "external-link" | "folder" - | "grid" | "layers" | "link" | "map" @@ -31,7 +30,6 @@ export type IconName = | "repository" | "sidebar" | "search" - | "session" | "settings" | "stop" | "terminal" @@ -131,15 +129,6 @@ function getIconPaths(name: IconName): ReactNode { return ( ); - case "grid": - return ( - <> - - - - - - ); case "layers": return ( <> @@ -229,7 +218,6 @@ function getIconPaths(name: IconName): ReactNode { ); - case "session": case "terminal": return ( <> 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..701070d --- /dev/null +++ b/apps/desktop/src/app/features/canvas/CanvasElementSearch.test.tsx @@ -0,0 +1,94 @@ +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(); + }); + + it("searches browser addresses without indexing sensitive query values or fragments", async () => { + const user = userEvent.setup(); + const onFocusNode = vi.fn(); + const browser: CanvasNode = { + id: "browser", kind: "browser", title: "Documentation", + url: "https://docs.example.com/guide?mode=compact&token=private-value#private-fragment", + x: 0, y: 0, width: 640, height: 420, + }; + render(); + const input = screen.getByRole("searchbox"); + await user.type(input, "browser docs.example.com compact"); + expect(screen.getByRole("button", { name: "Documentation https://docs.example.com/guide?mode=compact" })).toBeVisible(); + await user.keyboard("{Enter}"); + expect(onFocusNode).toHaveBeenCalledWith(browser); + for (const secret of ["private-value", "private-fragment"]) { + await user.clear(input); + await user.type(input, secret); + expect(screen.getByText(/No matching items/)).toBeVisible(); + } + }); +}); 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..b9d0202 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/CanvasElementSearch.tsx @@ -0,0 +1,127 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import type { AgentRecord, Session } from "../../../ipc/types"; +import { Icon } from "../../components/Icon"; +import { normalizeBrowserUrl, 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 + : node.kind === "browser" + ? normalizeBrowserUrl(node.url) + : [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, URL, agent, branch or path.

+ ) : ( +
    + {entries.map(({ node, details }, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/app/features/canvas/CanvasKnowledgePanel.tsx b/apps/desktop/src/app/features/canvas/CanvasKnowledgePanel.tsx new file mode 100644 index 0000000..5607ec6 --- /dev/null +++ b/apps/desktop/src/app/features/canvas/CanvasKnowledgePanel.tsx @@ -0,0 +1,121 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import { Icon } from "../../components/Icon"; +import { KnowledgePanel } from "../knowledge/KnowledgePanel"; +import type { KnowledgePanelProps } from "../knowledge/KnowledgePanel"; +import type { KnowledgeProject } from "../knowledge/knowledge-types"; +import { KnowledgeSourceInspector } from "../knowledge/KnowledgeSourceInspector"; +import type { KnowledgeSourceInspectorProps } from "../knowledge/useKnowledgeSources"; +import "./canvas-knowledge-panel.css"; + +/** Keeps library drafts mounted while the canvas overlay is dismissed. */ +interface CanvasKnowledgePanelProps extends Omit { + readonly client: KnowledgePanelProps["client"] & KnowledgeSourceInspectorProps["client"]; + readonly connectionKey?: string; + readonly open: boolean; + readonly currentProject?: KnowledgeProject | null; + readonly projects: readonly KnowledgeProject[]; + readonly targetTitle?: string; + readonly onClose: () => void; +} + +export function CanvasKnowledgePanel({ + open, + client, + connectionKey, + currentProject, + projects, + targetTitle, + onClose, + ...props +}: CanvasKnowledgePanelProps) { + const panelRef = useRef(null); + const returnFocusRef = useRef(null); + const composingRef = useRef(false); + const [section, setSection] = useState<"library" | "sources">("library"); + // Keep the last project identity when it disappears instead of relabeling + // its in-memory drafts as global. Switching to another real project is safe. + const [scopeProject, setScopeProject] = useState(currentProject ?? null); + const nextScope = currentProject ?? ( + scopeProject && !projects.some((project) => project.id === scopeProject.id) + ? scopeProject + : null + ); + if (scopeProject !== nextScope) setScopeProject(nextScope); + + useLayoutEffect(() => { + if (!open) return; + const previous = document.activeElement; + returnFocusRef.current = previous instanceof HTMLElement ? previous : null; + const panel = panelRef.current; + Array.from(panel?.querySelectorAll('input[type="search"]') ?? []) + .find((input) => !input.closest("[hidden]"))?.focus(); + return () => { + if (panel?.contains(document.activeElement) && returnFocusRef.current?.isConnected) { + returnFocusRef.current.focus(); + } + }; + }, [open]); + + function requestClose() { + if (returnFocusRef.current?.isConnected) returnFocusRef.current.focus(); + onClose(); + } + + const missingProject = scopeProject && !projects.some((project) => project.id === scopeProject.id); + return ( + + ); +} diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx index 293eb14..df3a941 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.test.tsx @@ -1,3 +1,5 @@ +import { useImperativeHandle, useState } from "react"; +import type { ComponentProps, Ref } from "react"; import { act, fireEvent, @@ -9,11 +11,19 @@ import { import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Session } from "../../../ipc/types"; +import { IpcError } from "../../../ipc/client"; +import type { KnowledgeSourceEntry } from "../../../ipc/domain"; +import type { Session, Worktree } from "../../../ipc/types"; +import { createMockIpcClient } from "../../../test/mockIpc"; import type { BrowserRuntime } from "../browser/browser-runtime"; -import type { LiveTerminalTransport } from "../terminal/LiveTerminal"; +import type { KnowledgeRecord } from "../knowledge/knowledge-types"; +import type { + LiveTerminalInputHandle, + LiveTerminalTransport, +} from "../terminal/LiveTerminal"; import { CANVAS_STORAGE_KEY, + parseCanvasDocument, type BrowserCanvasNode, type CanvasDocument, type NoteCanvasNode, @@ -21,6 +31,24 @@ import { } from "./canvas-state"; import { CanvasWorkspace } from "./CanvasWorkspace"; +vi.mock("../terminal/LiveTerminal", () => ({ + 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 = { id: "0198f000-0000-7000-8000-000000000001", name: "Jig", @@ -39,6 +67,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, +}; + const BROWSER_NODE: BrowserCanvasNode = { id: "browser-test", kind: "browser", @@ -95,6 +157,513 @@ describe("CanvasWorkspace", () => { vi.restoreAllMocks(); }); + describe("Knowledge library", () => { + it.each(["Source content", "Discovery issue details"])( + "keeps canvas actions out of the focusable %s inspector surface", + async (surfaceName) => { + const user = userEvent.setup(); + const entry: KnowledgeSourceEntry = { + entryId: "rule-shortcuts", kind: "rule", provider: "codex", scope: "project", + sourcePath: "/workspace/jig/AGENTS.md", name: "AGENTS.md", scopeDirectory: ".", + precedenceHint: "Native loading depends on the CLI.", viaSymlink: false, availability: "available", + }; + const knowledgeClient = createMockIpcClient({ handlers: { + listKnowledge: async () => ({ entries: [], nextCursor: null }), + discoverKnowledge: async () => ({ + scanId: "scan-shortcuts", entries: [entry], truncated: false, + issues: [{ code: "nested_scope_unsupported", sourcePath: "/workspace/jig/nested", message: "Nested project scopes are not included." }], + }), + readKnowledge: async () => ({ entry, content: "Read these instructions without changing the canvas." }), + } }); + seedCanvasDocument([TERMINAL_NODE, NOTE_NODE]); + const { props } = renderProjectCanvas({ knowledgeClient, sessions: [LIVE_SESSION] }); + const terminal = screen.getByRole("article", { name: "Terminal 1, terminal canvas item" }); + const note = screen.getByRole("article", { name: "Notes, note canvas item" }); + await user.click(terminal); + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + await user.click(screen.getByRole("button", { name: "Rules & skills" })); + await user.click(await screen.findByRole("button", { name: entry.name })); + await screen.findByLabelText("Source content"); + const surface = screen.getByLabelText(surfaceName); + act(() => surface.focus()); + expect(surface).toHaveFocus(); + await user.keyboard("{Backspace}{Delete}{Control>}a{/Control}{Control>}{Shift>}p{/Shift}{/Control}"); + + expect(terminal).toBeInTheDocument(); + expect(terminal).toHaveAttribute("data-selected", "true"); + expect(note).toBeInTheDocument(); + expect(note).not.toHaveAttribute("data-selected", "true"); + expect(readCanvasDocument().nodes.map((node) => node.id)).toEqual([TERMINAL_NODE.id, NOTE_NODE.id]); + expect(screen.getByRole("region", { name: "Knowledge library" })).toBeVisible(); + expect(screen.getByRole("region", { name: "Rules & skills" })).toBeVisible(); + expect(screen.queryByRole("region", { name: "Prompt Composer" })).not.toBeInTheDocument(); + expect(surface).toHaveFocus(); + expect(props.writeTerminal).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + expect(props.onStopSession).not.toHaveBeenCalled(); + expect(knowledgeClient.saveKnowledge).not.toHaveBeenCalled(); + }, + ); + + it("preserves saved-content drafts while source inspection is opened, hidden and reopened", async () => { + const user = userEvent.setup(); + const entry: KnowledgeSourceEntry = { + entryId: "rule-one", kind: "rule", provider: "codex", scope: "project", + sourcePath: "/workspace/jig/AGENTS.md", name: "AGENTS.md", scopeDirectory: ".", + precedenceHint: "Native loading depends on the CLI.", viaSymlink: false, availability: "available", + }; + const knowledgeClient = createMockIpcClient({ handlers: { + listKnowledge: async () => ({ entries: [], nextCursor: null }), + discoverKnowledge: async () => ({ scanId: "canvas-scan", entries: [entry], truncated: false, issues: [] }), + } }); + const { props, rerender } = renderProjectCanvas({ knowledgeClient, knowledgeConnectionKey: "connected:daemon-a" }); + const trigger = screen.getByRole("button", { name: "Open prompts and context" }); + await user.click(trigger); + const library = screen.getByRole("region", { name: "Prompts & context" }); + await user.type(within(library).getByLabelText("Title"), "Unfinished review"); + await user.type(within(library).getByLabelText("Content"), "Keep my unsaved instructions"); + expect(knowledgeClient.discoverKnowledge).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Rules & skills" })); + await screen.findByRole("button", { name: entry.name }); + expect(knowledgeClient.discoverKnowledge).toHaveBeenCalledExactlyOnceWith({ projectId: PROJECT.id }); + expect(library).toBeInTheDocument(); + expect(library).not.toBeVisible(); + await user.click(screen.getByRole("button", { name: "Close knowledge library" })); + expect(screen.queryByRole("region", { name: "Rules & skills" })).not.toBeInTheDocument(); + rerender(); + await user.click(screen.getByRole("button", { name: "Add note" })); + expect(knowledgeClient.discoverKnowledge).toHaveBeenCalledTimes(1); + await user.click(trigger); + await screen.findByRole("button", { name: entry.name }); + expect(knowledgeClient.discoverKnowledge).toHaveBeenCalledTimes(2); + await user.click(screen.getByRole("button", { name: "Saved prompts & context" })); + + expect(library).toBeVisible(); + expect(within(library).getByLabelText("Title")).toHaveValue("Unfinished review"); + expect(within(library).getByLabelText("Content")).toHaveValue("Keep my unsaved instructions"); + expect(knowledgeClient.listKnowledge).toHaveBeenCalledTimes(1); + expect(knowledgeClient.readKnowledge).not.toHaveBeenCalled(); + expect(knowledgeClient.saveKnowledge).not.toHaveBeenCalled(); + expect(props.writeTerminal).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + }); + + it.each([true, false])("loads saved content only after an explicit open (connected: %s)", async (isConnected) => { + const user = userEvent.setup(); + const knowledgeClient = createMockIpcClient({ + handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) }, + }); + renderProjectCanvas({ knowledgeClient, isConnected }); + await user.click(screen.getByRole("button", { name: "Add note" })); + expect(knowledgeClient.listKnowledge).not.toHaveBeenCalled(); + expect(screen.queryByRole("region", { name: "Knowledge library" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + expect(screen.getByRole("region", { name: "Knowledge library" })).toBeVisible(); + await waitFor(() => expect(knowledgeClient.listKnowledge).toHaveBeenCalledExactlyOnceWith({ projectId: PROJECT.id })); + expect(knowledgeClient.saveKnowledge).not.toHaveBeenCalled(); + }); + + it("saves project context through the owned client and reloads the saved record", async () => { + const user = userEvent.setup(); + const saved = knowledgeEntry({ kind: "context", projectId: PROJECT.id, title: "Architecture", body: "Keep Linux and macOS support.\n" }); + const knowledgeClient = createMockIpcClient(); + knowledgeClient.listKnowledge.mockResolvedValueOnce({ entries: [], nextCursor: null }); + knowledgeClient.listKnowledge.mockResolvedValue({ entries: [saved], nextCursor: null }); + knowledgeClient.saveKnowledge.mockResolvedValue(saved); + const view = renderProjectCanvas({ knowledgeClient }); + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + const library = screen.getByRole("region", { name: "Prompts & context" }); + await user.selectOptions(within(library).getByLabelText("Type"), "context"); + await user.type(within(library).getByLabelText("Title"), "Architecture"); + await user.type(within(library).getByLabelText("Content"), "Keep Linux and macOS support.{Enter}"); + await user.click(within(library).getByRole("button", { name: "Save locally" })); + expect(knowledgeClient.saveKnowledge).toHaveBeenCalledExactlyOnceWith({ + kind: "context", projectId: PROJECT.id, title: saved.title, body: saved.body, + }); + expect(await within(library).findByText("Saved locally.")).toBeVisible(); + view.unmount(); + + renderProjectCanvas({ knowledgeClient }); + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + const reloaded = screen.getByRole("region", { name: "Prompts & context" }); + await user.click(await within(reloaded).findByRole("button", { name: saved.title })); + expect(within(reloaded).getByLabelText("Content")).toHaveValue(saved.body); + expect(within(reloaded).getByLabelText("Scope")).toHaveValue(PROJECT.id); + expect(view.props.writeTerminal).not.toHaveBeenCalled(); + expect(view.props.onStartSession).not.toHaveBeenCalled(); + }); + + it("keeps unsaved library edits across hide/reopen and ignores canvas deletion shortcuts inside its editor", async () => { + const user = userEvent.setup(); + const knowledgeClient = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } }); + seedCanvasDocument([TERMINAL_NODE]); + renderProjectCanvas({ knowledgeClient, sessions: [LIVE_SESSION] }); + const terminal = screen.getByRole("article", { name: "Terminal 1, terminal canvas item" }); + await user.click(terminal); + const trigger = screen.getByRole("button", { name: "Open prompts and context" }); + await user.click(trigger); + const library = screen.getByRole("region", { name: "Prompts & context" }); + await user.type(within(library).getByLabelText("Title"), "Unfinished context"); + const content = within(library).getByLabelText("Content"); + await user.type(content, "Keep this draft"); + fireEvent.keyDown(content, { key: "Delete", ctrlKey: true }); + fireEvent.keyDown(content, { key: "Backspace", metaKey: true }); + expect(terminal).toBeInTheDocument(); + expect(readCanvasDocument().nodes).toHaveLength(1); + await user.click(screen.getByRole("button", { name: "Close knowledge library" })); + expect(library).not.toBeVisible(); + await user.click(trigger); + + expect(within(screen.getByRole("region", { name: "Prompts & context" })).getByLabelText("Title")).toHaveValue("Unfinished context"); + expect(content).toHaveValue("Keep this draft"); + expect(knowledgeClient.listKnowledge).toHaveBeenCalledTimes(1); + expect(knowledgeClient.saveKnowledge).not.toHaveBeenCalled(); + expect(knowledgeClient.deleteKnowledge).not.toHaveBeenCalled(); + }); + + it("inserts the edited snapshot only into the current offline terminal draft without delivering input", async () => { + const user = userEvent.setup(); + const original = knowledgeEntry(); + const knowledgeClient = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }) } }); + const firstNode = { ...TERMINAL_NODE, promptDraft: "First draft", promptDraftRevision: 1 }; + const secondNode = { ...TERMINAL_NODE, id: "terminal-second", title: "Terminal 2", sessionId: undefined, promptDraft: "Second draft", promptDraftRevision: 1 }; + seedCanvasDocument([firstNode, secondNode, NOTE_NODE]); + const { props } = renderProjectCanvas({ knowledgeClient, isConnected: false }); + await user.click(screen.getByRole("article", { name: "Terminal 1, terminal canvas item" })); + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + const library = screen.getByRole("region", { name: "Prompts & context" }); + await user.click(await within(library).findByRole("button", { name: original.title })); + await user.clear(within(library).getByLabelText("Content")); + await user.type(within(library).getByLabelText("Content"), "Unsaved instructions{Enter}Keep exact whitespace. "); + await user.click(screen.getByRole("article", { name: "Notes, note canvas item" })); + expect(within(library).getByRole("button", { name: "Insert into draft" })).toBeDisabled(); + await user.click(screen.getByRole("article", { name: "Terminal 2, terminal canvas item" })); + await user.click(within(library).getByRole("button", { name: "Insert into draft" })); + + expect(library).not.toBeVisible(); + const editor = screen.getByRole("textbox", { name: "Prompt for Terminal 2" }); + expect(editor).toHaveFocus(); + const draft = (editor as HTMLTextAreaElement).value; + expect(draft).toContain("Second draft\n\nKnowledge snapshot: Review changes\n"); + expect(draft).toContain("Unsaved instructions\nKeep exact whitespace. \n"); + expect(draft).not.toContain(original.body); + expect(readPromptDraft(firstNode.id)).toBe("First draft"); + expect(readPromptDraft(secondNode.id)).toBe(draft); + expect(props.writeTerminal).not.toHaveBeenCalled(); + expect(props.onCreateSession).not.toHaveBeenCalled(); + expect(props.onStartSession).not.toHaveBeenCalled(); + expect(knowledgeClient.saveKnowledge).not.toHaveBeenCalled(); + }); + + it("keeps new library drafts and listing requests scoped to the selected project", async () => { + const user = userEvent.setup(); + const knowledgeClient = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } }); + const props = createProjectCanvasProps({ knowledgeClient, projects: [PROJECT, OTHER_PROJECT] }); + function ProjectSwitcher() { + const [project, setProject] = useState(PROJECT); + return <> + + + + ; + } + render(); + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + await user.type(screen.getByLabelText("Title"), "Jig draft"); + await user.type(screen.getByLabelText("Content"), "Jig context"); + await user.click(screen.getByRole("button", { name: "Use other project" })); + await ensureKnowledgeLibraryOpen(user); + await waitFor(() => expect(knowledgeClient.listKnowledge).toHaveBeenLastCalledWith({ projectId: OTHER_PROJECT.id })); + expect(screen.getByLabelText("Title")).toHaveValue(""); + expect(screen.getByLabelText("Content")).toHaveValue(""); + expect(screen.getByLabelText("Scope")).toHaveValue(OTHER_PROJECT.id); + await user.type(screen.getByLabelText("Title"), "Other draft"); + await user.type(screen.getByLabelText("Content"), "Other context"); + await user.click(screen.getByRole("button", { name: "Use Jig" })); + await ensureKnowledgeLibraryOpen(user); + + expect(screen.getByLabelText("Title")).toHaveValue("Jig draft"); + expect(screen.getByLabelText("Content")).toHaveValue("Jig context"); + expect(screen.getByLabelText("Scope")).toHaveValue(PROJECT.id); + await waitFor(() => expect(knowledgeClient.listKnowledge).toHaveBeenLastCalledWith({ projectId: PROJECT.id })); + expect(knowledgeClient.saveKnowledge).not.toHaveBeenCalled(); + expect(props.writeTerminal).not.toHaveBeenCalled(); + }); + }); + + 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(); @@ -177,6 +746,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()} @@ -212,6 +789,7 @@ describe("CanvasWorkspace", () => { relativeDirectory: undefined, }); expect(onStartSession).toHaveBeenCalledWith(createdSession.id); + expect(onSelectSession).toHaveBeenCalledWith(createdSession.id); }); const persisted = JSON.parse( localStorage.getItem(CANVAS_STORAGE_KEY) ?? "{}", @@ -250,6 +828,38 @@ describe("CanvasWorkspace", () => { }); }); + it("prepares an isolated checkout before explicitly starting its returned session", async () => { + const user = userEvent.setup(); + const session: Session = { + id: "isolated-session", projectId: PROJECT.id, agentId: SHELL_AGENT.id, name: "Shell", + cwd: "/managed/worktrees/review/tools", worktreeId: "isolated-worktree", + worktreePath: "/managed/worktrees/review", status: "unknown", createdAtMs: 1, updatedAtMs: 1, + }; + let resolveCreate!: (value: Session) => void; + const prepared = new Promise((resolve) => { resolveCreate = resolve; }); + const { props } = renderProjectCanvas({ + onCreateSession: vi.fn(() => prepared), + onStartSession: vi.fn(async () => ({ ...session, status: "running" as const })), + }); + await user.click(screen.getByRole("button", { name: "Add terminal card" })); + const dialog = screen.getByRole("dialog", { name: "New Terminal" }); + await user.selectOptions(within(dialog).getByRole("combobox", { name: "Working copy" }), "new_worktree"); + await user.clear(within(dialog).getByLabelText("Working directory")); + await user.type(within(dialog).getByLabelText("Working directory"), `${PROJECT.path}/tools`); + await user.click(within(dialog).getByRole("button", { name: "Create terminal" })); + await waitFor(() => expect(props.onCreateSession).toHaveBeenCalledExactlyOnceWith({ + projectId: PROJECT.id, name: "Shell", agentId: SHELL_AGENT.id, + isolation: "new_worktree", relativeDirectory: "tools", + })); + expect(props.onStartSession).not.toHaveBeenCalled(); + await act(async () => { resolveCreate(session); await prepared; }); + await waitFor(() => expect(props.onStartSession).toHaveBeenCalledExactlyOnceWith(session.id)); + const saved = parseCanvasDocument(localStorage.getItem(CANVAS_STORAGE_KEY)); + expect(saved.nodes).toContainEqual(expect.objectContaining({ + isolation: "new_worktree", sessionId: session.id, projectId: PROJECT.id, + })); + }); + it("adds an integrated browser card to the persisted canvas", async () => { const user = userEvent.setup(); const { container } = renderCanvas(); @@ -383,7 +993,7 @@ describe("CanvasWorkspace", () => { async () => undefined, ); seedCanvasDocument([{ ...BROWSER_NODE, url: browserUrl }, TERMINAL_NODE]); - renderCanvas({ + renderProjectCanvas({ sessions: [LIVE_SESSION], writeTerminal, subscribeTerminal: vi.fn(async () => vi.fn()), @@ -440,7 +1050,7 @@ describe("CanvasWorkspace", () => { await user.click(browser); const webPage = within(browser).getByRole("region", { name: "Web page" }); await waitFor(() => { - expect(browser).toHaveAttribute("aria-selected", "true"); + expect(browser).toHaveAttribute("data-selected", "true"); expect(webPage).toHaveAttribute("data-native-browser-visible", "true"); }); @@ -592,6 +1202,94 @@ describe("CanvasWorkspace", () => { expect(webPage).toHaveAttribute("data-native-browser-visible", "true"); }); + it("keeps only the primary browser active during group selection and movement", async () => { + const user = userEvent.setup(); + const runtime = createAvailableBrowserRuntime(); + stubVisibleBrowserGeometry(); + seedCanvasDocument([ + BROWSER_NODE, + { ...BROWSER_NODE, id: "browser-second", title: "Preview", x: 840 }, + ]); + renderCanvas({ browserRuntime: runtime }); + const browser = screen.getByRole("article", { name: "Browser, browser canvas item" }); + const preview = screen.getByRole("article", { name: "Preview, browser canvas item" }); + const firstPage = within(browser).getByRole("region", { name: "Web page" }); + const secondPage = within(preview).getByRole("region", { name: "Web page" }); + + await user.click(browser); + await waitFor(() => expect(firstPage).toHaveAttribute("data-native-browser-visible", "true")); + await user.keyboard("{Shift>}"); + await user.click(preview); + await user.keyboard("{/Shift}"); + + expect(browser).toHaveAttribute("data-selected", "true"); + expect(preview).toHaveAttribute("data-selected", "true"); + await waitFor(() => { + expect(firstPage).toHaveAttribute("data-native-browser-visible", "false"); + expect(secondPage).toHaveAttribute("data-native-browser-visible", "true"); + expect(runtime.close).toHaveBeenCalledWith({ nodeId: BROWSER_NODE.id }); + }); + expect(runtime.open).toHaveBeenCalledTimes(2); + + const header = within(preview).getByLabelText("Move Preview"); + fireEvent.pointerDown(header, { pointerId: 51, button: 0, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(header, { pointerId: 51, clientX: 124, clientY: 116 }); + expect(secondPage).toHaveAttribute("data-native-browser-visible", "false"); + expect(readNodePosition(BROWSER_NODE.id)).toEqual({ x: 184, y: 136 }); + expect(readNodePosition("browser-second")).toEqual({ x: 864, y: 136 }); + fireEvent.pointerUp(header, { pointerId: 51 }); + await waitFor(() => expect(secondPage).toHaveAttribute("data-native-browser-visible", "true")); + + await user.click(screen.getByRole("button", { name: "Duplicate selected canvas items" })); + expect(screen.getAllByRole("article", { name: /browser canvas item/ })).toHaveLength(4); + await waitFor(() => expect(runtime.open).toHaveBeenCalledTimes(3)); + expect(runtime.close).toHaveBeenCalledWith({ nodeId: "browser-second" }); + expect(document.querySelectorAll('[data-native-browser-visible="true"]')).toHaveLength(1); + }); + + it("scopes browsers and URL search to the selected project and hides the surface while searching", async () => { + const user = userEvent.setup(); + const runtime = createAvailableBrowserRuntime(); + stubVisibleBrowserGeometry(); + seedCanvasDocument([ + { ...BROWSER_NODE, projectId: PROJECT.id }, + { ...BROWSER_NODE, id: "other-browser", title: "Private preview", projectId: OTHER_PROJECT.id, url: "https://other.example.com/private" }, + ]); + const { props, rerender } = renderProjectCanvas({ + projects: [PROJECT, OTHER_PROJECT], + browserRuntime: runtime, + }); + const browser = screen.getByRole("article", { name: "Browser, browser canvas item" }); + const webPage = within(browser).getByRole("region", { name: "Web page" }); + expect(screen.queryByRole("article", { name: /Private preview/ })).not.toBeInTheDocument(); + expect(screen.getByText(/1 browsers/)).toBeVisible(); + await user.click(browser); + await waitFor(() => expect(webPage).toHaveAttribute("data-native-browser-visible", "true")); + + await user.click(screen.getByRole("button", { name: "Show canvas items" })); + expect(webPage).toHaveAttribute("data-native-browser-visible", "false"); + expect(screen.getByRole("region", { name: "Canvas items" })).toHaveAttribute("data-browser-obstruction", "true"); + const search = screen.getByRole("searchbox", { name: "Search canvas items" }); + await user.type(search, "docs.example.com guide"); + expect(screen.getByRole("button", { name: /Browser https:\/\/docs.example.com/ })).toBeVisible(); + await user.clear(search); + await user.type(search, "other.example.com"); + expect(screen.getByText(/No matching items/)).toBeVisible(); + await user.keyboard("{Escape}"); + expect(screen.getByRole("button", { name: "Show canvas items" })).toHaveFocus(); + await waitFor(() => expect(webPage).toHaveAttribute("data-native-browser-visible", "true")); + + await user.click(screen.getByRole("button", { name: "Add browser" })); + const added = readCanvasDocument().nodes.find((node) => node.kind === "browser" && node.url === ""); + expect(added).toEqual(expect.objectContaining({ projectId: PROJECT.id })); + rerender(); + expect(screen.getAllByRole("article", { name: /browser canvas item/ })).toHaveLength(1); + expect(screen.getByRole("article", { name: "Private preview, browser canvas item" })).toBeVisible(); + expect(screen.getByText(/1 browsers/)).toBeVisible(); + expect(runtime.close).toHaveBeenCalledWith({ nodeId: BROWSER_NODE.id }); + expect(runtime.open).not.toHaveBeenCalledWith(expect.objectContaining({ nodeId: "other-browser" })); + }); + it("moves a selected node with keyboard and pointer alternatives", async () => { const user = userEvent.setup(); const { container } = renderCanvas(); @@ -630,6 +1328,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(); @@ -733,6 +1618,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(); @@ -760,39 +1780,770 @@ 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("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] }); + 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(); + }); }); -interface RenderCanvasOptions { - readonly sessions?: readonly Session[]; - readonly browserRuntime?: BrowserRuntime; - readonly subscribeTerminal?: LiveTerminalTransport["subscribeTerminal"]; - readonly writeTerminal?: LiveTerminalTransport["writeTerminal"]; +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 renderCanvas({ - sessions = [], - browserRuntime, - subscribeTerminal = vi.fn(async () => vi.fn()), - writeTerminal = vi.fn(async () => undefined), -}: RenderCanvasOptions = {}) { - return render( - undefined)} - browserRuntime={browserRuntime} - />, - ); +function renderProjectCanvas( + overrides: Partial> = {}, +) { + const props = createProjectCanvasProps(overrides); + return { ...render(), props }; +} + +function createProjectCanvasProps( + overrides: Partial> = {}, +): ComponentProps { + return { + 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, + }; +} + +function readCanvasDocument(): CanvasDocument { + const document = parseCanvasDocument(localStorage.getItem(CANVAS_STORAGE_KEY)); + if (!document) throw new Error("Expected a persisted canvas document."); + 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 knowledgeEntry(overrides: Partial = {}): KnowledgeRecord { + return { + id: "knowledge-review", kind: "prompt", projectId: null, + title: "Review changes", body: "Review the diff.\n", revision: 3, + createdAtMs: 1, updatedAtMs: 2, ...overrides, + }; +} + +async function ensureKnowledgeLibraryOpen(user: ReturnType) { + if (!screen.queryByRole("region", { name: "Knowledge library" })) { + await user.click(screen.getByRole("button", { name: "Open prompts and context" })); + } +} + +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); + if (!coordinates || coordinates.length < 2) { + throw new Error("Expected a rendered canvas connection path."); + } + return Number(coordinates[coordinates.length - 2]); } function seedCanvasDocument( @@ -804,16 +2555,11 @@ function seedCanvasDocument( nodes, connections, zoom: 1, + hiddenSessionIds: [], }; localStorage.setItem(CANVAS_STORAGE_KEY, JSON.stringify(document)); } -function readCanvasDocument(): CanvasDocument { - return JSON.parse( - localStorage.getItem(CANVAS_STORAGE_KEY) ?? "{}", - ) as CanvasDocument; -} - function createAvailableBrowserRuntime(): BrowserRuntime { return { isAvailable: () => true, diff --git a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx index 51d3f3c..83b2e78 100644 --- a/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx +++ b/apps/desktop/src/app/features/canvas/CanvasWorkspace.tsx @@ -1,11 +1,24 @@ -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { Ref } from "react"; +import type { IpcClient } from "../../../ipc/client"; import type { AgentRecord, + ApiErrorData, CreateCustomAgentInput, CreateSessionInput, Project, Session, + Worktree, } from "../../../ipc/types"; import { Icon } from "../../components/Icon"; import { StatusBadge } from "../../components/StatusBadge"; @@ -17,8 +30,11 @@ import { import { createCanvasNode, createInitialCanvasDocument, + createSessionTerminalCanvasNode, createTerminalCanvasNode, + duplicateCanvasSelection, getCanvasNodeSize, + normalizeBrowserUrl, type BrowserCanvasNode, type CanvasTerminalConfiguration, type CanvasNode, @@ -30,6 +46,9 @@ import { browserUrlForTerminal, } from "./browser-handoff"; import { CanvasConnections } from "./CanvasConnections"; +import { CanvasKnowledgePanel } from "./CanvasKnowledgePanel"; +import type { KnowledgeInsertion } from "../knowledge/knowledge-types"; +import { CanvasElementSearch } from "./CanvasElementSearch"; import { CANVAS_ORIGIN_X, CANVAS_ORIGIN_Y, @@ -37,54 +56,98 @@ 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 { isLiveStatus } from "../../utils"; +import type { TerminalInputModes } from "../terminal/terminal-runtime"; +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; readonly browserRuntime?: BrowserRuntime; + readonly knowledgeClient?: Pick; + readonly knowledgeConnectionKey?: string; + readonly knowledgeOpenRevision?: number; } const ZOOM_STEP = 0.1; +const COMPACT_TERMINAL_GUTTER_PX = 48; const SCROLL_SETTLE_DELAY_MS = 160; /** 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, browserRuntime = defaultBrowserRuntime, + knowledgeClient, + knowledgeConnectionKey, + knowledgeOpenRevision = 0, 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 focusSelectionRef = useRef(false); + const layersTriggerRef = useRef(null); + const terminalInputsRef = useRef(new Map()); + const pendingComposerInputRef = useRef(new Set()); + const canvasComposingRef = useRef(false); + const handledKnowledgeRequestRef = useRef(0); const panRef = useRef<{ readonly pointerId: number; readonly clientX: number; @@ -96,6 +159,12 @@ export function CanvasWorkspace({ null, ); const [layersOpen, setLayersOpen] = useState(false); + const [composerOpen, setComposerOpen] = useState(false); + const [knowledgeOpen, setKnowledgeOpen] = useState(false); + const [knowledgeVisited, setKnowledgeVisited] = useState(false); + const [pendingComposerNodes, setPendingComposerNodes] = useState>( + () => new Set(), + ); const [terminalDialogOpen, setTerminalDialogOpen] = useState(false); const [pendingTerminals, setPendingTerminals] = useState>( () => new Set(), @@ -112,21 +181,279 @@ 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 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), + ); 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 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); + }, []); + useLayoutEffect(() => { + if (!knowledgeClient || knowledgeOpenRevision <= handledKnowledgeRequestRef.current) return; + handledKnowledgeRequestRef.current = knowledgeOpenRevision; + setKnowledgeVisited(true); + setKnowledgeOpen(true); + setLayersOpen(false); + }, [knowledgeClient, knowledgeOpenRevision]); + 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 markCanvasScrolling = useCallback(() => { + setCanvasScrolling(true); + if (scrollSettleTimerRef.current !== null) { + globalThis.clearTimeout(scrollSettleTimerRef.current); + } + scrollSettleTimerRef.current = globalThis.setTimeout(() => { + scrollSettleTimerRef.current = null; + setCanvasScrolling(false); + }, SCROLL_SETTLE_DELAY_MS); + }, []); + 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); + markCanvasScrolling(); + 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, markCanvasScrolling, onSelectSession, state.zoom], + ); + + 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), + sessionNodes: projectSessions.map((session, index) => + createSessionTerminalCanvasNode( + reconciledSessionPosition(index), + session, + ), + ), + }); + }, [dispatch, isConnected, projectSessions, sessionCanvasTopologyKey, sessions]); + + useLayoutEffect(() => { + 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 && + !visibleNodeIds.has(state.connectionSourceId) + ) { + dispatch({ type: "connection/cancel" }); + } + }, [ + dispatch, + state.connectionSourceId, + 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; + 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( () => () => { @@ -172,22 +499,33 @@ 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 addBrowser() { + const node = createCanvasNode("browser", nextNodePosition()); setBrowserHandoffStatus(null); + onSelectSession(null); dispatch({ type: "node/add", - node: createCanvasNode("browser", 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, @@ -265,14 +603,16 @@ export function CanvasWorkspace({ projectId: project.id, name: node.title, agentId: agent.id, - isolation: "current", + isolation: node.isolation ?? "current", relativeDirectory: relativeWorkingDirectory(project, node.workingDirectory), }); dispatch({ type: "terminal/attach", nodeId: node.id, sessionId: created.id, + projectId: created.projectId, }); + onSelectSession(created.id); await onStartSession(created.id); } catch (error) { setTerminalErrors((current) => ({ @@ -288,72 +628,143 @@ export function CanvasWorkspace({ } } - function setZoom(zoom: number) { - dispatch({ type: "zoom/set", zoom: Number(zoom.toFixed(2)) }); + 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)); + } } - function markCanvasScrolling() { - setCanvasScrolling(true); - if (scrollSettleTimerRef.current !== null) { - globalThis.clearTimeout(scrollSettleTimerRef.current); - } - scrollSettleTimerRef.current = globalThis.setTimeout(() => { - scrollSettleTimerRef.current = null; - setCanvasScrolling(false); - }, SCROLL_SETTLE_DELAY_MS); + 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 focusNode(node: CanvasNode) { - const viewport = viewportRef.current; - dispatch({ type: "node/select", nodeId: node.id }); + function sendComposerKey(node: TerminalCanvasNode, key: PromptTerminalKey) { + return deliverComposerInput(node, (modes) => encodePromptTerminalKey(key, modes)); + } + + function toggleComposer(node: TerminalCanvasNode) { + selectNode(node); setLayersOpen(false); - if (!viewport) { - return; + setKnowledgeOpen(false); + setComposerOpen((open) => !(open && selectedNode?.id === node.id)); + } + + function toggleKnowledge() { + if (!knowledgeClient) return; + setKnowledgeVisited(true); + setKnowledgeOpen((open) => !open); + setLayersOpen(false); + } + + function insertKnowledge(content: KnowledgeInsertion) { + if (selectedNode?.kind !== "terminal") { + throw new Error("Select a terminal before inserting this snapshot."); } + const current = selectedNode.promptDraft ?? ""; + const separator = current.endsWith("\n\n") || !current ? "" : current.endsWith("\n") ? "\n" : "\n\n"; + const title = content.title || (content.kind === "prompt" ? "Untitled prompt" : "Untitled context"); + const text = `${current}${separator}Knowledge snapshot: ${title}\n${content.body}\n`; + dispatch({ type: "terminal/draft", nodeId: selectedNode.id, text }); + setComposerOpen(true); + setKnowledgeOpen(false); + } - const size = getCanvasNodeSize(node); + function setZoom(zoom: number) { markCanvasScrolling(); - 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: "smooth", + 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 || state.nodes.length === 0) { + if (!viewport || storedVisibleNodes.length === 0) { 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 maximumY = Math.max( - ...state.nodes.map((node) => node.y + getCanvasNodeSize(node).height), - ); - const contentWidth = maximumX - minimumX; - const contentHeight = maximumY - minimumY; - const viewportWidth = viewport.clientWidth || 960; + const storedBounds = canvasNodeBounds(storedVisibleNodes); + const measuredViewportWidth = viewport.clientWidth || viewportWidth || 960; const viewportHeight = viewport.clientHeight || 640; - const nextZoom = Math.min( - 1, - Math.max( - 0.5, - Math.min( - (viewportWidth - 160) / contentWidth, - (viewportHeight - 160) / contentHeight, + 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, ), ); @@ -362,15 +773,53 @@ export function CanvasWorkspace({ viewport.scrollTo({ left: Math.max( 0, - (CANVAS_ORIGIN_X + minimumX + contentWidth / 2) * nextZoom - - viewportWidth / 2, + (CANVAS_ORIGIN_X + fittedBounds.minimumX + fittedBounds.width / 2) * + nextZoom - + measuredViewportWidth / 2, ), top: Math.max( 0, - (CANVAS_ORIGIN_Y + minimumY + contentHeight / 2) * nextZoom - + (CANVAS_ORIGIN_Y + fittedBounds.minimumY + fittedBounds.height / 2) * + nextZoom - viewportHeight / 2, ), - behavior: "smooth", + behavior: canvasScrollBehavior(), + }); + } + + function resetCanvasLayout() { + onSelectSession(null); + if (!project) { + dispatch({ + type: "document/hydrate", + document: createInitialCanvasDocument(), + }); + return; + } + + const projectNodeIds = new Set( + state.nodes + .filter((node) => node.projectId === project.id) + .map((node) => node.id), + ); + const projectSessionIds = new Set( + projectSessions.map((session) => session.id), + ); + dispatch({ + type: "document/hydrate", + document: { + version: 2, + nodes: state.nodes.filter((node) => !projectNodeIds.has(node.id)), + connections: state.connections.filter( + (connection) => + !projectNodeIds.has(connection.sourceNodeId) && + !projectNodeIds.has(connection.targetNodeId), + ), + zoom: state.zoom, + hiddenSessionIds: state.hiddenSessionIds.filter( + (sessionId) => !projectSessionIds.has(sessionId), + ), + }, }); } @@ -380,6 +829,50 @@ 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; + if (target?.closest('[data-shortcut-scope="knowledge-library"]')) return; + 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; + } + 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 }); + } + }} >
{projects.length} {projects.length === 1 ? "project" : "projects"} - {state.nodes.filter((node) => node.kind === "terminal").length}{" "} + {visibleNodes.filter((node) => node.kind === "terminal").length}{" "} terminals - {state.nodes.filter((node) => node.kind === "browser").length}{" "} + {visibleNodes.filter((node) => node.kind === "browser").length}{" "} browsers
@@ -407,15 +900,6 @@ export function CanvasWorkspace({ aria-label="Canvas tools" data-browser-obstruction="true" > - -
-
- {project ? ( - - ) : ( - - )} -
+ {selectedNodeIds.length > 0 + ? `${selectedNodeIds.length} selected · Shift+click to add or remove` + : "Shift+click to select multiple items"} +

- {state.connectionSourceId ? ( + {visibleConnectionSourceId ? (
{ if (event.defaultPrevented || event.currentTarget !== event.target) { return; @@ -551,18 +1060,28 @@ export function CanvasWorkspace({ if (movement) { event.preventDefault(); markCanvasScrolling(); - 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) => { 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, @@ -613,41 +1132,60 @@ 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 ( agent.id === (session?.agentId ?? + (node.kind === "terminal" ? node.agentId : undefined)), + )} + worktree={worktree} + isConnected={isConnected} + 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 }) + onSelect={(additive, fromFocus) => + selectNode(node, additive, fromFocus) } 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 }); @@ -656,12 +1194,19 @@ 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 }) + onMove={(delta) => + dispatch({ + type: "nodes/move", + nodeIds: selectedNodeIds.includes(node.id) + ? selectedNodeIds + : [node.id], + delta, + }) } onResize={(size) => dispatch({ type: "node/resize", nodeId: node.id, size }) @@ -670,10 +1215,29 @@ 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) } + composerOpen={composerNode?.id === node.id} + onToggleComposer={() => { + if (node.kind === "terminal") toggleComposer(node); + }} + onTerminalInput={registerTerminalInput} + 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={{ @@ -682,9 +1246,13 @@ export function CanvasWorkspace({ resizeTerminal, }} browserRuntime={browserRuntime} + browserActive={state.selectedNodeId === node.id} browserVisible={ state.zoom === 1 && !terminalDialogOpen && + !layersOpen && + !composerNode && + !knowledgeOpen && !canvasInteracting && !canvasScrolling } @@ -695,6 +1263,12 @@ export function CanvasWorkspace({ ? "The browser is hidden while the canvas view moves." : terminalDialogOpen ? "The browser is hidden while a dialog covers the canvas." + : layersOpen + ? "The browser is hidden while canvas search is open." + : composerNode + ? "The browser is hidden while Prompt Composer is open." + : knowledgeOpen + ? "The browser is hidden while the knowledge library is open." : state.zoom !== 1 ? "Use 100% zoom to interact with this page." : undefined @@ -710,48 +1284,48 @@ export function CanvasWorkspace({
{layersOpen ? ( -
-
-
- Workspace -

Canvas items

-
- {state.nodes.length} -
-
    - {state.nodes.map((node) => ( -
  • - -
  • - ))} -
-
+ + ) : null} + + {composerNode && !layersOpen && !terminalDialogOpen && !knowledgeOpen ? ( +
+ dispatch({ type: "terminal/draft", nodeId: composerNode.id, text })} + onSend={(text) => sendComposerPrompt(composerNode, text)} + onTerminalKey={(key) => sendComposerKey(composerNode, key)} + onClose={() => setComposerOpen(false)} + disabledReason={composerDisabledReason} + contextItems={composerContextItems} + clearOnSend={false} + /> +
+ ) : null} + + {knowledgeVisited && knowledgeClient ? ( + setKnowledgeOpen(false)} + /> ) : null} - {selectedNode && selectedConnections.length > 0 && !layersOpen ? ( + {selectedNode && selectedConnections.length > 0 && !layersOpen && !composerNode && !knowledgeOpen ? (
+ {node.kind === "terminal" ? ( + + ) : null} {selected ? ( Selected ) : null} + {session ? ( + + ) : null} + {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, @@ -1205,6 +2293,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; +} + interface BrowserHandoff { readonly browser: BrowserCanvasNode; readonly target: NoteCanvasNode | TerminalCanvasNode; @@ -1252,22 +2367,27 @@ function keyboardResize( function TerminalNodeBody({ node, + agent, session, - onOpenSession, onStart, pending, error, + startDisabledReason, transport, + inputRef, }: { readonly node: TerminalCanvasNode; + readonly agent?: AgentRecord; readonly session?: Session; - readonly onOpenSession: () => void; readonly onStart: () => void; readonly pending: boolean; readonly error?: string; + readonly startDisabledReason?: string; readonly transport: LiveTerminalTransport; + readonly inputRef: Ref; }) { const live = session ? isLiveStatus(session.status) : false; + const startDisabled = pending || startDisabledReason !== undefined; return (
) : ( - {node.executable ?? "Shell"} draft + {agent?.displayName ?? node.executable ?? (node.agentId ? "Saved agent" : "Shell")} draft )}
{session && live ? (
- +
) : (

{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.`)} + : `${agent?.displayName ?? node.executable ?? (node.agentId ? "The saved agent" : "A login shell")} is ready to start in this project.`)}

- {session ? ( - - ) : null}
)} @@ -1328,6 +2449,16 @@ async function resolveTerminalAgent( input: CreateCustomAgentInput, ) => Promise, ): Promise { + if (node.agentId) { + const savedAgent = agents.find((agent) => agent.id === node.agentId); + if (!savedAgent) { + throw new Error("The original agent is unavailable. Restore it before starting this copy."); + } + if (!savedAgent.enabled) { + throw new Error("The original agent is disabled. Enable it before starting this copy."); + } + return savedAgent; + } if (node.preset === "custom") { if (!node.executable) { throw new Error("Choose an executable before starting this terminal."); @@ -1402,3 +2533,12 @@ function NoteNodeBody({ ); } + +function isCanvasEditingTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest( + "input, textarea, select, button, a, summary, [contenteditable]:not([contenteditable='false']), [role='textbox'], [data-terminal-root], [data-shortcut-scope], .xterm, [role='dialog']", + ) !== null + ); +} 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..3f511cf --- /dev/null +++ b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.test.tsx @@ -0,0 +1,42 @@ +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", + isolation: "current", + executable: "gemini", + workingDirectory: "/projects/demo", + }); + }); + + it("passes an explicit isolated working copy choice without launching a process", async () => { + const user = userEvent.setup(); + const onCreate = vi.fn(); + render(); + await user.selectOptions(screen.getByRole("combobox", { name: "Working copy" }), "new_worktree"); + expect(screen.getByText(/Requires a Git repository with a commit/)).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Create terminal" })); + expect(onCreate).toHaveBeenCalledExactlyOnceWith({ + title: "Shell", preset: "shell", isolation: "new_worktree", executable: undefined, + workingDirectory: "/projects/demo/tools", + }); + }); +}); diff --git a/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx index 36df807..bb89c9c 100644 --- a/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx +++ b/apps/desktop/src/app/features/canvas/NewCanvasTerminalDialog.tsx @@ -1,5 +1,6 @@ import { useId, useRef, useState } from "react"; import type { FormEvent } from "react"; +import type { SessionIsolation } from "../../../ipc/types"; import { Dialog } from "../../components/Dialog"; import { Icon } from "../../components/Icon"; @@ -25,6 +26,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", @@ -44,8 +46,10 @@ export function NewCanvasTerminalDialog({ const nameId = useId(); const commandId = useId(); const directoryId = useId(); + const isolationId = useId(); const nameRef = useRef(null); const [preset, setPreset] = useState("shell"); + const [isolation, setIsolation] = useState("current"); const [name, setName] = useState(""); const [executable, setExecutable] = useState(""); const [workingDirectory, setWorkingDirectory] = useState( @@ -74,6 +78,7 @@ export function NewCanvasTerminalDialog({ onCreate({ title: name.trim() || selectedPreset.label, preset, + isolation, executable: executable.trim() || undefined, workingDirectory: workingDirectory.trim() || undefined, }); @@ -170,7 +175,22 @@ export function NewCanvasTerminalDialog({ placeholder="~" onChange={(event) => setWorkingDirectory(event.currentTarget.value)} /> + +
+

+ {isolation === "new_worktree" + ? "Creates a new branch and checkout before starting. Requires a Git repository with a commit; the directory above is resolved relative to the project inside the new checkout." + : "Uses the existing project directory. Changes share this working copy with other sessions."} +

{error ? (
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}

+
+ +
+
+ +