From ed5269d6b0b10426f855bd3d0c2cb0749fc18ab7 Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Sun, 2 Aug 2026 08:55:35 +0530 Subject: [PATCH 1/3] feat(desktop): the canvas becomes three columns, and an arrangement stops being work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real user feedback this project has had said the same thing twice: the workspace picker and the greeting are good, and the canvas inside a workspace is not. That reading holds up on the merits and not only on taste. A coding task maps to a branch, and branches are a list. Spatial arrangement earns its keep when the relationships between things matter more than their contents, and for four agents on four tasks it does not. So the plane goes and three regions take its place: what you can open on the left, what is open in the centre, what the project knows on the right. Nodes become tabs in the order they were made, and a tab opens into the focused pane of a split tree. What that deletes is the point. `AgentNode` carried a terminal, a spawn, a status machine, a lock, an expand, two remembered sizes and a pair of connection ports, because it was a box on a plane that also happened to run an agent. A pane is a view over a process it does not own, so the spawn moved to `spawn.ts`, the status machine to `nodeState.ts`, and the four node components collapsed into one `Pane`. `FocusView` went with them: a pane holding the only leaf is already full size, so the overlay that existed to fake that has nothing left to do — and with it went the refit event, which existed only because an overlay could change a pty's wrapping without changing any box. `tidy` and the board thumbnail went because both were functions of positions. React Flow is no longer a dependency. Three things in here are less obvious than the layout: **`Edge` is untouched, deliberately.** `canvas.rs:35` says it plainly: two nodes share context only when an edge joins them, and it is enforced in three places in `identra-mcp`. Drawing a wire was the user's consent gesture for agent-to-agent access, so deleting the canvas without noticing that would have revoked every permission anyone had granted, as a side effect of a UI decision. Edges load, save and carry across untouched. What is missing is the drawing of them, which is the next commit and is called out in the comment above `edgesRef`: `connect_nodes` is a bus command any agent can issue, the canvas at least made that grant appear in front of you, and nothing in this shell does yet. **Positions are ignored, not erased.** `x`, `y` and the viewport are read and written back exactly as they came, so a workspace last saved by v0.1.2 round-trips rather than being rewritten by the first save from here. And a workspace that *was* arranged says so once, on first open: the agents are still here as tabs, the arrangement is not, the connections carried over. Ten months of layout vanishing into something the user did not choose, with nothing saying it was deliberate, reads as a corrupted file rather than a new version. **The split tree is session-only and the keyboard came first.** Ratios are not persisted and not draggable; an even split is what a split means until someone asks for it to mean something else. The keys are the whole v0.2.0 set — tab by number, `[`/`]` between panes, `\` to split, `shift+\` to close a pane, `e` for the right column — captured on the window, because every pane here is a terminal that takes keys first. Retrofitting focus into a pane tree means touching every pane, so the tree was built around it. `layout.ts` is pure and has its own tests, including the two that would be silent bugs: closing the last pane leaves it alone, and closing a node blanks every pane showing it rather than leaving a dead id mounting a terminal onto a pty that is gone. tsc, bun test and cargo fmt are clean, and the frontend builds. --- apps/identra-desktop/frontend/bun.lock | 41 - apps/identra-desktop/frontend/package.json | 1 - .../frontend/src/AgentNode.tsx | 427 ----- apps/identra-desktop/frontend/src/App.tsx | 1705 ++++++++--------- .../frontend/src/BoardPreview.tsx | 82 +- .../frontend/src/BrowserNode.tsx | 68 - .../identra-desktop/frontend/src/FileNode.tsx | 102 - .../frontend/src/FocusView.tsx | 68 - .../identra-desktop/frontend/src/NoteNode.tsx | 17 - apps/identra-desktop/frontend/src/Pane.tsx | 341 ++++ .../frontend/src/WorkspacePicker.tsx | 2 - .../frontend/src/attachTerminal.ts | 79 +- .../frontend/src/layout.test.ts | 74 + apps/identra-desktop/frontend/src/layout.ts | 114 ++ .../identra-desktop/frontend/src/nodeState.ts | 88 + .../frontend/src/preview.test.ts | 52 - apps/identra-desktop/frontend/src/preview.ts | 48 - apps/identra-desktop/frontend/src/spawn.ts | 66 + apps/identra-desktop/frontend/src/styles.css | 723 +++---- .../identra-desktop/frontend/src/tidy.test.ts | 53 - apps/identra-desktop/frontend/src/tidy.ts | 58 - 21 files changed, 1917 insertions(+), 2292 deletions(-) delete mode 100644 apps/identra-desktop/frontend/src/AgentNode.tsx delete mode 100644 apps/identra-desktop/frontend/src/BrowserNode.tsx delete mode 100644 apps/identra-desktop/frontend/src/FileNode.tsx delete mode 100644 apps/identra-desktop/frontend/src/FocusView.tsx delete mode 100644 apps/identra-desktop/frontend/src/NoteNode.tsx create mode 100644 apps/identra-desktop/frontend/src/Pane.tsx create mode 100644 apps/identra-desktop/frontend/src/layout.test.ts create mode 100644 apps/identra-desktop/frontend/src/layout.ts create mode 100644 apps/identra-desktop/frontend/src/nodeState.ts delete mode 100644 apps/identra-desktop/frontend/src/preview.test.ts delete mode 100644 apps/identra-desktop/frontend/src/preview.ts create mode 100644 apps/identra-desktop/frontend/src/spawn.ts delete mode 100644 apps/identra-desktop/frontend/src/tidy.test.ts delete mode 100644 apps/identra-desktop/frontend/src/tidy.ts diff --git a/apps/identra-desktop/frontend/bun.lock b/apps/identra-desktop/frontend/bun.lock index dd663ef..3e21977 100644 --- a/apps/identra-desktop/frontend/bun.lock +++ b/apps/identra-desktop/frontend/bun.lock @@ -9,7 +9,6 @@ "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", }, @@ -188,18 +187,6 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], - - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], @@ -214,40 +201,16 @@ "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], - "@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="], - - "@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="], - "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "electron-to-chromium": ["electron-to-chromium@1.5.391", "", {}, "sha512-YmCu4856jkgKT1Nh6fwRdeVrM6Ydf/fBnq51tpmSfX+jOcUMTxh31yH6hjKScRenhB2oDSvA9oooxcpjogPeig=="], @@ -306,12 +269,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], } } diff --git a/apps/identra-desktop/frontend/package.json b/apps/identra-desktop/frontend/package.json index 9818452..c9d3acc 100644 --- a/apps/identra-desktop/frontend/package.json +++ b/apps/identra-desktop/frontend/package.json @@ -15,7 +15,6 @@ "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.3.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/apps/identra-desktop/frontend/src/AgentNode.tsx b/apps/identra-desktop/frontend/src/AgentNode.tsx deleted file mode 100644 index 2eef05c..0000000 --- a/apps/identra-desktop/frontend/src/AgentNode.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { memo, useEffect, useRef, useState, type CSSProperties } from "react"; -import { Handle, Position, useReactFlow, type NodeProps } from "@xyflow/react"; -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; -import { - agentsByKind, - devCommand, - memoryList, - onExit, - onOutput, - terminalInput, - terminalResize, - terminalSnapshot, - terminalStart, - terminalStatus, - type Memory, - type OutputEvent, -} from "./api"; -import { REFIT_EVENT } from "./attachTerminal"; -import { appendTail, findLocalUrl } from "./devurl"; -import { pastSnapshot } from "./reattach"; -import { AgentIcon, auraFor } from "./icons"; - -// `kind` is the agent id (codex, claude, …); the node resolves its binary and args from it. -// `seat` is stamped on at render by App when this node holds the orchestrator seat. It is not -// persisted here: the canvas stores one seat id, and this is that fact arriving where it is drawn. -export type AgentNodeData = { - title: string; - cwd: string | null; - kind: string; - locked?: boolean; - seat?: boolean; - // Stamped on by App along with `seat`. A stable callback, so it does not break the memo on this - // component every time App re-renders for something unrelated. - onToggleLock?: (id: string) => void; - // Stamped on the same way. A dev-server node calls it when the user clicks the address badge, - // and App answers by standing a browser node up next to it. - onOpenPreview?: (id: string, url: string) => void; - // Stamped on the same way. Opens this node's conversation at full window size. - onFocus?: (id: string) => void; -}; - -function AgentNodeImpl({ id, data }: NodeProps) { - const nodeData = data as AgentNodeData; - const { deleteElements } = useReactFlow(); - const termHost = useRef(null); - // Four honest states. Output means it is working; 1.5s of quiet settles it. Exit is the only one - // the node cannot infer for itself, so the engine tells it: without that, an agent that finished - // looks exactly like one that is thinking, forever. And when it settles, quiet splits in two: - // finished, or waiting on an answer. That last one the engine works out from the transcript, - // because the difference is in what was printed, not in the timing. - const [state, setState] = useState< - "ready" | "running" | "needs-input" | "exited" - >("ready"); - // What the project already knows, shown once when the node opens. This is the payoff made - // visible: the agent has not typed a word and the human can already see it is not starting cold. - // A few facts, not the whole store, because it is a glance and not the memory panel. - const [recall, setRecall] = useState([]); - const [recallShown, setRecallShown] = useState(true); - // A dev-server node runs the project's own dev command instead of an agent CLI. Same PTY, same - // terminal, same lifecycle; what differs is where the command comes from and that its output is - // watched for the preview address. - const isDev = nodeData.kind === "dev"; - const [previewUrl, setPreviewUrl] = useState(null); - - useEffect(() => { - // A dev server has no conversation to remember; the recall strip on it would be noise. - if (isDev) return; - let dropped = false; - void memoryList(3).then((facts) => { - if (!dropped) setRecall(facts); - }); - return () => { - dropped = true; - }; - }, [isDev]); - - // One terminal per node, wired to the backend PTY. Runs once per mount; on a hot reload it - // reattaches to the still-running PTY instead of restarting it. - useEffect(() => { - const host = termHost.current; - if (!host) return; - - const term = new Terminal({ - fontFamily: "Ubuntu Mono, Menlo, Consolas, monospace", - fontSize: 13, - cursorBlink: true, - theme: { - background: "#300a24", - foreground: "#ffffff", - cursor: "#e95420", - }, - }); - const fit = new FitAddon(); - term.loadAddon(fit); - term.open(host); - fit.fit(); - - let lastSeq = 0; - let ready = false; // until the snapshot is applied, buffer live chunks - let disposed = false; - const buffered: OutputEvent[] = []; - - // The preview address is fished out of the dev server's own banner. A rolling tail, because - // chunk boundaries land anywhere, including mid-url. - let urlTail = ""; - let urlFound = false; - const decoder = new TextDecoder(); - const scanForUrl = (bytes: Uint8Array) => { - if (!isDev || urlFound) return; - urlTail = appendTail(urlTail, decoder.decode(bytes, { stream: true })); - const url = findLocalUrl(urlTail); - if (url !== null) { - urlFound = true; - setPreviewUrl(url); - } - }; - - let running = false; - let exited = false; - let idleTimer: number | undefined; - const markOutput = () => { - if (exited) return; // a dead agent producing bytes is drain, not life - if (!running) { - running = true; - setState("running"); - } - window.clearTimeout(idleTimer); - idleTimer = window.setTimeout(() => { - running = false; - setState("ready"); - // Settling is the one moment worth asking the engine what this quiet means. I set ready - // first and correct to needs-input after, so the node never waits on IPC to stop looking - // busy. Anything other than needs-input leaves it as it is: the engine uses the same 1.5s - // threshold, so it can still say "running" here by a hair, and the local timer is the one - // that should win that tie. - void terminalStatus(id) - .then((status) => { - if (disposed || exited || running) return; - if (status === "needs-input") setState("needs-input"); - }) - .catch(() => { - // The node was killed between settling and asking. Ready is already right. - }); - }, 1500); - }; - - const write = (e: OutputEvent) => { - if (pastSnapshot(e.seq, lastSeq)) { - const bytes = new Uint8Array(e.data); - term.write(bytes); - scanForUrl(bytes); - lastSeq = e.seq; - } - }; - - const unlisten = onOutput((e) => { - if (e.id !== id) return; - markOutput(); - if (ready) write(e); - else buffered.push(e); - }); - - const unlistenExit = onExit((e) => { - if (e.id !== id) return; - window.clearTimeout(idleTimer); // it cannot go back to running now - running = false; - exited = true; - setState("exited"); - // Say so in the terminal too. The dot tells you at a glance across the canvas; this tells you - // why when you look, and a non-zero code is the difference between finished and crashed. - const how = e.code === null ? "was stopped" : `exited (${e.code})`; - term.write(`\r\n\x1b[90m${nodeData.kind} ${how}\x1b[0m\r\n`); - }); - - void (async () => { - const snap = await terminalSnapshot(id); - if (disposed) return; - if (snap === null) { - // Fresh node, or the app was fully restarted: launch this node's command now. An agent - // node resolves its CLI from the registry by kind; a dev node asks the engine what this - // project's dev command is. - if (isDev) { - const cmd = await devCommand(); - if (disposed) return; - if (cmd === null || cmd.length === 0) { - term.write( - "\r\n\x1b[31mThis project does not declare a dev command\x1b[0m in package.json, a justfile, or a Makefile.\r\n", - ); - } else { - try { - await terminalStart( - id, - nodeData.kind, - cmd[0]!, - cmd.slice(1), - nodeData.cwd, - term.rows, - term.cols, - ); - } catch (err) { - term.write( - `\r\n\x1b[31mThe dev server didn't start:\x1b[0m ${err}\r\n`, - ); - } - } - } else { - const agent = (await agentsByKind()).get(nodeData.kind); - if (disposed) return; - if (!agent || !agent.available) { - // Say what to do with what is actually on this machine. - // - // This used to tell people to run `just doctor`. That command lives in Identra's own - // repo, and someone who installed the app has no repo, no justfile, and most likely no - // `just` — so the first error the product ever shows them offered a fix they could not - // carry out. An error that names an impossible next step is worse than one that names - // none, because it costs a search before it comes to nothing. - // - // What is useful here is the machine's own answer: the other agents already installed - // are one click away, and switching to one of them beats installing anything. - const ready = [...(await agentsByKind()).values()] - .filter((a) => a.available) - .map((a) => a.name); - term.write( - `\r\n\x1b[31m${nodeData.kind} isn't installed\x1b[0m or not on your PATH.\r\n`, - ); - term.write( - ready.length > 0 - ? `Already installed here: ${ready.join(", ")}. Delete this node and add one of those instead.\r\n` - : "Install a coding agent, then reopen this node. Identra runs the CLI on your machine; it does not ship one.\r\n", - ); - } else { - try { - await terminalStart( - id, - nodeData.kind, - agent.cmd, - agent.args, - nodeData.cwd, - term.rows, - term.cols, - ); - } catch (err) { - term.write( - `\r\n\x1b[31m${agent.name} didn't start:\x1b[0m ${err}\r\n`, - ); - } - } - } - } else { - const bytes = new Uint8Array(snap.data); - term.write(bytes); - // The reattach replay holds the banner that was printed before the reload, so the URL is - // in there, not in any chunk still to come. - scanForUrl(bytes); - lastSeq = snap.lastSeq; - } - for (const e of buffered) write(e); // drain what arrived during the await - buffered.length = 0; - ready = true; - })(); - - // A keystroke sent to an agent that has already exited fails at the pipe, and that is expected - // for a dead node, so it drops to one warning rather than an unhandled rejection. The exit line - // already told the user the agent is gone; there is nothing more to say and nothing to type into. - const onData = term.onData( - (d) => - void terminalInput(id, d).catch((err) => - console.warn(`input to ${id} dropped:`, err), - ), - ); - - // Same story as input: a resize racing a killed terminal rejects at the backend, and there is - // nothing to resize and nothing to tell the user, so it is swallowed rather than left unhandled. - const claimSize = () => { - try { - fit.fit(); - void terminalResize(id, term.rows, term.cols).catch(() => {}); - } catch { - /* host detached mid-resize */ - } - }; - - const ro = new ResizeObserver(claimSize); - ro.observe(host); - - // Take the pty's size back when the focus view hands it over. - // - // There is one pty per node and its size is single valued, so whichever view last pushed one - // owns how the agent wraps every line it draws. The focus view is an overlay: opening it does - // not change this node's box, so nothing here fires, and it quietly re-wraps the agent to a - // full window. Closing it does not change this node's box either — so without this the node - // stays wrapped for a window it is not in, with lines running off its edge, until something - // else happens to resize it. Which for a node sitting still on a canvas can be never. - const refit = () => claimSize(); - window.addEventListener(REFIT_EVENT, refit); - - return () => { - disposed = true; - window.clearTimeout(idleTimer); - window.removeEventListener(REFIT_EVENT, refit); - ro.disconnect(); - onData.dispose(); - void unlisten.then((un) => un()); - void unlistenExit.then((un) => un()); - term.dispose(); - // Frontend teardown only: the backend PTY stays alive so a reload can - // reattach. App.onNodesDelete is what actually kills it. - }; - }, [id]); - - return ( -
- - -
- - - - {nodeData.title || nodeData.kind} - - {nodeData.seat === true && ( - // Named, not just coloured. A ring alone would say this node is special without saying - // why, and the thing worth knowing is that the command bar types in here. - - command center - - )} - {previewUrl !== null && ( - // The server's own address, read from its banner, and the one-click way to see the - // page: clicking stands a browser node up next to this one. The offer is a click the - // user takes, never a node that appears uninvited. - - )} - {nodeData.onFocus !== undefined && ( - // Same hover-reveal as lock and close. Reading a conversation in a tile is squinting; - // this is the door to reading it at full size, same PTY, nothing moved. - - )} - {nodeData.onToggleLock !== undefined && ( - // Always visible once locked, hover-only when open, the same as the close button. A lock - // that hides itself is a setting the user cannot tell is on, and the whole value of this - // is knowing at a glance which nodes agents cannot touch. - - )} - -
- {recall.length > 0 && recallShown && ( - // Calm and earned, not a popup: it sits above the terminal, states what is known, and gets - // out of the way the moment the human is done with it. This is the single most important - // visual in the product, because it is the one that makes "it remembers" a thing you see - // rather than a claim you read. -
-
- Identra remembers ({recall.length}) - -
-
    - {recall.map((m) => ( -
  • {m.content}
  • - ))} -
-
- )} -
-
- ); -} - -export default memo(AgentNodeImpl); diff --git a/apps/identra-desktop/frontend/src/App.tsx b/apps/identra-desktop/frontend/src/App.tsx index 9652c0e..50a4bc0 100644 --- a/apps/identra-desktop/frontend/src/App.tsx +++ b/apps/identra-desktop/frontend/src/App.tsx @@ -1,47 +1,31 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import logo from "./assets/identra.png"; import { convertFileSrc } from "@tauri-apps/api/core"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { - ReactFlow, - Background, - Controls, - MiniMap, - addEdge, - applyEdgeChanges, - applyNodeChanges, - type Connection, - type Edge as FEdge, - type EdgeChange, - type Node, - type NodeChange, - type Viewport, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; import "@xterm/xterm/css/xterm.css"; -import AgentNode, { type AgentNodeData } from "./AgentNode"; -import BrowserNode from "./BrowserNode"; -import FileNode from "./FileNode"; import FilesPanel from "./FilesPanel"; -import NoteNode from "./NoteNode"; import Onboarding from "./Onboarding"; +import Pane from "./Pane"; import WorkspacePicker from "./WorkspacePicker"; import SettingsPanel from "./SettingsPanel"; import WorkPanel from "./WorkPanel"; import WorkspaceMenu from "./WorkspaceMenu"; import CommandBar, { MOD_LABEL, type DispatchState } from "./CommandBar"; -import FocusView from "./FocusView"; -import { REFIT_EVENT } from "./attachTerminal"; import WallpaperPicker from "./WallpaperPicker"; import { AgentIcon } from "./icons"; -import { tidyPositions } from "./tidy"; import { - backgroundCss, - DEFAULT_WALLPAPER, - dotColor, - needsScrim, -} from "./wallpaper"; + clearNode, + closeLeaf, + leaf, + leaves, + setNode, + splitLeaf, + stepLeaf, + type Pane as PaneTree, +} from "./layout"; +import { useNodeState } from "./nodeState"; +import { backgroundCss, DEFAULT_WALLPAPER, needsScrim } from "./wallpaper"; import { composeDispatch, planLine, @@ -75,20 +59,14 @@ import { type CanvasCommand, type CanvasNode, type CanvasResult, + type Edge, + type Viewport, type Wallpaper, type WorkspaceMeta, } from "./api"; -type FNode = Node; - -const nodeTypes = { - agent: AgentNode, - browser: BrowserNode, - note: NoteNode, - file: FileNode, -}; -// Long enough that a drag is one write rather than sixty, short enough that the window I have to -// flush on close stays small. +// Long enough that a burst of changes is one write rather than sixty, short enough that the window +// I have to flush on close stays small. const SAVE_DEBOUNCE_MS = 400; // The longest the window waits for its final save before closing anyway. Long enough that an // ordinary write to a local file finishes inside it many times over, short enough that a user who @@ -97,93 +75,94 @@ const CLOSE_FLUSH_MAX_MS = 2000; // How often the command bar re-reads the board and the seat's state. Slow enough to be free, fast // enough that "it is asking you something" does not sit unnoticed. Only runs while a seat exists. const SEAT_POLL_MS = 2500; -// How often the topbar re-reads how many facts the project has learned, for the badge and the +// How often the shell re-reads how many facts the project has learned, for the badge and the // one-time reveal. Matches the panel's own poll: two small reads a few seconds apart cost nothing. const MEMORY_POLL_MS = 2000; // The headless orchestrator still runs inside a PTY, and a PTY has a size whether or not anyone is // looking at it. This is only the size it is born at: the moment the command center pane mounts it -// re-sizes the PTY to the box actually showing it, the same as any node. What these have to be is +// re-sizes the PTY to the box actually showing it, the same as any pane. What these have to be is // big enough that the CLI's first screen, drawn before the pane has attached, is not folded into // nonsense that then has to be re-wrapped. const SEAT_COLS = 120; const SEAT_ROWS = 40; +// What a node's saved box used to be. Nothing draws at this size any more — a pane is whatever the +// split tree gives it — but the fields are still in the file, and writing a plausible number keeps +// a canvas exported from here readable by anything that still reads them. const DEFAULT_W = 480; const DEFAULT_H = 320; -// Every agent kind renders through the one AgentNode; the kind rides in node data. A saved -// claude or gemini node must reload as itself, so the real kind flows both ways here. -function toFlow(n: CanvasNode): FNode { - return { - id: n.id, - type: - n.kind === "browser" || n.kind === "note" || n.kind === "file" - ? n.kind - : "agent", - position: { x: n.x, y: n.y }, - data: { title: n.title, cwd: n.cwd, kind: n.kind, locked: n.locked }, - style: { width: n.width || DEFAULT_W, height: n.height || DEFAULT_H }, - }; -} +// The right column's modes. Files and the work panel already existed as slide-overs; docking them +// is the change. Changes and Review are named in the plan and are not built, and an empty tab that +// says "coming soon" is worse than a column with two honest ones. +type RightMode = "work" | "files"; -function toCanvasNode(n: FNode): CanvasNode { - return { - id: n.id, - kind: n.data.kind, - x: n.position.x, - y: n.position.y, - width: Number(n.style?.width) || DEFAULT_W, - height: Number(n.style?.height) || DEFAULT_H, - title: n.data.title || n.data.kind, - cwd: n.data.cwd ?? null, - locked: n.data.locked === true, - }; -} +// Whether this workspace has been told its canvas is gone. Kept in the browser's own storage rather +// than in the engine, because it is a fact about what this person has read and not about the +// project: no agent needs it, nothing else reads it, and putting it in canvas.json would mean a +// migration for a sentence. +// +// ponytail: localStorage, per workspace. If the notice ever has to survive a reinstall, it moves to +// the same place `memory_reveal_once` lives. +const CANVAS_NOTICE_KEY = "identra:canvas-gone:"; export default function App() { const [workspace, setWorkspace] = useState(null); - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); + const [nodes, setNodes] = useState([]); const [agents, setAgents] = useState([]); - const [panelOpen, setPanelOpen] = useState(false); - // The Work panel opens on this tab. A manual open lands on tasks; the first-fact reveal lands on - // memory, because memory is the thing it is revealing. - const [panelTab, setPanelTab] = useState<"tasks" | "memory">("tasks"); + // Which mode the right column is showing, or null when it is collapsed. + const [right, setRight] = useState(null); // How many facts this project has learned. Drives the ambient badge and the one-time reveal, and - // is polled whether or not the panel is open, so the badge is right even while it is closed. + // is polled whether or not the column is open, so the badge is right even while it is closed. const [memoryCount, setMemoryCount] = useState(0); - const [filesOpen, setFilesOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); // The dev command this workspace declares, or null. Existence is what the Run button keys on. const [devCmd, setDevCmd] = useState(null); - // The node open at full window size, or null. One at a time by construction. - const [focused, setFocused] = useState(null); - // Set when a write to disk fails. The board is on screen and not saved, and the only wrong move - // is to say nothing. + // Set when a write to disk fails. The work is on screen and not saved, and the only wrong move is + // to say nothing. const [saveError, setSaveError] = useState(null); - const viewport = useRef({ x: 0, y: 0, zoom: 1 }); - // scheduleSave persists the whole canvas but each handler only has its own slice; these refs + // Said once per workspace, on the first open after the canvas went away. + const [canvasNotice, setCanvasNotice] = useState(false); + + // The centre column. Session-only by design: see the note at the top of layout.ts. + const [tree, setTree] = useState(() => leaf("pane-0")); + const [focusLeaf, setFocusLeaf] = useState("pane-0"); + const treeRef = useRef(tree); + treeRef.current = tree; + const focusLeafRef = useRef(focusLeaf); + focusLeafRef.current = focusLeaf; + + // scheduleSave persists the whole workspace but each handler only has its own slice; these refs // hold the latest of both so a save always writes a consistent nodes+edges pair. - const nodesRef = useRef([]); - const edgesRef = useRef([]); + // + // Edges are a ref and not state on purpose, for now. Nothing in this shell draws a connection — + // the wire that used to draw it is what the canvas took with it — so state would be a re-render + // nobody watches. That is a gap and not a resolution: an edge is the user's grant of + // agent-to-agent access, agents can grant themselves one through `connect_nodes`, and the canvas + // at least made that appear in front of you. The control that lists them is the next item in this + // release, and it is the thing that turns this back into state. + const nodesRef = useRef([]); + const edgesRef = useRef([]); const titleRef = useRef(""); - // Which node holds the orchestrator seat. State because the canvas draws it, and a ref alongside - // for the same reason the nodes have one: snapshot() runs outside render and has to write the - // current seat, not the one from the render that scheduled the save. + // The viewport is dead as a concept and alive as a field: nothing pans or zooms any more, but + // canvas.json still carries one and a workspace last saved by v0.1.2 has a real value in it. + // Round-tripping what was read keeps this window from being the thing that rewrote it. + const viewportRef = useRef({ x: 0, y: 0, zoom: 1 }); + // Which node holds the orchestrator seat. State because the bar draws it, and a ref alongside for + // the same reason the nodes have one: snapshot() runs outside render and has to write the current + // seat, not the one from the render that scheduled the save. const [seat, setSeat] = useState(null); - // The display name of whatever agent is holding the seat, for the bar's label. Set when the seat - // is stood up; there is no node to read it back off any more. + // The display name of whatever agent is holding the seat, for the bar's label. const [seatAgent, setSeatAgent] = useState(null); const seatRef = useRef(null); - // The background this workspace wears. State because the canvas draws it, a ref so snapshot() - // writes the current choice rather than the one from the render that scheduled the save. + // The background this workspace wears. It is behind the columns now rather than under nodes, so + // it shows at the edges and through the gaps; the field and the picker are unchanged. const [wallpaper, setWallpaper] = useState(DEFAULT_WALLPAPER); const wallpaperRef = useRef(DEFAULT_WALLPAPER); - // Where the wallpaper popover is open, or null. Set by right-clicking the canvas background. const [wallMenu, setWallMenu] = useState<{ x: number; y: number } | null>( null, ); const saveTimer = useRef(undefined); - // Is the board on screen different from the board on disk. This is what the close handler asks. + // Is what is on screen different from what is on disk. This is what the close handler asks. const unsaved = useRef(false); // The canvas-command handler runs outside React's render, so it reads agents from a ref rather // than closing over state that would be stale by the time an agent calls. @@ -197,7 +176,7 @@ export default function App() { }, []); // The first-run panel offers a recheck so a user who just installed an agent does not have to - // relaunch. This clears the probe cache and refreshes what both the dock and the panel read. + // relaunch. This clears the probe cache and refreshes what both the sidebar and the panel read. // It returns the promise so the panel can show a checking state and a failure, rather than a // button that eats the click in silence. const recheckAgents = useCallback(async () => { @@ -208,8 +187,8 @@ export default function App() { // Backspace outside a text field is history-back in WebKit, and the shell's history is the app // itself: one stray keypress with nothing focused and the window walks backward out of Identra. - // Editable targets keep the key, which covers every input here including xterm's hidden - // textarea, so typing is untouched and only the navigation gesture dies. + // Editable targets keep the key, which covers every input here including xterm's hidden textarea, + // so typing is untouched and only the navigation gesture dies. useEffect(() => { const guard = (e: KeyboardEvent) => { if (e.key !== "Backspace") return; @@ -224,29 +203,7 @@ export default function App() { return () => window.removeEventListener("keydown", guard); }, []); - // A terminal and an iframe both need the wheel for their own scrolling, so they swallow it, which - // leaves you unable to zoom the canvas while the pointer is over a node. Holding the modifier - // turns that off for as long as it is down: the class stops matching, and the wheel reaches the - // canvas. Cheaper than hunting for empty space, and it is the same key you already hold to zoom. - const [wheelToCanvas, setWheelToCanvas] = useState(false); - useEffect(() => { - const down = (e: KeyboardEvent) => - (e.metaKey || e.ctrlKey) && setWheelToCanvas(true); - const up = (e: KeyboardEvent) => - !e.metaKey && !e.ctrlKey && setWheelToCanvas(false); - // Releasing the key outside the window never fires keyup, which would leave it stuck on. - const blur = () => setWheelToCanvas(false); - window.addEventListener("keydown", down); - window.addEventListener("keyup", up); - window.addEventListener("blur", blur); - return () => { - window.removeEventListener("keydown", down); - window.removeEventListener("keyup", up); - window.removeEventListener("blur", blur); - }; - }, []); - - // Opening is what makes a workspace active in the engine: it repoints the canvas, and writes the + // Opening is what makes a workspace active in the engine: it repoints the window, and writes the // bus config and the agent guide into that folder so any agent launched here can find its peers. const openWorkspace = useCallback(async (w: WorkspaceMeta) => { // Two lookups, because there are two kinds of id. A workspace Identra made is found by slug in @@ -255,44 +212,49 @@ export default function App() { const canvas = isAdopted(w) ? await workspaceOpenRecent(w.path) : await workspaceOpen(w.slug); - const loaded = canvas.nodes.map(toFlow); - nodesRef.current = loaded; + nodesRef.current = canvas.nodes; edgesRef.current = canvas.edges; titleRef.current = canvas.title; - // Opening a workspace always starts with no seat, and it is worth saying why rather than - // leaving the check that used to be here. That check asked whether the saved seat id was still - // a node on the canvas, which made sense when the orchestrator was one. It is headless now, so - // it is never in `canvas.nodes` and the answer was always no. - // - // The honest version is the same answer for a better reason: a seat is a running process, and - // processes do not survive the app closing. A restored id could only ever name a PTY that is - // gone, and the first instruction stands a fresh one up anyway. + viewportRef.current = canvas.viewport; + // Opening a workspace always starts with no seat. A seat is a running process, and processes do + // not survive the app closing, so a restored id could only ever name a PTY that is gone — and + // the first instruction stands a fresh one up anyway. seatRef.current = null; setSeat(null); setSeatAgent(null); wallpaperRef.current = canvas.wallpaper; setWallpaper(canvas.wallpaper); - setNodes(loaded); - setEdges(canvas.edges); - viewport.current = canvas.viewport; + setNodes(canvas.nodes); + // One pane, showing whatever was made first. Nodes become tabs in the order they were created, + // which is the only ordering the file still carries now that positions are not read. + const first = canvas.nodes[0]?.id ?? null; + setTree(leaf("pane-0", first)); + setFocusLeaf("pane-0"); setWorkspace(w); + // The one thing this window knows and the user does not: their arrangement is gone on purpose. + // A canvas with everything at the origin was never arranged, so it gets no notice. + const arranged = canvas.nodes.some((n) => n.x !== 0 || n.y !== 0); + const key = CANVAS_NOTICE_KEY + w.slug; + setCanvasNotice(arranged && window.localStorage.getItem(key) === null); // Whether this project declares a dev command decides whether the Run control exists at all. // Probed per open, because it is a property of the folder, not of the app. setDevCmd(null); void devCommand().then(setDevCmd, () => setDevCmd(null)); }, []); + const dismissCanvasNotice = useCallback(() => { + setCanvasNotice(false); + if (workspace !== null) { + window.localStorage.setItem(CANVAS_NOTICE_KEY + workspace.slug, "read"); + } + }, [workspace]); - // The whole board, from the refs, so a save always writes a consistent nodes+edges pair. + // The whole workspace, from the refs, so a save always writes a consistent nodes+edges pair. const snapshot = useCallback( () => ({ - nodes: nodesRef.current.map(toCanvasNode), - edges: edgesRef.current.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - })), - viewport: viewport.current, + nodes: nodesRef.current, + edges: edgesRef.current, + viewport: viewportRef.current, title: titleRef.current, seat: seatRef.current, wallpaper: wallpaperRef.current, @@ -300,10 +262,9 @@ export default function App() { [], ); - // Write now and wait for it. A failure here is the user's layout not being on disk, so it goes on - // the screen: this used to be a bare `void canvasSave(...)`, which meant a full disk or a - // read-only workspace looked exactly like a successful save until the app was reopened and the - // work was gone. + // Write now and wait for it. A failure here is the user's work not being on disk, so it goes on + // the screen: a bare `void canvasSave(...)` would make a full disk or a read-only workspace look + // exactly like a successful save until the app was reopened and the work was gone. const saveNow = useCallback(async () => { window.clearTimeout(saveTimer.current); try { @@ -311,92 +272,207 @@ export default function App() { unsaved.current = false; setSaveError(null); } catch (e) { - // Leave unsaved set. The board on screen is still not the board on disk, and the next close + // Leave unsaved set. What is on screen is still not what is on disk, and the next close // should try again rather than assume this one counted. setSaveError(String(e)); } }, [snapshot]); - // Back to the picker, which had no way in from here at all. - // - // The workspace menu could swap you to another workspace but never out to the list, so the home - // screen was somewhere you passed through once at launch and could not return to. Getting back to - // it meant restarting the app, which is a strange thing to have to do to see a list of your own - // projects. + // Debounced atomic save. The engine writes atomically; we just avoid thrashing. + const scheduleSave = useCallback(() => { + unsaved.current = true; + window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + void saveNow(); + }, SAVE_DEBOUNCE_MS); + }, [saveNow]); + + // Back to the picker. // // Everything this clears is deliberate. The nodes and edges go because the next thing rendered is // the picker and stale ones would flash up under whatever is opened next. The seat goes because it // names a PTY belonging to the workspace being left. What does not happen here is killing the - // agents: they are this workspace's processes and they keep running, the same as they do while you - // are looking at another node, so coming back finds them where you left them. + // agents: they are this workspace's processes and they keep running, so coming back finds them + // where you left them. const goHome = useCallback(async () => { - // The board on screen may be newer than the board on disk. Leaving is exactly the moment that - // matters, and unlike closing there is no bound needed: nothing is waiting on it and the picker - // can afford one write. + // What is on screen may be newer than what is on disk. Leaving is exactly the moment that + // matters, and unlike closing there is no bound needed: nothing is waiting on it. await saveNow().catch(() => {}); setWorkspace(null); setNodes([]); - setEdges([]); nodesRef.current = []; edgesRef.current = []; seatRef.current = null; setSeat(null); setSeatAgent(null); - setFocused(null); - setPanelOpen(false); - setFilesOpen(false); + setRight(null); setSettingsOpen(false); + setTree(leaf("pane-0")); + setFocusLeaf("pane-0"); }, [saveNow]); - // Debounced atomic save. The engine writes atomically; we just avoid thrashing on drag. - const scheduleSave = useCallback(() => { - unsaved.current = true; - window.clearTimeout(saveTimer.current); - saveTimer.current = window.setTimeout(() => { - void saveNow(); - }, SAVE_DEBOUNCE_MS); - }, [saveNow]); - - // Straighten the board. Positions only: nothing is started, stopped, or rewired, so this is - // always safe to press. It lays out into the top left of what is currently on screen rather than - // at the canvas origin, because a canvas that has been panned would otherwise tidy itself out of - // view and look like it had deleted everything. - const [minimapOn, setMinimapOn] = useState(false); - const tidy = useCallback(() => { - const vp = viewport.current; - const origin = { x: -vp.x / vp.zoom + 40, y: -vp.y / vp.zoom + 40 }; - const placed = new Map( - tidyPositions( - nodesRef.current.map((n) => ({ - id: n.id, - position: n.position, - width: Number(n.style?.width) || DEFAULT_W, - height: Number(n.style?.height) || DEFAULT_H, - })), - origin, - ).map((p) => [p.id, p]), + // What to say when the window refuses to go. Shares the save banner because it is the same kind + // of message — something the app cannot fix, that the user is the only one who can act on, and + // that must stay on screen rather than being mentioned once. + const reportStuck = useCallback((e: unknown) => { + setSaveError( + `Identra could not close itself: ${String(e)}. Close the window from your desktop instead.`, ); - setNodes((cur) => { - const next = cur.map((n) => { - const at = placed.get(n.id); - return at ? { ...n, position: { x: at.x, y: at.y } } : n; - }); + }, []); + + // Closing inside the debounce window drops whatever changed last, and making a change and then + // quitting is a completely ordinary thing to do. I hold the close, flush, then let it go. + // + // One close request owns the exit. A user who clicks close twice while the flush runs must not + // start a second save or, worse, race two destroys; and whatever the save does, the window has to + // actually go, because an app that refuses to close is holding its user hostage over a write they + // cannot see. A tester on macOS hit exactly that wedge. + const closing = useRef(false); + useEffect(() => { + const win = getCurrentWindow(); + const pending = win.onCloseRequested(async (event) => { + // Asked twice. The first request is still flushing, and the honest reading of a second click + // is "I want out now", so it goes now. This used to preventDefault and return, which was fine + // exactly as long as the first request always reached its destroy. When it did not, the flag + // stayed latched and every close from then on was refused. + if (closing.current) { + void win.destroy().catch(reportStuck); + return; + } + if (!unsaved.current) return; + event.preventDefault(); + closing.current = true; + // Bounded, and that bound is the point. `canvas_save` crosses IPC into the engine, and an + // engine that is wedged must not take the window with it: flushing on close exists to save + // the user's work, not to make quitting conditional on a write succeeding. + await Promise.race([ + saveNow().catch(() => {}), + new Promise((resolve) => setTimeout(resolve, CLOSE_FLUSH_MAX_MS)), + ]); + void win.destroy().catch(reportStuck); + }); + return () => { + void pending.then((unlisten) => unlisten()); + }; + }, [saveNow, reportStuck]); + + // Ask the window to close, and say so out loud if it will not. + // + // The `.catch` is the whole reason this bug survived several rounds of being fixed. Window close + // and destroy are permissioned in Tauri, `core:window:default` grants neither, and every call + // site here was `void win.close()` with nothing watching. So the rejection went nowhere: the + // button did nothing, alt+F4 did nothing, and there was no error anywhere to say why. + const closeIdentra = useCallback(async () => { + try { + await getCurrentWindow().close(); + } catch (e) { + reportStuck(e); + } + }, [reportStuck]); + + // Nodes are a list now, so every change to one is a list operation and they all save the same way. + const putNodes = useCallback( + (next: CanvasNode[]) => { nodesRef.current = next; + setNodes(next); scheduleSave(); - return next; - }); - }, [scheduleSave]); + }, + [scheduleSave], + ); + + const putEdges = useCallback( + (next: Edge[]) => { + edgesRef.current = next; + scheduleSave(); + }, + [scheduleSave], + ); + + // Returns the new node's id, because an agent that asked for this needs to be able to name it. + // The new node also takes the focused pane, which is what makes opening an agent feel like opening + // a thing rather than adding a row to a list you then have to click. + const addNode = useCallback( + (kind: string, title: string, cwd: string | null = null) => { + const id = crypto.randomUUID(); + putNodes([ + ...nodesRef.current, + { + id, + kind, + x: 0, + y: 0, + width: DEFAULT_W, + height: DEFAULT_H, + title, + cwd, + locked: false, + }, + ]); + setTree((cur) => setNode(cur, focusLeafRef.current, id)); + return id; + }, + [putNodes], + ); - // Take the board out to a file, or bring one in. + // Close a node for good: the process, the conversation, the tab, and any pane showing it. This is + // the gesture the canvas spelled as deleting a box, and it is the only one that ends an agent. + const closeNode = useCallback( + (id: string) => { + const node = nodesRef.current.find((n) => n.id === id); + const name = node?.title || node?.kind || "this"; + const cost = + node?.kind === "dev" + ? "The dev server stops." + : "The agent stops and its conversation is forgotten."; + if (!window.confirm(`Close ${name}?\n\n${cost}`)) return; + void terminalKill(id).catch((err) => { + // A node that never launched has no terminal to kill and the engine says so. That is not a + // failure worth showing anyone, but it must not become an unhandled rejection either. + console.warn(`could not close node ${id} cleanly`, err); + }); + putNodes(nodesRef.current.filter((n) => n.id !== id)); + // An edge whose end is gone is not a permission any more, it is a dangling id. The bus reads + // this slice per call, so leaving it would be a grant pointing at nothing. + putEdges( + edgesRef.current.filter((e) => e.source !== id && e.target !== id), + ); + setTree((cur) => clearNode(cur, id)); + }, + [putNodes, putEdges], + ); + + const setNodeCwd = useCallback( + (id: string, cwd: string) => { + putNodes(nodesRef.current.map((n) => (n.id === id ? { ...n, cwd } : n))); + }, + [putNodes], + ); + + const wire = useCallback( + (from: string, to: string) => { + // Same pair twice is the same permission, and a second edge would mean revoking took two + // clicks to do one thing. + if (edgesRef.current.some((e) => e.source === from && e.target === to)) { + return; + } + putEdges([ + ...edgesRef.current, + { id: `${from}->${to}`, source: from, target: to }, + ]); + }, + [putEdges], + ); + + // Take the workspace out to a file, or bring one in. // // Export sends what is on screen rather than what is on disk, so a change made in the last few // hundred milliseconds is in the file too. Both report through the save banner, which is already - // the place this window says a canvas operation failed. + // the place this window says a workspace operation failed. const exportCanvas = useCallback(async () => { try { await canvasExport(snapshot()); } catch (e) { - setSaveError(`That canvas was not exported: ${String(e)}`); + setSaveError(`That workspace was not exported: ${String(e)}`); } }, [snapshot]); @@ -406,7 +482,7 @@ export default function App() { if ( nodesRef.current.length > 0 && !window.confirm( - "Import a canvas?\n\nThis replaces the board in this workspace. The agents running here stop, and their conversations are forgotten.", + "Import a workspace?\n\nThis replaces everything open here. The agents running here stop, and their conversations are forgotten.", ) ) { return; @@ -415,60 +491,38 @@ export default function App() { const imported = await canvasImport(); if (imported === null) return; // cancelled, nothing to say // Stop what is running before the nodes go. These are the nodes being replaced, so the same - // teardown a close does has to happen here or their PTYs outlive the board they belonged to. + // teardown a close does has to happen here or their PTYs outlive the work they belonged to. for (const n of nodesRef.current) { void terminalKill(n.id).catch(() => { - // Best effort. The board is being replaced either way, and a node that would not die - // cleanly is not a reason to leave the user looking at a canvas they just replaced. + // Best effort. Everything is being replaced either way. }); } - const loaded = imported.nodes.map(toFlow); - nodesRef.current = loaded; + nodesRef.current = imported.nodes; edgesRef.current = imported.edges; titleRef.current = imported.title; - const restored = imported.nodes.some((n) => n.id === imported.seat) - ? imported.seat - : null; - seatRef.current = restored; - setSeat(restored); - // An imported board may reference an image that is not in this machine's library. It draws - // as the plain background rather than erroring, which is the same fallback a removed - // library file gets. + viewportRef.current = imported.viewport; + seatRef.current = null; + setSeat(null); + setSeatAgent(null); + // An imported workspace may reference an image that is not in this machine's library. It + // draws as the plain background rather than erroring, the same fallback a removed library + // file gets. wallpaperRef.current = imported.wallpaper; setWallpaper(imported.wallpaper); - setNodes(loaded); - setEdges(imported.edges); - viewport.current = imported.viewport; - // The engine already wrote it to disk as part of importing, so the window is in step with - // the file rather than one debounce behind it. + setNodes(imported.nodes); + setTree(leaf("pane-0", imported.nodes[0]?.id ?? null)); + setFocusLeaf("pane-0"); + // The engine already wrote it to disk as part of importing, so the window is in step with the + // file rather than one debounce behind it. unsaved.current = false; setSaveError(null); } catch (e) { - setSaveError(`That canvas was not imported: ${String(e)}`); + setSaveError(`That workspace was not imported: ${String(e)}`); } }, []); - // Close a node to agents, or open it again. The user's own hands are never restricted by this: - // they can still wire a locked node themselves, because it is their canvas and the lock is about - // what happens while they are not watching. - const toggleLock = useCallback( - (nodeId: string) => { - setNodes((cur) => { - const next = cur.map((n) => - n.id === nodeId - ? { ...n, data: { ...n.data, locked: n.data.locked !== true } } - : n, - ); - nodesRef.current = next; - scheduleSave(); - return next; - }); - }, - [scheduleSave], - ); - - // Picking a wallpaper applies immediately and rides the debounced save, exactly like moving a - // node: the choice is one field on the canvas, not its own persistence path. + // Picking a wallpaper applies immediately and rides the debounced save: the choice is one field + // on the workspace, not its own persistence path. const pickWallpaper = useCallback( (w: Wallpaper) => { wallpaperRef.current = w; @@ -478,9 +532,7 @@ export default function App() { [scheduleSave], ); - // Moving the seat is one write. Nothing is spawned or killed here: the seat is a role, so taking - // it from a node leaves that node running exactly as it was, just no longer the one the command - // bar talks to. + // Moving the seat is one write. Nothing is spawned or killed here: the seat is a role. const assignSeat = useCallback( (nodeId: string | null) => { seatRef.current = nodeId; @@ -490,262 +542,88 @@ export default function App() { [scheduleSave], ); - // Closing inside the debounce window drops whatever was moved last, and dragging a node and then - // quitting is a completely ordinary thing to do. I hold the close, flush, then let it go. + // ── the keyboard ──────────────────────────────────────────────────────────── // - // The question is "is there work not on disk", which is why it asks `unsaved` and not the timer: - // clearTimeout does not reset the handle, so a timer ref is only ever undefined before the very - // first save and would answer "yes, pending" forever after. If the flush fails the error is - // already on screen, and I still close, because refusing to quit over a failed save traps someone - // in an app they are trying to leave. - // One close request owns the exit. A user who clicks close twice while the flush runs must - // not start a second save or, worse, race two destroys; and whatever the save does, the window - // has to actually go, because an app that refuses to close is holding its user hostage over a - // write they cannot see. A tester on macOS hit exactly that wedge. - // What to say when the window refuses to go. Shares the save banner because it is the same kind - // of message — something the app cannot fix, that the user is the only one who can act on, and - // that must stay on screen rather than being mentioned once. - const reportStuck = useCallback((e: unknown) => { - setSaveError( - `Identra could not close itself: ${String(e)}. Close the window from your desktop instead.`, - ); + // Every pane in this shell is a terminal that takes keys first, so these are captured on the + // window the same way the quit shortcut is. A shortcut that only works when nothing has focus is + // not a shortcut here: there is almost always a terminal with focus. + // + // This is the whole v0.2.0 set, and it is deliberately small: switch tab, walk the panes, split, + // close a pane, reach the right column, quit. A command palette and drag-to-reorder are additions + // to a shell that exists, and neither is cheaper to decide now. + const showInFocused = useCallback((nodeId: string) => { + setTree((cur) => setNode(cur, focusLeafRef.current, nodeId)); }, []); - const closing = useRef(false); - useEffect(() => { - const win = getCurrentWindow(); - const pending = win.onCloseRequested(async (event) => { - // Asked twice. The first request is still flushing, and the honest reading of a second click - // is "I want out now", so it goes now. This used to preventDefault and return, which was fine - // exactly as long as the first request always reached its destroy. When it did not, the flag - // stayed latched and every close from then on was refused: the window could not be shut at - // all and the only way out was killing the process. - if (closing.current) { - void win.destroy().catch(reportStuck); - return; - } - if (!unsaved.current) return; - event.preventDefault(); - closing.current = true; - // Bounded, and that bound is the point. `canvas_save` crosses IPC into the engine, and an - // engine that is wedged must not take the window with it: flushing on close exists to save - // the user's layout, not to make quitting conditional on a write succeeding. Whatever - // happens in the next couple of seconds, the window goes. - await Promise.race([ - saveNow().catch(() => {}), - new Promise((resolve) => setTimeout(resolve, CLOSE_FLUSH_MAX_MS)), - ]); - // Same story as the button: a destroy that is refused used to vanish, and this is the path a - // title bar cross and alt+F4 both come down. The window stayed open, the flag stayed latched, - // and nothing anywhere said a permission had been denied. - void win.destroy().catch(reportStuck); - }); - return () => { - void pending.then((unlisten) => unlisten()); - }; - }, [saveNow, reportStuck]); + const splitFocused = useCallback(() => { + const id = `pane-${crypto.randomUUID()}`; + setTree((cur) => splitLeaf(cur, focusLeafRef.current, id, "row")); + setFocusLeaf(id); + }, []); - // Ask the window to close, and say so out loud if it will not. - // - // The `.catch` is the whole reason this bug survived several rounds of being fixed. Window close - // and destroy are permissioned in Tauri, `core:window:default` grants neither, and every call - // site here was `void win.close()` with nothing watching. So the rejection went nowhere: the - // button did nothing, alt+F4 did nothing, and there was no error anywhere to say why. A silent - // failure on the one control that has to work is worse than a loud one, and this is now loud. - const closeIdentra = useCallback(async () => { - try { - await getCurrentWindow().close(); - } catch (e) { - reportStuck(e); - } - }, [reportStuck]); + const closeFocusedPane = useCallback(() => { + const going = focusLeafRef.current; + const rest = leaves(treeRef.current).filter((l) => l.id !== going); + if (rest.length === 0) return; // the last pane stays; there would be nowhere to go + setTree((cur) => closeLeaf(cur, going)); + setFocusLeaf(rest[0]!.id); + }, []); - // Ctrl+Q / Cmd+Q, because that is the key people press to leave an app and Identra was not - // listening for it. - // - // Every exit this app had came from the window manager: a cross on a title bar, or alt+F4 handled - // by the desktop. Neither is something an app can count on. A desktop that draws no decorations, - // a window that never gets the keystroke because a terminal inside it swallowed it first, and the - // only way out left is a process list — which is where this actually ended up. - // - // Capturing on window, same as the command bar's shortcut and for the same reason: every node on - // this canvas is a terminal that takes keys first, and a quit shortcut that only works when - // nothing has focus is not a quit shortcut. It raises the ordinary close request, so the flush - // and the agent teardown are the same ones the button and the title bar go through. useEffect(() => { const key = (e: KeyboardEvent) => { - if (e.key !== "q" && e.key !== "Q") return; if (!(e.metaKey || e.ctrlKey) || e.altKey) return; - e.preventDefault(); - e.stopPropagation(); - void closeIdentra(); + const take = () => { + e.preventDefault(); + e.stopPropagation(); + }; + if (e.key === "q" || e.key === "Q") { + // Ctrl+Q / Cmd+Q, because that is the key people press to leave an app. It raises the + // ordinary close request, so the flush is the same one the title bar goes through. + take(); + void closeIdentra(); + return; + } + if (e.key >= "1" && e.key <= "9") { + const at = Number(e.key) - 1; + const node = nodesRef.current[at]; + if (node === undefined) return; + take(); + showInFocused(node.id); + return; + } + if (e.key === "]" || e.key === "[") { + take(); + setFocusLeaf((cur) => + stepLeaf(treeRef.current, cur, e.key === "]" ? 1 : -1), + ); + return; + } + if (e.key === "\\") { + take(); + if (e.shiftKey) closeFocusedPane(); + else splitFocused(); + return; + } + if (e.key === "e" || e.key === "E") { + take(); + setRight((cur) => (cur === null ? "files" : null)); + } }; window.addEventListener("keydown", key, true); return () => window.removeEventListener("keydown", key, true); - }, [closeIdentra]); - - const onNodesChange = useCallback( - (changes: NodeChange[]) => { - setNodes((cur) => { - const next = applyNodeChanges(changes, cur); - nodesRef.current = next; - scheduleSave(); - return next; - }); - }, - [scheduleSave], - ); - - const onEdgesChange = useCallback( - (changes: EdgeChange[]) => { - setEdges((cur) => { - const next = applyEdgeChanges(changes, cur); - edgesRef.current = next; - scheduleSave(); - return next; - }); - }, - [scheduleSave], - ); - - // Drawing a wire onto an agent that is already running does nothing, and used to do nothing - // silently. - // - // An edge is the permission to share context, and it is read once: a CLI loads its MCP servers at - // startup, so a wire drawn afterwards is real on the canvas, saved to the canvas, and completely - // inert until that node next starts. The README has said "draw the wire, then launch" since the - // feature shipped. The app never said it, so the honest reading of the user's experience is that - // they wired two agents, watched nothing happen, and concluded the bus was broken. - // - // The wire says it itself rather than a message saying it. This codebase has no toast surface and - // says so twice in its own stylesheet, and it is right: a line that appears and goes is the wrong - // shape for a fact that stays true until you relaunch. A dashed edge that reads "connects at next - // launch" is the state, drawn where the user is already looking, for exactly as long as it holds. - // - // The mark is deliberately not persisted. `snapshot` writes edges as id, source and target only, - // so reopening the workspace drops it — which is correct, because reopening relaunches the agents - // and the wire genuinely does take effect. The one gap is relaunching a single node mid-session: - // the mark goes stale until the next reload. Closing that would mean this component tracking every - // node's launches, and a slightly stale hint is a far smaller cost than no hint at all, which is - // what shipped. - const onConnect = useCallback( - (c: Connection) => { - // The status probe first, so the edge is drawn already marked rather than flickering from - // plain to dashed. It is one local IPC call, and a null status means a node that has never - // started, which is the case where the wire works normally. - void (async () => { - let inert = false; - try { - const ends = [c.source, c.target].filter((id): id is string => id !== null); - const statuses = await Promise.all( - ends.map((id) => terminalStatus(id).catch(() => null)), - ); - inert = statuses.some((s) => s !== null); - } catch { - // A probe that will not answer must not cost the user their wire. Draw it plain: the - // worst case is the old behaviour, and the wire itself is still saved either way. - inert = false; - } - setEdges((cur) => { - const next = addEdge( - inert - ? { - ...c, - label: "connects at next launch", - style: { strokeDasharray: "6 4" }, - className: "identra-edge--inert", - } - : c, - cur, - ); - edgesRef.current = next; - scheduleSave(); - return next; - }); - })(); - }, - [scheduleSave], - ); - - // React Flow has already taken the node off the canvas by the time this runs, so this is where - // the engine side goes: the PTY, the resumed conversation, and the node's bus credential. A node - // that never launched has no terminal to kill and the engine says so; that is not a failure worth - // showing anyone, but it must not become an unhandled rejection either. - const onNodesDelete = useCallback((deleted: FNode[]) => { - for (const n of deleted) { - void terminalKill(n.id).catch((err) => { - console.warn(`could not close node ${n.id} cleanly`, err); - }); - } - // No seat to vacate here. The orchestrator is headless, so deleting a node can never be the - // thing that takes the command center's agent away; it goes when its process does, which the - // liveness check at dispatch notices on its own. - // A focus view over a node that just went is a window onto nothing; back to the canvas. - setFocused((cur) => - cur !== null && deleted.some((n) => n.id === cur) ? null : cur, - ); - }, []); + }, [closeIdentra, showInFocused, splitFocused, closeFocusedPane]); - // Returns the new node's id, because an agent that asked for this needs to be able to name it. - const addNode = useCallback( - ( - kind: string, - title: string, - cwd: string | null = null, - at?: { x: number; y: number }, - ) => { - const vp = viewport.current; - // Drop the node near the middle of what's currently on screen. - const spot = at ?? { - x: (-vp.x + window.innerWidth / 2 - DEFAULT_W / 2) / vp.zoom, - y: (-vp.y + window.innerHeight / 2 - DEFAULT_H / 2) / vp.zoom, - }; - const id = crypto.randomUUID(); - setNodes((cur) => { - const next = [ - ...cur, - toFlow({ - id, - kind, - x: spot.x, - y: spot.y, - width: DEFAULT_W, - height: DEFAULT_H, - title, - cwd, - locked: false, - }), - ]; - nodesRef.current = next; - scheduleSave(); - return next; - }); - return id; - }, - [scheduleSave], - ); - - // The command center. One instruction goes to one node, and that node already holds every bus - // tool it needs to break the work up and hand it out, so this adds no new mechanism: it is the - // canvas typing into a terminal on the user's behalf. + // ── the command center ────────────────────────────────────────────────────── const [dispatch, setDispatch] = useState({ kind: "idle" }); - // No transcript state here any more. The pane attaches to the seat's PTY itself and replays it - // from the engine's ring buffer, so what the orchestrator said while the user was looking - // somewhere else is on screen because the engine kept it, not because this component was - // accumulating a copy in memory. // The seat is briefed once per session, in front of the first instruction it receives. Kept in a // ref rather than state because nothing renders from it and it must not be stale inside the async // dispatch below. const seatBriefed = useRef(false); - // The poll that used to live here waited for AgentNode to mount, measure a terminal and start the - // CLI, because the seat was a node and that was the only way its PTY came into being. A headless - // seat starts its own PTY and `terminal_start` has spawned the process by the time it returns, so - // there is nothing left to wait for. const sendToSeat = useCallback( async (instruction: string) => { - // Liveness, not canvas membership: the seat is headless and never appears as a node, so the - // only question that means anything is whether its process is still there. + // Liveness, not membership: the seat is headless and never appears as a tab, so the only + // question that means anything is whether its process is still there. const current = seatRef.current; const alive = current === null @@ -780,14 +658,9 @@ export default function App() { note: `Starting ${agent?.name ?? plan.agentId} as the orchestrator`, }); // The orchestrator is headless on purpose. It runs a real CLI, because that is the only - // thing that can actually do the work, but it never becomes a node: putting it on the - // canvas made the command center a remote control for a terminal the user then had to go - // and read. The conversation belongs in the bar they typed into. - // - // Starting the PTY here rather than letting a node component do it is what makes that - // possible, and it also removes the wait that used to be needed: `terminal_start` has - // spawned the process by the time it returns, so there is no window where the seat exists - // on the canvas but has nothing behind it. + // thing that can actually do the work, but it never becomes a tab: making it one turned the + // command center into a remote control for a terminal the user then had to go and read. The + // conversation belongs in the bar they typed into. const spec = (await agentsByKind()).get(plan.agentId); if (!spec || !spec.available) { setDispatch({ @@ -814,10 +687,8 @@ export default function App() { }); return; } - // A new orchestrator is a new conversation, and the pane is keyed on this id, so assigning - // it is also what tears the last one's terminal down and mounts a fresh one. Leaving the - // previous seat's screen above the new one would read as a session that had suddenly - // forgotten itself. + // A new orchestrator is a new conversation, and the bar's pane is keyed on this id, so + // assigning it is also what tears the last one's terminal down and mounts a fresh one. assignSeat(nodeId); setSeatAgent(spec.name); fresh = true; @@ -844,8 +715,6 @@ export default function App() { note: "Sent. Its reply appears here as it works.", }); } catch (e) { - // The seat node was closed, or its agent has exited. Either way the instruction did not - // land, and the user is the only one who can do anything about it. setDispatch({ kind: "failed", error: `That did not reach the orchestrator: ${String(e)}`, @@ -858,13 +727,11 @@ export default function App() { // What the seat is doing, shown next to the bar so the user does not have to read a scrolling // terminal to know whether anything came of what they typed. const [plan, setPlan] = useState(null); - const [seatAsking, setSeatAsking] = useState(false); // Polled rather than pushed, and only while a seat exists. The board is written by agents through // the bus and the seat's status is read from output timing, so neither has an event to subscribe - // to. Two cheap reads every few seconds is the honest cost of showing this at all, and it stops - // entirely when there is no seat. + // to. Two cheap reads every few seconds is the honest cost of showing this at all. useEffect(() => { if (seat === null) { setPlan(null); @@ -874,8 +741,7 @@ export default function App() { let dropped = false; const poll = async () => { // Both are best effort. The board can be mid-write and the seat can be closed between the - // check and the call, and neither is worth a visible error: the strip just keeps its last - // reading until the next tick. + // check and the call, and neither is worth a visible error. const tasks = await boardList().catch(() => null); const status = await terminalStatus(seat).catch(() => null); if (dropped) return; @@ -890,7 +756,7 @@ export default function App() { }; }, [seat]); - // The first fact a workspace ever learns opens the panel once, on the memory tab, so the moment + // The first fact a workspace ever learns opens the right column once, on memory, so the moment // the promise becomes true is seen and not buried under the terminals. memory_reveal_once returns // true on exactly one call per workspace, ever, so this cannot re-fire on a later fact or a later // session; the ref only keeps it from asking the engine on every poll tick this session. @@ -906,11 +772,7 @@ export default function App() { if (list.length > 0 && !revealAsked.current) { revealAsked.current = true; const first = await memoryRevealOnce().catch(() => false); - if (first && !dropped) { - setFilesOpen(false); - setPanelTab("memory"); - setPanelOpen(true); - } + if (first && !dropped) setRight("work"); } }; void tick(); @@ -921,95 +783,63 @@ export default function App() { }; }, [workspace]); - const wire = useCallback( - (from: string, to: string) => { - setEdges((cur) => { - const next = addEdge( - { source: from, target: to, sourceHandle: null, targetHandle: null }, - cur, - ); - edgesRef.current = next; - scheduleSave(); - return next; - }); - }, - [scheduleSave], - ); - - // Dropping a file from the OS onto the canvas opens it in a viewer node at the drop point. - // This is the user's own door to the viewer; the engine still refuses anything outside the - // workspace, and the node shows that refusal rather than this handler pre-judging it. + // Dropping a file from the OS onto the shell opens it as a tab. This is the user's own door to + // the viewer; the engine still refuses anything outside the workspace, and the pane shows that + // refusal rather than this handler pre-judging it. const workspaceOpenRef = useRef(false); workspaceOpenRef.current = workspace !== null; useEffect(() => { const un = getCurrentWebview().onDragDropEvent((event) => { - // Before a workspace is open there is no canvas to put a node on, and no folder for the - // containment rule to mean anything against. + // Before a workspace is open there is nowhere to put a tab, and no folder for the containment + // rule to mean anything against. if (event.payload.type !== "drop" || !workspaceOpenRef.current) return; - const { paths, position } = event.payload; - // Physical pixels from the webview, logical in the browser, flow coords on the board. - const dpr = window.devicePixelRatio || 1; - const vp = viewport.current; - paths.forEach((path, i) => { - const name = path.split("/").pop() ?? path; - addNode("file", name, path, { - x: (position.x / dpr - vp.x) / vp.zoom + i * 28, - y: (position.y / dpr - vp.y) / vp.zoom + i * 28, - }); - }); + for (const path of event.payload.paths) { + addNode("file", path.split("/").pop() ?? path, path); + } }); return () => { void un.then((f) => f()); }; }, [addNode]); - // The click on a dev node's address badge. A browser node opens beside the server, wired to it - // so the pair reads as one thing. Clicking again stacks nothing: if a browser node is already - // showing that address, there is nothing left to offer. + // A dev server announcing its address. A browser tab opens on it, wired to the server so the pair + // reads as one thing. Asking twice stacks nothing: if a browser tab is already showing that + // address, there is nothing left to offer. const openPreview = useCallback( (devId: string, url: string) => { - const already = nodesRef.current.some( - (n) => n.data.kind === "browser" && n.data.cwd === url, - ); - if (already) return; - const dev = nodesRef.current.find((n) => n.id === devId); - const at = dev - ? { - x: dev.position.x + (Number(dev.style?.width) || DEFAULT_W) + 60, - y: dev.position.y, - } - : undefined; - const browserId = addNode("browser", "Preview", url, at); - if (dev) wire(devId, browserId); + if (nodesRef.current.some((n) => n.kind === "browser" && n.cwd === url)) { + return; + } + const browserId = addNode("browser", "Preview", url); + wire(devId, browserId); }, [addNode, wire], ); - // An agent asking the canvas to change. The canvas is the single writer of its own state, so the + // An agent asking the shell to change. The window is the single writer of its own state, so the // engine sends the request here rather than editing canvas.json underneath us, and we answer. // Every branch must reply exactly once: an agent is blocked on this until it hears back. // // This is also the one place a node's lock is enforced, and it is the right place: it is the only - // door an agent has onto the canvas. The user's own drags go through onConnect and are never - // checked, which is the intended asymmetry. + // door an agent has. The user's own grants never come through here, which is the intended + // asymmetry. const applyCanvasCommand = useCallback( (cmd: CanvasCommand): CanvasResult => { const p = cmd.params; const locked = (id?: string) => - nodesRef.current.some((n) => n.id === id && n.data.locked === true); + nodesRef.current.some((n) => n.id === id && n.locked); // Named, so the agent can tell the user which node it was and they can decide, rather than // just reporting that something was refused. const lockedReason = (id: string) => { - const name = - nodesRef.current.find((n) => n.id === id)?.data.title ?? id; - return `${name} is locked, so it cannot be wired to by an agent. The person at the keyboard can unlock it or wire it themselves.`; + const name = nodesRef.current.find((n) => n.id === id)?.title ?? id; + return `${name} is locked, so an agent cannot connect to it. The person at the keyboard can unlock it or connect it themselves.`; }; switch (cmd.action) { case "add_terminal": { const kind = typeof p.kind === "string" ? p.kind : "codex"; const known = agentsRef.current.find((a) => a.id === kind); - // Refuse rather than drop a node that can never run. The agent gets a reason it can act - // on, which is better than a broken node appearing on the user's canvas. + // Refuse rather than open a tab that can never run. The agent gets a reason it can act + // on, which is better than a broken tab appearing in the user's shell. if (!known) return { ok: false, @@ -1021,13 +851,8 @@ export default function App() { error: `${known.name} is not installed on this machine`, }; } - // Place a spawned node below its parent so a fan-out reads as a tree, not a pile. - const parent = nodesRef.current.find((n) => n.id === p.connectTo); - const at = parent - ? { x: parent.position.x, y: parent.position.y + DEFAULT_H + 60 } - : undefined; - // Refuse before spawning, not after. Creating the node and then failing to wire it would - // leave a stray agent running on the user's canvas that nobody asked for and nobody owns. + // Refuse before spawning, not after. Opening the tab and then failing to connect it would + // leave a stray agent running that nobody asked for and nobody owns. if ( typeof p.connectTo === "string" && p.connectTo && @@ -1037,7 +862,7 @@ export default function App() { } const title = typeof p.title === "string" && p.title ? p.title : known.name; - const id = addNode(kind, title, null, at); + const id = addNode(kind, title); if (typeof p.connectTo === "string" && p.connectTo) wire(p.connectTo, id); return { ok: true, id }; @@ -1047,23 +872,20 @@ export default function App() { const has = (id?: string) => nodesRef.current.some((n) => n.id === id); // Checking for undefined here as well as membership is what narrows both to a string for - // the rest of the branch, so the wire call below needs no cast to say what it already - // knows. + // the rest of the branch. if ( from === undefined || to === undefined || !has(from) || !has(to) ) { - return { - ok: false, - error: "one of those nodes is not on the canvas", - }; + return { ok: false, error: "one of those is not open here" }; } if (from === to) - return { ok: false, error: "a node cannot be wired to itself" }; + return { ok: false, error: "a node cannot be connected to itself" }; // Either end being locked is enough to refuse. An edge is the bus authorization and it - // reads both ways, so wiring out of a locked node exposes it exactly as much as wiring in. + // reads both ways, so connecting out of a locked node exposes it exactly as much as + // connecting in. if (locked(from)) return { ok: false, error: lockedReason(from) }; if (locked(to)) return { ok: false, error: lockedReason(to) }; wire(from, to); @@ -1077,12 +899,12 @@ export default function App() { } case "show_file": { const path = typeof p.path === "string" ? p.path : ""; - if (!path) return { ok: false, error: "a file node needs a path" }; + if (!path) return { ok: false, error: "a file tab needs a path" }; const title = typeof p.title === "string" && p.title ? p.title : (path.split("/").pop() ?? "file"); - // Same lock rule as every other wire an agent asks for, checked before the node + // Same lock rule as every other connection an agent asks for, checked before the tab // exists so a refusal leaves nothing behind. if ( typeof p.connectTo === "string" && @@ -1091,19 +913,7 @@ export default function App() { ) { return { ok: false, error: lockedReason(p.connectTo) }; } - // Artifacts fan right of the agent that made them; helpers fan down. The two reading - // directions are what keep a busy board legible. - const parent = nodesRef.current.find((n) => n.id === p.connectTo); - const at = parent - ? { - x: - parent.position.x + - (Number(parent.style?.width) || DEFAULT_W) + - 60, - y: parent.position.y, - } - : undefined; - const id = addNode("file", title, path, at); + const id = addNode("file", title, path); if (typeof p.connectTo === "string" && p.connectTo) wire(p.connectTo, id); return { ok: true, id }; @@ -1111,7 +921,7 @@ export default function App() { default: return { ok: false, - error: `the canvas does not know how to ${cmd.action}`, + error: `Identra does not know how to ${cmd.action}`, }; } }, @@ -1123,13 +933,8 @@ export default function App() { // Subscribing on `applyCanvasCommand` looked right and is a trap. Its identity changes whenever // anything it closes over does, and the unlisten is a promise, so a re-subscribe is: register the // new listener, then await the old one's teardown. A command arriving inside that window is - // delivered to both, and both act on it — an agent's `add_terminal` becomes two nodes on the - // canvas and its `connect_nodes` wires twice, with no error anywhere to explain it. - // - // It has not happened yet only because the dependency chain happens to bottom out somewhere - // stable today. That is not a property anyone can see from here, and the first innocuous change - // that makes `addNode` re-created per render turns it into a duplicating canvas. A ref costs one - // line and takes the whole class off the table. + // delivered to both, and both act on it — an agent's `add_terminal` becomes two tabs and its + // `connect_nodes` connects twice, with no error anywhere to explain it. const applyRef = useRef(applyCanvasCommand); applyRef.current = applyCanvasCommand; useEffect(() => { @@ -1148,29 +953,8 @@ export default function App() { }; }, []); - // The seat is canvas state, not node state, so it is stamped onto the nodes at render rather than - // stored in them. That keeps one seat id as the only truth, and it keeps `seat` out of what gets - // written back to canvas.json as part of a node. - const flowNodes = useMemo( - () => - nodes.map((n) => { - const data = { - ...n.data, - onToggleLock: toggleLock, - onOpenPreview: openPreview, - onFocus: setFocused, - }; - // No seat stamping here any more: the orchestrator is headless, so no node on this canvas - // is ever the seat. Every node here is work the user placed themselves. - return { ...n, data }; - }), - [nodes, toggleLock, openPreview], - ); - - // Held in state rather than read off a node, because the seat no longer is one. It lives for the - // session only, which is all it needs to: terminals die with the process, so a seat id restored - // from a saved canvas has nothing running behind it and the next instruction stands a fresh one - // up anyway. + // Held in state rather than read off a node, because the seat is not one. It lives for the + // session only, which is all it needs to. const seatName = seat === null ? null : seatAgent; if (!workspace) { @@ -1182,360 +966,429 @@ export default function App() { ); } + const paneCount = leaves(tree).length; + return ( -
- {/* The wallpaper is a layer behind the flow, not the flow's own background, so the grid - dots and the nodes always sit above it. data-scrim pulls a user image toward the app - background; the built-ins and swatches are curated dark values and need no help. */} +
+ {/* Behind the columns rather than under nodes: it shows at the edges and through the gaps, + which is as much of a wallpaper as a shell has room for. data-scrim pulls a user image + toward the app background; the built-ins and swatches are curated dark values. */}
- - nodes={flowNodes} - edges={edges} - nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - onConnect={onConnect} - onNodesDelete={onNodesDelete} - onPaneContextMenu={(e) => { - // Right-clicking the empty canvas is where you change what the empty canvas looks - // like. The browser menu would cover ours, so it goes. + + - {panelOpen && ( - setPanelOpen(false)} /> - )} - {filesOpen && ( - setFilesOpen(false)} - onOpenFile={(rel, name) => { - // The panel speaks workspace-relative; the viewer node stores the absolute path, - // same as every other door to it, so a saved canvas needs no second path shape. - addNode("file", name, `${workspace.path}/${rel}`); - }} - /> - )} - {settingsOpen && setSettingsOpen(false)} />} - {focused !== null && - (() => { - // Resolved at render rather than stored, so a renamed node's focus bar follows it. - const node = nodes.find((n) => n.id === focused); - return node === undefined ? null : ( - { - setFocused(null); - // Hand the pty's size back to the node on the canvas. Closing this overlay does not - // change that node's box, so nothing it watches fires, and it would go on drawing - // an agent wrapped for a full window it is no longer in. - window.dispatchEvent(new Event(REFIT_EVENT)); - }} +
+ {saveError !== null && ( + // It stays until a save works. Work that is not on disk is not a thing to mention once + // and then hide: everything from here is work that will not be there tomorrow, and the + // user is the only one who can do anything about a full disk or a read-only folder. +
+ This workspace is not being saved. {saveError} +
+ )} + {canvasNotice && ( + // Said once, on the first open after the canvas went away. Ten months of arrangement + // disappearing into a layout the user did not choose, with nothing saying it was + // deliberate, reads as a corrupted file rather than a new version. +
+ + Your agents are still here, as tabs. The arrangement is not — that + is deliberate. Everything you connected stayed connected. + + +
+ )} + +
+ {nodes.map((n, i) => ( + l.nodeId === n.id)} + onOpen={() => showInFocused(n.id)} + onClose={() => closeNode(n.id)} /> - ); - })()} + ))} + {nodes.length > 0 && ( + + )} +
- {/* Above the dock, because the dock is how you place one agent yourself and this is how you - ask for the whole job to be done. Hidden until an agent exists to run it: on a machine - with nothing installed the onboarding panel is the thing to read, and a command bar that - can only fail is worse than no command bar. */} - {!noAgentsInstalled(agents) && ( - - void sendToSeat(instruction).catch((e: unknown) => - setDispatch({ - kind: "failed", - error: `The instruction did not go anywhere: ${String(e)}`, - }), +
+ {nodes.length === 0 ? ( + // A blank shell reads as a broken app. With no agent installed the sidebar is all + // disabled, so the usual hint would point at controls you cannot use. + noAgentsInstalled(agents) ? ( + + ) : ( +
+

This workspace is empty.

+ {/* Two ways in, in the order they are worth trying. Saying what you want is the + whole product and it is one keystroke away, so it leads; the sidebar is the + manual path for when you already know which agent you want. */} +

+ Say what you want done in the bar below — press{" "} + {MOD_LABEL} from + anywhere — and an orchestrator breaks the work up and opens + the agents it needs. +

+

+ Or open one yourself from the left. Open a second, connect + them, and they can split the work between themselves. +

+
) - } + ) : ( + { + const rest = leaves(tree).filter((l) => l.id !== id); + if (rest.length === 0) return; + setTree((cur) => closeLeaf(cur, id)); + setFocusLeaf(rest[0]!.id); + }} + closable={paneCount > 1} + onSetCwd={setNodeCwd} + onPreviewUrl={openPreview} + /> + )} +
+ + {/* Hidden until an agent exists to run it: on a machine with nothing installed the + onboarding panel is the thing to read, and a command bar that can only fail is worse + than no command bar. */} + {!noAgentsInstalled(agents) && ( + + void sendToSeat(instruction).catch((e: unknown) => + setDispatch({ + kind: "failed", + error: `The instruction did not go anywhere: ${String(e)}`, + }), + ) + } + /> + )} +
+ + + + {wallMenu !== null && ( + setWallMenu(null)} /> )} + {settingsOpen && setSettingsOpen(false)} />} +
+ ); +} -
- {agents.map((a) => { - const state = a.available - ? a.logged_in - ? "ready" - : "setup" - : "missing"; - return ( - - ); - })} - -
+// One tab. It is the node's whole existence in the shell: open it into a pane, or close it for +// good. The dot is here as well as on the pane because a tab you are not looking at is exactly the +// one you want to know has stopped. +function Tab({ + node, + index, + active, + onOpen, + onClose, +}: { + node: CanvasNode; + index: number; + active: boolean; + onOpen: () => void; + onClose: () => void; +}) { + const terminal = + node.kind !== "browser" && node.kind !== "file" && node.kind !== "note"; + return ( +
+ +
); } + +function TabDot({ nodeId }: { nodeId: string }) { + const state = useNodeState(nodeId); + return ; +} + +// The split tree, drawn. Flex the whole way down, so a split is a box beside a box and the browser +// does the arithmetic. Ratios are not stored or dragged in v0.2.0: an even split is what a split +// means until someone has asked for it to mean something else. +function PaneTreeView({ + pane, + nodes, + focusLeaf, + onFocusLeaf, + onClosePane, + closable, + onSetCwd, + onPreviewUrl, +}: { + pane: PaneTree; + nodes: CanvasNode[]; + focusLeaf: string; + onFocusLeaf: (id: string) => void; + onClosePane: (id: string) => void; + closable: boolean; + onSetCwd: (nodeId: string, cwd: string) => void; + onPreviewUrl: (nodeId: string, url: string) => void; +}) { + if (pane.kind === "split") { + return ( +
+ {[pane.a, pane.b].map((half) => ( + + ))} +
+ ); + } + const node = nodes.find((n) => n.id === pane.nodeId); + if (node === undefined) { + return ( +
onFocusLeaf(pane.id)} + > +

Pick a tab to show it here.

+
+ ); + } + return ( + onFocusLeaf(pane.id)} + onClosePane={() => onClosePane(pane.id)} + closable={closable} + onSetCwd={onSetCwd} + onPreviewUrl={onPreviewUrl} + /> + ); +} diff --git a/apps/identra-desktop/frontend/src/BoardPreview.tsx b/apps/identra-desktop/frontend/src/BoardPreview.tsx index 428a350..4a835c9 100644 --- a/apps/identra-desktop/frontend/src/BoardPreview.tsx +++ b/apps/identra-desktop/frontend/src/BoardPreview.tsx @@ -1,70 +1,44 @@ -// A board in miniature: the workspace's wallpaper with its nodes drawn as small shapes, edges -// and all. Drawn from the canvas data, never captured, so it costs one layout pass and works for -// a workspace that has never been on screen. +// What a workspace tile draws now that there is no board to draw. // -// The layout is computed at the given size and the svg carries a viewBox, so CSS can stretch the -// same drawing: the picker row keeps it at postage-stamp size, the home grid shows it as a card. -import { convertFileSrc } from "@tauri-apps/api/core"; +// This used to be the arrangement in miniature: nodes as rectangles where the user had put them, +// wires and all. That thumbnail was worth having because the arrangement was the thing a person +// recognised their project by. With the canvas gone there is no arrangement, and drawing the same +// rectangles out of a file where every position is the origin would put one identical stack of +// boxes on every tile in the picker — the same picture for every project, which is worse than no +// picture at all. +// +// So the tile says what is actually in there: which agents this workspace has open, in their own +// colours, and how many. That differs between projects and it is something a person can recognise, +// which was the whole job the thumbnail was doing. import type { Canvas } from "./api"; -import { auraFor } from "./icons"; -import { previewLayout } from "./preview"; -import { backgroundCss, needsScrim } from "./wallpaper"; +import { AgentIcon } from "./icons"; type Props = { canvas: Canvas; - width?: number; - height?: number; className?: string; }; +// How many icons fit before the tile stops being a glance. Past this it becomes a count, which +// reads faster than nine overlapping squares anyway. +const SHOWN = 4; + export default function BoardPreview({ canvas, - width = 96, - height = 60, className = "identra-preview", }: Props) { - const rects = previewLayout(canvas.nodes, width, height); - const byId = new Map(rects.map((r) => [r.id, r])); + // Agents and dev servers only. A file viewer, a note and a web view are things the workspace + // holds, not things running in it, and the question this tile answers is what is going on here. + const running = canvas.nodes.filter( + (n) => n.kind !== "file" && n.kind !== "note" && n.kind !== "browser", + ); return ( -