diff --git a/README.md b/README.md index c5d142d..4fff63f 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # Identra -**A desktop canvas for running coding agents.** +**A desktop shell for running coding agents, with a memory they open already holding.** -You drop an agent onto the board, it runs in a real terminal inside a node, you wire nodes -together, and Identra keeps a memory of what happened so the next agent you open already -knows the project. +You open an agent in a tab, it runs in a real terminal, you connect the ones that should read +each other's work, and Identra keeps a memory of what happened — which the next agent receives +in its opening handshake, without calling a tool to ask. [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-E95420.svg)](#requirements) @@ -23,7 +23,7 @@ knows the project. -Rust engine, Tauri shell, React canvas. Apache-2.0. +Rust engine, Tauri shell, React front end. Apache-2.0. --- @@ -78,13 +78,13 @@ claude --version # if this works, Identra can run it ``` Identra also runs [codex](https://github.com/openai/codex), gemini, and opencode, and you can -mix them on one canvas. Claude Code is the one to start with: a project's memory reaches an +mix them in one workspace. Claude Code is the one to start with: a project's memory reaches an agent over the MCP handshake, and it is the CLI that surfaces what arrives there, so it is the one where an agent opens already knowing what the project has learned. The others work, they just start that first session blank. -Without any of them a node still opens. It names the agents you do have rather than pretending -to work, but you will not see one run. +Without any of them a tab still opens. It names the agents you do have rather than pretending to +work, but you will not see one run. ## Why i built this @@ -94,29 +94,30 @@ approach we already tried and threw away, the constraints that are not written d The agent is sharp in the moment and blank the next morning. So the context ends up scattered across a dozen terminal tabs and my own head, and none of it -survives. Identra is my fix for that. The canvas is where the work happens. The memory layer -sits underneath it and quietly keeps the parts worth keeping, then hands them back to the next -agent so nobody starts from zero. +survives. Identra is my fix for that. The shell is where the work happens. The memory layer sits +underneath it and quietly keeps the parts worth keeping, then hands them back to the next agent +so nobody starts from zero. ## What it does -- Drop an agent node on an infinite canvas. It spawns a real `claude` or `codex` process in a - terminal you can type into. No shell wrapper, no fake sandbox, the actual CLI on your machine. +- Open an agent from the sidebar and it becomes a tab. It spawns a real `claude` or `codex` + process in a terminal you can type into. No shell wrapper, no fake sandbox, the actual CLI on + your machine. Split the pane to watch two of them at once. - Type what you want done into the command bar (`⌘K` / `Ctrl+K` from anywhere, including from - inside a node's terminal) and an orchestrator seat breaks the work up, - spawns the helper nodes it needs, wires them, and puts the pieces on a shared task board you - can watch. Wired agents message each other, split work, and hand results back over a local - bus. An edge between two nodes is the permission: no wire, no shared context. + inside an agent's terminal) and an orchestrator seat breaks the work up, opens the helpers it + needs, connects them, and puts the pieces on a shared task board you can watch. Connected + agents message each other, split work, and hand results back over a local bus. The connection + is the permission: no connection, no shared context. Every one of them is listed under Links, + including any an agent made for itself, and any of them can be revoked there. - A workspace is a folder, usually your repo. Open one you have, make an empty one, or paste a - git URL and Identra clones it. Each workspace keeps its own canvas, board, memory, and - wallpaper, and the picker shows each board at a glance. -- Press Run and Identra starts your project's own dev command in a node, reads the local URL - off the server's banner, and one click opens the page in a browser node beside it. -- Reading a conversation in a tile is squinting, so every node expands to the full window over - the same terminal. Drop a file on the canvas to view it, browse and search the workspace from - the Files panel, and agents can open a file node themselves to show you what they made. -- The canvas saves itself. Close the app, open it next week, your nodes, layout, and each - agent's own conversation come back exactly where you left them. + git URL and Identra clones it. Each workspace keeps its own agents, board, memory, and + wallpaper, and the picker shows what is open in each at a glance. +- Open the dev server and Identra starts your project's own dev command in a tab, reads the + local URL off the server's banner, and one click opens the page in a browser tab beside it. +- Drop a file on the window to view it in a tab, browse and search the workspace from the Files + column, and agents can open a file themselves to show you what they made. +- The workspace saves itself. Close the app, open it next week, your agents, your connections + and each agent's own conversation come back where you left them. - A memory layer watches your sessions and pulls out the durable facts: decisions, rejected directions, conventions. When you open a fresh agent in a project Identra already knows, it shows what it remembers before the agent's first prompt. @@ -130,8 +131,8 @@ per workspace. ### How much agents do without asking -An approval prompt is reasonable once and unusable four times at once, which is what a canvas of -parallel agents turns it into. Worse, the orchestrator runs headless, so a prompt it stops on is one +An approval prompt is reasonable once and unusable four times at once, which is what a shell full +of parallel agents turns it into. Worse, the orchestrator runs headless, so a prompt it stops on is one nobody can see: Codex opens a folder it has not met on "Do you trust the contents of this directory?" and waits there, and the instruction you typed goes into that menu. @@ -141,7 +142,7 @@ working when they open and they connect to each other on their own. **That is a real trade and it is on by default, so it is worth saying plainly.** An agent running like this can reach anything you can. Run Identra on work you have committed. Settings has one -switch that puts every prompt back, and then you answer them in the node like any other terminal. +switch that puts every prompt back, and then you answer them in the tab like any other terminal. ## Requirements @@ -176,8 +177,8 @@ just doctor # check your machine is ready just dev # build and launch with hot reload ``` -First launch builds the Rust side, so give it a minute. After that, right-click the canvas or -use the dock to add a Codex node, and start typing. +First launch builds the Rust side, so give it a minute. After that, open Codex from the sidebar +and start typing. `just build` produces the release bundles for whatever OS you are on. Run it from `apps/identra-desktop` if you call `cargo tauri build` yourself: the frontend build script it @@ -190,22 +191,21 @@ Identra is a small Rust engine with thin shells on top of it. ``` identra/ crates/ - identra-core the engine: PTY/terminal manager, canvas store, agent detection + identra-core the engine: PTY/terminal manager, workspace store, agent detection identra-memory the memory layer: fact extraction, local embeddings, history - identra-mcp the context bus: an MCP server wired agents share context through + identra-mcp the context bus: an MCP server connected agents share context through apps/ - identra-desktop Tauri v2 + React: the canvas, the nodes, the dock + identra-desktop Tauri v2 + React: the shell, the panes, the memory panel presets/ identra-agents agent presets and orchestration recipes docs/ architecture and design notes ``` -The engine owns the hard parts. A node in the UI is a thin client that talks to `identra-core` +The engine owns the hard parts. A pane in the UI is a thin client that talks to `identra-core` over a small typed command channel (`terminal:start`, `terminal:input`, and so on). Output -streams back with a sequence number so a node can reattach after a reload without dropping or -duplicating a line. The canvas is the source of truth for layout, and it saves to -`.identra/canvas.json` in your project with a debounced atomic write, so a fast drag never -thrashes your disk. +streams back with a sequence number so a pane can reattach after a reload without dropping or +duplicating a line. What each workspace holds — its agents and the connections between them — +saves to `.identra/canvas.json` with a debounced atomic write. Memory is its own crate. After a session it runs one extraction pass, dedupes by content hash, embeds locally with fastembed, and stores the result in a single SQLite file with a vector @@ -215,7 +215,7 @@ it to. ## Where your data lives -- Canvas state: `.identra/canvas.json` inside each project (gitignored by default). +- What each workspace holds: `.identra/canvas.json` inside each project (gitignored by default). - Memory: a local SQLite database. Nothing leaves your machine. - The embedding model: inside the app. A release build ships it, so recall works by meaning the first time you open Identra, with nothing fetched and nothing to wait for. Identra downloads @@ -231,7 +231,7 @@ so here is the whole list rather than a promise that it is tidy. | Path | What it is | Yours already? | |------|-----------|----------------| -| `.identra/canvas.json` | Node layout, wallpaper, the seat | No, Identra's | +| `.identra/canvas.json` | Which agents are open, which are connected, wallpaper, the seat | No, Identra's | | `.identra/memory.db` | What the project has learned, plus `-wal` and `-shm` sidecars while it is open | No, Identra's | | `.identra/bus.db` | The task board and the message queue, same two sidecars | No, Identra's | | `.identra/opencode.json` | Bus config, kept out of the project root because opencode is pointed at it by env | No, Identra's | @@ -267,12 +267,13 @@ Identra uses a `justfile` for everything. Run `just` to list them. ## Status -Early, and honest about it. What works today: agent nodes each running their own real CLI in a -persistent canvas, the command bar driving an orchestrator seat that spawns and wires helpers, -a shared task board, workspaces with clone and per-board wallpaper, the dev server Run button -with a live browser preview, full-window focus on any node, file viewing on the canvas, the -Files panel with search, and auto update. Connection edges let two wired agents share context -through the bus (draw the wire, then launch, since a CLI reads its MCP servers at startup). +Early, and honest about it. What works today: agents each running their own real CLI as tabs in a +three-column shell you can split, the command bar driving an orchestrator seat that opens and +connects helpers, a shared task board, workspaces with clone and per-workspace wallpaper, the dev +server with a live browser preview, file viewing, the Files column with search, and auto update. +A connection lets two agents share context through the bus (connect them, then launch, since a +CLI reads its MCP servers at startup), and the Links column lists every connection with who made +it and a way to revoke it. Memory is on the recall path: agents read and write it through the bus, and the work panel shows you every fact the project holds, because memory only agents can read is memory you cannot check or correct. @@ -288,7 +289,7 @@ they are, because the model's ranking is good but its scores cannot tell a real question about something this project never touched. Judging that is the agent's job, and it is better at it than a threshold would be. -The browser node ships as a live preview (see `docs/browser-bridge.md` for why the agent-drive half +The browser tab ships as a live preview (see `docs/browser-bridge.md` for why the agent-drive half needs Chromium). If something is rough, it is because I would rather ship the core working than a wide surface half 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..45baf0b 100644 --- a/apps/identra-desktop/frontend/src/App.tsx +++ b/apps/identra-desktop/frontend/src/App.tsx @@ -1,47 +1,32 @@ -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 ConnectionsPanel from "./ConnectionsPanel"; 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 +60,15 @@ import { type CanvasCommand, type CanvasNode, type CanvasResult, + type Edge, + type Grantor, + 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 +77,89 @@ 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. Connections is new, and it is not a convenience: it is the only place a grant of +// agent-to-agent access can now be seen. 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 three honest ones. +type RightMode = "work" | "files" | "connections"; -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 [edges, setEdges] = 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([]); + 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 +173,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 +184,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 +200,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 +209,50 @@ 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; + setEdges(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 +260,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,28 +270,31 @@ 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([]); @@ -342,61 +304,190 @@ export default function App() { 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], + ); - // Take the board out to a file, or bring one in. + const putEdges = useCallback( + (next: Edge[]) => { + edgesRef.current = next; + setEdges(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], + ); + + // 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], + ); + + // Connect two nodes, recording who asked. `by` is not bookkeeping: an agent can call + // `connect_nodes` and grant itself access to another agent's context, and the connections panel is + // now the only place that shows up. A grant with no attribution is one the user cannot audit. + const wire = useCallback( + (from: string, to: string, by: Grantor) => { + // Same pair twice is the same permission, and a second edge would mean revoking took two + // clicks to do one thing. The first grant keeps its attribution: an agent re-asking for a + // connection you already made does not make it the agent's. + if (edgesRef.current.some((e) => e.source === from && e.target === to)) { + return; + } + putEdges([ + ...edgesRef.current, + { id: `${from}->${to}`, source: from, target: to, by }, + ]); + }, + [putEdges], + ); + + // Take a connection back. `get_peer_context` reads this slice on every call, so a revoke takes + // effect on the next read rather than at the next launch — unlike granting one, which a CLI only + // picks up when it next starts. The asymmetry is the right way round: a permission should be + // slower to give than to take away. + const revoke = useCallback( + (edgeId: string) => { + putEdges(edgesRef.current.filter((e) => e.id !== edgeId)); + }, + [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 +497,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 +506,39 @@ 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; + setEdges(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 +548,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 +558,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 +674,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 +703,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 +731,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 +743,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 +757,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 +772,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 +788,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 +799,64 @@ 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; + } + // The user clicked the address badge, so this connection is theirs. + const browserId = addNode("browser", "Preview", url); + wire(devId, browserId, "you"); }, [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 +868,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,9 +879,9 @@ 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); + wire(p.connectTo, id, "agent"); return { ok: true, id }; } case "connect_nodes": { @@ -1047,26 +889,23 @@ 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); + wire(from, to, "agent"); return { ok: true, id: `${from}->${to}` }; } case "add_note": { @@ -1077,12 +916,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,27 +930,15 @@ 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); + wire(p.connectTo, id, "agent"); return { ok: true, id }; } 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 +950,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 +970,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,130 +983,38 @@ 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 ( -