(() => {
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.
-
Reconnect
+ {workspace.connection.status === "disconnected" ? (
+
+
+ Daemon disconnected. {" "}
+ {workspace.snapshot
+ ? "Existing metadata may be stale."
+ : "Notes and terminal drafts are available offline."}
+
+
+ {workspace.snapshot ? "Reconnect" : "Retry Connection"}
+
) : 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 ?
setNavigationOpen(false)} /> : null}
+
setCanvasSidebarCollapsed(false)}
+ aria-label={
+ isNavigationHidden
+ ? isCompactNavigation
+ ? "Open navigation"
+ : "Show workspace sidebar"
+ : "Hide navigation"
+ }
+ aria-controls="canvas-navigation"
+ aria-expanded={!isNavigationHidden}
+ onClick={toggleNavigation}
>
-
+
- ) : null}
- {navigationOpen ? setNavigationOpen(false)} /> : null}
+
+
+
+ {workspace.selectedProject ? (
+
+ New session
+
+ ) : (
+
+ Add project
+
+ )}
+
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 (
-
- );
-}
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 (
+
+ );
+}
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 (
+ event.stopPropagation()}
+ onCompositionStartCapture={() => { composingRef.current = true; }}
+ onCompositionEndCapture={() => { composingRef.current = false; }}
+ onKeyDown={(event) => {
+ if (
+ event.key === "Escape" && !event.defaultPrevented && !event.repeat
+ && !composingRef.current && !event.nativeEvent.isComposing
+ && event.nativeEvent.keyCode !== 229
+ && !(event.target instanceof Element && event.target.closest("dialog"))
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ requestClose();
+ }
+ }}
+ >
+
+
+ setSection("library")}>
+ Saved prompts & context
+
+ setSection("sources")}>
+ Rules & skills
+
+
+ {missingProject ? (
+
+ {scopeProject.name} is no longer in the workspace. Its library drafts stay associated with that project.
+
+ ) : null}
+
+
+
+ {open && section === "sources" ? (
+
+
+
+ ) : null}
+
+ );
+}
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 <>
+ setProject(PROJECT)}>Use Jig
+ setProject(OTHER_PROJECT)}>Use other project
+
+ >;
+ }
+ 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 <>
+ setProject(OTHER_PROJECT)}>Switch project
+
+ >;
+ }
+ 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"
>
-
-
-
-
+ {
+ if (selectedNode?.kind === "terminal") toggleComposer(selectedNode);
+ }}
+ >
+
{
- if (state.connectionSourceId) {
+ if (visibleConnectionSourceId) {
dispatch({ type: "connection/cancel" });
} else if (selectedNode) {
dispatch({ type: "connection/start", nodeId: selectedNode.id });
@@ -468,13 +973,34 @@ export function CanvasWorkspace({
{
- if (selectedNode) {
- dispatch({ type: "node/delete", nodeId: selectedNode.id });
- }
- }}
+ aria-label="Select all canvas items"
+ title="Select all canvas items (⌘/Ctrl+A)"
+ disabled={visibleNodes.length === 0}
+ onClick={selectAllNodes}
+ >
+
+
+
+
+
+ 1
+ ? "Remove selected items from canvas"
+ : "Remove selected item from canvas"
+ }
+ title="Remove selected cards (Delete); sessions keep running"
+ disabled={selectedNodeIds.length === 0}
+ onClick={removeSelectedNodes}
>
@@ -482,43 +1008,26 @@ export function CanvasWorkspace({
className="canvas-tool"
type="button"
aria-label="Reset canvas layout"
- onClick={() =>
- dispatch({
- type: "document/hydrate",
- document: createInitialCanvasDocument(),
- })
+ title={
+ project ? "Reset this project's canvas layout" : "Reset canvas layout"
}
+ onClick={resetCanvasLayout}
>
-
- {project ? (
-
- New session
-
- ) : (
-
- Add 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 ? (
-
-
-
- {state.nodes.map((node) => (
-
- focusNode(node)}>
-
- {node.title}
-
- {state.connections.filter(
- (connection) =>
- connection.sourceNodeId === node.id ||
- connection.targetNodeId === node.id,
- ).length} connections
-
-
-
- ))}
-
-
+
+ ) : 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 ? (
@@ -878,28 +1455,47 @@ export function CanvasWorkspace({
interface CanvasNodeCardProps {
readonly node: CanvasNode;
+ readonly storedTerminalSize?: {
+ readonly width: number;
+ readonly height: number;
+ };
readonly session?: Session;
+ readonly agent?: AgentRecord;
+ readonly worktree?: Worktree;
+ readonly isConnected: boolean;
readonly selected: boolean;
readonly connectionSource: string | null;
readonly connectionCount: number;
readonly zoom: number;
- readonly onSelect: () => void;
+ readonly onSelect: (additive?: boolean, fromFocus?: boolean) => void;
readonly onConnect: () => void;
readonly onCancelConnection: () => void;
readonly onDelete: () => void;
- readonly onMove: (position: { readonly x: number; readonly y: number }) => void;
+ readonly onMove: (delta: { readonly x: number; readonly y: number }) => void;
readonly onResize: (size: {
readonly width: number;
readonly height: number;
}) => void;
readonly onManipulationChange: (interacting: boolean) => void;
readonly onNoteChange: (text: string) => void;
- readonly onOpenSession: () => void;
readonly onStartTerminal: () => void;
+ readonly composerOpen: boolean;
+ readonly onToggleComposer: () => void;
+ readonly onTerminalInput: (nodeId: string, handle: LiveTerminalInputHandle | null) => void;
+ 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 elementRef: (element: HTMLElement | null) => void;
readonly terminalPending: boolean;
readonly terminalError?: string;
readonly terminalTransport: LiveTerminalTransport;
readonly browserRuntime: BrowserRuntime;
+ readonly browserActive: boolean;
readonly browserVisible: boolean;
readonly browserUnavailableReason?: string;
readonly onBrowserNavigate: (url: string) => void;
@@ -907,7 +1503,11 @@ interface CanvasNodeCardProps {
function CanvasNodeCard({
node,
+ storedTerminalSize,
session,
+ agent,
+ worktree,
+ isConnected,
selected,
connectionSource,
connectionCount,
@@ -920,23 +1520,37 @@ function CanvasNodeCard({
onResize,
onManipulationChange,
onNoteChange,
- onOpenSession,
onStartTerminal,
+ composerOpen,
+ onToggleComposer,
+ onTerminalInput,
+ onStartSession,
+ onRestartSession,
+ onRenameSession,
+ onStopSession,
+ onDeleteSession,
+ onRemoveWorktree,
+ onGitStatus,
+ onOpenPath,
+ elementRef,
terminalPending,
terminalError,
terminalTransport,
browserRuntime,
+ browserActive,
browserVisible,
browserUnavailableReason,
onBrowserNavigate,
}: CanvasNodeCardProps) {
+ const registerInput = useCallback((handle: LiveTerminalInputHandle | null) => {
+ onTerminalInput(node.id, handle);
+ }, [node.id, onTerminalInput]);
const dragRef = useRef<{
readonly pointerId: number;
readonly clientX: number;
readonly clientY: number;
- readonly nodeX: number;
- readonly nodeY: number;
} | null>(null);
+ const pointerSelectingRef = useRef(false);
const resizeRef = useRef<{
readonly pointerId: number;
readonly clientX: number;
@@ -957,6 +1571,7 @@ function CanvasNodeCard({
return (
{
+ if (event.currentTarget === event.target && !pointerSelectingRef.current) {
+ onSelect(false, true);
+ } else if (
+ event.target instanceof Element &&
+ event.target.closest("[data-terminal-root]")
+ ) {
+ onSelect();
+ }
+ }}
onKeyDown={(event) => {
if (event.currentTarget !== event.target) {
return;
@@ -979,7 +1605,11 @@ function CanvasNodeCard({
if (movement) {
event.preventDefault();
onManipulationChange(true);
- onMove({ x: node.x + movement.x, y: node.y + movement.y });
+ onSelect();
+ onMove(movement);
+ } else if (event.key === " " && event.shiftKey) {
+ event.preventDefault();
+ onSelect(true);
} else if (event.key === "Escape" && connectionSource) {
event.preventDefault();
onCancelConnection();
@@ -1000,25 +1630,51 @@ function CanvasNodeCard({
}}
onPointerDown={(event) => {
event.stopPropagation();
- onSelect();
+ if (event.button !== 0) return;
+ if (
+ event.target instanceof Element &&
+ event.target.closest("[data-terminal-root]")
+ ) {
+ onSelect();
+ return;
+ }
+ if (isCanvasEditingTarget(event.target)) return;
+ pointerSelectingRef.current = true;
+ onSelect(event.shiftKey);
+ }}
+ onPointerUp={() => {
+ pointerSelectingRef.current = false;
+ }}
+ onPointerCancel={() => {
+ pointerSelectingRef.current = false;
}}
>
+ {selected ? (
+
+ Selected canvas item
+
+ ) : null}
{
- if ((event.target as HTMLElement).closest("button")) {
+ if (
+ event.button !== 0 ||
+ (event.target instanceof Element &&
+ event.target.closest("button, summary"))
+ ) {
return;
}
event.preventDefault();
event.stopPropagation();
- onSelect();
+ pointerSelectingRef.current = true;
+ onSelect(event.shiftKey);
+ event.currentTarget.closest("article")?.focus({ preventScroll: true });
+ if (event.shiftKey) return;
dragRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
- nodeX: node.x,
- nodeY: node.y,
};
onManipulationChange(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
@@ -1029,9 +1685,14 @@ function CanvasNodeCard({
return;
}
onMove({
- x: drag.nodeX + (event.clientX - drag.clientX) / zoom,
- y: drag.nodeY + (event.clientY - drag.clientY) / zoom,
+ x: (event.clientX - drag.clientX) / zoom,
+ y: (event.clientY - drag.clientY) / zoom,
});
+ dragRef.current = {
+ pointerId: event.pointerId,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ };
}}
onPointerUp={(event) => {
if (dragRef.current?.pointerId === event.pointerId) {
@@ -1065,9 +1726,36 @@ function CanvasNodeCard({
) : null}
+ {node.kind === "terminal" ? (
+
{
+ event.stopPropagation();
+ onToggleComposer();
+ }}
+ >
+ ) : null}
{selected ? (
Selected
) : null}
+ {session ? (
+
+ ) : null}
{
event.stopPropagation();
onDelete();
@@ -1100,12 +1789,18 @@ function CanvasNodeCard({
{node.kind === "terminal" ? (
) : node.kind === "note" ? (
@@ -1114,7 +1809,7 @@ function CanvasNodeCard({
nodeId={node.id}
url={node.url}
accessibleLabel={`Browser surface for ${node.title}`}
- active={selected}
+ active={browserActive}
visible={browserVisible}
unavailableReason={browserUnavailableReason}
runtime={browserRuntime}
@@ -1132,7 +1827,15 @@ function CanvasNodeCard({
title="Drag to resize. Arrow keys resize; hold Alt for 1 px."
onKeyDown={(event) => {
const step = event.altKey ? 1 : 16;
- const size = keyboardResize(event.key, node, step);
+ const size = keyboardResize(
+ event.key,
+ {
+ ...node,
+ width: storedTerminalSize?.width ?? node.width,
+ height: storedTerminalSize?.height ?? node.height,
+ },
+ step,
+ );
if (size) {
event.preventDefault();
onManipulationChange(true);
@@ -1152,8 +1855,8 @@ function CanvasNodeCard({
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
- width: node.width,
- height: node.height,
+ width: storedTerminalSize?.width ?? node.width,
+ height: storedTerminalSize?.height ?? node.height,
};
onManipulationChange(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
@@ -1187,6 +1890,391 @@ function CanvasNodeCard({
);
}
+function useElementWidth(elementRef: {
+ readonly current: HTMLElement | null;
+}): number {
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => {
+ const element = elementRef.current;
+ if (!element || typeof window === "undefined") {
+ return () => undefined;
+ }
+ const observer =
+ typeof ResizeObserver === "function"
+ ? new ResizeObserver(() => onStoreChange())
+ : undefined;
+ observer?.observe(element);
+ window.addEventListener("resize", onStoreChange);
+ return () => {
+ observer?.disconnect();
+ window.removeEventListener("resize", onStoreChange);
+ };
+ },
+ [elementRef],
+ );
+ const getSnapshot = useCallback(
+ () => elementRef.current?.clientWidth ?? 0,
+ [elementRef],
+ );
+ return useSyncExternalStore(subscribe, getSnapshot, () => 0);
+}
+
+function effectiveCanvasNodes(
+ nodes: readonly CanvasNode[],
+ isCompact: boolean,
+ viewportWidth: number,
+ zoom: number,
+): readonly CanvasNode[] {
+ if (!isCompact || viewportWidth <= 0) {
+ return nodes;
+ }
+ const maximumTerminalWidth = Math.max(
+ 1,
+ (viewportWidth - COMPACT_TERMINAL_GUTTER_PX) / zoom,
+ );
+ return nodes.map((node) =>
+ node.kind === "terminal" && node.width > maximumTerminalWidth
+ ? { ...node, width: maximumTerminalWidth }
+ : node,
+ );
+}
+
+function canvasNodeBounds(nodes: readonly CanvasNode[]) {
+ const minimumX = Math.min(...nodes.map((node) => node.x));
+ const minimumY = Math.min(...nodes.map((node) => node.y));
+ const maximumX = Math.max(
+ ...nodes.map((node) => node.x + getCanvasNodeSize(node).width),
+ );
+ const maximumY = Math.max(
+ ...nodes.map((node) => node.y + getCanvasNodeSize(node).height),
+ );
+ return {
+ minimumX,
+ minimumY,
+ width: maximumX - minimumX,
+ height: maximumY - minimumY,
+ };
+}
+
+interface CanvasSessionActionsProps {
+ readonly session: Session;
+ readonly worktree?: Worktree;
+ readonly isConnected: boolean;
+ 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;
+}
+
+function CanvasSessionActions({
+ session,
+ worktree,
+ isConnected,
+ onStartSession,
+ onRestartSession,
+ onRenameSession,
+ onStopSession,
+ onDeleteSession,
+ onRemoveWorktree,
+ onGitStatus,
+ onOpenPath,
+}: CanvasSessionActionsProps) {
+ const actionsContainerRef = useRef(null);
+ const triggerRef = useRef(null);
+ const [actionsOpen, setActionsOpen] = useState(false);
+ const [pendingAction, setPendingAction] = useState();
+ const [actionError, setActionError] = useState();
+ const live = isLiveStatus(session.status);
+ const managedWorktreeUnavailable = Boolean(session.worktreeId && !worktree);
+ const availablePath = worktree?.path ?? session.worktreePath ?? session.cwd;
+ const path = managedWorktreeUnavailable
+ ? undefined
+ : availablePath || undefined;
+ const unavailableWorktreeReason = managedWorktreeUnavailable
+ ? "The managed worktree is no longer available."
+ : undefined;
+ const disconnectedReason = !isConnected
+ ? "Connect the local daemon first."
+ : undefined;
+
+ useEffect(() => {
+ if (!actionsOpen) {
+ return;
+ }
+
+ function closeForOutsideInteraction(event: Event) {
+ const target = event.target;
+ if (
+ target instanceof Node &&
+ !actionsContainerRef.current?.contains(target)
+ ) {
+ setActionsOpen(false);
+ }
+ }
+
+ document.addEventListener("pointerdown", closeForOutsideInteraction, true);
+ document.addEventListener("focusin", closeForOutsideInteraction);
+ return () => {
+ document.removeEventListener(
+ "pointerdown",
+ closeForOutsideInteraction,
+ true,
+ );
+ document.removeEventListener("focusin", closeForOutsideInteraction);
+ };
+ }, [actionsOpen]);
+
+ function closeActionDisclosure() {
+ triggerRef.current?.focus();
+ setActionsOpen(false);
+ }
+
+ async function runDirectAction(
+ name: string,
+ action: () => Promise,
+ ) {
+ if (pendingAction) {
+ return;
+ }
+ setPendingAction(name);
+ setActionError(undefined);
+ try {
+ await action();
+ closeActionDisclosure();
+ } catch (error) {
+ setActionError(errorData(error));
+ } finally {
+ setPendingAction(undefined);
+ }
+ }
+
+ function runOverlayAction(action: () => void) {
+ if (pendingAction) {
+ return;
+ }
+ setActionError(undefined);
+ try {
+ action();
+ closeActionDisclosure();
+ } catch (error) {
+ setActionError(errorData(error));
+ }
+ }
+
+ return (
+ event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (actionsOpen && event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ closeActionDisclosure();
+ }
+ }}
+ >
+
setActionsOpen((open) => !open)}
+ >
+
+
+ {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}
+ setActionError(undefined)}
+ >
+
+
+
+ ) : 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 (
+ <>
+ {
+ if (!disabled) {
+ onClick();
+ }
+ }}
+ >
+
+ {pending ? `${label}…` : label}
+
+ {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.`)}
{
+ if (!startDisabled) {
+ onStart();
+ }
+ }}
>
{pending ? "Starting…" : error ? "Retry terminal" : "Start terminal"}
- {session ? (
-
- Session details
-
- ) : 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)}
/>
+ Working copy
+ setIsolation(event.currentTarget.value === "new_worktree" ? "new_worktree" : "current")}
+ >
+ Use project working copy
+ Create an isolated Git worktree
+
+
+ {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 ? (
diff --git a/apps/desktop/src/app/features/canvas/PromptComposer.test.tsx b/apps/desktop/src/app/features/canvas/PromptComposer.test.tsx
new file mode 100644
index 0000000..36d54c1
--- /dev/null
+++ b/apps/desktop/src/app/features/canvas/PromptComposer.test.tsx
@@ -0,0 +1,269 @@
+import { useState } from "react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { PromptComposer } from "./PromptComposer";
+import type { PromptComposerProps, PromptTerminalKey } from "./PromptComposer";
+
+interface FixtureProps extends Omit {
+ readonly initialValue?: string;
+}
+
+function Fixture({ initialValue = "", ...props }: FixtureProps) {
+ const [value, setValue] = useState(initialValue);
+ return ;
+}
+
+function deferred() {
+ let complete: () => void = () => {};
+ const promise = new Promise((resolve) => { complete = resolve; });
+ return { promise, complete };
+}
+
+function setup(overrides: Partial = {}) {
+ const onSend = vi.fn().mockResolvedValue(undefined);
+ const onClose = vi.fn();
+ const props = { nodeId: "terminal-a", title: "Codex API", onSend, onClose, ...overrides };
+ const view = render( );
+ return { ...view, onSend, onClose, user: userEvent.setup() };
+}
+
+describe("PromptComposer", () => {
+ it("labels the target, focuses its draft and sends the exact text", async () => {
+ const { user, onSend } = setup({ initialValue: " Explain this function\nthen add tests " });
+ const editor = screen.getByRole("textbox", { name: "Prompt for Codex API" });
+ expect(screen.getByRole("region", { name: "Prompt Composer" })).toBeVisible();
+ expect(screen.getByText("Send to")).toBeVisible();
+ expect(editor).toHaveFocus();
+
+ await user.click(screen.getByRole("button", { name: "Send prompt" }));
+
+ expect(onSend).toHaveBeenCalledExactlyOnceWith(" Explain this function\nthen add tests ");
+ expect(editor).toHaveValue("");
+ expect(screen.getByRole("status")).toHaveTextContent("Prompt sent to Codex API.");
+ });
+
+ it("sends on Enter and inserts a newline with Shift+Enter", async () => {
+ const { user, onSend } = setup();
+ const editor = screen.getByRole("textbox");
+ await user.type(editor, "First line");
+ await user.keyboard("{Shift>}{Enter}{/Shift}");
+ expect(editor).toHaveValue("First line\n");
+ expect(onSend).not.toHaveBeenCalled();
+
+ await user.type(editor, "Second line{Enter}");
+
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("First line\nSecond line");
+ expect(editor).toHaveValue("");
+ });
+
+ it("ignores repeated Enter, IME confirmation and modified shortcuts", async () => {
+ const { onSend, onClose, user } = setup({ initialValue: "Draft" });
+ const editor = screen.getByRole("textbox");
+ fireEvent.keyDown(editor, { key: "Enter", repeat: true });
+ fireEvent.keyDown(editor, { key: "Enter", isComposing: true });
+ fireEvent.keyDown(editor, { key: "Enter", keyCode: 229 });
+ fireEvent.compositionStart(editor);
+ fireEvent.keyDown(editor, { key: "Enter" });
+ fireEvent.keyDown(editor, { key: "Escape" });
+ fireEvent.compositionEnd(editor);
+ fireEvent.keyDown(editor, { key: "Enter", ctrlKey: true });
+ fireEvent.keyDown(editor, { key: "Enter", metaKey: true });
+ fireEvent.keyDown(editor, { key: "Enter", altKey: true });
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(editor).toHaveValue("Draft");
+
+ await user.keyboard("{Enter}");
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("Draft");
+ });
+
+ it.each(["", " \n\t"])("does not submit an empty or whitespace-only draft %j", async (initialValue) => {
+ const { user, onSend } = setup({ initialValue });
+ expect(screen.getByRole("button", { name: "Send prompt" })).toBeDisabled();
+ await user.keyboard("{Enter}");
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.getByRole("textbox")).toHaveValue(initialValue);
+ });
+
+ it("explains an unavailable terminal while allowing draft edits", async () => {
+ const onTerminalKey = vi.fn>().mockResolvedValue(undefined);
+ const { user, onSend } = setup({ disabledReason: "Attach a running session to send input.", onTerminalKey });
+ const editor = screen.getByRole("textbox");
+ expect(editor).toHaveAccessibleDescription(expect.stringContaining("Attach a running session"));
+ await user.keyboard("{Enter}{ArrowUp}");
+ await user.type(editor, "Saved for later{Enter}");
+
+ expect(editor).toHaveValue("Saved for later");
+ expect(screen.getByRole("button", { name: "Send prompt" })).toBeDisabled();
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onTerminalKey).not.toHaveBeenCalled();
+ });
+
+ it("retains the draft after a rejected send and offers an explicit retry", async () => {
+ const onSend = vi.fn()
+ .mockRejectedValueOnce(new Error("Transport failed"))
+ .mockResolvedValueOnce(undefined);
+ const { user } = setup({ initialValue: "Keep this request", onSend });
+ await user.click(screen.getByRole("button", { name: "Send prompt" }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Your draft is still here");
+ expect(screen.getByRole("textbox")).toHaveValue("Keep this request");
+ await user.click(screen.getByRole("button", { name: "Try again" }));
+
+ expect(onSend).toHaveBeenCalledTimes(2);
+ expect(onSend).toHaveBeenLastCalledWith("Keep this request");
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("does not duplicate pending sends or clear a draft edited while sending", async () => {
+ const delivery = deferred();
+ const onSend = vi.fn().mockReturnValue(delivery.promise);
+ const { user } = setup({ initialValue: "Submitted", onSend });
+ const editor = screen.getByRole("textbox");
+ await user.keyboard("{Enter}{Enter}");
+ expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
+ expect(screen.getByRole("status")).toHaveTextContent("Sending prompt…");
+ await user.clear(editor);
+ await user.type(editor, "Next request");
+ await act(async () => delivery.complete());
+
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("Submitted");
+ expect(editor).toHaveValue("Next request");
+ expect(screen.getByRole("button", { name: "Send prompt" })).toBeEnabled();
+ });
+
+ it("preserves a revised draft even when it equals the submitted text again", async () => {
+ const delivery = deferred();
+ const { user } = setup({ initialValue: "Repeat", onSend: () => delivery.promise });
+ const editor = screen.getByRole("textbox");
+ await user.keyboard("{Enter}");
+ await user.clear(editor);
+ await user.type(editor, "Repeat");
+ await act(async () => delivery.complete());
+ expect(editor).toHaveValue("Repeat");
+ });
+
+ it("ignores a previous terminal's pending completion after switching targets", async () => {
+ const delivery = deferred();
+ const onChange = vi.fn();
+ const onClose = vi.fn();
+ const { rerender } = render( delivery.promise} onClose={onClose}
+ />);
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
+ rerender( );
+ await act(async () => delivery.complete());
+
+ expect(screen.getByRole("textbox", { name: "Prompt for Terminal B" })).toHaveValue("B draft");
+ expect(screen.getByRole("textbox")).toHaveFocus();
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ });
+
+ it("inserts named context as an explicit text snapshot without sending it", async () => {
+ const { user, onSend, rerender } = setup({
+ initialValue: "Review this plan.",
+ contextItems: [{ id: "note-a", title: "Release plan", text: "Keep Linux and macOS support." }],
+ });
+ await user.click(screen.getByText(/Insert context/));
+ expect(screen.getByText("Copies the source text into this draft as a snapshot.")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Insert context from Release plan" }));
+
+ const expected = "Review this plan.\n\nContext snapshot: Release plan\nKeep Linux and macOS support.\n";
+ expect(screen.getByRole("textbox")).toHaveValue(expected);
+ expect(screen.getByRole("textbox")).toHaveFocus();
+ expect(onSend).not.toHaveBeenCalled();
+ rerender( );
+ expect(screen.getByRole("textbox")).toHaveValue(expected);
+ });
+
+ it.each(["Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"])(
+ "forwards %s only when the editor is empty and the keystroke is intentional",
+ async (key) => {
+ const onTerminalKey = vi.fn>().mockResolvedValue(undefined);
+ const { onSend, user } = setup({ onTerminalKey });
+ const editor = screen.getByRole("textbox");
+ fireEvent.keyDown(editor, { key, repeat: true });
+ fireEvent.keyDown(editor, { key, isComposing: true });
+ fireEvent.keyDown(editor, { key, ctrlKey: true });
+ expect(onTerminalKey).not.toHaveBeenCalled();
+ await act(async () => { fireEvent.keyDown(editor, { key }); });
+ expect(onTerminalKey).toHaveBeenCalledExactlyOnceWith(key);
+ expect(onSend).not.toHaveBeenCalled();
+ await user.type(editor, "New draft");
+ await act(async () => { fireEvent.keyDown(editor, { key }); });
+ expect(onTerminalKey).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("allows Shift+Tab to leave the empty editor", async () => {
+ const onTerminalKey = vi.fn>().mockResolvedValue(undefined);
+ const { user } = setup({ onTerminalKey });
+ await user.tab({ shift: true });
+ expect(screen.getByRole("button", { name: "Close Prompt Composer" })).toHaveFocus();
+ expect(onTerminalKey).not.toHaveBeenCalled();
+ });
+
+ it("keeps forward tab navigation available when terminal input is disabled", async () => {
+ const onTerminalKey = vi.fn>().mockResolvedValue(undefined);
+ const { user } = setup({
+ onTerminalKey,
+ disabledReason: "Attach a running session to send input.",
+ contextItems: [{ id: "note-a", title: "Plan", text: "Useful context" }],
+ });
+ await user.tab();
+ expect(screen.getByText(/Insert context/)).toHaveFocus();
+ expect(onTerminalKey).not.toHaveBeenCalled();
+ });
+
+ it("offers a retry for a key that could not be delivered", async () => {
+ const onTerminalKey = vi.fn>()
+ .mockRejectedValueOnce(new Error("Disconnected"))
+ .mockResolvedValueOnce(undefined);
+ const { user, onSend } = setup({ onTerminalKey });
+ await user.keyboard("{ArrowUp}");
+ expect(screen.getByRole("alert")).toHaveTextContent("Could not deliver the key");
+ await user.click(screen.getByRole("button", { name: "Retry key" }));
+ expect(onTerminalKey).toHaveBeenCalledTimes(2);
+ expect(onTerminalKey).toHaveBeenLastCalledWith("ArrowUp");
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.getByRole("status")).toHaveTextContent("Key sent to Codex API.");
+ });
+
+ it("closes with Escape or the close button without discarding the draft", async () => {
+ const { user, onClose, onSend } = setup({ initialValue: "Return to this later" });
+ await user.keyboard("{Escape}");
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole("textbox")).toHaveValue("Return to this later");
+ await user.click(screen.getByRole("button", { name: "Close Prompt Composer" }));
+ expect(onClose).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("restores focus to the trigger when closed from inside the composer", async () => {
+ const user = userEvent.setup();
+ function Host() {
+ const [open, setOpen] = useState(false);
+ return <>
+ setOpen(true)}>Compose
+ {open ? setOpen(false)} /> : null}
+ >;
+ }
+ render( );
+ const trigger = screen.getByRole("button", { name: "Compose" });
+ await user.click(trigger);
+ expect(screen.getByRole("textbox")).toHaveFocus();
+ await user.keyboard("{Escape}");
+ expect(trigger).toHaveFocus();
+ });
+});
diff --git a/apps/desktop/src/app/features/canvas/PromptComposer.tsx b/apps/desktop/src/app/features/canvas/PromptComposer.tsx
new file mode 100644
index 0000000..b419299
--- /dev/null
+++ b/apps/desktop/src/app/features/canvas/PromptComposer.tsx
@@ -0,0 +1,287 @@
+import { useId, useLayoutEffect, useRef, useState } from "react";
+import type { FormEvent, KeyboardEvent } from "react";
+
+import { Icon } from "../../components/Icon";
+import "../../../styles/prompt-composer.css";
+
+/** A source whose current text can be explicitly copied into the draft. */
+export interface PromptContextItem {
+ readonly id: string;
+ readonly title: string;
+ readonly text: string;
+}
+
+/** Keys an empty composer can forward to its attached terminal. */
+export type PromptTerminalKey =
+ | "Enter"
+ | "Tab"
+ | "ArrowUp"
+ | "ArrowDown"
+ | "ArrowLeft"
+ | "ArrowRight";
+
+/** Controlled terminal input; the owner persists drafts and delivers input. */
+export interface PromptComposerProps {
+ readonly nodeId: string;
+ readonly title: string;
+ readonly value: string;
+ readonly onChange: (value: string) => void;
+ readonly onSend: (text: string) => Promise;
+ readonly onClose: () => void;
+ /** Explains why input cannot be sent; drafting remains available. */
+ readonly disabledReason?: string;
+ readonly contextItems?: readonly PromptContextItem[];
+ readonly onTerminalKey?: (key: PromptTerminalKey) => Promise;
+ /** False when the owner acknowledges sends with its own persistent revision check. */
+ readonly clearOnSend?: boolean;
+}
+
+type PendingInput = { readonly kind: "prompt" } | {
+ readonly kind: "key";
+ readonly key: PromptTerminalKey;
+};
+
+/** Keeps each terminal's focus and in-flight input isolated when targets change. */
+export function PromptComposer(props: PromptComposerProps) {
+ return ;
+}
+
+function TerminalPromptComposer({
+ nodeId,
+ title,
+ value,
+ onChange,
+ onSend,
+ onClose,
+ disabledReason,
+ contextItems = [],
+ onTerminalKey,
+ clearOnSend = true,
+}: PromptComposerProps) {
+ const id = useId();
+ const panelRef = useRef(null);
+ const editorRef = useRef(null);
+ const mountedRef = useRef(false);
+ const composingRef = useRef(false);
+ const busyRef = useRef(false);
+ const draftRef = useRef({ value, onChange, revision: 0 });
+ const [pending, setPending] = useState();
+ const [failed, setFailed] = useState();
+ const [status, setStatus] = useState("");
+
+ useLayoutEffect(() => {
+ const previous = draftRef.current;
+ draftRef.current = {
+ value,
+ onChange,
+ revision: previous.revision + (previous.value === value ? 0 : 1),
+ };
+ }, [value, onChange]);
+
+ useLayoutEffect(() => {
+ mountedRef.current = true;
+ const panel = panelRef.current;
+ const previousFocus = document.activeElement;
+ editorRef.current?.focus();
+ return () => {
+ mountedRef.current = false;
+ if (
+ panel?.contains(document.activeElement)
+ && previousFocus instanceof HTMLElement
+ && previousFocus.isConnected
+ ) {
+ previousFocus.focus();
+ }
+ };
+ }, []);
+
+ function changeDraft(nextValue: string) {
+ draftRef.current = {
+ value: nextValue,
+ onChange,
+ revision: draftRef.current.revision + 1,
+ };
+ onChange(nextValue);
+ setFailed(undefined);
+ setStatus("");
+ }
+
+ async function sendPrompt() {
+ if (disabledReason || busyRef.current || !value.trim()) return;
+ const submitted = draftRef.current;
+ busyRef.current = true;
+ setPending({ kind: "prompt" });
+ setFailed(undefined);
+ setStatus("");
+ try {
+ await onSend(value);
+ if (!mountedRef.current) return;
+ const latest = draftRef.current;
+ if (clearOnSend && latest.value === submitted.value && latest.revision === submitted.revision) {
+ latest.onChange("");
+ }
+ setStatus(`Prompt sent to ${title}.`);
+ } catch {
+ if (mountedRef.current) setFailed({ kind: "prompt" });
+ } finally {
+ busyRef.current = false;
+ if (mountedRef.current) setPending(undefined);
+ }
+ }
+
+ async function sendTerminalKey(key: PromptTerminalKey) {
+ if (disabledReason || busyRef.current || !onTerminalKey) return;
+ busyRef.current = true;
+ setPending({ kind: "key", key });
+ setFailed(undefined);
+ setStatus("");
+ try {
+ await onTerminalKey(key);
+ if (mountedRef.current) setStatus(`Key sent to ${title}.`);
+ } catch {
+ if (mountedRef.current) setFailed({ kind: "key", key });
+ } finally {
+ busyRef.current = false;
+ if (mountedRef.current) setPending(undefined);
+ }
+ }
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ void sendPrompt();
+ }
+
+ function handleEditorKey(event: KeyboardEvent) {
+ if (
+ composingRef.current || event.nativeEvent.isComposing
+ || event.nativeEvent.keyCode === 229
+ || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey
+ ) return;
+
+ if (
+ value === "" && onTerminalKey && !disabledReason && !busyRef.current
+ && isTerminalKey(event.key)
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!event.repeat) void sendTerminalKey(event.key);
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!event.repeat) void sendPrompt();
+ }
+ }
+
+ function insertContext(item: PromptContextItem) {
+ const separator = value && !value.endsWith("\n\n") ? "\n\n" : "";
+ changeDraft(`${value}${separator}Context snapshot: ${item.title}\n${item.text}\n`);
+ setStatus(`Inserted a text snapshot from ${item.title}.`);
+ editorRef.current?.focus();
+ }
+
+ const feedbackId = `${id}-feedback`;
+ const disabledId = `${id}-disabled`;
+ const helpId = `${id}-help`;
+
+ return (
+ event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (
+ event.key === "Escape" && !event.repeat && !composingRef.current
+ && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ }
+ }}
+ >
+
+
+
Prompt Composer
+
Send to {title}
+
+
+
+
+
+ );
+}
+
+function isTerminalKey(key: string): key is PromptTerminalKey {
+ return key === "Enter" || key === "Tab" || key === "ArrowUp"
+ || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight";
+}
diff --git a/apps/desktop/src/app/features/canvas/canvas-knowledge-panel.css b/apps/desktop/src/app/features/canvas/canvas-knowledge-panel.css
new file mode 100644
index 0000000..7fc746e
--- /dev/null
+++ b/apps/desktop/src/app/features/canvas/canvas-knowledge-panel.css
@@ -0,0 +1,110 @@
+.canvas-knowledge-panel {
+ position: absolute;
+ z-index: 13;
+ inset: 5rem 1.25rem 5rem auto;
+ width: min(56rem, calc(100% - 2.5rem));
+ min-width: 0;
+ overflow: auto;
+ overscroll-behavior: contain;
+ border: 1px solid var(--canvas-control-border);
+ border-radius: var(--radius-dialog);
+ color: var(--canvas-text);
+ background: var(--canvas-card);
+ box-shadow: var(--shadow-dialog);
+ scroll-padding-top: 4rem;
+}
+
+.canvas-knowledge-panel[hidden] {
+ display: none;
+}
+
+.canvas-knowledge-panel__target {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ border-bottom: 1px solid var(--canvas-border);
+ color: var(--canvas-text);
+ background: var(--canvas-card);
+}
+
+.canvas-knowledge-panel__target p,
+.canvas-knowledge-panel__scope {
+ margin: 0;
+ font-size: 0.8125rem;
+ overflow-wrap: anywhere;
+}
+
+.canvas-knowledge-panel__sections {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--canvas-border);
+}
+
+.canvas-knowledge-panel__sections button {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--canvas-control-border);
+ border-radius: var(--radius);
+ color: var(--canvas-text);
+ background: var(--canvas-card);
+ font-size: 0.8125rem;
+}
+
+.canvas-knowledge-panel__sections button[aria-pressed="true"] {
+ border-color: var(--canvas-focus);
+ background: var(--color-surface-hover);
+ font-weight: 600;
+}
+
+.canvas-knowledge-panel__sections button:focus-visible {
+ outline: 3px solid var(--canvas-focus);
+ outline-offset: 2px;
+}
+
+.canvas-knowledge-panel__target button {
+ display: grid;
+ place-items: center;
+ width: 2rem;
+ height: 2rem;
+ flex-shrink: 0;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ color: var(--canvas-text);
+ background: var(--canvas-card);
+}
+
+.canvas-knowledge-panel__target button:is(:hover, :active) {
+ background: var(--color-surface-hover);
+}
+
+.canvas-knowledge-panel__target button:focus-visible {
+ outline: 3px solid var(--canvas-focus);
+ outline-offset: 2px;
+}
+
+.canvas-knowledge-panel__scope {
+ padding: var(--space-3) var(--space-4);
+ color: var(--color-warning);
+ background: var(--color-warning-muted);
+}
+
+@media (max-width: 68rem) {
+ .canvas-knowledge-panel {
+ top: 8rem;
+ }
+}
+
+@media (max-width: 48rem) {
+ .canvas-knowledge-panel {
+ right: 0.75rem;
+ bottom: 4.75rem;
+ width: calc(100% - 1.5rem);
+ }
+}
diff --git a/apps/desktop/src/app/features/canvas/canvas-state.test.ts b/apps/desktop/src/app/features/canvas/canvas-state.test.ts
index 7135218..2a20d86 100644
--- a/apps/desktop/src/app/features/canvas/canvas-state.test.ts
+++ b/apps/desktop/src/app/features/canvas/canvas-state.test.ts
@@ -6,7 +6,9 @@ import {
createCanvasNode,
createInitialCanvasDocument,
createInitialCanvasState,
+ createSessionTerminalCanvasNode,
createTerminalCanvasNode,
+ duplicateCanvasSelection,
normalizeBrowserNavigationUrl,
normalizeBrowserUrl,
parseCanvasDocument,
@@ -14,6 +16,169 @@ import {
} from "./canvas-state";
describe("canvas state", () => {
+ it("retains isolated working copy intent across reload and duplication", () => {
+ const node = createTerminalCanvasNode({ x: 0, y: 0 }, { isolation: "new_worktree" }, "isolated");
+ const initial = createInitialCanvasState({ version: 2, nodes: [node], connections: [], zoom: 1 });
+ const duplicated = canvasReducer(initial, duplicateCanvasSelection(initial, [node.id]));
+ expect(parseCanvasDocument(serializeCanvasDocument(duplicated)).nodes).toEqual(duplicated.nodes);
+ expect(duplicated.nodes[1]).toMatchObject({ isolation: "new_worktree" });
+ expect(duplicated.nodes[1]).toHaveProperty("sessionId", undefined);
+ });
+
+ it("persists each terminal's draft without changing its session or other cards", () => {
+ const initial = createInitialCanvasState();
+ const drafted = canvasReducer(initial, {
+ type: "terminal/draft", nodeId: "terminal-primary", text: "Review this\nthen explain.",
+ });
+ const saved = parseCanvasDocument(serializeCanvasDocument(drafted));
+ expect(saved.nodes.find((node) => node.id === "terminal-primary")).toMatchObject({
+ promptDraft: "Review this\nthen explain.", promptDraftRevision: 1,
+ });
+ expect(drafted.nodes.find((node) => node.id === "terminal-secondary")).toBe(initial.nodes.find((node) => node.id === "terminal-secondary"));
+ expect(canvasReducer(drafted, {
+ type: "terminal/draft", nodeId: "terminal-primary", text: "Review this\nthen explain.",
+ })).toBe(drafted);
+ expect(canvasReducer(drafted, {
+ type: "terminal/draft", nodeId: "note-first", text: "wrong target",
+ })).toBe(drafted);
+ });
+
+ it("clears only an acknowledged draft revision, including edit-away-and-back races", () => {
+ const drafted = canvasReducer(createInitialCanvasState(), {
+ type: "terminal/draft", nodeId: "terminal-primary", text: "A",
+ });
+ const acknowledgment = {
+ type: "terminal/draft_sent", nodeId: "terminal-primary", text: "A", revision: 1,
+ } as const;
+ const cleared = canvasReducer(drafted, acknowledgment);
+ expect(cleared.nodes.find((node) => node.id === "terminal-primary")).toMatchObject({
+ promptDraft: "", promptDraftRevision: 2,
+ });
+ expect(canvasReducer(cleared, acknowledgment)).toBe(cleared);
+ const edited = canvasReducer(drafted, {
+ type: "terminal/draft", nodeId: "terminal-primary", text: "B",
+ });
+ const editedBack = canvasReducer(edited, {
+ type: "terminal/draft", nodeId: "terminal-primary", text: "A",
+ });
+ expect(canvasReducer(editedBack, acknowledgment)).toBe(editedBack);
+ expect(canvasReducer(edited, acknowledgment)).toBe(edited);
+ const removed = canvasReducer(drafted, { type: "node/delete", nodeId: "terminal-primary" });
+ expect(canvasReducer(removed, acknowledgment)).toBe(removed);
+ });
+
+ it("migrates absent or malformed draft metadata without treating it as input", () => {
+ const document = createInitialCanvasDocument();
+ const parsed = parseCanvasDocument(JSON.stringify({
+ ...document, version: 1,
+ nodes: document.nodes.map((node) => ({ ...node, promptDraft: 123, promptDraftRevision: -1 })),
+ }));
+ expect(parsed.nodes.find((node) => node.id === "terminal-primary")).toMatchObject({
+ promptDraft: undefined, promptDraftRevision: undefined,
+ });
+ });
+
+ it("persists the Gemini quick-start preset with its native executable", () => {
+ const node = createTerminalCanvasNode({ x: 0, y: 0 }, { preset: "gemini" }, "gemini");
+ const state = createInitialCanvasState({ version: 2, nodes: [node], connections: [], zoom: 1 });
+ expect(parseCanvasDocument(serializeCanvasDocument(state)).nodes[0]).toMatchObject({
+ preset: "gemini", title: "Gemini", executable: "gemini",
+ });
+ });
+
+ it("toggles multi-selection, filters unknown IDs, and keeps selection transient", () => {
+ const initial = createInitialCanvasState();
+ const first = canvasReducer(initial, { type: "node/select", nodeId: "note-first" });
+ const multiple = canvasReducer(first, {
+ type: "node/select", nodeId: "terminal-primary", additive: true,
+ });
+ expect(multiple.selectedNodeIds).toEqual(["note-first", "terminal-primary"]);
+ expect(multiple.selectedNodeId).toBe("terminal-primary");
+ const toggled = canvasReducer(multiple, {
+ type: "node/select", nodeId: "terminal-primary", additive: true,
+ });
+ expect(toggled.selectedNodeIds).toEqual(["note-first"]);
+ expect(toggled.selectedNodeId).toBe("note-first");
+ const selected = canvasReducer(toggled, {
+ type: "nodes/select", nodeIds: ["terminal-primary", "missing", "terminal-primary", "note-first"],
+ });
+ expect(selected.selectedNodeIds).toEqual(["terminal-primary", "note-first"]);
+ expect(JSON.parse(serializeCanvasDocument(selected))).not.toHaveProperty("selectedNodeIds");
+ expect(createInitialCanvasState(parseCanvasDocument(serializeCanvasDocument(selected))).selectedNodeIds).toEqual([]);
+ expect(canvasReducer(selected, { type: "node/select", nodeId: null }).selectedNodeIds).toEqual([]);
+ });
+
+ it("moves a group by one bounded delta without distorting its layout", () => {
+ const initial = createInitialCanvasState({
+ version: 2,
+ nodes: [
+ createCanvasNode("note", { x: 7_990, y: -1_990 }, "edge"),
+ createCanvasNode("note", { x: 7_500, y: -1_500 }, "neighbor"),
+ createCanvasNode("note", { x: 0, y: 0 }, "unselected"),
+ ],
+ connections: [], zoom: 1,
+ });
+ const moved = canvasReducer(initial, {
+ type: "nodes/move", nodeIds: ["edge", "neighbor"], delta: { x: 80, y: -80 },
+ });
+ expect(moved.nodes[0]).toMatchObject({ x: 8_000, y: -2_000 });
+ expect(moved.nodes[1]).toMatchObject({ x: 7_510, y: -1_510 });
+ expect(moved.nodes[2]).toBe(initial.nodes[2]);
+ expect(canvasReducer(moved, {
+ type: "nodes/move", nodeIds: ["edge"], delta: { x: NaN, y: 3 },
+ })).toBe(moved);
+ });
+
+ it("duplicates notes, agent references, and internal connections without live sessions", () => {
+ const terminal = createSessionTerminalCanvasNode({ x: 0, y: 0 }, {
+ id: "live-session", agentId: "custom-agent", projectId: "project-one", name: "Review", cwd: "/repo",
+ });
+ const note = { ...createCanvasNode("note", { x: 400, y: 0 }, "note"), title: "Release", text: "Ship it", projectId: "project-one" };
+ const outside = createCanvasNode("note", { x: 0, y: 400 }, "outside");
+ const initial = createInitialCanvasState({
+ version: 2, nodes: [terminal, note, outside], zoom: 1,
+ connections: [
+ { id: "internal", sourceNodeId: terminal.id, targetNodeId: note.id },
+ { id: "external", sourceNodeId: terminal.id, targetNodeId: outside.id },
+ ],
+ });
+ const action = duplicateCanvasSelection(initial, [terminal.id, note.id]);
+ const copied = canvasReducer(initial, action);
+ expect(copied.nodes).toHaveLength(5);
+ const copies = copied.nodes.slice(3);
+ expect(copies[0]).toMatchObject({ kind: "terminal", title: "Review copy", agentId: "custom-agent", projectId: "project-one", x: 32, y: 32 });
+ expect(copies[0]).not.toHaveProperty("sessionId", "live-session");
+ expect(copies[1]).toMatchObject({ kind: "note", title: "Release copy", text: "Ship it", x: 432, y: 32 });
+ expect(copied.connections).toHaveLength(3);
+ expect(copied.connections[2]).toMatchObject({ sourceNodeId: copies[0]?.id, targetNodeId: copies[1]?.id });
+ expect(copied.selectedNodeIds).toEqual(copies.map((node) => node.id));
+ expect(copied.nodes[0]).toBe(terminal);
+ expect(canvasReducer(copied, action)).toBe(copied);
+ expect(parseCanvasDocument(serializeCanvasDocument(copied)).nodes[3]).toMatchObject({ agentId: "custom-agent" });
+ });
+
+ it("removes a selected group atomically, hiding its sessions and retaining other projects", () => {
+ const terminal = createSessionTerminalCanvasNode({ x: 0, y: 0 }, {
+ id: "live", projectId: "project-one", name: "Agent", cwd: "/repo",
+ });
+ const note = createCanvasNode("note", { x: 400, y: 0 }, "note");
+ const otherProject = { ...createCanvasNode("note", { x: 0, y: 0 }, "other"), projectId: "project-two" };
+ const initial = createInitialCanvasState({
+ version: 2, nodes: [terminal, note, otherProject], zoom: 1,
+ connections: [{ id: "edge", sourceNodeId: terminal.id, targetNodeId: note.id }],
+ });
+ const selected = canvasReducer(initial, { type: "nodes/select", nodeIds: [terminal.id, note.id] });
+ const deleted = canvasReducer(selected, { type: "nodes/delete", nodeIds: selected.selectedNodeIds });
+ expect(deleted.nodes).toEqual([otherProject]);
+ expect(deleted.connections).toEqual([]);
+ expect(deleted.hiddenSessionIds).toEqual(["live"]);
+ expect(deleted.selectedNodeIds).toEqual([]);
+ expect(deleted.selectedNodeId).toBeNull();
+ expect(canvasReducer(deleted, {
+ type: "sessions/reconcile", knownSessionIds: ["live"], sessionNodes: [terminal],
+ }).nodes).toEqual([otherProject]);
+ });
+
it("provides the reference terminal and note composition on first launch", () => {
const document = createInitialCanvasDocument();
@@ -175,22 +340,44 @@ describe("canvas state", () => {
kind: "note",
title: "Legacy note",
text: "Keep me",
+ projectId: "legacy-project",
x: 100,
y: 200,
},
],
connections: [],
zoom: 0.75,
+ hiddenSessionIds: ["hidden-session"],
}),
);
expect(parsed).toMatchObject({
version: 2,
- nodes: [{ id: "note-legacy", text: "Keep me" }],
+ nodes: [{ id: "note-legacy", text: "Keep me", projectId: "legacy-project" }],
zoom: 0.75,
+ hiddenSessionIds: ["hidden-session"],
});
});
+ it("copies a browser and agent together while retaining scope and clearing the live session", () => {
+ const browser = { ...createBrowserCanvasNode({ x: 100, y: 200 }, "https://example.com/?token=secret&tab=code#private", "browser"), projectId: "project" };
+ const terminal = createSessionTerminalCanvasNode({ x: 800, y: 200 }, {
+ id: "session", agentId: "gemini-agent", projectId: "project", name: "Gemini", cwd: "/repo",
+ });
+ const state = createInitialCanvasState({
+ version: 2, nodes: [browser, terminal], zoom: 1,
+ connections: [{ id: "edge", sourceNodeId: browser.id, targetNodeId: terminal.id }],
+ hiddenSessionIds: ["other-session"],
+ });
+ const copied = canvasReducer(state, duplicateCanvasSelection(state, [browser.id, terminal.id]));
+ expect(copied.nodes[2]).toMatchObject({ kind: "browser", projectId: "project", url: "https://example.com/?tab=code", width: browser.width, height: browser.height });
+ expect(copied.nodes[3]).toMatchObject({ kind: "terminal", agentId: "gemini-agent", sessionId: undefined, projectId: "project" });
+ const restored = parseCanvasDocument(serializeCanvasDocument(copied));
+ expect(restored.nodes).toEqual(copied.nodes);
+ expect(restored.hiddenSessionIds).toEqual(["other-session"]);
+ expect(restored.connections).toHaveLength(2);
+ });
+
it("moves, renames, and updates notes without changing other nodes", () => {
const initial = createInitialCanvasState();
const moved = canvasReducer(initial, {
@@ -222,6 +409,21 @@ describe("canvas state", () => {
);
});
+ it("clears the selected node when the canvas background is selected", () => {
+ const initial = createInitialCanvasState();
+ const selected = canvasReducer(initial, {
+ type: "node/select",
+ nodeId: "note-first",
+ });
+ const cleared = canvasReducer(selected, {
+ type: "node/select",
+ nodeId: null,
+ });
+
+ expect(selected.selectedNodeId).toBe("note-first");
+ expect(cleared.selectedNodeId).toBeNull();
+ });
+
it("connects distinct nodes once and removes their edges with the node", () => {
const initial = createInitialCanvasState({
version: 2,
@@ -265,6 +467,123 @@ describe("canvas state", () => {
expect(deleted.connections).toEqual([]);
});
+ it("dismisses attached cards without deleting or immediately recreating sessions", () => {
+ const sessionNode = createSessionTerminalCanvasNode(
+ { x: 20, y: 30 },
+ {
+ id: "session-one",
+ projectId: "project-one",
+ name: "Agent session",
+ cwd: "/repos/project-one",
+ },
+ );
+ const initial = createInitialCanvasState({
+ version: 2,
+ nodes: [sessionNode],
+ connections: [],
+ zoom: 1,
+ hiddenSessionIds: [],
+ });
+
+ const dismissed = canvasReducer(initial, {
+ type: "node/delete",
+ nodeId: sessionNode.id,
+ });
+ const reconciled = canvasReducer(dismissed, {
+ type: "sessions/reconcile",
+ knownSessionIds: ["session-one"],
+ sessionNodes: [sessionNode],
+ });
+
+ expect(reconciled.nodes).toEqual([]);
+ expect(reconciled.hiddenSessionIds).toEqual(["session-one"]);
+ expect(parseCanvasDocument(serializeCanvasDocument(reconciled))).toEqual(
+ expect.objectContaining({ hiddenSessionIds: ["session-one"] }),
+ );
+ });
+
+ it("reconciles known sessions, prunes stale dismissals, and refreshes names", () => {
+ const firstNode = createSessionTerminalCanvasNode(
+ { x: 20, y: 30 },
+ {
+ id: "session-one",
+ projectId: "project-one",
+ name: "Old name",
+ cwd: "/repos/project-one",
+ },
+ );
+ const secondNode = createSessionTerminalCanvasNode(
+ { x: 200, y: 300 },
+ {
+ id: "session-two",
+ projectId: "project-one",
+ name: "Hidden session",
+ cwd: "/repos/project-one",
+ },
+ );
+ const initial = createInitialCanvasState({
+ version: 2,
+ nodes: [firstNode],
+ connections: [],
+ zoom: 1,
+ hiddenSessionIds: ["session-two", "deleted-session"],
+ });
+ const renamedFirstNode = createSessionTerminalCanvasNode(
+ { x: 900, y: 900 },
+ {
+ id: "session-one",
+ projectId: "project-one",
+ name: "Renamed session",
+ cwd: "/repos/project-one",
+ },
+ );
+
+ const reconciled = canvasReducer(initial, {
+ type: "sessions/reconcile",
+ knownSessionIds: ["session-one", "session-two"],
+ sessionNodes: [renamedFirstNode, secondNode],
+ });
+
+ expect(reconciled.nodes).toHaveLength(1);
+ expect(reconciled.nodes[0]).toMatchObject({
+ id: firstNode.id,
+ sessionId: "session-one",
+ projectId: "project-one",
+ title: "Renamed session",
+ x: 20,
+ y: 30,
+ });
+ expect(reconciled.hiddenSessionIds).toEqual(["session-two"]);
+ });
+
+ it("reveals a dismissed session atomically and selects its terminal node", () => {
+ const sessionNode = createSessionTerminalCanvasNode(
+ { x: 20, y: 30 },
+ {
+ id: "session-one",
+ projectId: "project-one",
+ name: "Agent session",
+ cwd: "/repos/project-one",
+ },
+ );
+ const initial = createInitialCanvasState({
+ version: 2,
+ nodes: [],
+ connections: [],
+ zoom: 1,
+ hiddenSessionIds: ["session-one"],
+ });
+
+ const revealed = canvasReducer(initial, {
+ type: "session/reveal",
+ node: sessionNode,
+ });
+
+ expect(revealed.nodes).toEqual([sessionNode]);
+ expect(revealed.hiddenSessionIds).toEqual([]);
+ expect(revealed.selectedNodeId).toBe(sessionNode.id);
+ });
+
it("round-trips valid documents and drops unsafe persisted references", () => {
const serialized = serializeCanvasDocument(createInitialCanvasState());
expect(parseCanvasDocument(serialized)).toEqual(
@@ -273,7 +592,7 @@ describe("canvas state", () => {
const parsed = parseCanvasDocument(
JSON.stringify({
- version: 1,
+ version: 2,
zoom: 9,
nodes: [
{
@@ -308,5 +627,6 @@ describe("canvas state", () => {
]);
expect(parsed.connections).toEqual([]);
expect(parsed.zoom).toBe(1.5);
+ expect(parsed.hiddenSessionIds).toEqual([]);
});
});
diff --git a/apps/desktop/src/app/features/canvas/canvas-state.ts b/apps/desktop/src/app/features/canvas/canvas-state.ts
index 81e5082..29aeb6a 100644
--- a/apps/desktop/src/app/features/canvas/canvas-state.ts
+++ b/apps/desktop/src/app/features/canvas/canvas-state.ts
@@ -1,9 +1,11 @@
+import type { SessionIsolation } from "../../../ipc/types";
+
export const CANVAS_STORAGE_KEY = "cli-master.canvas.v1";
export const CANVAS_DOCUMENT_VERSION = 2;
export const CANVAS_DOCUMENT_UPDATED_EVENT = "cli-master:canvas-document-updated";
export type CanvasNodeKind = "terminal" | "note" | "browser";
-export type TerminalPreset = "shell" | "codex" | "claude" | "opencode" | "custom";
+export type TerminalPreset = "shell" | "codex" | "claude" | "gemini" | "opencode" | "custom";
export const DEFAULT_TERMINAL_SIZE = { width: 432, height: 256 } as const;
export const DEFAULT_BROWSER_SIZE = { width: 640, height: 420 } as const;
@@ -18,11 +20,19 @@ interface CanvasNodeBase extends CanvasPoint {
readonly id: string;
readonly title: string;
readonly kind: CanvasNodeKind;
+ /** New nodes are scoped to a project; absent means a legacy shared node. */
+ readonly projectId?: string;
}
export interface TerminalCanvasNode extends CanvasNodeBase {
readonly kind: "terminal";
readonly sessionId?: string;
+ /** Reference the persisted agent definition without copying its environment. */
+ readonly agentId?: string;
+ /** User-authored composer text, never PTY output or an auto-send instruction. */
+ readonly promptDraft?: string;
+ readonly promptDraftRevision?: number;
+ readonly isolation?: SessionIsolation;
readonly preset: TerminalPreset;
readonly executable?: string;
readonly workingDirectory?: string;
@@ -47,6 +57,7 @@ export type CanvasNode = TerminalCanvasNode | NoteCanvasNode | BrowserCanvasNode
export interface CanvasTerminalConfiguration {
readonly title: string;
readonly preset: TerminalPreset;
+ readonly isolation?: SessionIsolation;
readonly executable?: string;
readonly workingDirectory?: string;
}
@@ -62,13 +73,25 @@ export interface CanvasDocument {
readonly nodes: readonly CanvasNode[];
readonly connections: readonly CanvasConnection[];
readonly zoom: number;
+ /** Session cards dismissed from the canvas without deleting session metadata. */
+ readonly hiddenSessionIds?: readonly string[];
}
export interface CanvasState extends CanvasDocument {
+ readonly hiddenSessionIds: readonly string[];
readonly selectedNodeId: string | null;
+ readonly selectedNodeIds: readonly string[];
readonly connectionSourceId: string | null;
}
+export interface CanvasSessionReference {
+ readonly id: string;
+ readonly projectId: string;
+ readonly name: string;
+ readonly cwd: string;
+ readonly agentId?: string;
+}
+
export type CanvasAction =
| { readonly type: "document/hydrate"; readonly document: CanvasDocument }
| { readonly type: "node/add"; readonly node: CanvasNode }
@@ -87,13 +110,24 @@ export type CanvasAction =
readonly nodeId: string;
readonly text: string;
}
+ | {
+ readonly type: "terminal/draft";
+ readonly nodeId: string;
+ readonly text: string;
+ }
+ | {
+ readonly type: "terminal/draft_sent";
+ readonly nodeId: string;
+ readonly text: string;
+ readonly revision: number;
+ }
| {
readonly type: "terminal/configure";
readonly nodeId: string;
readonly configuration: CanvasTerminalConfiguration;
}
| {
- readonly type: "node/resize";
+ readonly type: "node/resize" | "terminal/resize";
readonly nodeId: string;
readonly size: { readonly width: number; readonly height: number };
}
@@ -101,6 +135,16 @@ export type CanvasAction =
readonly type: "terminal/attach";
readonly nodeId: string;
readonly sessionId: string;
+ readonly projectId: string;
+ }
+ | {
+ readonly type: "sessions/reconcile";
+ readonly knownSessionIds: readonly string[];
+ readonly sessionNodes: readonly TerminalCanvasNode[];
+ }
+ | {
+ readonly type: "session/reveal";
+ readonly node: TerminalCanvasNode;
}
| {
readonly type: "browser/navigate";
@@ -108,7 +152,11 @@ export type CanvasAction =
readonly url: string;
}
| { readonly type: "node/delete"; readonly nodeId: string }
- | { readonly type: "node/select"; readonly nodeId: string | null }
+ | { readonly type: "node/select"; readonly nodeId: string | null; readonly additive?: boolean }
+ | { readonly type: "nodes/select"; readonly nodeIds: readonly string[] }
+ | { readonly type: "nodes/move"; readonly nodeIds: readonly string[]; readonly delta: CanvasPoint }
+ | { readonly type: "nodes/delete"; readonly nodeIds: readonly string[] }
+ | { readonly type: "nodes/duplicate"; readonly nodes: readonly CanvasNode[]; readonly connections: readonly CanvasConnection[] }
| { readonly type: "connection/start"; readonly nodeId: string }
| { readonly type: "connection/complete"; readonly targetNodeId: string }
| { readonly type: "connection/cancel" }
@@ -188,6 +236,7 @@ export function createInitialCanvasDocument(): CanvasDocument {
createConnection(nodes[0]?.id ?? "", nodes[2]?.id ?? ""),
],
zoom: 1,
+ hiddenSessionIds: [],
};
}
@@ -196,7 +245,9 @@ export function createInitialCanvasState(
): CanvasState {
return {
...document,
+ hiddenSessionIds: document.hiddenSessionIds ?? [],
selectedNodeId: null,
+ selectedNodeIds: [],
connectionSourceId: null,
};
}
@@ -216,7 +267,32 @@ export function canvasReducer(
...state,
nodes: [...state.nodes, normalizeNode(action.node)],
selectedNodeId: action.node.id,
+ selectedNodeIds: [action.node.id],
};
+ case "nodes/select":
+ return selectNodes(state, action.nodeIds);
+ case "nodes/move":
+ return moveNodes(state, action.nodeIds, action.delta);
+ case "nodes/delete":
+ return deleteNodes(state, action.nodeIds);
+ case "nodes/duplicate": {
+ const nodes = action.nodes.map(normalizeNode);
+ if (nodes.length === 0 || new Set(nodes.map((node) => node.id)).size !== nodes.length ||
+ nodes.some((node) => nodeExists(state.nodes, node.id))) {
+ return state;
+ }
+ const nodeIds = new Set(nodes.map((node) => node.id));
+ const connections = action.connections.flatMap((connection) => {
+ const parsed = parseConnection(connection, nodeIds);
+ return parsed ? [parsed] : [];
+ });
+ return selectNodes({
+ ...state,
+ nodes: [...state.nodes, ...nodes],
+ connections: [...state.connections, ...connections],
+ connectionSourceId: null,
+ }, nodes.map((node) => node.id));
+ }
case "node/move":
return updateNode(state, action.nodeId, (node) => ({
...node,
@@ -234,6 +310,16 @@ export function canvasReducer(
? { ...node, text: action.text.slice(0, MAX_NOTE_LENGTH) }
: node,
);
+ case "terminal/draft":
+ return updateNode(state, action.nodeId, (node) =>
+ node.kind === "terminal" ? updatePromptDraft(node, action.text) : node,
+ );
+ case "terminal/draft_sent":
+ return updateNode(state, action.nodeId, (node) =>
+ node.kind === "terminal" && (node.promptDraft ?? "") === action.text
+ && (node.promptDraftRevision ?? 0) === action.revision
+ ? updatePromptDraft(node, "") : node,
+ );
case "terminal/configure":
return updateNode(state, action.nodeId, (node) =>
node.kind === "terminal"
@@ -244,12 +330,28 @@ export function canvasReducer(
return updateNode(state, action.nodeId, (node) =>
resizeCanvasNode(node, action.size),
);
+ case "terminal/resize":
+ return updateNode(state, action.nodeId, (node) =>
+ node.kind === "terminal" ? resizeCanvasNode(node, action.size) : node,
+ );
case "terminal/attach":
return updateNode(state, action.nodeId, (node) =>
node.kind === "terminal"
- ? { ...node, sessionId: action.sessionId }
+ ? {
+ ...node,
+ sessionId: action.sessionId,
+ projectId: action.projectId,
+ }
: node,
);
+ case "sessions/reconcile":
+ return reconcileSessionNodes(
+ state,
+ action.knownSessionIds,
+ action.sessionNodes,
+ );
+ case "session/reveal":
+ return revealSessionNode(state, action.node);
case "browser/navigate":
return updateNode(state, action.nodeId, (node) =>
node.kind === "browser"
@@ -257,32 +359,23 @@ export function canvasReducer(
: node,
);
case "node/delete":
- return {
- ...state,
- nodes: state.nodes.filter((node) => node.id !== action.nodeId),
- connections: state.connections.filter(
- (connection) =>
- connection.sourceNodeId !== action.nodeId &&
- connection.targetNodeId !== action.nodeId,
- ),
- selectedNodeId:
- state.selectedNodeId === action.nodeId
- ? null
- : state.selectedNodeId,
- connectionSourceId:
- state.connectionSourceId === action.nodeId
- ? null
- : state.connectionSourceId,
- };
+ return deleteNodes(state, [action.nodeId]);
case "node/select":
- return nodeExists(state.nodes, action.nodeId)
- ? { ...state, selectedNodeId: action.nodeId }
- : state;
+ if (action.nodeId === null) {
+ return selectNodes(state, []);
+ }
+ if (!nodeExists(state.nodes, action.nodeId)) return state;
+ return selectNodes(state, action.additive
+ ? state.selectedNodeIds.includes(action.nodeId)
+ ? state.selectedNodeIds.filter((id) => id !== action.nodeId)
+ : [...state.selectedNodeIds, action.nodeId]
+ : [action.nodeId]);
case "connection/start":
return nodeExists(state.nodes, action.nodeId)
? {
...state,
selectedNodeId: action.nodeId,
+ selectedNodeIds: [action.nodeId],
connectionSourceId: action.nodeId,
}
: state;
@@ -305,12 +398,15 @@ export function canvasReducer(
}
}
-export function toCanvasDocument(state: CanvasState): CanvasDocument {
+export function toCanvasDocument(state: CanvasDocument): CanvasDocument {
return {
version: CANVAS_DOCUMENT_VERSION,
- nodes: state.nodes,
+ nodes: state.nodes.map((node) => node.kind === "browser"
+ ? { ...node, url: normalizeBrowserUrl(node.url) }
+ : node),
connections: state.connections,
zoom: state.zoom,
+ hiddenSessionIds: state.hiddenSessionIds,
};
}
@@ -329,6 +425,31 @@ export function serializeCanvasDocument(state: CanvasState): string {
return JSON.stringify(toCanvasDocument(state));
}
+/** Copies graph metadata, never a running process or its terminal stream. */
+export function duplicateCanvasSelection(
+ state: CanvasState,
+ nodeIds: readonly string[],
+): CanvasAction {
+ const selectedIds = new Set(nodeIds);
+ const originals = state.nodes.filter((node) => selectedIds.has(node.id));
+ const idMap = new Map(originals.map((node) => [node.id, createId(node.kind)]));
+ const delta = boundedMoveDelta(originals, { x: 32, y: 32 });
+ const nodes = originals.map((node): CanvasNode => ({
+ ...node,
+ ...(node.kind === "terminal" ? { sessionId: undefined } : {}),
+ id: idMap.get(node.id)!,
+ title: `${node.title.slice(0, MAX_TITLE_LENGTH - 5)} copy`,
+ x: node.x + delta.x,
+ y: node.y + delta.y,
+ }));
+ const connections = state.connections.flatMap((connection) => {
+ const source = idMap.get(connection.sourceNodeId);
+ const target = idMap.get(connection.targetNodeId);
+ return source && target ? [createConnection(source, target)] : [];
+ });
+ return { type: "nodes/duplicate", nodes, connections };
+}
+
export function createCanvasNode(
kind: CanvasNodeKind,
position: CanvasPoint,
@@ -384,6 +505,7 @@ export function createTerminalCanvasNode(
kind: "terminal",
title: normalizeTitle(configuration.title, titleForPreset(preset)),
preset,
+ isolation: configuration.isolation === "new_worktree" ? "new_worktree" : undefined,
executable,
workingDirectory: normalizeOptionalText(
configuration.workingDirectory,
@@ -395,6 +517,27 @@ export function createTerminalCanvasNode(
};
}
+/** Creates the stable canvas representation of an existing daemon session. */
+export function createSessionTerminalCanvasNode(
+ position: CanvasPoint,
+ session: CanvasSessionReference,
+): TerminalCanvasNode {
+ return {
+ ...createTerminalCanvasNode(
+ position,
+ {
+ title: session.name,
+ preset: "shell",
+ workingDirectory: session.cwd,
+ },
+ `terminal-session-${session.id}`,
+ ),
+ sessionId: session.id,
+ agentId: session.agentId,
+ projectId: session.projectId,
+ };
+}
+
export function getCanvasNodeSize(
node: CanvasNode,
): { readonly width: number; readonly height: number } {
@@ -468,6 +611,89 @@ function navigateBrowserNode(
return url ? { ...node, url } : node;
}
+function reconcileSessionNodes(
+ state: CanvasState,
+ knownSessionIds: readonly string[],
+ sessionNodes: readonly TerminalCanvasNode[],
+): CanvasState {
+ const knownSessionIdSet = new Set(knownSessionIds);
+ const hiddenSessionIds = state.hiddenSessionIds.filter((sessionId) =>
+ knownSessionIdSet.has(sessionId),
+ );
+ const hiddenSessionIdSet = new Set(hiddenSessionIds);
+ let nodes = state.nodes;
+
+ for (const sessionNode of sessionNodes) {
+ if (!sessionNode.sessionId) {
+ continue;
+ }
+ const existingIndex = nodes.findIndex(
+ (node) =>
+ node.kind === "terminal" && node.sessionId === sessionNode.sessionId,
+ );
+ if (existingIndex === -1) {
+ if (!hiddenSessionIdSet.has(sessionNode.sessionId)) {
+ nodes = [...nodes, normalizeNode(sessionNode)];
+ }
+ continue;
+ }
+
+ const existingNode = nodes[existingIndex];
+ if (
+ existingNode?.kind === "terminal" &&
+ (existingNode.title !== sessionNode.title ||
+ existingNode.projectId !== sessionNode.projectId ||
+ existingNode.agentId !== sessionNode.agentId)
+ ) {
+ nodes = nodes.map((node, index) =>
+ index === existingIndex
+ ? {
+ ...existingNode,
+ title: sessionNode.title,
+ projectId: sessionNode.projectId,
+ agentId: sessionNode.agentId,
+ }
+ : node,
+ );
+ }
+ }
+
+ if (
+ nodes === state.nodes &&
+ stringArraysEqual(hiddenSessionIds, state.hiddenSessionIds)
+ ) {
+ return state;
+ }
+ return { ...state, nodes, hiddenSessionIds };
+}
+
+function revealSessionNode(
+ state: CanvasState,
+ sessionNode: TerminalCanvasNode,
+): CanvasState {
+ if (!sessionNode.sessionId) {
+ return state;
+ }
+ const existingNode = state.nodes.find(
+ (node) =>
+ node.kind === "terminal" && node.sessionId === sessionNode.sessionId,
+ );
+ const revealedNode = existingNode ?? normalizeNode(sessionNode);
+ if (revealedNode.kind !== "terminal") {
+ return state;
+ }
+ return {
+ ...state,
+ nodes: existingNode ? state.nodes : [...state.nodes, revealedNode],
+ hiddenSessionIds: state.hiddenSessionIds.filter(
+ (sessionId) => sessionId !== sessionNode.sessionId,
+ ),
+ selectedNodeId: revealedNode.id,
+ selectedNodeIds: [revealedNode.id],
+ connectionSourceId: null,
+ };
+}
+
function completeConnection(
state: CanvasState,
targetNodeId: string,
@@ -490,6 +716,7 @@ function completeConnection(
return {
...state,
selectedNodeId: targetNodeId,
+ selectedNodeIds: [targetNodeId],
connectionSourceId: null,
connections: duplicate
? state.connections
@@ -508,18 +735,73 @@ function createConnection(
};
}
+function selectNodes(state: CanvasState, nodeIds: readonly string[]): CanvasState {
+ const selectedNodeIds = [...new Set(nodeIds)].filter((id) => nodeExists(state.nodes, id));
+ if (stringArraysEqual(selectedNodeIds, state.selectedNodeIds)) return state;
+ return {
+ ...state,
+ selectedNodeIds,
+ selectedNodeId: selectedNodeIds[selectedNodeIds.length - 1] ?? null,
+ };
+}
+
+function boundedMoveDelta(nodes: readonly CanvasNode[], delta: CanvasPoint): CanvasPoint {
+ if (nodes.length === 0 || !Number.isFinite(delta.x) || !Number.isFinite(delta.y)) {
+ return { x: 0, y: 0 };
+ }
+ // Clamp the entire group once so cards keep their relative positions at edges.
+ return nodes.reduce((bounded, node) => ({
+ x: clamp(bounded.x, MIN_POSITION - node.x, MAX_POSITION - node.x),
+ y: clamp(bounded.y, MIN_POSITION - node.y, MAX_POSITION - node.y),
+ }), delta);
+}
+
+function moveNodes(state: CanvasState, nodeIds: readonly string[], delta: CanvasPoint): CanvasState {
+ const ids = new Set(nodeIds);
+ const bounded = boundedMoveDelta(state.nodes.filter((node) => ids.has(node.id)), delta);
+ if (bounded.x === 0 && bounded.y === 0) return state;
+ return {
+ ...state,
+ nodes: state.nodes.map((node) => ids.has(node.id)
+ ? { ...node, x: node.x + bounded.x, y: node.y + bounded.y }
+ : node),
+ };
+}
+
+function deleteNodes(state: CanvasState, nodeIds: readonly string[]): CanvasState {
+ const ids = new Set(nodeIds);
+ const deletedNodes = state.nodes.filter((node) => ids.has(node.id));
+ if (deletedNodes.length === 0) return state;
+ const selectedNodeIds = state.selectedNodeIds.filter((id) => !ids.has(id));
+ return {
+ ...state,
+ nodes: state.nodes.filter((node) => !ids.has(node.id)),
+ connections: state.connections.filter((connection) =>
+ !ids.has(connection.sourceNodeId) && !ids.has(connection.targetNodeId)),
+ selectedNodeIds,
+ selectedNodeId: selectedNodeIds[selectedNodeIds.length - 1] ?? null,
+ connectionSourceId: state.connectionSourceId && ids.has(state.connectionSourceId)
+ ? null : state.connectionSourceId,
+ hiddenSessionIds: [...new Set([
+ ...state.hiddenSessionIds,
+ ...deletedNodes.flatMap((node) => node.kind === "terminal" && node.sessionId ? [node.sessionId] : []),
+ ])],
+ };
+}
+
function updateNode(
state: CanvasState,
nodeId: string,
update: (node: CanvasNode) => CanvasNode,
): CanvasState {
- if (!state.nodes.some((node) => node.id === nodeId)) {
- return state;
- }
+ const current = state.nodes.find((node) => node.id === nodeId);
+ if (!current) return state;
+ const updated = update(current);
+ if (updated === current) return state;
return {
...state,
nodes: state.nodes.map((node) =>
- node.id === nodeId ? update(node) : node,
+ node === current ? updated : node,
),
};
}
@@ -556,6 +838,7 @@ function normalizeDocument(value: unknown): CanvasDocument {
nodes,
connections,
zoom: normalizeNumber(value.zoom, 1, MIN_ZOOM, MAX_ZOOM),
+ hiddenSessionIds: normalizeStringArray(value.hiddenSessionIds),
};
}
@@ -572,6 +855,8 @@ function parseNode(value: unknown): CanvasNode | null {
const base = {
id: value.id,
kind: value.kind,
+ projectId:
+ typeof value.projectId === "string" ? value.projectId : undefined,
title: normalizeTitle(
value.title,
value.kind === "note"
@@ -616,6 +901,7 @@ function parseNode(value: unknown): CanvasNode | null {
...base,
kind: "terminal",
preset: normalizeTerminalPreset(value.preset),
+ isolation: value.isolation === "new_worktree" ? "new_worktree" : undefined,
executable: normalizeOptionalText(
typeof value.executable === "string"
? value.executable
@@ -640,6 +926,25 @@ function parseNode(value: unknown): CanvasNode | null {
),
sessionId:
typeof value.sessionId === "string" ? value.sessionId : undefined,
+ agentId:
+ typeof value.agentId === "string" ? value.agentId : undefined,
+ promptDraft: typeof value.promptDraft === "string" ? value.promptDraft : undefined,
+ promptDraftRevision: typeof value.promptDraft === "string"
+ ? normalizePromptDraftRevision(value.promptDraftRevision) : undefined,
+ };
+}
+
+function normalizePromptDraftRevision(value: unknown): number {
+ return typeof value === "number" && Number.isSafeInteger(value)
+ && value >= 0 && value < Number.MAX_SAFE_INTEGER ? value : 0;
+}
+
+function updatePromptDraft(node: TerminalCanvasNode, text: string): TerminalCanvasNode {
+ if ((node.promptDraft ?? "") === text) return node;
+ return {
+ ...node,
+ promptDraft: text,
+ promptDraftRevision: normalizePromptDraftRevision(node.promptDraftRevision) + 1,
};
}
@@ -652,6 +957,7 @@ function configureTerminalNode(
...node,
title: normalizeTitle(configuration.title, titleForPreset(preset)),
preset,
+ isolation: configuration.isolation === "new_worktree" ? "new_worktree" : undefined,
executable: normalizeOptionalText(
configuration.executable ?? executableForPreset(preset),
MAX_EXECUTABLE_LENGTH,
@@ -704,6 +1010,7 @@ function resizeCanvasNode(
function normalizeTerminalPreset(value: unknown): TerminalPreset {
return value === "codex" ||
value === "claude" ||
+ value === "gemini" ||
value === "opencode" ||
value === "custom"
? value
@@ -716,6 +1023,8 @@ function executableForPreset(preset: TerminalPreset): string | undefined {
return "codex";
case "claude":
return "claude";
+ case "gemini":
+ return "gemini";
case "opencode":
return "opencode";
case "shell":
@@ -730,6 +1039,8 @@ function titleForPreset(preset: TerminalPreset): string {
return "Codex";
case "claude":
return "Claude";
+ case "gemini":
+ return "Gemini";
case "opencode":
return "OpenCode";
case "shell":
@@ -796,6 +1107,23 @@ function normalizeNumber(
: fallback;
}
+function normalizeStringArray(value: unknown): readonly string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return [...new Set(value.filter((item): item is string => typeof item === "string"))];
+}
+
+function stringArraysEqual(
+ left: readonly string[],
+ right: readonly string[],
+): boolean {
+ return (
+ left.length === right.length &&
+ left.every((value, index) => value === right[index])
+ );
+}
+
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
diff --git a/apps/desktop/src/app/features/canvas/prompt-input.test.ts b/apps/desktop/src/app/features/canvas/prompt-input.test.ts
new file mode 100644
index 0000000..a03ed00
--- /dev/null
+++ b/apps/desktop/src/app/features/canvas/prompt-input.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { encodePromptInput, encodePromptTerminalKey, getPromptInputError, MAX_PROMPT_INPUT_BYTES } from "./prompt-input";
+
+const plain = { bracketedPasteMode: false, applicationCursorKeysMode: false };
+const application = { bracketedPasteMode: true, applicationCursorKeysMode: true };
+const decode = (value: Uint8Array) => new TextDecoder().decode(value);
+
+describe("prompt input encoding", () => {
+ it("uses live paste mode and one trailing Return without shell interpolation", () => {
+ const prompt = "printf '$HOME'\r\nreview `file`\n$(literal) — ação";
+ const normalized = "printf '$HOME'\rreview `file`\r$(literal) — ação";
+ expect(decode(encodePromptInput(prompt, plain))).toBe(`${normalized}\r`);
+ expect(decode(encodePromptInput(prompt, application))).toBe(`\u001b[200~${normalized}\u001b[201~\r`);
+ });
+
+ it.each(["\u001b[201~run", "stop\u0003", "null\u0000", "delete\u007f", "csi\u009b"])(
+ "rejects embedded terminal control sequences without echoing user input",
+ (text) => {
+ expect(() => encodePromptInput(text, application)).toThrow("Remove terminal control characters");
+ expect(getPromptInputError(text)).not.toContain(text);
+ },
+ );
+
+ it("preserves tabs and whitespace but rejects an empty submission", () => {
+ expect(decode(encodePromptInput(" line\t ", plain))).toBe(" line\t \r");
+ expect(() => encodePromptInput(" \n\t", plain)).toThrow("Write a prompt");
+ });
+
+ it("bounds UTF-8 input including framing without truncating the prompt", () => {
+ expect(encodePromptInput("x".repeat(MAX_PROMPT_INPUT_BYTES - 13), application)).toHaveLength(MAX_PROMPT_INPUT_BYTES);
+ expect(() => encodePromptInput("x".repeat(MAX_PROMPT_INPUT_BYTES - 12), application)).toThrow("64 KiB");
+ expect(encodePromptInput("x".repeat(MAX_PROMPT_INPUT_BYTES - 1), plain)).toHaveLength(MAX_PROMPT_INPUT_BYTES);
+ expect(() => encodePromptInput("🦉".repeat(16_384), application)).toThrow("64 KiB");
+ });
+
+ it("uses normal or application cursor sequences for the same logical key", () => {
+ for (const [key, suffix] of [["ArrowUp", "A"], ["ArrowDown", "B"], ["ArrowRight", "C"], ["ArrowLeft", "D"]] as const) {
+ expect(decode(encodePromptTerminalKey(key, plain))).toBe(`\u001b[${suffix}`);
+ expect(decode(encodePromptTerminalKey(key, application))).toBe(`\u001bO${suffix}`);
+ }
+ expect(decode(encodePromptTerminalKey("Enter", application))).toBe("\r");
+ expect(decode(encodePromptTerminalKey("Tab", plain))).toBe("\t");
+ });
+});
diff --git a/apps/desktop/src/app/features/canvas/prompt-input.ts b/apps/desktop/src/app/features/canvas/prompt-input.ts
new file mode 100644
index 0000000..4994e30
--- /dev/null
+++ b/apps/desktop/src/app/features/canvas/prompt-input.ts
@@ -0,0 +1,47 @@
+import type { TerminalInputModes } from "../terminal/terminal-runtime";
+
+/** Mirrors core wire MAX_PTY_INPUT_BYTES, including paste framing and Return. */
+export const MAX_PROMPT_INPUT_BYTES = 64 * 1024;
+
+export type PromptInputKey = "Enter" | "Tab" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight";
+
+const BRACKETED_PASTE_START = "\u001b[200~";
+const BRACKETED_PASTE_END = "\u001b[201~";
+const encoder = new TextEncoder();
+
+/** Matches xterm's paste newline normalization without interpreting shell syntax. */
+function promptPayload(text: string, bracketedPasteMode: boolean): string {
+ const normalized = text.replace(/\r\n|\n/g, "\r");
+ return bracketedPasteMode
+ ? `${BRACKETED_PASTE_START}${normalized}${BRACKETED_PASTE_END}\r`
+ : `${normalized}\r`;
+}
+
+/** Static errors never include the prompt, paths, or other user input. */
+export function getPromptInputError(text: string, modes?: TerminalInputModes): string | undefined {
+ if (!text.trim()) return "Write a prompt before sending.";
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(text)) {
+ return "Remove terminal control characters from the prompt before sending.";
+ }
+ // Before a terminal is available, reserve the largest supported paste framing.
+ const payload = promptPayload(text, modes?.bracketedPasteMode ?? true);
+ if (encoder.encode(payload).byteLength > MAX_PROMPT_INPUT_BYTES) {
+ return "This prompt exceeds the terminal's 64 KiB input limit. Shorten it before sending.";
+ }
+ return undefined;
+}
+
+/** Encodes one explicitly submitted prompt using the live terminal's modes. */
+export function encodePromptInput(text: string, modes: TerminalInputModes): Uint8Array {
+ const error = getPromptInputError(text, modes);
+ if (error) throw new Error(error);
+ return encoder.encode(promptPayload(text, modes.bracketedPasteMode));
+}
+
+/** Empty-composer navigation honors the program's application-cursor mode. */
+export function encodePromptTerminalKey(key: PromptInputKey, modes: TerminalInputModes): Uint8Array {
+ if (key === "Enter") return encoder.encode("\r");
+ if (key === "Tab") return encoder.encode("\t");
+ const suffix = { ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D" }[key];
+ return encoder.encode(`\u001b${modes.applicationCursorKeysMode ? "O" : "["}${suffix}`);
+}
diff --git a/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx b/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx
index 858c7de..d66cc4a 100644
--- a/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx
+++ b/apps/desktop/src/app/features/canvas/useCanvasState.test.tsx
@@ -1,9 +1,10 @@
import { act, renderHook } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CANVAS_DOCUMENT_UPDATED_EVENT,
CANVAS_STORAGE_KEY,
+ createBrowserCanvasNode,
} from "./canvas-state";
import { useCanvasState } from "./useCanvasState";
@@ -11,6 +12,64 @@ describe("useCanvasState", () => {
beforeEach(() => {
localStorage.clear();
});
+ afterEach(() => vi.restoreAllMocks());
+
+ it("redacts browser URLs in both storage and published documents", () => {
+ const publish = vi.spyOn(globalThis, "dispatchEvent");
+ const { result } = renderHook(() => useCanvasState());
+ const browser = {
+ ...createBrowserCanvasNode({ x: 20, y: 30 }, "", "browser"),
+ projectId: "project",
+ url: "https://example.com/?tab=review&token=private#secret",
+ };
+ act(() => result.current.dispatch({
+ type: "document/hydrate",
+ document: { version: 2, nodes: [browser], connections: [], zoom: 1, hiddenSessionIds: ["hidden"] },
+ }));
+ const persisted = JSON.parse(localStorage.getItem(CANVAS_STORAGE_KEY) ?? "{}");
+ expect(persisted.nodes[0]).toMatchObject({ url: "https://example.com/?tab=review", projectId: "project" });
+ expect(persisted.hiddenSessionIds).toEqual(["hidden"]);
+ expect(publish).toHaveBeenLastCalledWith(expect.objectContaining({
+ detail: expect.objectContaining({ nodes: persisted.nodes }),
+ }));
+ expect(JSON.stringify(persisted)).not.toMatch(/private|secret/);
+ });
+
+ it("does not save or publish another document for transient selection changes", () => {
+ const write = spyOnStorageWrites();
+ const publish = vi.spyOn(globalThis, "dispatchEvent");
+ const { result } = renderHook(() => useCanvasState());
+ write.mockClear();
+ publish.mockClear();
+
+ act(() => result.current.dispatch({
+ type: "nodes/select", nodeIds: ["terminal-primary", "note-first"],
+ }));
+ act(() => result.current.dispatch({ type: "connection/start", nodeId: "terminal-primary" }));
+ act(() => result.current.dispatch({ type: "connection/cancel" }));
+
+ expect(write).not.toHaveBeenCalled();
+ expect(publish).not.toHaveBeenCalled();
+ act(() => result.current.dispatch({ type: "note/update", nodeId: "note-first", text: "Changed" }));
+ expect(write).toHaveBeenCalledTimes(1);
+ expect(publish).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports failed saves while retaining edits and recovers on the next successful save", () => {
+ const write = spyOnStorageWrites().mockImplementation(() => {
+ throw new DOMException("Storage full", "QuotaExceededError");
+ });
+ const { result } = renderHook(() => useCanvasState());
+ expect(result.current.persistenceAvailable).toBe(false);
+ act(() => result.current.dispatch({ type: "note/update", nodeId: "note-first", text: "Keep my draft" }));
+ expect(result.current.state.nodes.find((node) => node.id === "note-first")).toMatchObject({ text: "Keep my draft" });
+ expect(result.current.persistenceAvailable).toBe(false);
+
+ write.mockRestore();
+ act(() => result.current.dispatch({ type: "zoom/set", zoom: 1.2 }));
+ expect(result.current.persistenceAvailable).toBe(true);
+ expect(localStorage.getItem(CANVAS_STORAGE_KEY)).toContain("Keep my draft");
+ });
it("hydrates the first-launch graph and persists durable mutations", () => {
const onDocumentUpdated = vi.fn();
@@ -66,3 +125,11 @@ describe("useCanvasState", () => {
]);
});
});
+
+function spyOnStorageWrites() {
+ // Node 25's fallback owns its methods; jsdom Storage exposes them on its prototype.
+ const owner = Object.prototype.hasOwnProperty.call(localStorage, "setItem")
+ ? localStorage
+ : Storage.prototype;
+ return vi.spyOn(owner, "setItem");
+}
diff --git a/apps/desktop/src/app/features/canvas/useCanvasState.ts b/apps/desktop/src/app/features/canvas/useCanvasState.ts
index 9c6d00d..37c0c40 100644
--- a/apps/desktop/src/app/features/canvas/useCanvasState.ts
+++ b/apps/desktop/src/app/features/canvas/useCanvasState.ts
@@ -1,12 +1,12 @@
-import { useEffect, useReducer } from "react";
+import { useEffect, useMemo, useReducer, useState } from "react";
import {
CANVAS_STORAGE_KEY,
CANVAS_DOCUMENT_UPDATED_EVENT,
+ CANVAS_DOCUMENT_VERSION,
canvasReducer,
createInitialCanvasState,
parseCanvasDocument,
- serializeCanvasDocument,
toCanvasDocument,
} from "./canvas-state";
import type { CanvasAction, CanvasState } from "./canvas-state";
@@ -30,20 +30,29 @@ export function useCanvasState(): CanvasStateController {
),
),
);
+ const [persistenceAvailable, setPersistenceAvailable] = useState(storage !== null);
+ const { nodes, connections, zoom, hiddenSessionIds } = state;
+ const document = useMemo(() => toCanvasDocument({
+ version: CANVAS_DOCUMENT_VERSION,
+ nodes,
+ connections,
+ zoom,
+ hiddenSessionIds,
+ }), [nodes, connections, zoom, hiddenSessionIds]);
useEffect(() => {
- safelyWrite(storage, CANVAS_STORAGE_KEY, serializeCanvasDocument(state));
+ setPersistenceAvailable(safelyWrite(storage, CANVAS_STORAGE_KEY, JSON.stringify(document)));
globalThis.dispatchEvent?.(
new CustomEvent(CANVAS_DOCUMENT_UPDATED_EVENT, {
- detail: toCanvasDocument(state),
+ detail: document,
}),
);
- }, [state, storage]);
+ }, [document, storage]);
return {
state,
dispatch,
- persistenceAvailable: storage !== null,
+ persistenceAvailable,
};
}
@@ -67,10 +76,13 @@ function safelyWrite(
storage: Storage | null,
key: string,
value: string,
-): void {
+): boolean {
try {
- storage?.setItem(key, value);
+ if (!storage) return false;
+ storage.setItem(key, value);
+ return true;
} catch {
// Private browsing or a full quota must not make the canvas unusable.
+ return false;
}
}
diff --git a/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx
new file mode 100644
index 0000000..d2d209f
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx
@@ -0,0 +1,302 @@
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { IpcError } from "../../../ipc/client";
+import { createMockIpcClient } from "../../../test/mockIpc";
+import { KnowledgePanel } from "./KnowledgePanel";
+import type { KnowledgePage, KnowledgeRecord } from "./knowledge-types";
+
+const project = { id: "project-a", name: "Project A" };
+
+/** Content fixtures intentionally keep exact whitespace in the reusable body. */
+function entry(overrides: Partial = {}): KnowledgeRecord {
+ return {
+ id: "entry-a", kind: "prompt", projectId: null, title: "Review changes",
+ body: "Review the diff.\nKeep the public API stable.\n", revision: 3,
+ createdAtMs: 100, updatedAtMs: 200, ...overrides,
+ };
+}
+
+/** Controllable responses exercise real user-visible async races. */
+function deferred() {
+ let settle: (value: T) => void = () => { throw new Error("Promise not initialized"); };
+ const promise = new Promise((resolve) => { settle = resolve; });
+ return { promise, resolve: settle };
+}
+
+describe("KnowledgePanel", () => {
+ it("saves project context and only inserts exact text after an explicit click", async () => {
+ const user = userEvent.setup();
+ const onInsert = vi.fn();
+ const saved = entry({ kind: "context", projectId: project.id, title: "Architecture", body: "Keep this context.\n\n" });
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } });
+ client.saveKnowledge.mockResolvedValue(saved);
+ render( );
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledWith({ projectId: project.id }));
+
+ await user.selectOptions(screen.getByLabelText("Type"), "context");
+ await user.type(screen.getByLabelText("Title"), " Architecture ");
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: saved.body } });
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(client.saveKnowledge).toHaveBeenCalledWith({ kind: "context", projectId: project.id, title: "Architecture", body: saved.body });
+ expect(await screen.findByText("Saved locally.")).toBeVisible();
+ expect(onInsert).not.toHaveBeenCalled();
+ expect(client.writeTerminal).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole("button", { name: "Insert into draft" }));
+ expect(onInsert).toHaveBeenCalledWith({ sourceId: saved.id, sourceRevision: saved.revision, kind: "context", title: saved.title, body: saved.body });
+ expect(screen.getByText("Inserted into the session draft.")).toBeVisible();
+ expect(client.writeTerminal).not.toHaveBeenCalled();
+ });
+
+ it("inserts unsaved edits with the revision they were based on, even after a newer list response", async () => {
+ const user = userEvent.setup();
+ const original = entry();
+ const onInsert = vi.fn();
+ const client = createMockIpcClient();
+ client.listKnowledge.mockResolvedValueOnce({ entries: [original], nextCursor: null });
+ client.listKnowledge.mockResolvedValue({ entries: [entry({ revision: 4, body: "Changed elsewhere" })], nextCursor: null });
+ render( );
+ await user.click(await screen.findByRole("button", { name: original.title }));
+ fireEvent.change(screen.getByLabelText("Title"), { target: { value: "Edited review" } });
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Unsaved content\n" } });
+ await user.click(screen.getByRole("button", { name: "Refresh" }));
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledTimes(2));
+ await user.click(screen.getByRole("button", { name: "Insert into draft" }));
+ expect(onInsert).toHaveBeenCalledWith({
+ sourceId: original.id, sourceRevision: 3, kind: "prompt",
+ title: "Edited review", body: "Unsaved content\n",
+ });
+ expect(client.saveKnowledge).not.toHaveBeenCalled();
+ expect(client.writeTerminal).not.toHaveBeenCalled();
+ expect(client.startSession).not.toHaveBeenCalled();
+ });
+
+ it("inserts a never-saved draft with neither a source ID nor a source revision", async () => {
+ const user = userEvent.setup();
+ const onInsert = vi.fn();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } });
+ render( );
+ await user.type(screen.getByLabelText("Title"), "New draft");
+ await user.type(screen.getByLabelText("Content"), "New text");
+ await user.click(screen.getByRole("button", { name: "Insert into draft" }));
+ expect(onInsert).toHaveBeenCalledWith({
+ sourceId: null, sourceRevision: null, kind: "prompt", title: "New draft", body: "New text",
+ });
+ expect(client.saveKnowledge).not.toHaveBeenCalled();
+ expect(client.writeTerminal).not.toHaveBeenCalled();
+ expect(client.startSession).not.toHaveBeenCalled();
+ });
+
+ it("preserves item and new-project drafts when selecting items, refreshing, or changing project", async () => {
+ const user = userEvent.setup();
+ const first = entry();
+ const second = entry({ id: "entry-b", title: "Project context", kind: "context", projectId: project.id });
+ const client = createMockIpcClient({ handlers: { listKnowledge: async ({ projectId }) => ({ entries: projectId ? [first, second] : [first], nextCursor: null }) } });
+ const onInsert = vi.fn();
+ const view = render( );
+ await user.click(await screen.findByRole("button", { name: /Review changes/ }));
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Unsaved review" } });
+ await user.click(screen.getByRole("button", { name: /Project context/ }));
+ await user.click(screen.getByRole("button", { name: /Review changes/ }));
+ expect(screen.getByLabelText("Content")).toHaveValue("Unsaved review");
+ await user.click(screen.getByRole("button", { name: "Refresh" }));
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledTimes(2));
+ expect(screen.getByLabelText("Content")).toHaveValue("Unsaved review");
+
+ await user.click(screen.getByRole("button", { name: "New item" }));
+ await user.type(screen.getByLabelText("Title"), "Project draft");
+ await user.selectOptions(screen.getByLabelText("Scope"), "");
+ view.rerender( );
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: null }));
+ await user.type(screen.getByLabelText("Title"), "Global draft");
+ view.rerender( );
+ expect(screen.getByLabelText("Title")).toHaveValue("Project draft");
+ expect(screen.getByLabelText("Scope")).toHaveValue("");
+ await user.click(await screen.findByRole("button", { name: /Review changes/ }));
+ expect(screen.getByLabelText("Content")).toHaveValue("Unsaved review");
+ expect(client.saveKnowledge).not.toHaveBeenCalled();
+ });
+
+ it("retains conflict edits and saves a copy without overwriting the newer revision", async () => {
+ const user = userEvent.setup();
+ const original = entry();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }) } });
+ client.saveKnowledge.mockRejectedValueOnce(new IpcError({ code: "revision_conflict", message: "Revision changed" }));
+ client.saveKnowledge.mockResolvedValueOnce(entry({ id: "entry-copy", body: "My retained draft", revision: 1 }));
+ render( );
+ await user.click(await screen.findByRole("button", { name: /Review changes/ }));
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "My retained draft" } });
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Your edits are preserved");
+ expect(screen.getByLabelText("Content")).toHaveValue("My retained draft");
+ expect(client.saveKnowledge).toHaveBeenNthCalledWith(1, { id: original.id, expectedRevision: 3, kind: "prompt", projectId: null, title: original.title, body: "My retained draft" });
+ await user.click(screen.getByRole("button", { name: "Save as copy" }));
+ expect(client.saveKnowledge).toHaveBeenNthCalledWith(2, { kind: "prompt", projectId: null, title: original.title, body: "My retained draft" });
+ expect(await screen.findByText("Copy saved locally.")).toBeVisible();
+ });
+
+ it("requires confirmation before revision-bound deletion and keeps cancelled edits", async () => {
+ const user = userEvent.setup();
+ const original = entry();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }), deleteKnowledge: async () => undefined } });
+ render( );
+ await user.click(await screen.findByRole("button", { name: /Review changes/ }));
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Keep my edits" } });
+ await user.click(screen.getByRole("button", { name: "Delete item" }));
+ expect(client.deleteKnowledge).not.toHaveBeenCalled();
+ const dialog = screen.getByRole("dialog", { name: "Delete saved content" });
+ expect(within(dialog).getByRole("button", { name: "Keep item" })).toHaveFocus();
+ await user.click(within(dialog).getByRole("button", { name: "Keep item" }));
+ expect(screen.getByLabelText("Content")).toHaveValue("Keep my edits");
+ await user.click(screen.getByRole("button", { name: "Delete item" }));
+ await user.click(screen.getByRole("button", { name: "Confirm delete" }));
+ expect(client.deleteKnowledge).toHaveBeenCalledWith({ id: original.id, expectedRevision: original.revision });
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ expect(screen.getByLabelText("Title")).toHaveValue("");
+ });
+
+ it("loads additional pages and searches all saved title/body content", async () => {
+ const user = userEvent.setup();
+ const first = entry();
+ const second = entry({ id: "entry-b", title: "Deployment notes", kind: "context" });
+ const client = createMockIpcClient({ handlers: { listKnowledge: async ({ cursor, query }) => query ? { entries: [second], nextCursor: null } : cursor ? { entries: [second], nextCursor: null } : { entries: [first], nextCursor: first.id } } });
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Load more" }));
+ expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: project.id, cursor: first.id });
+ expect(await screen.findByRole("button", { name: /Deployment notes/ })).toBeVisible();
+ expect(screen.getByRole("button", { name: /Review changes/ })).toBeVisible();
+ await user.type(screen.getByRole("searchbox"), "deployment");
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: project.id, query: "deployment" }));
+ expect(await screen.findByRole("button", { name: /Deployment notes/ })).toBeVisible();
+ expect(screen.queryByRole("button", { name: /Review changes/ })).not.toBeInTheDocument();
+ });
+
+ it("keeps the confirmed delete target when the host switches projects", async () => {
+ const user = userEvent.setup();
+ const original = entry({ projectId: project.id });
+ const client = createMockIpcClient({ handlers: {
+ listKnowledge: async ({ projectId }) => ({ entries: projectId ? [original] : [], nextCursor: null }),
+ deleteKnowledge: async () => undefined,
+ } });
+ const onInsert = vi.fn();
+ const view = render( );
+ await user.click(await screen.findByRole("button", { name: "Review changes" }));
+ await user.click(screen.getByRole("button", { name: "Delete item" }));
+ view.rerender( );
+ await user.click(screen.getByRole("button", { name: "Confirm delete" }));
+ expect(client.deleteKnowledge).toHaveBeenCalledWith({ id: original.id, expectedRevision: original.revision });
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ });
+
+ it("does not replace a successful save with an older refresh response", async () => {
+ const user = userEvent.setup();
+ const oldResponse = deferred();
+ const original = entry();
+ const updated = entry({ title: "Updated title", revision: 4 });
+ const client = createMockIpcClient();
+ client.listKnowledge.mockResolvedValueOnce({ entries: [original], nextCursor: null });
+ client.listKnowledge.mockReturnValueOnce(oldResponse.promise);
+ client.listKnowledge.mockResolvedValue({ entries: [updated], nextCursor: null });
+ client.saveKnowledge.mockResolvedValue(updated);
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Review changes" }));
+ fireEvent.change(screen.getByLabelText("Title"), { target: { value: "Updated title" } });
+ await user.click(screen.getByRole("button", { name: "Refresh" }));
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledTimes(2));
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(await screen.findByRole("button", { name: "Updated title" })).toBeVisible();
+ await act(async () => oldResponse.resolve({ entries: [original], nextCursor: null }));
+ expect(screen.queryByRole("button", { name: "Review changes" })).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Title")).toHaveValue("Updated title");
+ });
+
+ it("shows deletion failures inside the confirmation and retains the edited content", async () => {
+ const user = userEvent.setup();
+ const original = entry();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }) } });
+ client.deleteKnowledge.mockRejectedValue(new IpcError({ code: "revision_conflict", message: "This item has a newer revision." }));
+ render( );
+ await user.click(await screen.findByRole("button", { name: "Review changes" }));
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "My draft survives" } });
+ await user.click(screen.getByRole("button", { name: "Delete item" }));
+ await user.click(screen.getByRole("button", { name: "Confirm delete" }));
+ const dialog = screen.getByRole("dialog", { name: "Delete saved content" });
+ expect(await within(dialog).findByRole("alert")).toHaveTextContent("This item has a newer revision.");
+ await user.click(within(dialog).getByRole("button", { name: "Keep item" }));
+ expect(screen.getByLabelText("Content")).toHaveValue("My draft survives");
+ });
+
+ it("ignores late search responses without replacing a newer result or editor draft", async () => {
+ const user = userEvent.setup();
+ const oldResponse = deferred();
+ const current = entry({ id: "entry-current", title: "Current result" });
+ const client = createMockIpcClient({ handlers: { listKnowledge: async ({ query }) => query === "old" ? oldResponse.promise : { entries: query ? [current] : [], nextCursor: null } } });
+ render( );
+ await user.type(screen.getByLabelText("Title"), "Unfinished note");
+ await user.type(screen.getByRole("searchbox"), "old");
+ await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledWith({ projectId: null, query: "old" }));
+ await user.clear(screen.getByRole("searchbox"));
+ await user.type(screen.getByRole("searchbox"), "new");
+ expect(await screen.findByRole("button", { name: /Current result/ })).toBeVisible();
+ await act(async () => oldResponse.resolve({ entries: [entry({ title: "Outdated result" })], nextCursor: null }));
+ expect(screen.queryByRole("button", { name: /Outdated result/ })).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Title")).toHaveValue("Unfinished note");
+ });
+
+ it("validates UTF-8 limits, focuses invalid fields, and matches Rust whitespace rules", async () => {
+ const user = userEvent.setup();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } });
+ client.saveKnowledge.mockResolvedValue(entry({ title: "\uFEFF", body: "\uFEFF" }));
+ render( );
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(screen.getByLabelText("Title")).toHaveFocus();
+ expect(screen.getByText("Enter a title.")).toBeVisible();
+ fireEvent.change(screen.getByLabelText("Title"), { target: { value: "é".repeat(129) } });
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Valid body" } });
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(screen.getByText("Use a title of at most 256 UTF-8 bytes.")).toBeVisible();
+ fireEvent.change(screen.getByLabelText("Title"), { target: { value: "Valid title" } });
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "é".repeat(32_769) } });
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(screen.getByLabelText("Content")).toHaveFocus();
+ expect(screen.getByText(/Content must be at most 64 KiB/)).toBeVisible();
+ expect(client.saveKnowledge).not.toHaveBeenCalled();
+ fireEvent.change(screen.getByLabelText("Title"), { target: { value: "\uFEFF" } });
+ fireEvent.change(screen.getByLabelText("Content"), { target: { value: "\uFEFF" } });
+ await user.click(screen.getByRole("button", { name: "Save locally" }));
+ expect(client.saveKnowledge).toHaveBeenCalledWith({ kind: "prompt", projectId: null, title: "\uFEFF", body: "\uFEFF" });
+ });
+
+ it("blocks duplicate saves and explains unavailable insertion", async () => {
+ const user = userEvent.setup();
+ const pending = deferred();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } });
+ client.saveKnowledge.mockReturnValue(pending.promise);
+ render( );
+ await user.type(screen.getByLabelText("Title"), "Review changes");
+ await user.type(screen.getByLabelText("Content"), "Content");
+ expect(screen.getByRole("button", { name: "Insert into draft" })).toBeDisabled();
+ expect(screen.getByText("Select a session to insert this content.")).toBeVisible();
+ await user.dblClick(screen.getByRole("button", { name: "Save locally" }));
+ expect(client.saveKnowledge).toHaveBeenCalledOnce();
+ expect(screen.getByRole("button", { name: "Saving changes…" })).toBeDisabled();
+ expect(screen.getByLabelText("Content")).toBeDisabled();
+ await act(async () => pending.resolve(entry()));
+ expect(await screen.findByText("Saved locally.")).toBeVisible();
+ });
+
+ it("offers a retry after list failure while retaining a new draft", async () => {
+ const user = userEvent.setup();
+ const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } });
+ client.listKnowledge.mockRejectedValueOnce(new IpcError({ code: "storage_failed", message: "Could not load saved content." }));
+ client.listKnowledge.mockResolvedValue({ entries: [entry()], nextCursor: null });
+ render( );
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not load saved content.");
+ await user.type(screen.getByLabelText("Title"), "Keep this draft");
+ await user.click(screen.getByRole("button", { name: "Retry loading" }));
+ expect(await screen.findByRole("button", { name: /Review changes/ })).toBeVisible();
+ expect(screen.getByLabelText("Title")).toHaveValue("Keep this draft");
+ });
+});
diff --git a/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx
new file mode 100644
index 0000000..3e30ae7
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx
@@ -0,0 +1,215 @@
+import { useId, useRef, useState, type FormEvent } from "react";
+
+import { Dialog } from "../../components/Dialog";
+import type { KnowledgeLibraryProps, KnowledgeRecord } from "./knowledge-types";
+import { hasDraftChanges, trimKnowledgeText, useKnowledgeLibrary } from "./useKnowledgeLibrary";
+import "./knowledge-library.css";
+
+/** Local content library. The host owns persistence and insertion into a session draft. */
+export function KnowledgeLibrary(props: KnowledgeLibraryProps) {
+ const library = useKnowledgeLibrary(props);
+ const { draft, busy } = library;
+ const id = useId();
+ const titleRef = useRef(null);
+ const bodyRef = useRef(null);
+ const cancelDeleteRef = useRef(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleteFailed, setDeleteFailed] = useState(false);
+ const dirty = hasDraftChanges(draft);
+ const projectId = props.currentProject?.id ?? null;
+
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ saveDraft();
+ }
+
+ function saveDraft(asCopy = false) {
+ const invalidField = library.validate();
+ if (invalidField === "title") titleRef.current?.focus();
+ else if (invalidField === "body") bodyRef.current?.focus();
+ else void library.save(asCopy);
+ }
+
+ async function confirmDelete() {
+ if (!deleteTarget) return;
+ setDeleteFailed(false);
+ if (await library.remove(deleteTarget)) {
+ setDeleteTarget(null);
+ titleRef.current?.focus();
+ } else {
+ setDeleteFailed(true);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ Search prompts and context
+ library.setQuery(event.currentTarget.value)}
+ />
+
+
+
{props.currentProject ? `${props.currentProject.name} + global` : "Global library"}
+
+ Refresh
+
+
+ {library.listError ? (
+
+
{library.listError}
+
Retry loading
+
+ ) : null}
+
+ {library.loading ? "Loading saved content…" : `${library.records.length} ${library.records.length === 1 ? "item" : "items"} loaded${library.nextCursor ? "; more available" : ""}.`}
+
+
+ {library.records.map((record) => {
+ const itemDraft = library.drafts[record.id];
+ return (
+
+ library.selectRecord(record)}
+ >
+ {record.title}
+
+ {record.kind === "prompt" ? "Prompt" : "Context"}
+ {" · "}
+ {record.projectId ? "Project" : "Global"}
+ {itemDraft && hasDraftChanges(itemDraft) ? " · Unsaved" : ""}
+
+ {record.body.slice(0, 160)}
+
+
+ );
+ })}
+
+ {!library.loading && !library.listError && !library.records.length ? (
+
+ {trimKnowledgeText(library.query) ? "No saved content matches this search." : "No saved content yet. Create a prompt or context note to reuse across sessions."}
+
+ ) : null}
+ {library.nextCursor ? (
+
void library.loadMore()}>
+ {library.loadingMore ? "Loading more…" : "Load more"}
+
+ ) : null}
+
+
+
+
+
{draft.original ? "Edit saved content" : "New saved content"}
+ {dirty ? Unsaved changes : null}
+
+ {draft.error ? {draft.error}
: null}
+
+
+ Type
+ {
+ const kind = event.currentTarget.value;
+ if (kind === "prompt" || kind === "context") library.updateDraft({ kind });
+ }}>
+ Prompt
+ Context
+
+
+
+ Scope
+ library.updateDraft({ projectId: event.currentTarget.value || null })}>
+ Global
+ {props.currentProject ? Project: {props.currentProject.name} : null}
+ {draft.projectId && draft.projectId !== projectId ? Previously selected project : null}
+
+
+
+
+ Title
+ library.updateDraft({ title: event.currentTarget.value, titleError: undefined })}
+ />
+
+ {draft.titleError ? {draft.titleError}
: null}
+
+ Content
+ library.updateDraft({ body: event.currentTarget.value, bodyError: undefined })}
+ />
+
+ {draft.bodyError ? {draft.bodyError}
: null}
+ Insert this text into a session draft when you are ready to use it.
+
+ {busy ? "Saving changes…" : "Save locally"}
+ {draft.original ? saveDraft(true)}>Save as copy : null}
+ Insert into draft
+
+ {props.insertDisabledReason ? {props.insertDisabledReason}
: null}
+ {draft.notice ?? ""}
+
+ Discard changes
+ {draft.original ? { setDeleteFailed(false); setDeleteTarget(draft.original); }}>Delete item : null}
+
+
+
+
+ setDeleteTarget(null)}
+ footer={<>
+ setDeleteTarget(null)}>Keep item
+ void confirmDelete()}>{busy ? "Deleting…" : "Confirm delete"}
+ >}
+ >
+ Existing session drafts keep any text already inserted. Unsaved edits to this library item will be discarded.
+ {deleteFailed ? {deleteTarget ? library.drafts[deleteTarget.id]?.error : "Could not delete this item. Try again."}
: null}
+
+
+ );
+}
diff --git a/apps/desktop/src/app/features/knowledge/KnowledgePanel.tsx b/apps/desktop/src/app/features/knowledge/KnowledgePanel.tsx
new file mode 100644
index 0000000..5af4de9
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/KnowledgePanel.tsx
@@ -0,0 +1,23 @@
+import { useCallback } from "react";
+
+import type { IpcClient } from "../../../ipc/client";
+import { KnowledgeLibrary } from "./KnowledgeLibrary";
+import type {
+ KnowledgeDeleteInput,
+ KnowledgeLibraryProps,
+ KnowledgeListInput,
+ KnowledgeSaveInput,
+} from "./knowledge-types";
+
+/** Canvas integration boundary using only the project-owned IPC client. */
+export interface KnowledgePanelProps extends Omit {
+ readonly client: Pick;
+}
+
+/** Keeps callback identities stable and preserves the client's method receiver. */
+export function KnowledgePanel({ client, ...props }: KnowledgePanelProps) {
+ const onList = useCallback((input: KnowledgeListInput) => client.listKnowledge(input), [client]);
+ const onSave = useCallback((input: KnowledgeSaveInput) => client.saveKnowledge(input), [client]);
+ const onDelete = useCallback((input: KnowledgeDeleteInput) => client.deleteKnowledge(input), [client]);
+ return ;
+}
diff --git a/apps/desktop/src/app/features/knowledge/KnowledgeSourceInspector.test.tsx b/apps/desktop/src/app/features/knowledge/KnowledgeSourceInspector.test.tsx
new file mode 100644
index 0000000..a2482f0
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/KnowledgeSourceInspector.test.tsx
@@ -0,0 +1,224 @@
+import { act, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import { IpcError } from "../../../ipc/client";
+import type { KnowledgeDiscoverResponse, KnowledgeReadResponse, KnowledgeSourceAvailability, KnowledgeSourceEntry } from "../../../ipc/domain";
+import { createMockIpcClient } from "../../../test/mockIpc";
+import { KnowledgeSourceInspector } from "./KnowledgeSourceInspector";
+
+const project = { id: "project-a", name: "Project A" };
+
+/** Source metadata is display-only; read requests carry the two opaque IDs. */
+function source(overrides: Partial = {}): KnowledgeSourceEntry {
+ return {
+ entryId: "source-a", kind: "rule", provider: "codex", scope: "project",
+ sourcePath: "/repo/AGENTS.md", name: "AGENTS.md", scopeDirectory: ".",
+ precedenceHint: "Project instructions follow global instructions. Native loading depends on the CLI.",
+ viaSymlink: false, availability: "available", ...overrides,
+ };
+}
+
+/** Builds one bounded metadata inventory with no implicitly read content. */
+function inventory(entries: readonly KnowledgeSourceEntry[], overrides: Partial = {}): KnowledgeDiscoverResponse {
+ return { scanId: "scan-a", entries, truncated: false, issues: [], ...overrides };
+}
+
+/** Allows old operations to settle after user navigation or a reconnect. */
+function deferred() {
+ let settle: (value: T) => void = () => { throw new Error("Promise not initialized"); };
+ const promise = new Promise((resolve) => { settle = resolve; });
+ return { promise, resolve: settle };
+}
+
+describe("KnowledgeSourceInspector", () => {
+ it("discovers global sources without a project and reads literal content only on selection", async () => {
+ const user = userEvent.setup();
+ const entry = source({ scope: "global", sourcePath: "/home/example/.codex/AGENTS.md", scopeDirectory: "" });
+ const content = "# Instructions\n\n\n";
+ const client = createMockIpcClient({ handlers: {
+ discoverKnowledge: async () => inventory([entry]),
+ readKnowledge: async () => ({ entry, content }),
+ } });
+ render( );
+ const button = await screen.findByRole("button", { name: entry.name });
+ expect(client.discoverKnowledge).toHaveBeenCalledWith({ projectId: null });
+ expect(client.readKnowledge).not.toHaveBeenCalled();
+ expect(screen.getByText(/Native CLI loading remains unverified/)).toBeVisible();
+ await user.click(button);
+ expect(client.readKnowledge).toHaveBeenCalledWith({ scanId: "scan-a", entryId: entry.entryId });
+ expect(await screen.findByLabelText("Source content")).toHaveTextContent("");
+ expect(screen.getByLabelText("Source content").textContent).toBe(content);
+ expect(screen.getByText(entry.precedenceHint)).toBeVisible();
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ expect(document.querySelector("script")).not.toBeInTheDocument();
+ expect(client.writeTerminal).not.toHaveBeenCalled();
+ expect(client.startSession).not.toHaveBeenCalled();
+ expect(client.openPath).not.toHaveBeenCalled();
+ });
+
+ const unavailable: readonly KnowledgeSourceAvailability[] = ["too_large", "symlink", "non_regular", "unreadable"];
+ it.each(unavailable)("keeps %s sources inspectable without requesting their body", async (availability) => {
+ const user = userEvent.setup();
+ const entry = source({ availability });
+ const client = createMockIpcClient({ handlers: { discoverKnowledge: async () => inventory([entry]) } });
+ render( );
+ await user.click(await screen.findByRole("button", { name: entry.name }));
+ expect(screen.getByText(entry.precedenceHint)).toBeVisible();
+ expect(screen.getByText("Source path")).toBeVisible();
+ expect(screen.queryByLabelText("Source content")).not.toBeInTheDocument();
+ expect(client.readKnowledge).not.toHaveBeenCalled();
+ expect(client.discoverKnowledge).toHaveBeenCalledWith({ projectId: project.id });
+ });
+
+ it("shows truncation, unsupported scope issues and their locations alongside available results", async () => {
+ const client = createMockIpcClient({ handlers: { discoverKnowledge: async () => inventory([source()], {
+ truncated: true,
+ issues: [{ code: "nested_scope_unsupported", sourcePath: "/repo/nested", message: "Nested project scopes are not included in this inventory." }],
+ }) } });
+ render( );
+ expect(await screen.findByText(/Inventory incomplete/)).toBeVisible();
+ expect(screen.getByText("1 discovery issue")).toBeVisible();
+ expect(screen.getByText("Nested project scopes are not included in this inventory.")).toBeVisible();
+ expect(screen.getByText("/repo/nested")).toBeVisible();
+ expect(screen.getByRole("button", { name: "AGENTS.md" })).toBeVisible();
+ });
+
+ it("searches metadata across providers and distinguishes a successful empty source", async () => {
+ const user = userEvent.setup();
+ const first = source();
+ const skill = source({ entryId: "source-b", kind: "skill", provider: "claude", scope: "admin", name: "review", sourcePath: "/etc/skills/review/SKILL.md", scopeDirectory: "", viaSymlink: true });
+ const client = createMockIpcClient({ handlers: {
+ discoverKnowledge: async () => inventory([first, skill]),
+ readKnowledge: async () => ({ entry: skill, content: "" }),
+ } });
+ render( );
+ await screen.findByRole("button", { name: first.name });
+ await user.type(screen.getByRole("searchbox"), "claude");
+ expect(screen.queryByRole("button", { name: first.name })).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: skill.name }));
+ expect(await screen.findByText("This source is empty.")).toBeVisible();
+ expect(screen.getByText("Resolved directory link")).toBeVisible();
+ expect(screen.getByText("Administrator")).toBeVisible();
+ });
+
+ it("retries failed discovery without fabricating an empty successful inventory", async () => {
+ const user = userEvent.setup();
+ const client = createMockIpcClient();
+ client.discoverKnowledge.mockRejectedValueOnce(new IpcError({ code: "knowledge_discovery_unavailable", message: "Source discovery is unavailable." }));
+ client.discoverKnowledge.mockResolvedValue(inventory([source()]));
+ render( );
+ expect(await screen.findByRole("alert")).toHaveTextContent("Source discovery is unavailable.");
+ expect(screen.queryByText("No sources found at the supported locations.")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Retry discovery" }));
+ expect(await screen.findByRole("button", { name: "AGENTS.md" })).toBeVisible();
+ });
+
+ it("ignores older scans across project changes and reconnection on the same client", async () => {
+ const pendingProject = deferred();
+ const pendingGlobal = deferred();
+ const fresh = source({ name: "Fresh inventory" });
+ const client = createMockIpcClient();
+ client.discoverKnowledge.mockReturnValueOnce(pendingProject.promise);
+ client.discoverKnowledge.mockReturnValueOnce(pendingGlobal.promise);
+ client.discoverKnowledge.mockResolvedValue(inventory([fresh], { scanId: "scan-fresh" }));
+ const view = render( );
+ await waitFor(() => expect(client.discoverKnowledge).toHaveBeenCalledTimes(1));
+ view.rerender( );
+ await waitFor(() => expect(client.discoverKnowledge).toHaveBeenCalledTimes(2));
+ view.rerender( );
+ expect(await screen.findByRole("button", { name: fresh.name })).toBeVisible();
+ await act(async () => {
+ pendingProject.resolve(inventory([source({ name: "Old project inventory" })]));
+ pendingGlobal.resolve(inventory([source({ name: "Old global inventory" })]));
+ });
+ expect(screen.queryByRole("button", { name: /Old .* inventory/ })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: fresh.name })).toBeVisible();
+ });
+
+ it("ignores a slow preview after the user selects another source", async () => {
+ const user = userEvent.setup();
+ const first = source();
+ const second = source({ entryId: "source-b", name: "Second source", sourcePath: "/repo/second/AGENTS.md" });
+ const pendingRead = deferred();
+ const client = createMockIpcClient({ handlers: { discoverKnowledge: async () => inventory([first, second]) } });
+ client.readKnowledge.mockReturnValueOnce(pendingRead.promise);
+ client.readKnowledge.mockResolvedValue({ entry: second, content: "Current source text" });
+ render( );
+ await user.click(await screen.findByRole("button", { name: first.name }));
+ expect(screen.getByText("Reading source…")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: second.name }));
+ expect(await screen.findByLabelText("Source content")).toHaveTextContent("Current source text");
+ await act(async () => pendingRead.resolve({ entry: first, content: "Outdated source text" }));
+ expect(screen.getByLabelText("Source content")).toHaveTextContent("Current source text");
+ expect(screen.queryByText("Outdated source text")).not.toBeInTheDocument();
+ });
+
+ it("invalidates a pending preview on reconnect and uses a fresh scan capability", async () => {
+ const user = userEvent.setup();
+ const entry = source();
+ const pendingRead = deferred();
+ const client = createMockIpcClient();
+ client.discoverKnowledge.mockResolvedValueOnce(inventory([entry]));
+ client.discoverKnowledge.mockResolvedValue(inventory([entry], { scanId: "scan-reconnected" }));
+ client.readKnowledge.mockReturnValueOnce(pendingRead.promise);
+ client.readKnowledge.mockResolvedValue({ entry, content: "Revalidated source" });
+ const view = render( );
+ await user.click(await screen.findByRole("button", { name: entry.name }));
+ view.rerender( );
+ await screen.findByRole("button", { name: entry.name });
+ await act(async () => pendingRead.resolve({ entry, content: "Old daemon preview" }));
+ expect(screen.queryByLabelText("Source content")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: entry.name }));
+ expect(await screen.findByLabelText("Source content")).toHaveTextContent("Revalidated source");
+ expect(client.readKnowledge).toHaveBeenLastCalledWith({ scanId: "scan-reconnected", entryId: entry.entryId });
+ });
+
+ it.each(["knowledge_scan_expired", "knowledge_source_changed"])("clears stale content and offers a rescan after %s", async (code) => {
+ const user = userEvent.setup();
+ const first = source();
+ const second = source({ entryId: "source-b", name: "Changed source" });
+ const client = createMockIpcClient({ handlers: { discoverKnowledge: async () => inventory([first, second]) } });
+ client.readKnowledge.mockResolvedValueOnce({ entry: first, content: "Previous preview" });
+ client.readKnowledge.mockRejectedValueOnce(new IpcError({ code, message: "This source must be discovered again." }));
+ render( );
+ await user.click(await screen.findByRole("button", { name: first.name }));
+ await screen.findByLabelText("Source content");
+ await user.click(screen.getByRole("button", { name: second.name }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("This source must be discovered again.");
+ expect(screen.queryByLabelText("Source content")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Rescan sources" }));
+ await waitFor(() => expect(client.discoverKnowledge).toHaveBeenCalledTimes(2));
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("rejects a body whose returned provenance differs from the selected descriptor", async () => {
+ const user = userEvent.setup();
+ const entry = source();
+ const client = createMockIpcClient({ handlers: {
+ discoverKnowledge: async () => inventory([entry]),
+ readKnowledge: async () => ({ entry: { ...entry, sourcePath: "/another/source.md", provider: "cursor" }, content: "Wrong source body" }),
+ } });
+ render( );
+ await user.click(await screen.findByRole("button", { name: entry.name }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("invalid response");
+ expect(screen.queryByText("Wrong source body")).not.toBeInTheDocument();
+ expect(screen.queryByText("/another/source.md")).not.toBeInTheDocument();
+ });
+
+ it("provides native keyboard navigation to source selection and the read-only preview", async () => {
+ const user = userEvent.setup();
+ const entry = source();
+ const client = createMockIpcClient({ handlers: {
+ discoverKnowledge: async () => inventory([entry]),
+ readKnowledge: async () => ({ entry, content: "Keyboard-readable source" }),
+ } });
+ render( );
+ const list = await screen.findByRole("list", { name: "Discovered rules and skills" });
+ within(list).getByRole("button", { name: entry.name }).focus();
+ await user.keyboard("{Enter}");
+ const preview = await screen.findByLabelText("Source content");
+ await user.tab();
+ expect(preview).toHaveFocus();
+ });
+});
diff --git a/apps/desktop/src/app/features/knowledge/KnowledgeSourceInspector.tsx b/apps/desktop/src/app/features/knowledge/KnowledgeSourceInspector.tsx
new file mode 100644
index 0000000..c3f87be
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/KnowledgeSourceInspector.tsx
@@ -0,0 +1,146 @@
+import { useId, useState } from "react";
+
+import type { KnowledgeProvider, KnowledgeSourceAvailability, KnowledgeSourceEntry, KnowledgeSourceScope } from "../../../ipc/domain";
+import { trimKnowledgeText } from "./useKnowledgeLibrary";
+import { useKnowledgeSources, type KnowledgeSourceInspectorProps } from "./useKnowledgeSources";
+import "./knowledge-library.css";
+import "./knowledge-source-inspector.css";
+
+/** Inspects documented local rule and skill sources without executing or editing their contents. */
+export function KnowledgeSourceInspector(props: KnowledgeSourceInspectorProps) {
+ const sources = useKnowledgeSources(props);
+ const id = useId();
+ const [query, setQuery] = useState("");
+ const normalizedQuery = trimKnowledgeText(query).toLocaleLowerCase();
+ const entries = sources.result?.entries ?? [];
+ const visibleEntries = entries.filter((entry) => matchesSource(entry, normalizedQuery));
+ const selected = sources.selected;
+ const preview = sources.read;
+
+ return (
+
+
+ Discovered files are shown with their origin. Native CLI loading remains unverified.
+ {sources.scanError ? (
+
+
{sources.scanError.message}
+ {sources.scanError.action ?
{sources.scanError.action}
: null}
+
void sources.rescan()}>Retry discovery
+
+ ) : null}
+ {sources.result?.truncated ? Inventory incomplete: a discovery limit was reached. Some sources may be missing.
: null}
+ {sources.result?.issues.length ? (
+
+ {sources.result.issues.length} discovery {sources.result.issues.length === 1 ? "issue" : "issues"}
+
+ {sources.result.issues.map((issue, index) => (
+
+ {issue.message}
+ {issue.sourcePath ? {issue.sourcePath} : null}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ Search discovered sources
+ setQuery(event.currentTarget.value)} />
+
+
{props.currentProject ? `${props.currentProject.name} + global and administrator sources` : "Global and administrator sources"}
+
+ {sources.loading ? "Discovering local rules and skills…" : `${visibleEntries.length} of ${entries.length} discovered sources shown.`}
+
+
+ {visibleEntries.map((entry) => (
+
+ void sources.selectSource(entry)}
+ >
+ {entry.name}
+
+ {providerLabels[entry.provider]} · {entry.kind === "rule" ? "Rule" : "Skill"} · {scopeLabels[entry.scope]}
+
+ {entry.sourcePath}
+ {availabilityLabels[entry.availability]}
+
+
+ ))}
+
+ {!sources.loading && !sources.scanError && !visibleEntries.length ? (
+
{normalizedQuery ? "No discovered sources match this search." : "No sources found at the supported locations."}
+ ) : null}
+
+
+ {selected ? selected.name : "Source preview"}
+ {selected ? <>
+
+
Provider {providerLabels[selected.provider]}
+
Type {selected.kind === "rule" ? "Rule" : "Skill"}
+
Scope {scopeLabels[selected.scope]}
+ {selected.scopeDirectory ?
Directory {selected.scopeDirectory}{selected.scopeDirectory === "." ? " (project root)" : ""} : null}
+
Source path {selected.sourcePath}
+
Availability {availabilityLabels[selected.availability]}
+ {selected.viaSymlink ?
Origin Resolved directory link : null}
+
+ Loading and precedence {selected.precedenceHint}
+ {selected.availability !== "available" ? {unavailableExplanation(selected.availability)}
: null}
+ {preview?.loading ? Reading source…
: null}
+ {preview?.error ? (
+
+
{preview.error.message}
+ {preview.error.action ?
{preview.error.action}
: null}
+
Rescan sources to check the latest file and obtain a fresh preview.
+
+ ) : null}
+ {preview?.result ? <>
+ Read-only source content
+ {preview.result.content ? {preview.result.content} : This source is empty.
}
+ > : null}
+ > : Select a source to inspect its origin and preview its content.
}
+
+
+
+ );
+}
+
+/** Searches the complete bounded inventory's metadata without reading additional files. */
+function matchesSource(entry: KnowledgeSourceEntry, query: string): boolean {
+ return !query || [entry.name, entry.sourcePath, entry.scopeDirectory, entry.kind, providerLabels[entry.provider], scopeLabels[entry.scope]]
+ .some((value) => value.toLocaleLowerCase().includes(query));
+}
+
+const providerLabels: Readonly> = { codex: "Codex", claude: "Claude Code", cursor: "Cursor" };
+const scopeLabels: Readonly> = { global: "Global", project: "Project", admin: "Administrator" };
+const availabilityLabels: Readonly> = {
+ available: "Ready to read",
+ too_large: "Too large to preview",
+ symlink: "File symlink not followed",
+ non_regular: "Not a regular file",
+ unreadable: "Unable to read safely",
+};
+
+/** Explains unavailable candidates while retaining their source provenance. */
+function unavailableExplanation(availability: KnowledgeSourceAvailability): string {
+ switch (availability) {
+ case "too_large": return "This source exceeds the 64 KiB preview limit.";
+ case "symlink": return "The source file is a symbolic link. Its content is unavailable for preview.";
+ case "non_regular": return "This source is not a regular text file and cannot be previewed.";
+ case "unreadable": return "The source could not be opened safely with current permissions.";
+ case "available": return "";
+ }
+}
diff --git a/apps/desktop/src/app/features/knowledge/index.ts b/apps/desktop/src/app/features/knowledge/index.ts
new file mode 100644
index 0000000..1c16543
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/index.ts
@@ -0,0 +1,16 @@
+export { KnowledgeLibrary } from "./KnowledgeLibrary";
+export { KnowledgePanel } from "./KnowledgePanel";
+export { KnowledgeSourceInspector } from "./KnowledgeSourceInspector";
+export type { KnowledgeSourceInspectorProps } from "./useKnowledgeSources";
+export type { KnowledgePanelProps } from "./KnowledgePanel";
+export type {
+ KnowledgeDeleteInput,
+ KnowledgeInsertion,
+ KnowledgeKind,
+ KnowledgeLibraryProps,
+ KnowledgeListInput,
+ KnowledgePage,
+ KnowledgeProject,
+ KnowledgeRecord,
+ KnowledgeSaveInput,
+} from "./knowledge-types";
diff --git a/apps/desktop/src/app/features/knowledge/knowledge-library.css b/apps/desktop/src/app/features/knowledge/knowledge-library.css
new file mode 100644
index 0000000..3f80715
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/knowledge-library.css
@@ -0,0 +1,188 @@
+.knowledge-library {
+ min-width: 0;
+ container-type: inline-size;
+ color: var(--color-text);
+ background: var(--color-surface);
+}
+
+.knowledge-library__header,
+.knowledge-library__list-heading,
+.knowledge-library__editor-heading,
+.knowledge-library__secondary-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.knowledge-library__header {
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.knowledge-library h2,
+.knowledge-library h3,
+.knowledge-library p {
+ margin: 0;
+}
+
+.knowledge-library h2 { font-size: 1rem; }
+.knowledge-library h3 { font-size: 0.875rem; }
+
+.knowledge-library__header p,
+.knowledge-library__hint,
+.knowledge-library__list-heading p,
+.knowledge-library__list-status,
+.knowledge-library__meta,
+.knowledge-library__preview,
+.knowledge-library__empty {
+ color: var(--color-text-muted);
+ font-size: 0.75rem;
+ overflow-wrap: anywhere;
+}
+
+.knowledge-library__header p { margin-top: var(--space-1); }
+
+.knowledge-library__layout {
+ display: grid;
+ grid-template-columns: minmax(12rem, 0.8fr) minmax(0, 1.4fr);
+ align-items: start;
+}
+
+.knowledge-library__browser,
+.knowledge-library__editor {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ min-width: 0;
+ padding: var(--space-4);
+}
+
+.knowledge-library__browser { border-right: 1px solid var(--color-border); }
+
+.knowledge-library__field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+.knowledge-library__field :is(input, select, textarea) {
+ min-width: 0;
+ width: 100%;
+ min-height: var(--control-height);
+ padding: var(--space-2);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-small);
+ color: var(--color-text);
+ background: var(--color-surface);
+ font-weight: 400;
+}
+
+.knowledge-library__field :is(input, select, textarea):disabled {
+ color: var(--color-text-muted);
+ background: var(--color-inset);
+}
+
+.knowledge-library__field [aria-invalid="true"] { border-color: var(--color-danger); }
+
+.knowledge-library__content textarea {
+ min-height: 12rem;
+ font-family: var(--font-mono);
+ line-height: 1.5;
+ resize: vertical;
+}
+
+.knowledge-library__options {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-3);
+}
+
+.knowledge-library__list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ max-height: 26rem;
+ margin: 0;
+ padding: var(--space-1);
+ overflow-y: auto;
+ list-style: none;
+ scroll-padding-block: var(--space-2);
+}
+
+.knowledge-library__item {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ width: 100%;
+ min-width: 0;
+ min-height: 3rem;
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ background: var(--color-surface-raised);
+ text-align: left;
+ overflow-wrap: anywhere;
+ transition: background-color var(--duration-feedback) var(--ease-feedback);
+}
+
+.knowledge-library__item[aria-pressed="true"] {
+ border-color: var(--color-accent);
+ background: var(--color-accent-muted);
+}
+
+.knowledge-library__item:disabled { opacity: 0.72; }
+
+.knowledge-library__preview {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.knowledge-library__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.knowledge-library__secondary-actions {
+ margin-top: auto;
+ padding-top: var(--space-2);
+ border-top: 1px solid var(--color-border);
+}
+
+.knowledge-library__error {
+ padding: var(--space-2);
+ border-left: 2px solid var(--color-danger);
+ color: var(--color-danger);
+ background: var(--color-danger-muted);
+ font-size: 0.8125rem;
+ overflow-wrap: anywhere;
+}
+
+.knowledge-library__error .button { margin-top: var(--space-2); }
+.knowledge-library__notice { min-height: 1.25rem; color: var(--color-success); font-size: 0.8125rem; }
+
+@media (hover: hover) {
+ .knowledge-library__item:not(:disabled):hover { background: var(--color-surface-hover); }
+}
+
+@container (max-width: 38rem) {
+ .knowledge-library__layout { grid-template-columns: minmax(0, 1fr); }
+ .knowledge-library__browser { border-right: 0; border-bottom: 1px solid var(--color-border); }
+ .knowledge-library__list { max-height: 14rem; }
+}
+
+@container (max-width: 22rem) {
+ .knowledge-library__options { grid-template-columns: minmax(0, 1fr); }
+ .knowledge-library__actions .button { width: 100%; white-space: normal; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .knowledge-library__item { transition: none; }
+}
diff --git a/apps/desktop/src/app/features/knowledge/knowledge-source-inspector.css b/apps/desktop/src/app/features/knowledge/knowledge-source-inspector.css
new file mode 100644
index 0000000..88049fb
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/knowledge-source-inspector.css
@@ -0,0 +1,71 @@
+.knowledge-inspector .knowledge-inspector__provenance,
+.knowledge-inspector .knowledge-inspector__notice,
+.knowledge-inspector .knowledge-inspector__issues {
+ margin: var(--space-3) var(--space-4);
+ font-size: 0.8125rem;
+ overflow-wrap: anywhere;
+}
+
+.knowledge-inspector__provenance { color: var(--color-text-muted); }
+
+.knowledge-inspector__issues {
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-small);
+ background: var(--color-surface-raised);
+}
+
+.knowledge-inspector__issues summary {
+ min-height: var(--control-height);
+ padding: var(--space-1);
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.knowledge-inspector__issues ul {
+ display: grid;
+ gap: var(--space-3);
+ max-height: 12rem;
+ padding-inline-start: var(--space-4);
+ overflow-y: auto;
+}
+
+.knowledge-inspector__issues code { font-size: 0.75rem; }
+
+.knowledge-inspector__warning {
+ padding: var(--space-3);
+ border-left: 2px solid var(--color-warning);
+ color: var(--color-warning);
+ background: var(--color-warning-muted);
+ font-size: 0.8125rem;
+}
+
+.knowledge-inspector__unavailable { color: var(--color-warning); font-size: 0.75rem; }
+
+.knowledge-inspector__metadata { display: grid; gap: var(--space-2); margin: 0; }
+.knowledge-inspector__metadata div { display: grid; grid-template-columns: 6rem minmax(0, 1fr); gap: var(--space-2); }
+.knowledge-inspector__metadata dt { color: var(--color-text-muted); font-size: 0.75rem; }
+.knowledge-inspector__metadata dd { min-width: 0; margin: 0; font-size: 0.8125rem; overflow-wrap: anywhere; }
+.knowledge-inspector__metadata code { font-size: 0.75rem; }
+.knowledge-inspector__precedence { display: grid; gap: var(--space-1); font-size: 0.8125rem; }
+.knowledge-inspector__precedence h4 { margin: 0; font-size: inherit; }
+
+.knowledge-inspector__content {
+ max-height: 32rem;
+ min-width: 0;
+ margin: 0;
+ padding: var(--space-3);
+ overflow: auto;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-small);
+ background: var(--color-inset);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ scroll-padding: var(--space-3);
+}
+
+@container (max-width: 22rem) {
+ .knowledge-inspector__metadata div { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
+}
diff --git a/apps/desktop/src/app/features/knowledge/knowledge-types.ts b/apps/desktop/src/app/features/knowledge/knowledge-types.ts
new file mode 100644
index 0000000..c66aa0a
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/knowledge-types.ts
@@ -0,0 +1,63 @@
+import type {
+ KnowledgeDeleteRequest,
+ KnowledgeEntry,
+ KnowledgeKind,
+ KnowledgeListRequest,
+ KnowledgeListResponse,
+ KnowledgeSaveRequest,
+} from "../../../ipc/domain";
+
+/** Feature names alias the authoritative domain mirror instead of duplicating it. */
+export type KnowledgeRecord = KnowledgeEntry;
+export type KnowledgeListInput = KnowledgeListRequest;
+export type KnowledgePage = KnowledgeListResponse;
+export type KnowledgeSaveInput = KnowledgeSaveRequest;
+export type KnowledgeDeleteInput = KnowledgeDeleteRequest;
+export type { KnowledgeKind } from "../../../ipc/domain";
+
+/** Plain text the host can append to a session draft after an explicit click. */
+export interface KnowledgeInsertion {
+ /** Saved item used as the editor's base, or null for a never-saved draft. */
+ readonly sourceId: string | null;
+ /**
+ * Revision used as the editor's base; null exactly when sourceId is null.
+ * This is provenance, not a claim that the inserted draft matches that revision:
+ * title and body include the user's unsaved edits.
+ */
+ readonly sourceRevision: number | null;
+ readonly kind: KnowledgeKind;
+ /** Current draft title, which can differ from the source revision. */
+ readonly title: string;
+ /** Exact current draft body, which can differ from the source revision. */
+ readonly body: string;
+}
+
+/** Project identity for labeling and scoping the local library. */
+export interface KnowledgeProject {
+ readonly id: string;
+ readonly name: string;
+}
+
+/** Host-owned operations; the library never writes to a terminal itself. */
+export interface KnowledgeLibraryProps {
+ readonly currentProject?: KnowledgeProject | null;
+ readonly onList: (input: KnowledgeListInput) => Promise;
+ readonly onSave: (input: KnowledgeSaveInput) => Promise;
+ readonly onDelete: (input: KnowledgeDeleteInput) => Promise;
+ readonly onInsert: (content: KnowledgeInsertion) => void;
+ /** Explain why insertion is unavailable, for example when no session is selected. */
+ readonly insertDisabledReason?: string;
+}
+
+/** In-memory editor state; it survives selection and scope changes while mounted. */
+export interface KnowledgeDraft {
+ readonly original: KnowledgeRecord | null;
+ readonly kind: KnowledgeKind;
+ readonly projectId: string | null;
+ readonly title: string;
+ readonly body: string;
+ readonly error?: string;
+ readonly notice?: string;
+ readonly titleError?: string;
+ readonly bodyError?: string;
+}
diff --git a/apps/desktop/src/app/features/knowledge/useKnowledgeLibrary.ts b/apps/desktop/src/app/features/knowledge/useKnowledgeLibrary.ts
new file mode 100644
index 0000000..96e6ac0
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/useKnowledgeLibrary.ts
@@ -0,0 +1,282 @@
+import { useEffect, useRef, useState } from "react";
+
+import { KNOWLEDGE_BODY_BYTES, KNOWLEDGE_TITLE_BYTES } from "../../../ipc/knowledge-schema";
+import { errorData } from "../../utils";
+import type {
+ KnowledgeDraft,
+ KnowledgeLibraryProps,
+ KnowledgeRecord,
+} from "./knowledge-types";
+
+interface LibraryListing {
+ readonly projectId: string | null;
+ readonly query: string;
+ readonly records: readonly KnowledgeRecord[];
+ readonly nextCursor: string | null;
+ readonly loading: boolean;
+ readonly error?: string;
+}
+
+/** Keeps editor drafts independent of asynchronous list responses and navigation. */
+export function useKnowledgeLibrary({
+ currentProject,
+ onList,
+ onSave,
+ onDelete,
+ onInsert,
+}: KnowledgeLibraryProps) {
+ const projectId = currentProject?.id ?? null;
+ const contextKey = projectId ?? "global";
+ const newKey = `new:${contextKey}`;
+ const [selections, setSelections] = useState>({});
+ const [drafts, setDrafts] = useState>({});
+ const [reload, setReload] = useState(0);
+ const [query, setQuery] = useState("");
+ const normalizedQuery = trimKnowledgeText(query);
+ const [busy, setBusy] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const operationInFlight = useRef(false);
+ const moreInFlight = useRef(false);
+ const listingVersion = useRef(0);
+ const [listing, setListing] = useState({ projectId, query: "", records: [], nextCursor: null, loading: true });
+ const selectedKey = selections[contextKey] ?? newKey;
+ const draft = drafts[selectedKey] ?? emptyDraft(projectId);
+
+ useEffect(() => {
+ let cancelled = false;
+ const requestVersion = ++listingVersion.current;
+ moreInFlight.current = false;
+ setLoadingMore(false);
+ setListing((previous) => ({
+ projectId,
+ query: normalizedQuery,
+ records: previous.projectId === projectId && previous.query === normalizedQuery ? previous.records : [],
+ nextCursor: null,
+ loading: true,
+ }));
+ async function load() {
+ try {
+ const page = await onList({ projectId, ...(normalizedQuery ? { query: normalizedQuery } : {}) });
+ if (!cancelled && requestVersion === listingVersion.current) {
+ setListing({ projectId, query: normalizedQuery, records: page.entries, nextCursor: page.nextCursor, loading: false });
+ }
+ } catch (error) {
+ if (!cancelled && requestVersion === listingVersion.current) {
+ setListing({ projectId, query: normalizedQuery, records: [], nextCursor: null, loading: false, error: errorData(error).message });
+ }
+ }
+ }
+ const timer = window.setTimeout(() => void load(), normalizedQuery ? 200 : 0);
+ return () => { cancelled = true; window.clearTimeout(timer); };
+ }, [onList, projectId, normalizedQuery, reload]);
+
+ async function loadMore() {
+ if (!listing.nextCursor || moreInFlight.current) return;
+ const requestVersion = listingVersion.current;
+ moreInFlight.current = true;
+ setLoadingMore(true);
+ setListing((previous) => ({ ...previous, error: undefined }));
+ try {
+ const page = await onList({
+ projectId,
+ ...(normalizedQuery ? { query: normalizedQuery } : {}),
+ cursor: listing.nextCursor,
+ });
+ if (requestVersion === listingVersion.current) {
+ setListing((previous) => ({
+ ...previous,
+ records: [...previous.records, ...page.entries.filter((record) => !previous.records.some((existing) => existing.id === record.id))],
+ nextCursor: page.nextCursor,
+ }));
+ }
+ } catch (error) {
+ if (requestVersion === listingVersion.current) {
+ setListing((previous) => ({ ...previous, error: errorData(error).message }));
+ }
+ } finally {
+ if (requestVersion === listingVersion.current) {
+ moreInFlight.current = false;
+ setLoadingMore(false);
+ }
+ }
+ }
+
+ function updateDraft(patch: Partial) {
+ setDrafts((previous) => ({
+ ...previous,
+ [selectedKey]: { ...draft, ...patch, notice: undefined },
+ }));
+ }
+
+ function selectRecord(record: KnowledgeRecord) {
+ setDrafts((previous) => {
+ const existing = previous[record.id];
+ return {
+ ...previous,
+ [record.id]: existing && (hasDraftChanges(existing) || existing.error) ? existing : recordDraft(record),
+ };
+ });
+ setSelections((previous) => ({ ...previous, [contextKey]: record.id }));
+ }
+
+ function selectNew() {
+ setSelections((previous) => ({ ...previous, [contextKey]: newKey }));
+ }
+
+ function discardChanges() {
+ setDrafts((previous) => ({
+ ...previous,
+ [selectedKey]: draft.original ? recordDraft(draft.original) : emptyDraft(projectId),
+ }));
+ }
+
+ function validate(): "title" | "body" | null {
+ const titleError = !trimKnowledgeText(draft.title)
+ ? "Enter a title."
+ : draft.title.includes("\0") ? "Remove null characters from the title."
+ : utf8Size(trimKnowledgeText(draft.title)) > KNOWLEDGE_TITLE_BYTES ? "Use a title of at most 256 UTF-8 bytes." : undefined;
+ const bodyError = !trimKnowledgeText(draft.body)
+ ? "Enter some content."
+ : draft.body.includes("\0") ? "Remove null characters from the content."
+ : utf8Size(draft.body) > KNOWLEDGE_BODY_BYTES ? "Content must be at most 64 KiB (65,536 UTF-8 bytes)." : undefined;
+ updateDraft({ titleError, bodyError });
+ return titleError ? "title" : bodyError ? "body" : null;
+ }
+
+ async function save(asCopy = false) {
+ if (operationInFlight.current || validate()) return;
+ operationInFlight.current = true;
+ setBusy(true);
+ updateDraft({ error: undefined });
+ try {
+ const saved = await onSave({
+ ...(draft.original && !asCopy
+ ? { id: draft.original.id, expectedRevision: draft.original.revision }
+ : {}),
+ kind: draft.kind,
+ projectId: draft.projectId,
+ title: trimKnowledgeText(draft.title),
+ body: draft.body,
+ });
+ listingVersion.current += 1;
+ setDrafts((previous) => {
+ const next = { ...previous };
+ if (!draft.original) delete next[selectedKey];
+ next[saved.id] = { ...recordDraft(saved), notice: asCopy ? "Copy saved locally." : "Saved locally." };
+ return next;
+ });
+ setSelections((previous) => ({ ...previous, [contextKey]: saved.id }));
+ setListing((previous) => previous.projectId === projectId ? {
+ ...previous,
+ records: [saved, ...previous.records.filter((record) => record.id !== saved.id)],
+ } : previous);
+ setReload((value) => value + 1);
+ } catch (error) {
+ const detail = errorData(error);
+ const conflict = detail.code.includes("conflict");
+ setDrafts((previous) => ({
+ ...previous,
+ [selectedKey]: {
+ ...(previous[selectedKey] ?? draft),
+ error: conflict
+ ? "This item changed in another window. Your edits are preserved. Save a copy to keep both versions."
+ : detail.message,
+ },
+ }));
+ } finally {
+ operationInFlight.current = false;
+ setBusy(false);
+ }
+ }
+
+ async function remove(original: KnowledgeRecord): Promise {
+ if (operationInFlight.current) return false;
+ operationInFlight.current = true;
+ setBusy(true);
+ updateDraft({ error: undefined });
+ try {
+ await onDelete({ id: original.id, expectedRevision: original.revision });
+ listingVersion.current += 1;
+ setDrafts((previous) => {
+ const next = { ...previous };
+ delete next[original.id];
+ return next;
+ });
+ setSelections((previous) => Object.fromEntries(
+ Object.entries(previous).filter(([, id]) => id !== original.id),
+ ));
+ setListing((previous) => ({
+ ...previous,
+ records: previous.records.filter((record) => record.id !== original.id),
+ }));
+ setReload((value) => value + 1);
+ return true;
+ } catch (error) {
+ setDrafts((previous) => ({
+ ...previous,
+ [original.id]: { ...(previous[original.id] ?? recordDraft(original)), error: errorData(error).message },
+ }));
+ return false;
+ } finally {
+ operationInFlight.current = false;
+ setBusy(false);
+ }
+ }
+
+ function insert() {
+ if (!trimKnowledgeText(draft.body)) return;
+ try {
+ onInsert({
+ sourceId: draft.original?.id ?? null,
+ sourceRevision: draft.original?.revision ?? null,
+ kind: draft.kind,
+ title: trimKnowledgeText(draft.title),
+ body: draft.body,
+ });
+ setDrafts((previous) => ({
+ ...previous,
+ [selectedKey]: { ...draft, notice: "Inserted into the session draft.", error: undefined },
+ }));
+ } catch (error) {
+ updateDraft({ error: errorData(error).message });
+ }
+ }
+
+ return {
+ records: listing.projectId === projectId && listing.query === normalizedQuery ? listing.records : [],
+ loading: listing.projectId !== projectId || listing.query !== normalizedQuery || listing.loading,
+ listError: listing.projectId === projectId && listing.query === normalizedQuery ? listing.error : undefined,
+ reload: () => setReload((value) => value + 1),
+ query, setQuery, loadMore, loadingMore, nextCursor: listing.projectId === projectId && listing.query === normalizedQuery ? listing.nextCursor : null,
+ selectedKey, draft, drafts, busy, updateDraft, selectRecord, selectNew,
+ discardChanges, validate, save, remove, insert,
+ };
+}
+
+/** Counts encoded bytes, matching the Rust validation limits for non-ASCII text. */
+function utf8Size(value: string): number {
+ return new TextEncoder().encode(value).byteLength;
+}
+
+/** Matches Rust str::trim instead of JavaScript's different Unicode whitespace set. */
+export function trimKnowledgeText(value: string): string {
+ return value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, "");
+}
+
+/** Creates a separate unsaved draft for each project and the global library. */
+function emptyDraft(projectId: string | null): KnowledgeDraft {
+ return { original: null, kind: "prompt", projectId, title: "", body: "" };
+}
+
+/** Takes a revision snapshot only when explicitly opening an unedited item. */
+function recordDraft(record: KnowledgeRecord): KnowledgeDraft {
+ return { original: record, kind: record.kind, projectId: record.projectId, title: record.title, body: record.body };
+}
+
+/** Computes unsaved state without duplicating it in React state. */
+export function hasDraftChanges(draft: KnowledgeDraft): boolean {
+ const original = draft.original;
+ return original
+ ? draft.title !== original.title || draft.body !== original.body || draft.kind !== original.kind || draft.projectId !== original.projectId
+ : Boolean(draft.title || draft.body);
+}
diff --git a/apps/desktop/src/app/features/knowledge/useKnowledgeSources.ts b/apps/desktop/src/app/features/knowledge/useKnowledgeSources.ts
new file mode 100644
index 0000000..931afd1
--- /dev/null
+++ b/apps/desktop/src/app/features/knowledge/useKnowledgeSources.ts
@@ -0,0 +1,121 @@
+import { useEffect, useRef, useState } from "react";
+
+import type { IpcClient } from "../../../ipc/client";
+import type { KnowledgeDiscoverResponse, KnowledgeReadResponse, KnowledgeSourceEntry } from "../../../ipc/domain";
+import type { ApiErrorData } from "../../../ipc/types";
+import { IpcContractError } from "../../../ipc/schema";
+import { errorData } from "../../utils";
+import type { KnowledgeProject } from "./knowledge-types";
+
+/** Read-only IPC boundary; a changed connection key invalidates ephemeral capabilities. */
+export interface KnowledgeSourceInspectorProps {
+ readonly client: Pick;
+ readonly currentProject?: KnowledgeProject | null;
+ /** Pass the daemon instance ID or reconnect generation when reusing a client object. */
+ readonly connectionKey?: string | number;
+}
+
+interface ScanState {
+ readonly client: KnowledgeSourceInspectorProps["client"];
+ readonly projectId: string | null;
+ readonly connectionKey: KnowledgeSourceInspectorProps["connectionKey"];
+ readonly refreshIndex: number;
+ readonly requestId: number;
+ readonly result?: KnowledgeDiscoverResponse;
+ readonly error?: ApiErrorData;
+}
+
+interface ReadState {
+ readonly entryId: string;
+ readonly loading: boolean;
+ readonly result?: KnowledgeReadResponse;
+ readonly error?: ApiErrorData;
+}
+
+/** Keeps scans and explicit previews bound to the current project, selection and connection. */
+export function useKnowledgeSources({ client, currentProject, connectionKey }: KnowledgeSourceInspectorProps) {
+ const projectId = currentProject?.id ?? null;
+ const scanEpoch = useRef(0);
+ const readEpoch = useRef(0);
+ const [scan, setScan] = useState();
+ const [refreshIndex, setRefreshIndex] = useState(0);
+ const [selection, setSelection] = useState<{ readonly requestId: number; readonly entryId: string }>();
+ const [read, setRead] = useState();
+ const isCurrent = scan?.client === client && scan.projectId === projectId && scan.connectionKey === connectionKey && scan.refreshIndex === refreshIndex;
+ const result = isCurrent ? scan.result : undefined;
+ const selected = selection?.requestId === scan?.requestId
+ ? result?.entries.find((entry) => entry.entryId === selection?.entryId)
+ : undefined;
+
+ useEffect(() => {
+ const epoch = ++scanEpoch.current;
+ readEpoch.current += 1;
+ async function discover() {
+ try {
+ const discovered = await client.discoverKnowledge({ projectId });
+ if (epoch === scanEpoch.current) {
+ setScan({ client, projectId, connectionKey, refreshIndex, requestId: epoch, result: discovered });
+ }
+ } catch (error) {
+ if (epoch === scanEpoch.current) {
+ setScan({ client, projectId, connectionKey, refreshIndex, requestId: epoch, error: errorData(error) });
+ }
+ }
+ }
+ void discover();
+ return () => {
+ scanEpoch.current += 1;
+ readEpoch.current += 1;
+ };
+ }, [client, projectId, connectionKey, refreshIndex]);
+
+ function rescan() {
+ scanEpoch.current += 1;
+ readEpoch.current += 1;
+ setSelection(undefined);
+ setRead(undefined);
+ setRefreshIndex((value) => value + 1);
+ }
+
+ async function selectSource(entry: KnowledgeSourceEntry) {
+ if (!result || !scan) return;
+ const epoch = ++readEpoch.current;
+ const sourceScanEpoch = scanEpoch.current;
+ setSelection({ requestId: scan.requestId, entryId: entry.entryId });
+ setRead(undefined);
+ if (entry.availability !== "available") return;
+ setRead({ entryId: entry.entryId, loading: true });
+ try {
+ const preview = await client.readKnowledge({ scanId: result.scanId, entryId: entry.entryId });
+ if (!sameSource(entry, preview.entry)) {
+ throw new IpcContractError("Discovery read changed the selected source provenance");
+ }
+ if (epoch === readEpoch.current && sourceScanEpoch === scanEpoch.current) {
+ setRead({ entryId: entry.entryId, result: preview, loading: false });
+ }
+ } catch (error) {
+ if (epoch === readEpoch.current && sourceScanEpoch === scanEpoch.current) {
+ setRead({ entryId: entry.entryId, error: errorData(error), loading: false });
+ }
+ }
+ }
+
+ return {
+ result,
+ loading: !isCurrent,
+ scanError: isCurrent ? scan.error : undefined,
+ selected,
+ read: selected && read?.entryId === selected.entryId ? read : undefined,
+ rescan,
+ selectSource,
+ };
+}
+
+/** A preview must retain all provenance from its selected inventory descriptor. */
+function sameSource(selected: KnowledgeSourceEntry, returned: KnowledgeSourceEntry): boolean {
+ const fields: readonly (keyof KnowledgeSourceEntry)[] = [
+ "entryId", "kind", "provider", "scope", "sourcePath", "name",
+ "scopeDirectory", "precedenceHint", "viaSymlink", "availability",
+ ];
+ return fields.every((field) => selected[field] === returned[field]);
+}
diff --git a/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx b/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx
index 1c873aa..3252b2a 100644
--- a/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx
+++ b/apps/desktop/src/app/features/navigation/CanvasSidebar.test.tsx
@@ -2,7 +2,7 @@ import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
-import type { Project } from "../../../ipc/types";
+import type { Project, Session } from "../../../ipc/types";
import {
CANVAS_DOCUMENT_UPDATED_EVENT,
createInitialCanvasDocument,
@@ -17,10 +17,29 @@ const PROJECT: Project = {
lastOpenedAtMs: 2,
};
+const SESSION: Session = {
+ id: "session-1",
+ projectId: PROJECT.id,
+ name: "Review",
+ agentId: "agent-1",
+ cwd: PROJECT.path,
+ status: "exited",
+ createdAtMs: 1,
+ updatedAtMs: 2,
+};
+
describe("CanvasSidebar", () => {
it("shows a minimal default workspace when no project exists", () => {
renderSidebar([]);
+ act(() => {
+ window.dispatchEvent(
+ new CustomEvent(CANVAS_DOCUMENT_UPDATED_EVENT, {
+ detail: createInitialCanvasDocument(),
+ }),
+ );
+ });
+
expect(screen.getByRole("searchbox", { name: "Filter workspaces" })).toBeVisible();
expect(screen.getByText("My Workspace")).toBeVisible();
expect(screen.getByLabelText("2 canvas terminals")).toHaveTextContent("2");
@@ -50,11 +69,26 @@ describe("CanvasSidebar", () => {
screen.getByRole("searchbox", { name: "Filter workspaces" }),
"master",
);
- await user.click(screen.getByRole("button", { name: /CLI Master/ }));
+ await user.click(screen.getByRole("button", { name: /^CLI Master/ }));
expect(onSelectProject).toHaveBeenCalledWith(PROJECT.id);
});
+ it("reports daemon sessions instead of global canvas terminal cards", () => {
+ renderSidebar(
+ [PROJECT],
+ vi.fn(),
+ vi.fn(),
+ "canvas",
+ vi.fn(),
+ vi.fn(),
+ vi.fn(),
+ [SESSION],
+ );
+
+ expect(screen.getByLabelText("1 sessions")).toHaveTextContent("1");
+ });
+
it("offers a control to hide the workspace sidebar", async () => {
const user = userEvent.setup();
const onHide = vi.fn();
@@ -67,6 +101,36 @@ describe("CanvasSidebar", () => {
expect(onHide).toHaveBeenCalledOnce();
});
+ it("keeps project rename and remove controls visible and connected", async () => {
+ const user = userEvent.setup();
+ const onRenameProject = vi.fn();
+ const onRemoveProject = vi.fn();
+ renderSidebar(
+ [PROJECT],
+ vi.fn(),
+ vi.fn(),
+ "canvas",
+ vi.fn(),
+ onRenameProject,
+ onRemoveProject,
+ );
+
+ const renameButton = screen.getByRole("button", {
+ name: `Rename ${PROJECT.name}`,
+ });
+ const removeButton = screen.getByRole("button", {
+ name: `Remove ${PROJECT.name} from workspaces`,
+ });
+ expect(renameButton).toBeVisible();
+ expect(removeButton).toBeVisible();
+
+ await user.click(renameButton);
+ await user.click(removeButton);
+
+ expect(onRenameProject).toHaveBeenCalledWith(PROJECT.id);
+ expect(onRemoveProject).toHaveBeenCalledWith(PROJECT.id);
+ });
+
it("returns to the canvas from the settings view", async () => {
const user = userEvent.setup();
const onOpenCanvas = vi.fn();
@@ -88,17 +152,22 @@ function renderSidebar(
onHide = vi.fn(),
activeView: "canvas" | "settings" | "diagnostics" = "canvas",
onOpenCanvas = vi.fn(),
+ onRenameProject = vi.fn(),
+ onRemoveProject = vi.fn(),
+ sessions: readonly Session[] = [],
) {
return render(
,
diff --git a/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx b/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx
index f762690..bd2f816 100644
--- a/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx
+++ b/apps/desktop/src/app/features/navigation/CanvasSidebar.tsx
@@ -19,6 +19,8 @@ interface CanvasSidebarProps {
readonly onOpenCanvas: () => void;
readonly onHide: () => void;
readonly onAddProject: () => void;
+ readonly onRenameProject: (projectId: string) => void;
+ readonly onRemoveProject: (projectId: string) => void;
readonly onOpenSettings: () => void;
readonly onOpenDiagnostics: () => void;
}
@@ -34,6 +36,8 @@ export function CanvasSidebar({
onOpenCanvas,
onHide,
onAddProject,
+ onRenameProject,
+ onRemoveProject,
onOpenSettings,
onOpenDiagnostics,
}: CanvasSidebarProps) {
@@ -54,7 +58,10 @@ export function CanvasSidebar({
useEffect(() => {
function handleCanvasDocument(event: Event) {
- const document = (event as CustomEvent).detail;
+ if (!(event instanceof CustomEvent)) {
+ return;
+ }
+ const document: CanvasDocument = event.detail;
setCanvasTerminalCount(
document.nodes.filter((node) => node.kind === "terminal").length,
);
@@ -123,14 +130,13 @@ export function CanvasSidebar({
) : visibleProjects.length > 0 ? (