From 964a93a611a6eeff83723ca5d671660d3f8895df Mon Sep 17 00:00:00 2001 From: Blake Wood Date: Thu, 1 Oct 2026 10:01:17 -0700 Subject: [PATCH] feat(desktop): keep each workspace's view alive across workspace switches --- apps/desktop/src/App.tsx | 16 +- apps/desktop/src/Overview.tsx | 813 +++++++++--------- apps/desktop/src/components/AiAssistModal.tsx | 9 +- .../desktop/src/components/MarkdownEditor.tsx | 22 +- .../src/components/NewWorkspaceModal.tsx | 7 +- apps/desktop/src/components/RunAgentModal.tsx | 7 +- apps/desktop/src/components/SearchModal.tsx | 9 +- apps/desktop/src/components/TabsNav.tsx | 5 +- .../src/components/chat/ChatRightSidebar.tsx | 6 +- .../components/project-form/useProjectForm.ts | 5 +- .../provider-form/useProviderForm.ts | 7 +- .../src/components/remote/PairDeviceModal.tsx | 8 +- .../src/components/right-panel/DiffPanel.tsx | 17 +- .../desktop/src/contexts/AsideCardContext.tsx | 5 +- .../src/contexts/ChatSidebarContext.tsx | 44 +- .../src/contexts/HeaderSlotContext.tsx | 5 +- .../src/contexts/ModalPortalContext.tsx | 5 +- apps/desktop/src/contexts/SnapshotContext.tsx | 16 +- .../desktop/src/contexts/WorkspaceContext.tsx | 22 +- apps/desktop/src/lib/useKeptElement.ts | 13 + apps/desktop/src/lib/useLocalStorage.ts | 4 + apps/desktop/src/lib/useResetOnChange.ts | 14 + apps/desktop/src/queries/queryClient.ts | 4 +- apps/desktop/src/router.tsx | 126 +-- apps/desktop/src/views/AgentEdit.tsx | 7 +- apps/desktop/src/views/Conversations.tsx | 54 +- apps/desktop/src/views/LogViewer.tsx | 36 +- apps/desktop/src/views/RootLayout.tsx | 176 +--- apps/desktop/src/views/Sidebar.tsx | 9 +- apps/desktop/src/views/TaskDetail.tsx | 18 +- apps/desktop/src/views/Tasks.tsx | 7 +- apps/desktop/src/views/Titlebar.tsx | 36 +- apps/desktop/src/views/Wiki.tsx | 5 +- apps/desktop/src/views/WorkspaceLayout.tsx | 144 ++++ 34 files changed, 925 insertions(+), 756 deletions(-) create mode 100644 apps/desktop/src/lib/useKeptElement.ts create mode 100644 apps/desktop/src/lib/useResetOnChange.ts create mode 100644 apps/desktop/src/views/WorkspaceLayout.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index ca94864..fdccee3 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,19 +1,17 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { RouterProvider } from "react-router-dom"; import { queryClient } from "./queries/queryClient"; -import { router } from "./router"; +import { RootLayout } from "./views/RootLayout"; -/** App entrypoint. The whole component tree (layout + workspace state + - * snapshot stream + selection + routes) is set up in `router.tsx` → - * `RootLayout`. The `QueryClientProvider` sits above every route so all data - * hooks share one cache. This file is intentionally minimal so adding new - * state providers, route guards, or error boundaries doesn't require touching - * the root component. */ +/** App entrypoint. The whole component tree (workspace state + per-workspace + * routers + layout) is set up in `RootLayout`. The `QueryClientProvider` sits + * above every workspace so all data hooks share one cache. This file is + * intentionally minimal so adding new state providers, route guards, or error + * boundaries doesn't require touching the root component. */ export default function App() { return ( - + {import.meta.env.DEV && } ); diff --git a/apps/desktop/src/Overview.tsx b/apps/desktop/src/Overview.tsx index 38a48ab..2837e47 100644 --- a/apps/desktop/src/Overview.tsx +++ b/apps/desktop/src/Overview.tsx @@ -378,320 +378,431 @@ export default function Overview({ workspace }: { workspace: string }) { const snap = useSnapshot(workspace); const { mode } = useThemeContext(); - // Snap held in a ref so the scene-construction effect (which reruns on - // theme change) can rebuild with the latest data without depending on - // `snap` directly — depending on snap would tear the scene down on every - // SSE tick. + // Snap held in a ref so a scene built on theme change starts from the latest + // data without the build effect depending on `snap`. const snapRef = useRef(null); useEffect(() => { snapRef.current = snap; }, [snap]); + // Built once per theme; hiding the workspace only pauses the render loop. + const sceneRef = useRef(null); useEffect(() => { const mount = mountRef.current; if (!mount) return; - while (mount.firstChild) mount.removeChild(mount.firstChild); - const palette = PALETTES[mode]; - - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setPixelRatio(window.devicePixelRatio); - const canvas = renderer.domElement; - canvas.style.cssText = "display:block;position:absolute;inset:0;width:100%;height:100%;"; - mount.appendChild(canvas); - - const scene = new THREE.Scene(); - scene.background = new THREE.Color(palette.bg); - - const lookTarget = new THREE.Vector3(0, 0.5, 0); - const camera = new THREE.OrthographicCamera(-10, 10, 6, -6, 0.1, 400); - camera.position.copy(lookTarget).add(new THREE.Vector3(50, 50, 50)); - camera.lookAt(lookTarget); - - // World extents we want visible at any aspect ratio. - const TARGET_W = 34; - const TARGET_H = 15; - function fit() { - if (!mount) return; - const w = mount.clientWidth || 1; - const h = mount.clientHeight || 1; - renderer.setSize(w, h, false); - const aspect = w / h; - const d = Math.max(TARGET_H / 2, TARGET_W / (2 * aspect)); - camera.left = -d * aspect; - camera.right = d * aspect; - camera.top = d; - camera.bottom = -d; - camera.updateProjectionMatrix(); - } - fit(); - const ro = new ResizeObserver(fit); - ro.observe(mount); - - // Flat lighting — slabs/tiles get just enough shading to read as solid. - scene.add(new THREE.AmbientLight(0xffffff, 0.85)); - const dir = new THREE.DirectionalLight(0xffffff, 0.25); - dir.position.set(8, 14, 6); - scene.add(dir); - - scene.add(new THREE.GridHelper(40, 40, palette.grid, palette.grid)); - - // Stations + flow line are dynamic too: they're rebuilt every snapshot - // because the pipeline lives in the DB and may change between ticks. - const SLAB_W = 4.2; - const SLAB_H = 0.12; - const SLAB_D = 3.6; - - const dynGroup = new THREE.Group(); - scene.add(dynGroup); - - function clearDynamic() { - while (dynGroup.children.length) { - const c = dynGroup.children[0]; - dynGroup.remove(c); - c.traverse?.((o: THREE.Object3D) => { - const m = o as THREE.Mesh; - if (m.geometry) m.geometry.dispose?.(); - const mat = m.material; - if (Array.isArray(mat)) mat.forEach((mm) => mm.dispose()); - else mat?.dispose?.(); - }); - } + if (sceneRef.current?.mode !== mode) { + sceneRef.current?.dispose(); + sceneRef.current = buildScene(mount, mode, snapRef.current); } + const scene = sceneRef.current; + scene.start(); + return () => { + scene.stop(); + // Still in the document means hidden, not unmounted: keep the scene. + if (mount.isConnected) return; + scene.dispose(); + sceneRef.current = null; + }; + }, [mode]); + + useEffect(() => { + if (snap) sceneRef.current?.rebuild(snap); + }, [snap]); + + return ( +
+
+ +
+ ); +} - function rebuildFromSnapshot(s: Snapshot) { - clearDynamic(); +interface OverviewScene { + mode: ThemeMode; + start(): void; + stop(): void; + rebuild(s: Snapshot): void; + dispose(): void; +} - // Stations — derived from the pipeline config block. - const pipeline = s.config?.pipeline ?? []; - const stations = buildStations(pipeline); +function buildScene(mount: HTMLDivElement, mode: ThemeMode, seed: Snapshot | null): OverviewScene { + while (mount.firstChild) mount.removeChild(mount.firstChild); + const palette = PALETTES[mode]; - // Pipeline flow line connecting station centers. - if (stations.length >= 2) { - const FIRST_X = stations[0].x; - const LAST_X = stations[stations.length - 1].x; - const flow = new THREE.Mesh( - new THREE.BoxGeometry(LAST_X - FIRST_X, 0.012, 0.06), - new THREE.MeshBasicMaterial({ color: palette.flowLine }), - ); - flow.position.set((FIRST_X + LAST_X) / 2, 0.006, 0); - dynGroup.add(flow); - } + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + const canvas = renderer.domElement; + canvas.style.cssText = "display:block;position:absolute;inset:0;width:100%;height:100%;"; + mount.appendChild(canvas); - // Station slabs — low rectangle + perimeter edge + labels. - const stationEdgeMat = new THREE.MeshBasicMaterial({ color: palette.stationEdge }); - stations.forEach((st) => { - const g = new THREE.Group(); - g.position.set(st.x, 0, 0); - - const slab = new THREE.Mesh( - new THREE.BoxGeometry(SLAB_W, SLAB_H, SLAB_D), - new THREE.MeshStandardMaterial({ - color: palette.stationBase, - roughness: 0.85, - metalness: 0.05, - }), - ); - slab.position.y = SLAB_H / 2; - g.add(slab); - - const eY = SLAB_H + 0.005; - const eT = 0.025; - const eH = 0.008; - const front = new THREE.Mesh(new THREE.BoxGeometry(SLAB_W, eH, eT), stationEdgeMat); - front.position.set(0, eY, SLAB_D / 2); - g.add(front); - const back = new THREE.Mesh(new THREE.BoxGeometry(SLAB_W, eH, eT), stationEdgeMat); - back.position.set(0, eY, -SLAB_D / 2); - g.add(back); - const leftEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, SLAB_D), stationEdgeMat); - leftEdge.position.set(-SLAB_W / 2, eY, 0); - g.add(leftEdge); - const rightEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, SLAB_D), stationEdgeMat); - rightEdge.position.set(SLAB_W / 2, eY, 0); - g.add(rightEdge); - - const codeLbl = makeTextSprite(st.code, palette.textCode, 28, 700); - codeLbl.position.set(0, 1.4, 2.05); - g.add(codeLbl); - const nameLbl = makeTextSprite(st.name, palette.textName, 56, 700); - nameLbl.position.set(0, 0.85, 2.05); - g.add(nameLbl); - - dynGroup.add(g); + const scene = new THREE.Scene(); + scene.background = new THREE.Color(palette.bg); + + const lookTarget = new THREE.Vector3(0, 0.5, 0); + const camera = new THREE.OrthographicCamera(-10, 10, 6, -6, 0.1, 400); + camera.position.copy(lookTarget).add(new THREE.Vector3(50, 50, 50)); + camera.lookAt(lookTarget); + + // World extents we want visible at any aspect ratio. + const TARGET_W = 34; + const TARGET_H = 15; + function fit() { + if (!mount) return; + const w = mount.clientWidth || 1; + const h = mount.clientHeight || 1; + renderer.setSize(w, h, false); + const aspect = w / h; + const d = Math.max(TARGET_H / 2, TARGET_W / (2 * aspect)); + camera.left = -d * aspect; + camera.right = d * aspect; + camera.top = d; + camera.bottom = -d; + camera.updateProjectionMatrix(); + } + const ro = new ResizeObserver(fit); + + // Flat lighting — slabs/tiles get just enough shading to read as solid. + scene.add(new THREE.AmbientLight(0xffffff, 0.85)); + const dir = new THREE.DirectionalLight(0xffffff, 0.25); + dir.position.set(8, 14, 6); + scene.add(dir); + + scene.add(new THREE.GridHelper(40, 40, palette.grid, palette.grid)); + + // Stations + flow line are dynamic too: they're rebuilt every snapshot + // because the pipeline lives in the DB and may change between ticks. + const SLAB_W = 4.2; + const SLAB_H = 0.12; + const SLAB_D = 3.6; + + const dynGroup = new THREE.Group(); + scene.add(dynGroup); + + function clearDynamic() { + while (dynGroup.children.length) { + const c = dynGroup.children[0]; + dynGroup.remove(c); + c.traverse?.((o: THREE.Object3D) => { + const m = o as THREE.Mesh; + if (m.geometry) m.geometry.dispose?.(); + const mat = m.material; + if (Array.isArray(mat)) mat.forEach((mm) => mm.dispose()); + else mat?.dispose?.(); }); + } + } - // Project rail — derive from this workspace's config. - const projectNames = (s.config?.projects ?? []) - .map((p) => p?.name) - .filter((n): n is string => typeof n === "string" && n.length > 0); - const projects = layoutProjects(projectNames); - const projectByName = new Map(projects.map((p) => [p.name, p])); - - const plaqueEdgeMat = new THREE.MeshBasicMaterial({ color: palette.stationEdge }); - for (const p of projects) { - const g = new THREE.Group(); - g.position.set(p.x, 0, PROJECT_Z); - - const body = new THREE.Mesh( - new THREE.BoxGeometry(PLAQUE_W, PLAQUE_H, PLAQUE_D), - new THREE.MeshStandardMaterial({ - color: palette.stationBase, - roughness: 0.85, - metalness: 0.05, - }), - ); - body.position.y = PLAQUE_H / 2; - g.add(body); - - const eY = PLAQUE_H + 0.005; - const eT = 0.025; - const eH = 0.008; - const front = new THREE.Mesh(new THREE.BoxGeometry(PLAQUE_W, eH, eT), plaqueEdgeMat); - front.position.set(0, eY, PLAQUE_D / 2); - g.add(front); - const back = new THREE.Mesh(new THREE.BoxGeometry(PLAQUE_W, eH, eT), plaqueEdgeMat); - back.position.set(0, eY, -PLAQUE_D / 2); - g.add(back); - const leftEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, PLAQUE_D), plaqueEdgeMat); - leftEdge.position.set(-PLAQUE_W / 2, eY, 0); - g.add(leftEdge); - const rightEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, PLAQUE_D), plaqueEdgeMat); - rightEdge.position.set(PLAQUE_W / 2, eY, 0); - g.add(rightEdge); - - const code = makeTextSprite(p.code, palette.textCode, 24, 700); - code.position.set(0, 1.05, 0); - g.add(code); - const nameLbl = makeTextSprite(p.name, palette.textMeta, 38, 700); - nameLbl.position.set(0, 0.55, 0); - g.add(nameLbl); - - dynGroup.add(g); - } + function rebuildFromSnapshot(s: Snapshot) { + clearDynamic(); + + // Stations — derived from the pipeline config block. + const pipeline = s.config?.pipeline ?? []; + const stations = buildStations(pipeline); + + // Pipeline flow line connecting station centers. + if (stations.length >= 2) { + const FIRST_X = stations[0].x; + const LAST_X = stations[stations.length - 1].x; + const flow = new THREE.Mesh( + new THREE.BoxGeometry(LAST_X - FIRST_X, 0.012, 0.06), + new THREE.MeshBasicMaterial({ color: palette.flowLine }), + ); + flow.position.set((FIRST_X + LAST_X) / 2, 0.006, 0); + dynGroup.add(flow); + } + + // Station slabs — low rectangle + perimeter edge + labels. + const stationEdgeMat = new THREE.MeshBasicMaterial({ color: palette.stationEdge }); + stations.forEach((st) => { + const g = new THREE.Group(); + g.position.set(st.x, 0, 0); + + const slab = new THREE.Mesh( + new THREE.BoxGeometry(SLAB_W, SLAB_H, SLAB_D), + new THREE.MeshStandardMaterial({ + color: palette.stationBase, + roughness: 0.85, + metalness: 0.05, + }), + ); + slab.position.y = SLAB_H / 2; + g.add(slab); + + const eY = SLAB_H + 0.005; + const eT = 0.025; + const eH = 0.008; + const front = new THREE.Mesh(new THREE.BoxGeometry(SLAB_W, eH, eT), stationEdgeMat); + front.position.set(0, eY, SLAB_D / 2); + g.add(front); + const back = new THREE.Mesh(new THREE.BoxGeometry(SLAB_W, eH, eT), stationEdgeMat); + back.position.set(0, eY, -SLAB_D / 2); + g.add(back); + const leftEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, SLAB_D), stationEdgeMat); + leftEdge.position.set(-SLAB_W / 2, eY, 0); + g.add(leftEdge); + const rightEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, SLAB_D), stationEdgeMat); + rightEdge.position.set(SLAB_W / 2, eY, 0); + g.add(rightEdge); + + const codeLbl = makeTextSprite(st.code, palette.textCode, 28, 700); + codeLbl.position.set(0, 1.4, 2.05); + g.add(codeLbl); + const nameLbl = makeTextSprite(st.name, palette.textName, 56, 700); + nameLbl.position.set(0, 0.85, 2.05); + g.add(nameLbl); + + dynGroup.add(g); + }); + + // Project rail — derive from this workspace's config. + const projectNames = (s.config?.projects ?? []) + .map((p) => p?.name) + .filter((n): n is string => typeof n === "string" && n.length > 0); + const projects = layoutProjects(projectNames); + const projectByName = new Map(projects.map((p) => [p.name, p])); + + const plaqueEdgeMat = new THREE.MeshBasicMaterial({ color: palette.stationEdge }); + for (const p of projects) { + const g = new THREE.Group(); + g.position.set(p.x, 0, PROJECT_Z); + + const body = new THREE.Mesh( + new THREE.BoxGeometry(PLAQUE_W, PLAQUE_H, PLAQUE_D), + new THREE.MeshStandardMaterial({ + color: palette.stationBase, + roughness: 0.85, + metalness: 0.05, + }), + ); + body.position.y = PLAQUE_H / 2; + g.add(body); + + const eY = PLAQUE_H + 0.005; + const eT = 0.025; + const eH = 0.008; + const front = new THREE.Mesh(new THREE.BoxGeometry(PLAQUE_W, eH, eT), plaqueEdgeMat); + front.position.set(0, eY, PLAQUE_D / 2); + g.add(front); + const back = new THREE.Mesh(new THREE.BoxGeometry(PLAQUE_W, eH, eT), plaqueEdgeMat); + back.position.set(0, eY, -PLAQUE_D / 2); + g.add(back); + const leftEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, PLAQUE_D), plaqueEdgeMat); + leftEdge.position.set(-PLAQUE_W / 2, eY, 0); + g.add(leftEdge); + const rightEdge = new THREE.Mesh(new THREE.BoxGeometry(eT, eH, PLAQUE_D), plaqueEdgeMat); + rightEdge.position.set(PLAQUE_W / 2, eY, 0); + g.add(rightEdge); + + const code = makeTextSprite(p.code, palette.textCode, 24, 700); + code.position.set(0, 1.05, 0); + g.add(code); + const nameLbl = makeTextSprite(p.name, palette.textMeta, 38, 700); + nameLbl.position.set(0, 0.55, 0); + g.add(nameLbl); + + dynGroup.add(g); + } - const roadmapByTask = new Map(); - s.roadmap.forEach((r) => roadmapByTask.set(r.task, r)); + const roadmapByTask = new Map(); + s.roadmap.forEach((r) => roadmapByTask.set(r.task, r)); - // Resolve every agent to a task id once (handles workers that ship - // only --spec/--branch). Longest ids first inside the helper. - const sortedTaskIds = s.tasks.map((t) => t.id).sort((x, y) => y.length - x.length); - const agentTaskId = new Map(); - for (const a of s.agents) { - agentTaskId.set(a.pid, taskIdForAgent(a, sortedTaskIds)); - } + // Resolve every agent to a task id once (handles workers that ship + // only --spec/--branch). Longest ids first inside the helper. + const sortedTaskIds = s.tasks.map((t) => t.id).sort((x, y) => y.length - x.length); + const agentTaskId = new Map(); + for (const a of s.agents) { + agentTaskId.set(a.pid, taskIdForAgent(a, sortedTaskIds)); + } - const tasksWithAgent = new Set(); - for (const a of s.agents) { - const tid = agentTaskId.get(a.pid); - if (tid) tasksWithAgent.add(tid); + const tasksWithAgent = new Set(); + for (const a of s.agents) { + const tid = agentTaskId.get(a.pid); + if (tid) tasksWithAgent.add(tid); + } + + const byStation = new Map(); + // Task id → display name (external_id like "ENG-1234" when present, + // otherwise the title). Used to label agent tiles by their task. + const taskNiceName = new Map(); + for (const t of s.tasks) { + taskNiceName.set(t.id, t.external_id ?? t.title); + const { station, status } = stationForTask(t.id, roadmapByTask, stations); + const arr = byStation.get(station.key) ?? []; + arr.push({ task: t, status, state: taskState(t, status, pipeline) }); + byStation.set(station.key, arr); + } + + // Agent → station: follow the agent's task (if any) to its roadmap + // status, then to the station that owns that status. Agents with no + // task (e.g. jira-scan, project-manager) dock at the leftmost station. + const agentsByStation = new Map(); + const FALLBACK_STATION = stations[0]; + for (const a of s.agents) { + let stationKey: string = FALLBACK_STATION?.key ?? INBOX_KEY; + const tid = agentTaskId.get(a.pid); + if (tid) { + const r = roadmapByTask.get(tid); + if (r) { + const st = stations.find((x) => x.statuses.includes(r.status)); + if (st) stationKey = st.key; + } } + const arr = agentsByStation.get(stationKey) ?? []; + arr.push(a); + agentsByStation.set(stationKey, arr); + } - const byStation = new Map< - string, - { task: Task; status: string | null; state: TaskState }[] - >(); - // Task id → display name (external_id like "ENG-1234" when present, - // otherwise the title). Used to label agent tiles by their task. - const taskNiceName = new Map(); - for (const t of s.tasks) { - taskNiceName.set(t.id, t.external_id ?? t.title); - const { station, status } = stationForTask(t.id, roadmapByTask, stations); - const arr = byStation.get(station.key) ?? []; - arr.push({ task: t, status, state: taskState(t, status, pipeline) }); - byStation.set(station.key, arr); + const COLS = 5; + const ROWS = 5; + const MAX_VISIBLE = COLS * ROWS; + const TILE_W = 0.36; + const TILE_H = 0.05; + const COL_S = 0.62; + const ROW_S = 0.55; + const TILE_BASE_Y = SLAB_H + 0.005; + const TILE_BASE_Z = -1.2; + + // Filled as tasks render so agents drawn later can wire back to them. + const taskPos = new Map(); + + for (const station of stations) { + const tasks = byStation.get(station.key) ?? []; + const agents = agentsByStation.get(station.key) ?? []; + + // Front-edge accent — one severity wins, in priority order. + let accent: number | null = null; + if (tasks.some((t) => t.state === "blocked")) accent = palette.taskBlocked; + else if (tasks.some((t) => t.state === "rework")) accent = palette.taskRework; + else if (agents.length > 0) accent = palette.stationLive; + + if (accent !== null) { + const accentMesh = new THREE.Mesh( + new THREE.BoxGeometry(SLAB_W, 0.018, 0.06), + new THREE.MeshBasicMaterial({ color: accent }), + ); + accentMesh.position.set(station.x, SLAB_H + 0.018, SLAB_D / 2); + dynGroup.add(accentMesh); } - // Agent → station: follow the agent's task (if any) to its roadmap - // status, then to the station that owns that status. Agents with no - // task (e.g. jira-scan, project-manager) dock at the leftmost station. - const agentsByStation = new Map(); - const FALLBACK_STATION = stations[0]; - for (const a of s.agents) { - let stationKey: string = FALLBACK_STATION?.key ?? INBOX_KEY; - const tid = agentTaskId.get(a.pid); - if (tid) { - const r = roadmapByTask.get(tid); - if (r) { - const st = stations.find((x) => x.statuses.includes(r.status)); - if (st) stationKey = st.key; - } + // Tasks — flat tiles in a 5x5 grid on the slab top. + const visible = tasks.slice(0, MAX_VISIBLE); + visible.forEach((entry, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const px = station.x + (col - (COLS - 1) / 2) * COL_S; + const py = TILE_BASE_Y + TILE_H / 2; + const pz = TILE_BASE_Z + row * ROW_S; + + const tile = new THREE.Mesh( + new THREE.BoxGeometry(TILE_W, TILE_H, TILE_W), + new THREE.MeshBasicMaterial({ color: taskColor(palette, entry.state) }), + ); + tile.position.set(px, py, pz); + dynGroup.add(tile); + taskPos.set(entry.task.id, { px, py, pz }); + + // "Agent on it" marker — a thin vertical pin above the tile. + if (tasksWithAgent.has(entry.task.id)) { + const pinH = 0.4; + const pin = new THREE.Mesh( + new THREE.BoxGeometry(0.025, pinH, 0.025), + new THREE.MeshBasicMaterial({ color: palette.agentPin }), + ); + pin.position.set(px, py + TILE_H / 2 + pinH / 2, pz); + dynGroup.add(pin); } - const arr = agentsByStation.get(stationKey) ?? []; - arr.push(a); - agentsByStation.set(stationKey, arr); - } - const COLS = 5; - const ROWS = 5; - const MAX_VISIBLE = COLS * ROWS; - const TILE_W = 0.36; - const TILE_H = 0.05; - const COL_S = 0.62; - const ROW_S = 0.55; - const TILE_BASE_Y = SLAB_H + 0.005; - const TILE_BASE_Z = -1.2; - - // Filled as tasks render so agents drawn later can wire back to them. - const taskPos = new Map(); - - for (const station of stations) { - const tasks = byStation.get(station.key) ?? []; - const agents = agentsByStation.get(station.key) ?? []; - - // Front-edge accent — one severity wins, in priority order. - let accent: number | null = null; - if (tasks.some((t) => t.state === "blocked")) accent = palette.taskBlocked; - else if (tasks.some((t) => t.state === "rework")) accent = palette.taskRework; - else if (agents.length > 0) accent = palette.stationLive; - - if (accent !== null) { - const accentMesh = new THREE.Mesh( - new THREE.BoxGeometry(SLAB_W, 0.018, 0.06), - new THREE.MeshBasicMaterial({ color: accent }), + // Wires to each project this task has worktrees in. + const touched = entry.task.projects_touched ?? []; + for (const projName of touched) { + const proj = projectByName.get(projName); + if (!proj) continue; + const RISE_Y = 0.42; + const points = [ + new THREE.Vector3(px, py + TILE_H / 2 + 0.005, pz), + new THREE.Vector3(px, RISE_Y, pz), + new THREE.Vector3(proj.x, RISE_Y, PROJECT_Z + PLAQUE_D / 2), + new THREE.Vector3(proj.x, PLAQUE_H + 0.005, PROJECT_Z + PLAQUE_D / 2), + ]; + const geom = new THREE.BufferGeometry().setFromPoints(points); + const wire = new THREE.Line( + geom, + new THREE.LineBasicMaterial({ + color: palette.agentEdge, + transparent: true, + opacity: tasksWithAgent.has(entry.task.id) ? 0.7 : 0.35, + }), ); - accentMesh.position.set(station.x, SLAB_H + 0.018, SLAB_D / 2); - dynGroup.add(accentMesh); + dynGroup.add(wire); } + }); + + // Count label sits under the station name, in front of the slab. + if (tasks.length > 0) { + const overflow = tasks.length > MAX_VISIBLE ? ` +${tasks.length - MAX_VISIBLE}` : ""; + const txt = `${String(tasks.length).padStart(2, "0")}${overflow}`; + const lbl = makeTextSprite(txt, palette.textMeta, 36, 700); + lbl.position.set(station.x, 0.35, 2.05); + dynGroup.add(lbl); + } - // Tasks — flat tiles in a 5x5 grid on the slab top. - const visible = tasks.slice(0, MAX_VISIBLE); - visible.forEach((entry, i) => { - const col = i % COLS; - const row = Math.floor(i / COLS); - const px = station.x + (col - (COLS - 1) / 2) * COL_S; - const py = TILE_BASE_Y + TILE_H / 2; - const pz = TILE_BASE_Z + row * ROW_S; + // Agents — flat tiles in front of the slab. Single accent stripe. + if (agents.length > 0) { + const A_W = 0.95; + const A_H = 0.08; + const A_D = 0.55; + const A_SPACING = 1.05; + const A_Z = 3.7; + agents.forEach((agent, i) => { + const offset = (i - (agents.length - 1) / 2) * A_SPACING; + const px = station.x + offset; const tile = new THREE.Mesh( - new THREE.BoxGeometry(TILE_W, TILE_H, TILE_W), - new THREE.MeshBasicMaterial({ color: taskColor(palette, entry.state) }), + new THREE.BoxGeometry(A_W, A_H, A_D), + new THREE.MeshStandardMaterial({ + color: palette.agentTile, + roughness: 0.85, + metalness: 0.05, + }), ); - tile.position.set(px, py, pz); + tile.position.set(px, A_H / 2, A_Z); dynGroup.add(tile); - taskPos.set(entry.task.id, { px, py, pz }); - - // "Agent on it" marker — a thin vertical pin above the tile. - if (tasksWithAgent.has(entry.task.id)) { - const pinH = 0.4; - const pin = new THREE.Mesh( - new THREE.BoxGeometry(0.025, pinH, 0.025), - new THREE.MeshBasicMaterial({ color: palette.agentPin }), - ); - pin.position.set(px, py + TILE_H / 2 + pinH / 2, pz); - dynGroup.add(pin); + + const stripe = new THREE.Mesh( + new THREE.BoxGeometry(A_W, 0.018, 0.03), + new THREE.MeshBasicMaterial({ color: palette.agentEdge }), + ); + stripe.position.set(px, A_H + 0.005, A_Z + A_D / 2 - 0.018); + dynGroup.add(stripe); + + const roleLbl = makeTextSprite(shortLabelForAgent(agent.name), palette.textMeta, 26, 700); + roleLbl.position.set(px, A_H + 0.22, A_Z); + dynGroup.add(roleLbl); + + const taskLabel = agent.task ? (taskNiceName.get(agent.task) ?? "-") : "-"; + const tkt = makeTextSprite(taskLabel, palette.textPrimary, 36, 700); + tkt.position.set(px, A_H + 0.55, A_Z); + dynGroup.add(tkt); + + const e = elapsedShort(agent.started_at); + if (e) { + const eLbl = makeTextSprite(e, palette.textSubtle, 22, 600); + eLbl.position.set(px, A_H + 0.85, A_Z); + dynGroup.add(eLbl); } - // Wires to each project this task has worktrees in. - const touched = entry.task.projects_touched ?? []; - for (const projName of touched) { - const proj = projectByName.get(projName); - if (!proj) continue; + // Wire agent → task. Mirrors the task→project bend: rise + // from the front edge of the agent tile, jog over the slab to + // the task's column, then drop onto the task's tile. + const tid = agentTaskId.get(agent.pid); + const tpos = tid ? taskPos.get(tid) : null; + if (tpos) { const RISE_Y = 0.42; const points = [ - new THREE.Vector3(px, py + TILE_H / 2 + 0.005, pz), - new THREE.Vector3(px, RISE_Y, pz), - new THREE.Vector3(proj.x, RISE_Y, PROJECT_Z + PLAQUE_D / 2), - new THREE.Vector3(proj.x, PLAQUE_H + 0.005, PROJECT_Z + PLAQUE_D / 2), + new THREE.Vector3(px, A_H + 0.005, A_Z - A_D / 2), + new THREE.Vector3(px, RISE_Y, A_Z - A_D / 2), + new THREE.Vector3(tpos.px, RISE_Y, tpos.pz), + new THREE.Vector3(tpos.px, tpos.py + TILE_H / 2 + 0.005, tpos.pz), ]; const geom = new THREE.BufferGeometry().setFromPoints(points); const wire = new THREE.Line( @@ -699,118 +810,45 @@ export default function Overview({ workspace }: { workspace: string }) { new THREE.LineBasicMaterial({ color: palette.agentEdge, transparent: true, - opacity: tasksWithAgent.has(entry.task.id) ? 0.7 : 0.35, + opacity: 0.7, }), ); dynGroup.add(wire); } }); - - // Count label sits under the station name, in front of the slab. - if (tasks.length > 0) { - const overflow = tasks.length > MAX_VISIBLE ? ` +${tasks.length - MAX_VISIBLE}` : ""; - const txt = `${String(tasks.length).padStart(2, "0")}${overflow}`; - const lbl = makeTextSprite(txt, palette.textMeta, 36, 700); - lbl.position.set(station.x, 0.35, 2.05); - dynGroup.add(lbl); - } - - // Agents — flat tiles in front of the slab. Single accent stripe. - if (agents.length > 0) { - const A_W = 0.95; - const A_H = 0.08; - const A_D = 0.55; - const A_SPACING = 1.05; - const A_Z = 3.7; - agents.forEach((agent, i) => { - const offset = (i - (agents.length - 1) / 2) * A_SPACING; - const px = station.x + offset; - - const tile = new THREE.Mesh( - new THREE.BoxGeometry(A_W, A_H, A_D), - new THREE.MeshStandardMaterial({ - color: palette.agentTile, - roughness: 0.85, - metalness: 0.05, - }), - ); - tile.position.set(px, A_H / 2, A_Z); - dynGroup.add(tile); - - const stripe = new THREE.Mesh( - new THREE.BoxGeometry(A_W, 0.018, 0.03), - new THREE.MeshBasicMaterial({ color: palette.agentEdge }), - ); - stripe.position.set(px, A_H + 0.005, A_Z + A_D / 2 - 0.018); - dynGroup.add(stripe); - - const roleLbl = makeTextSprite( - shortLabelForAgent(agent.name), - palette.textMeta, - 26, - 700, - ); - roleLbl.position.set(px, A_H + 0.22, A_Z); - dynGroup.add(roleLbl); - - const taskLabel = agent.task ? (taskNiceName.get(agent.task) ?? "-") : "-"; - const tkt = makeTextSprite(taskLabel, palette.textPrimary, 36, 700); - tkt.position.set(px, A_H + 0.55, A_Z); - dynGroup.add(tkt); - - const e = elapsedShort(agent.started_at); - if (e) { - const eLbl = makeTextSprite(e, palette.textSubtle, 22, 600); - eLbl.position.set(px, A_H + 0.85, A_Z); - dynGroup.add(eLbl); - } - - // Wire agent → task. Mirrors the task→project bend: rise - // from the front edge of the agent tile, jog over the slab to - // the task's column, then drop onto the task's tile. - const tid = agentTaskId.get(agent.pid); - const tpos = tid ? taskPos.get(tid) : null; - if (tpos) { - const RISE_Y = 0.42; - const points = [ - new THREE.Vector3(px, A_H + 0.005, A_Z - A_D / 2), - new THREE.Vector3(px, RISE_Y, A_Z - A_D / 2), - new THREE.Vector3(tpos.px, RISE_Y, tpos.pz), - new THREE.Vector3(tpos.px, tpos.py + TILE_H / 2 + 0.005, tpos.pz), - ]; - const geom = new THREE.BufferGeometry().setFromPoints(points); - const wire = new THREE.Line( - geom, - new THREE.LineBasicMaterial({ - color: palette.agentEdge, - transparent: true, - opacity: 0.7, - }), - ); - dynGroup.add(wire); - } - }); - } } } + } - let raf = 0; - function tick() { - renderer.render(scene, camera); - raf = requestAnimationFrame(tick); - } + let raf = 0; + function tick() { + renderer.render(scene, camera); raf = requestAnimationFrame(tick); + } - // Theme rebuilds tear the scene down and recreate it; seed with the - // most recent snapshot so the user sees data immediately rather than - // waiting for the next SSE tick. - rebuildFromSnapshot(snapRef.current ?? { tasks: [], roadmap: [], agents: [], ts: "" }); - (mount as HTMLDivElement & { __rebuild?: (s: Snapshot) => void }).__rebuild = - rebuildFromSnapshot; - - return () => { + // Seed with the most recent snapshot so the user sees data immediately + // rather than waiting for the next event. + let built: Snapshot | null = null; + function rebuild(s: Snapshot) { + if (s === built) return; + built = s; + rebuildFromSnapshot(s); + } + rebuild(seed ?? { tasks: [], roadmap: [], agents: [], ts: "" }); + + return { + mode, + rebuild, + start() { + fit(); + ro.observe(mount); + tick(); + }, + stop() { cancelAnimationFrame(raf); ro.disconnect(); + }, + dispose() { renderer.dispose(); scene.traverse((obj) => { const m = obj as THREE.Mesh; @@ -820,21 +858,8 @@ export default function Overview({ workspace }: { workspace: string }) { else mat?.dispose?.(); }); if (mount.contains(canvas)) mount.removeChild(canvas); - }; - }, [mode]); - - useEffect(() => { - if (!snap) return; - const m = mountRef.current as (HTMLDivElement & { __rebuild?: (s: Snapshot) => void }) | null; - m?.__rebuild?.(snap); - }, [snap]); - - return ( -
-
- -
- ); + }, + }; } function HUD({ snap, mode }: { snap: Snapshot | null; mode: ThemeMode }) { diff --git a/apps/desktop/src/components/AiAssistModal.tsx b/apps/desktop/src/components/AiAssistModal.tsx index 0a1b436..508e1fd 100644 --- a/apps/desktop/src/components/AiAssistModal.tsx +++ b/apps/desktop/src/components/AiAssistModal.tsx @@ -28,8 +28,15 @@ export function AiAssistModal({ const ref = useRef(null); const disabled = !prompt.trim() || !providerId; + // Once per open: effects also re-run when a hidden workspace is shown again. + const focused = useRef(false); useEffect(() => { - if (!open) return; + if (!open) { + focused.current = false; + return; + } + if (focused.current) return; + focused.current = true; const el = ref.current; el?.focus(); el?.setSelectionRange(el.value.length, el.value.length); diff --git a/apps/desktop/src/components/MarkdownEditor.tsx b/apps/desktop/src/components/MarkdownEditor.tsx index 783487b..d658c9f 100644 --- a/apps/desktop/src/components/MarkdownEditor.tsx +++ b/apps/desktop/src/components/MarkdownEditor.tsx @@ -789,6 +789,16 @@ function buildTheme(padding: string, fontSize: string, height: string, overflowY }); } +/** Effect cleanup that destroys the view only on a real unmount: an + * hide runs cleanups too, but leaves the host in the document. */ +function teardown(host: HTMLElement, viewRef: { current: EditorView | null }) { + return () => { + if (host.isConnected) return; + viewRef.current?.destroy(); + viewRef.current = null; + }; +} + export function MarkdownEditor({ value, onChange, @@ -816,10 +826,13 @@ export function MarkdownEditor({ cbs.current = { onChange, onSave }; useEffect(() => { - if (!hostRef.current) return; + const host = hostRef.current; + if (!host) return; + // Survived an hide: reuse it, with its scroll, selection and undo. + if (viewRef.current) return teardown(host, viewRef); const view = new EditorView({ doc: value, - parent: hostRef.current, + parent: host, extensions: [ // Vim must come before other keymaps so it can override them. ...(vimMode ? [vim()] : []), @@ -853,10 +866,7 @@ export function MarkdownEditor({ ], }); viewRef.current = view; - return () => { - view.destroy(); - viewRef.current = null; - }; + return teardown(host, viewRef); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/apps/desktop/src/components/NewWorkspaceModal.tsx b/apps/desktop/src/components/NewWorkspaceModal.tsx index 1e76999..781f45a 100644 --- a/apps/desktop/src/components/NewWorkspaceModal.tsx +++ b/apps/desktop/src/components/NewWorkspaceModal.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { Feedback } from "../hooks/useFeedback"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { BaseModal } from "./BaseModal"; import { Button } from "./Button"; import { FeedbackText } from "./FeedbackText"; @@ -22,12 +23,12 @@ export function NewWorkspaceModal({ const [name, setName] = useState(""); const [description, setDescription] = useState(""); - useEffect(() => { + useResetOnChange(open, () => { if (open) { setName(""); setDescription(""); } - }, [open]); + }); const disabled = busy || !name.trim(); const submit = () => onCreate({ name, description }); diff --git a/apps/desktop/src/components/RunAgentModal.tsx b/apps/desktop/src/components/RunAgentModal.tsx index bb33b92..bd2802d 100644 --- a/apps/desktop/src/components/RunAgentModal.tsx +++ b/apps/desktop/src/components/RunAgentModal.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { Feedback } from "../hooks/useFeedback"; import { contextLabel } from "../lib/contextArgs"; +import { useResetOnChange } from "../lib/useResetOnChange"; import type { AgentConfig } from "../types"; import { BaseModal } from "./BaseModal"; import { Button } from "./Button"; @@ -25,9 +26,9 @@ export function RunAgentModal({ }) { const [inputs, setInputs] = useState>({}); - useEffect(() => { + useResetOnChange(open, () => { if (open) setInputs({}); - }, [open]); + }); const requiredOk = agent.args.every((a) => { if (!a.required) return true; diff --git a/apps/desktop/src/components/SearchModal.tsx b/apps/desktop/src/components/SearchModal.tsx index ea37fe6..b5167f0 100644 --- a/apps/desktop/src/components/SearchModal.tsx +++ b/apps/desktop/src/components/SearchModal.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { WorkspaceContext } from "../contexts/WorkspaceContext"; import { SEARCH_KINDS } from "../lib/search"; import { useDebouncedValue } from "../lib/useDebounced"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { useSearch } from "../queries/search"; import type { SearchHit, SearchKind } from "../types"; import { BaseModal } from "./BaseModal"; @@ -33,11 +34,13 @@ export function SearchModal({ onClose }: { onClose: () => void }) { const hits = data ?? []; - useEffect(() => { - setActive(0); - }, [debounced]); + useResetOnChange(debounced, () => setActive(0)); + // Once per `active`, so showing a hidden workspace again doesn't re-scroll. + const scrolledTo = useRef(null); useEffect(() => { + if (scrolledTo.current === active) return; + scrolledTo.current = active; activeRef.current?.scrollIntoView({ block: "nearest" }); }, [active]); diff --git a/apps/desktop/src/components/TabsNav.tsx b/apps/desktop/src/components/TabsNav.tsx index 04941fb..35d94da 100644 --- a/apps/desktop/src/components/TabsNav.tsx +++ b/apps/desktop/src/components/TabsNav.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useLayoutEffect } from "react"; import { NavLink } from "react-router-dom"; import { useHeaderCenterActivate } from "../contexts/HeaderSlotContext"; import { HeaderCenterSlot, HeaderSlot } from "./HeaderSlot"; @@ -10,7 +10,8 @@ import { HeaderCenterSlot, HeaderSlot } from "./HeaderSlot"; * doesn't stay marked active on a sibling sub-route. */ export function TabsNav({ right }: { right?: React.ReactNode } = {}) { const setCenterActive = useHeaderCenterActivate(); - useEffect(() => { + // Before paint, so showing a hidden workspace doesn't flash the plain header. + useLayoutEffect(() => { setCenterActive(true); return () => setCenterActive(false); }, [setCenterActive]); diff --git a/apps/desktop/src/components/chat/ChatRightSidebar.tsx b/apps/desktop/src/components/chat/ChatRightSidebar.tsx index 81f201c..5497ffc 100644 --- a/apps/desktop/src/components/chat/ChatRightSidebar.tsx +++ b/apps/desktop/src/components/chat/ChatRightSidebar.tsx @@ -1,6 +1,6 @@ import { useQueries, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, ChevronRight, ChevronUp, FileDiff, GitCommitHorizontal } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useRightPanel } from "../../contexts/RightPanelProvider"; import { useSnapshot } from "../../contexts/SnapshotContext"; @@ -27,7 +27,7 @@ interface ProjectTarget { // Right-side metadata pane: per-project working-tree diffs + recent commits, // scoped to whatever the conversation is about. -export function ChatRightSidebar({ +export const ChatRightSidebar = memo(function ChatRightSidebar({ workspace, context, refreshNonce, @@ -220,7 +220,7 @@ export function ChatRightSidebar({ ); -} +}); function PanelSection({ label, diff --git a/apps/desktop/src/components/project-form/useProjectForm.ts b/apps/desktop/src/components/project-form/useProjectForm.ts index f3d8989..619ab3e 100644 --- a/apps/desktop/src/components/project-form/useProjectForm.ts +++ b/apps/desktop/src/components/project-form/useProjectForm.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { useResetOnChange } from "../../lib/useResetOnChange"; import { client } from "../../services/client"; import type { Project } from "../../types"; import type { ProjectFormState } from "./ProjectFields"; @@ -27,9 +28,7 @@ export function useProjectForm(ws: string, detail: Project | null) { detail ? formStateFrom(detail) : emptyFormState(), ); - useEffect(() => { - setState(detail ? formStateFrom(detail) : emptyFormState()); - }, [detail]); + useResetOnChange(detail, () => setState(detail ? formStateFrom(detail) : emptyFormState())); const patch = useCallback((k: K, v: ProjectFormState[K]) => { setState((s) => ({ ...s, [k]: v })); diff --git a/apps/desktop/src/components/provider-form/useProviderForm.ts b/apps/desktop/src/components/provider-form/useProviderForm.ts index a7dbc6b..4d4aa41 100644 --- a/apps/desktop/src/components/provider-form/useProviderForm.ts +++ b/apps/desktop/src/components/provider-form/useProviderForm.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { useResetOnChange } from "../../lib/useResetOnChange"; import { client } from "../../services/client"; import type { ProviderMcpEntry, ProviderSummary } from "../../types"; import type { McpRowState, ProviderFormState } from "./ProviderFields"; @@ -62,9 +63,7 @@ export function useProviderForm(detail: ProviderSummary | null) { detail ? formStateFrom(detail) : emptyFormState(), ); - useEffect(() => { - setState(detail ? formStateFrom(detail) : emptyFormState()); - }, [detail]); + useResetOnChange(detail, () => setState(detail ? formStateFrom(detail) : emptyFormState())); const patch = useCallback((k: K, v: ProviderFormState[K]) => { setState((s) => ({ ...s, [k]: v })); diff --git a/apps/desktop/src/components/remote/PairDeviceModal.tsx b/apps/desktop/src/components/remote/PairDeviceModal.tsx index 20c3232..49eabc5 100644 --- a/apps/desktop/src/components/remote/PairDeviceModal.tsx +++ b/apps/desktop/src/components/remote/PairDeviceModal.tsx @@ -19,7 +19,13 @@ export function PairDeviceModal({ onClose }: { onClose: () => void }) { const mint = useMutation({ mutationFn: () => client.remote.beginPairing() }); const { mutate } = mint; - useEffect(() => mutate(), [mutate]); + // Once per open: a second mint would swap out the code on screen. + const minted = useRef(false); + useEffect(() => { + if (minted.current) return; + minted.current = true; + mutate(); + }, [mutate]); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); diff --git a/apps/desktop/src/components/right-panel/DiffPanel.tsx b/apps/desktop/src/components/right-panel/DiffPanel.tsx index 49771ed..c93fd8d 100644 --- a/apps/desktop/src/components/right-panel/DiffPanel.tsx +++ b/apps/desktop/src/components/right-panel/DiffPanel.tsx @@ -33,13 +33,10 @@ export function DiffPanel({ const queryClient = useQueryClient(); const fileRefs = useRef>(new Map()); const { setTitle } = useRightPanel(); - // Track whether we've already scrolled to focusFile so polling refreshes - // don't keep yanking the user back to the original anchor. - const scrolledRef = useRef(false); - - useEffect(() => { - scrolledRef.current = false; - }, [focusFile, focusGroup, workspace, project, branch]); + // The focus target already scrolled to, so polling refreshes don't keep + // yanking the user back to the anchor. + const focusKey = `${workspace}/${project}/${branch ?? ""}/${focusGroup ?? ""}::${focusFile ?? ""}`; + const scrolledRef = useRef(null); const stageMutation = useMutation({ mutationFn: ({ leaf, files }: { leaf: "stage" | "unstage"; files: string[] }) => @@ -60,13 +57,13 @@ export function DiffPanel({ }, [data, setTitle]); useLayoutEffect(() => { - if (!data || !focusFile || scrolledRef.current) return; + if (!data || !focusFile || scrolledRef.current === focusKey) return; const key = `${focusGroup ?? ""}::${focusFile}`; const el = fileRefs.current.get(key); if (!el) return; - scrolledRef.current = true; + scrolledRef.current = focusKey; el.scrollIntoView({ behavior: "auto", block: "start" }); - }, [data, focusFile, focusGroup]); + }, [data, focusFile, focusGroup, focusKey]); const postFiles = useCallback( (leaf: "stage" | "unstage", files: string[]) => stageMutation.mutate({ leaf, files }), diff --git a/apps/desktop/src/contexts/AsideCardContext.tsx b/apps/desktop/src/contexts/AsideCardContext.tsx index a7fee84..1857e7d 100644 --- a/apps/desktop/src/contexts/AsideCardContext.tsx +++ b/apps/desktop/src/contexts/AsideCardContext.tsx @@ -1,5 +1,6 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext } from "react"; import { createPortal } from "react-dom"; +import { useKeptElement } from "../lib/useKeptElement"; const Ctx = createContext<{ el: HTMLElement | null; setEl: (el: HTMLElement | null) => void }>({ el: null, @@ -9,7 +10,7 @@ const Ctx = createContext<{ el: HTMLElement | null; setEl: (el: HTMLElement | nu /** A second content card beside the main one, filled by whichever route wants * one. The target sits outside the main card because that is the whole point. */ export function AsideCardProvider({ children }: { children: React.ReactNode }) { - const [el, setEl] = useState(null); + const [el, setEl] = useKeptElement(); return {children}; } diff --git a/apps/desktop/src/contexts/ChatSidebarContext.tsx b/apps/desktop/src/contexts/ChatSidebarContext.tsx index fee7136..f34b54e 100644 --- a/apps/desktop/src/contexts/ChatSidebarContext.tsx +++ b/apps/desktop/src/contexts/ChatSidebarContext.tsx @@ -1,30 +1,22 @@ -import { createContext, useContext, useMemo, useState } from "react"; +import { createContext, useContext, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useKeptElement } from "../lib/useKeptElement"; import { useLocalStorage } from "../lib/useLocalStorage"; -import type { ConversationContext } from "../types"; - -// Published by the chat view so the right-sidebar can render at RootLayout level. -export type ChatSidebarConfig = { - workspace: string; - context: ConversationContext; - refreshNonce: number; -} | null; interface ChatSidebarValue { open: boolean; toggle: () => void; - config: ChatSidebarConfig; - setConfig: (config: ChatSidebarConfig) => void; } const Ctx = createContext(null); +/** App-wide open/closed preference for the chat right-sidebar. */ export function ChatSidebarProvider({ children }: { children: React.ReactNode }) { const [open, setOpen] = useLocalStorage("modula.chat.right-sidebar-open", true); - const [config, setConfig] = useState(null); const value = useMemo( - () => ({ open, toggle: () => setOpen((v) => !v), config, setConfig }), - [open, setOpen, config], + () => ({ open, toggle: () => setOpen((v) => !v) }), + [open, setOpen], ); return {children}; } @@ -34,3 +26,27 @@ export function useChatSidebar() { if (!ctx) throw new Error("useChatSidebar must be used inside ChatSidebarProvider"); return ctx; } + +const TargetCtx = createContext<{ + el: HTMLElement | null; + setEl: (el: HTMLElement | null) => void; +}>({ el: null, setEl: () => {} }); + +/** Per-workspace slot the chat view portals its right-sidebar into. */ +export function ChatSidebarTargetProvider({ children }: { children: React.ReactNode }) { + const [el, setEl] = useKeptElement(); + return {children}; +} + +/** Layout-level sibling of the content column, like RightPanel. */ +export function ChatSidebarTarget() { + return
; +} + +/** Portaled from the chat view, so the sidebar lives exactly as long as the view. */ +export function ChatSidebarPortal({ children }: { children: React.ReactNode }) { + const { el } = useContext(TargetCtx); + const { open } = useChatSidebar(); + if (!el || !open) return null; + return createPortal(children, el); +} diff --git a/apps/desktop/src/contexts/HeaderSlotContext.tsx b/apps/desktop/src/contexts/HeaderSlotContext.tsx index a2dcefa..ee37c6c 100644 --- a/apps/desktop/src/contexts/HeaderSlotContext.tsx +++ b/apps/desktop/src/contexts/HeaderSlotContext.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useState } from "react"; +import { useKeptElement } from "../lib/useKeptElement"; type Ctx = { el: HTMLElement | null; @@ -19,8 +20,8 @@ const HeaderSlotContext = createContext({ }); export function HeaderSlotProvider({ children }: { children: React.ReactNode }) { - const [el, setEl] = useState(null); - const [centerEl, setCenterEl] = useState(null); + const [el, setEl] = useKeptElement(); + const [centerEl, setCenterEl] = useKeptElement(); const [centerActive, setCenterActive] = useState(false); return ( (null); @@ -9,7 +10,7 @@ export function ModalPortalProvider({ className?: string; children: React.ReactNode; }) { - const [node, setNode] = useState(null); + const [node, setNode] = useKeptElement(); return (
diff --git a/apps/desktop/src/contexts/SnapshotContext.tsx b/apps/desktop/src/contexts/SnapshotContext.tsx index c73a75b..7a44935 100644 --- a/apps/desktop/src/contexts/SnapshotContext.tsx +++ b/apps/desktop/src/contexts/SnapshotContext.tsx @@ -1,14 +1,14 @@ import { createContext, useContext } from "react"; import type { Snapshot } from "../types"; -/** Workspace state that's continuously refreshed by the SSE stream — tasks, - * roadmap, running agents, logs, config. Provided once at the RootLayout - * level; every view consumes it via useSnapshot() rather than receiving - * snapshot data as props. +/** Workspace state kept live by the engine event stream — tasks, roadmap, + * running agents, logs, config. Provided once per workspace by + * WorkspaceLayout; every view consumes it via useSnapshot() rather than + * receiving snapshot data as props. * - * `snap` is null during the brief window between workspace-switch and the - * first SSE message; consumers should handle that case (most views are only - * rendered after RootLayout's "connecting…" guard, so this is rare). */ + * `snap` is null until a workspace's first snapshot fetch lands; consumers + * should handle that case (most views are only rendered after + * WorkspaceLayout's "connecting…" guard, so this is rare). */ interface SnapshotState { snap: Snapshot | null; } @@ -26,7 +26,7 @@ export function SnapshotProvider({ } /** Returns the live snapshot. Components that can't tolerate `null` should - * be rendered after RootLayout's connecting-state guard so this is non-null. */ + * be rendered after WorkspaceLayout's connecting-state guard so this is non-null. */ export function useSnapshot(): SnapshotState { return useContext(SnapshotContext); } diff --git a/apps/desktop/src/contexts/WorkspaceContext.tsx b/apps/desktop/src/contexts/WorkspaceContext.tsx index 8e3be23..9e358f9 100644 --- a/apps/desktop/src/contexts/WorkspaceContext.tsx +++ b/apps/desktop/src/contexts/WorkspaceContext.tsx @@ -1,7 +1,21 @@ import { createContext } from "react"; +import type { WorkspaceInfo } from "../types"; -/** Current workspace id, available to descendants via useContext. Empty - * string means "no workspace selected yet" — happens briefly on first load - * before `WorkspaceService.List` returns. The `useWorkspaceState` hook auto-selects - * the first available workspace once the list arrives. */ +/** The workspace a subtree belongs to. Each visited workspace mounts its own + * layout, so this is fixed for the life of that subtree. */ export const WorkspaceContext = createContext(""); + +/** The active workspace, the list, and the switcher, shared by every layout. */ +interface WorkspaceSwitcherValue { + active: string; + workspaces: WorkspaceInfo[]; + setWorkspace: (ws: string) => void; + refreshWorkspaces: () => void; +} + +export const WorkspaceSwitcherContext = createContext({ + active: "", + workspaces: [], + setWorkspace: () => {}, + refreshWorkspaces: () => {}, +}); diff --git a/apps/desktop/src/lib/useKeptElement.ts b/apps/desktop/src/lib/useKeptElement.ts new file mode 100644 index 0000000..7752a80 --- /dev/null +++ b/apps/desktop/src/lib/useKeptElement.ts @@ -0,0 +1,13 @@ +import { useCallback, useState } from "react"; + +/** A portal target held in state, as `[el, ref]`. Hiding an `` detaches + * refs but keeps the node, so the target is cleared only once the node is gone. */ +export function useKeptElement() { + const [el, setEl] = useState(null); + const ref = useCallback((node: T | null) => { + if (node) setEl(node); + // Deferred: React detaches before it removes the node from the DOM. + else queueMicrotask(() => setEl((prev) => (prev?.isConnected ? prev : null))); + }, []); + return [el, ref] as const; +} diff --git a/apps/desktop/src/lib/useLocalStorage.ts b/apps/desktop/src/lib/useLocalStorage.ts index 060bc11..83c9d88 100644 --- a/apps/desktop/src/lib/useLocalStorage.ts +++ b/apps/desktop/src/lib/useLocalStorage.ts @@ -17,7 +17,11 @@ export function useLocalStorage(key: string, initial: T): [T, Dispatch(null); useEffect(() => { + if (written.current?.key === key && Object.is(written.current.value, value)) return; + written.current = { key, value }; try { localStorage.setItem(key, JSON.stringify(value)); } catch {} diff --git a/apps/desktop/src/lib/useResetOnChange.ts b/apps/desktop/src/lib/useResetOnChange.ts new file mode 100644 index 0000000..2d94694 --- /dev/null +++ b/apps/desktop/src/lib/useResetOnChange.ts @@ -0,0 +1,14 @@ +import { useState } from "react"; + +const UNSET = Symbol("unset"); + +/** Runs `reset` during render on mount and whenever `key` changes; `reset` may + * only set the caller's own state. An effect would also re-run when a hidden + * workspace is shown again and wipe what the user left there. */ +export function useResetOnChange(key: unknown, reset: () => void) { + const [prev, setPrev] = useState(UNSET); + if (!Object.is(prev, key)) { + setPrev(key); + reset(); + } +} diff --git a/apps/desktop/src/queries/queryClient.ts b/apps/desktop/src/queries/queryClient.ts index 7af0087..47eb601 100644 --- a/apps/desktop/src/queries/queryClient.ts +++ b/apps/desktop/src/queries/queryClient.ts @@ -2,11 +2,13 @@ import { QueryClient } from "@tanstack/react-query"; // Single module-scope client so the cache survives re-renders. Desktop app: // focus refetches are noisy, so they're off; 30s staleTime kills re-open flashing. +// Hidden workspaces drop their observers, so a finite gcTime would evict what +// they show. export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, - gcTime: 5 * 60_000, + gcTime: Number.POSITIVE_INFINITY, refetchOnWindowFocus: false, retry: 1, }, diff --git a/apps/desktop/src/router.tsx b/apps/desktop/src/router.tsx index 770b472..14eda38 100644 --- a/apps/desktop/src/router.tsx +++ b/apps/desktop/src/router.tsx @@ -1,4 +1,4 @@ -import { createBrowserRouter, Navigate } from "react-router-dom"; +import { createMemoryRouter, Navigate, type RouteObject } from "react-router-dom"; import { AgentEditPage } from "./views/AgentEdit"; import { AgentsView } from "./views/Agents"; import { ConversationDetailPage, ConversationsView } from "./views/Conversations"; @@ -9,15 +9,15 @@ import { ProjectsView } from "./views/Projects"; import { ProviderEditPage } from "./views/ProviderEdit"; import { ProvidersView } from "./views/Providers"; import { RoadmapView } from "./views/Roadmap"; -import { RootLayout } from "./views/RootLayout"; import { SettingsView } from "./views/Settings"; import { TaskDetailPage, TasksView } from "./views/Tasks"; import { UsageView } from "./views/Usage"; import { WikiView } from "./views/Wiki"; +import { WorkspaceLayout } from "./views/WorkspaceLayout"; -/** All routes. Single source of truth. The layout route owns app-wide state - * (workspace, snapshot, selection, pipeline) and renders the Header; child - * routes render into its . +/** One workspace's routes. Each visited workspace gets its own memory router, + * so it keeps its location and history while another is on screen. + * `WorkspaceLayout` renders the chrome and the matched child into its . * * URL conventions: * / — redirects to /tasks @@ -36,60 +36,70 @@ import { WikiView } from "./views/Wiki"; * /providers — providers CRUD * /overview — three.js operations grid */ -export const router = createBrowserRouter([ +const routes: RouteObject[] = [ + { index: true, element: }, { - path: "/", - element: , + path: "tasks", children: [ - { index: true, element: }, - { - path: "tasks", - children: [ - { index: true, element: }, - { path: ":id", element: }, - ], - }, - { path: "roadmap", element: }, - { - path: "agents", - children: [ - { index: true, element: }, - { path: "new", element: }, - { path: "edit/:id", element: }, - { path: "logs", element: }, - { path: "logs/:log", element: }, - { path: "usage", element: }, - ], - }, - { - path: "projects", - children: [ - { index: true, element: }, - { path: "new", element: }, - { path: "edit/:id", element: }, - ], - }, - { - path: "providers", - children: [ - { index: true, element: }, - { path: "new", element: }, - { path: "edit/:id", element: }, - ], - }, - { path: "wiki", element: }, - { path: "overview", element: }, - { path: "settings", element: }, - { - path: "conversations", - children: [ - { index: true, element: }, - { path: ":id", element: }, - ], - }, - - // Catch-all → tasks. - { path: "*", element: }, + { index: true, element: }, + { path: ":id", element: }, + ], + }, + { path: "roadmap", element: }, + { + path: "agents", + children: [ + { index: true, element: }, + { path: "new", element: }, + { path: "edit/:id", element: }, + { path: "logs", element: }, + { path: "logs/:log", element: }, + { path: "usage", element: }, ], }, -]); + { + path: "projects", + children: [ + { index: true, element: }, + { path: "new", element: }, + { path: "edit/:id", element: }, + ], + }, + { + path: "providers", + children: [ + { index: true, element: }, + { path: "new", element: }, + { path: "edit/:id", element: }, + ], + }, + { path: "wiki", element: }, + { path: "overview", element: }, + { path: "settings", element: }, + { + path: "conversations", + children: [ + { index: true, element: }, + { path: ":id", element: }, + ], + }, + + // Catch-all → tasks. + { path: "*", element: }, +]; + +type WorkspaceRouter = ReturnType; + +const routers = new Map(); + +export function workspaceRouter(ws: string): WorkspaceRouter { + let router = routers.get(ws); + if (!router) { + router = createMemoryRouter( + [{ path: "/", element: , children: routes }], + { initialEntries: ["/tasks"] }, + ); + routers.set(ws, router); + } + return router; +} diff --git a/apps/desktop/src/views/AgentEdit.tsx b/apps/desktop/src/views/AgentEdit.tsx index 71fee5e..f9b59b0 100644 --- a/apps/desktop/src/views/AgentEdit.tsx +++ b/apps/desktop/src/views/AgentEdit.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AgentIdenticon } from "../components/AgentIdenticon"; import { AiAssist, AiAssistTrigger } from "../components/AiAssist"; @@ -23,6 +23,7 @@ import { contextLabel } from "../lib/contextArgs"; import type { ProviderModel } from "../lib/providerCatalog"; import { ProviderTypeIcon } from "../lib/providerTypes"; import { linesToRules, parseRules, serializeRules } from "../lib/rules"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { useAgent, useAgentSkills } from "../queries/agent"; import { useProviderCatalog } from "../queries/catalog"; import { useProviders } from "../queries/provider"; @@ -145,9 +146,7 @@ function AgentForm({ detail }: { detail: AgentDetail | null }) { const optionalSkills = skillCatalog.filter((s) => !s.hidden); // Re-seed when a different detail arrives (workspace switch, refetch). - useEffect(() => { - setState(detail ? formStateFrom(detail) : emptyFormState()); - }, [detail]); + useResetOnChange(detail, () => setState(detail ? formStateFrom(detail) : emptyFormState())); function toggleSkill(slug: string) { setState((s) => ({ diff --git a/apps/desktop/src/views/Conversations.tsx b/apps/desktop/src/views/Conversations.tsx index 48dd750..efe6884 100644 --- a/apps/desktop/src/views/Conversations.tsx +++ b/apps/desktop/src/views/Conversations.tsx @@ -6,6 +6,7 @@ import { AiAssist, AiAssistTrigger } from "../components/AiAssist"; import { Button } from "../components/Button"; import { ChatInput } from "../components/chat/ChatInput"; import { ChatInputShell } from "../components/chat/ChatInputShell"; +import { ChatRightSidebar } from "../components/chat/ChatRightSidebar"; import { ContextPills } from "../components/chat/ContextPills"; import { MessageList } from "../components/chat/MessageList"; import { QueuedMessages } from "../components/chat/QueuedMessages"; @@ -13,12 +14,13 @@ import { SendButton } from "../components/chat/SendButton"; import { DropdownSelect } from "../components/DropdownMenu"; import { HeaderSlot } from "../components/HeaderSlot"; import { IconButton } from "../components/IconButton"; -import { useChatSidebar } from "../contexts/ChatSidebarContext"; +import { ChatSidebarPortal, useChatSidebar } from "../contexts/ChatSidebarContext"; import { useConversationStream } from "../contexts/ConversationStreamProvider"; import { useSnapshot } from "../contexts/SnapshotContext"; import { WorkspaceContext } from "../contexts/WorkspaceContext"; import { ProviderTypeIcon } from "../lib/providerTypes"; import { useLocalStorage } from "../lib/useLocalStorage"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { useProviderCatalog } from "../queries/catalog"; import { conversationKeys, useConversation } from "../queries/conversation"; import { client, errorMessage } from "../services/client"; @@ -62,9 +64,7 @@ export function ConversationsView() { }, [providers, providerId]); // Reset model when provider changes — the previous pick may not exist for the new type. - useEffect(() => { - setModel(null); - }, [providerId]); + useResetOnChange(providerId, () => setModel(null)); const providerType = providers.find((p) => p.id === providerId)?.type ?? null; const availableModels = providerType @@ -199,6 +199,8 @@ export function ConversationsView() { // ─── Thread (/conversations/:id) ─────────────────────────────────────────── +const NO_CONTEXT: ConversationContext = {}; + export function ConversationDetailPage() { const ws = useContext(WorkspaceContext); const { snap } = useSnapshot(); @@ -218,11 +220,7 @@ export function ConversationDetailPage() { `modula.chat.model.${ws}.${id ?? ""}`, null, ); - const { - open: sidebarOpen, - toggle: toggleSidebar, - setConfig: setChatSidebarConfig, - } = useChatSidebar(); + const { open: sidebarOpen, toggle: toggleSidebar } = useChatSidebar(); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); const userScrolledRef = useRef(false); @@ -242,10 +240,10 @@ export function ConversationDetailPage() { // Sync server-loaded messages into local state (which also holds optimistic // sends); on first load adopt the conversation's persisted model. - useEffect(() => { + useResetOnChange(conv, () => { setMessages(conv ? conv.messages : []); if (conv) setSelectedModel((prev) => (prev === null ? conv.model : prev)); - }, [conv]); + }); const { inFlightText, inFlightTools, streaming, error, injected, send, attach, cancel } = useConversationStream(ws, id ?? ""); @@ -274,23 +272,6 @@ export function ConversationDetailPage() { } }, [injected, ws, id, queryClient]); - // Publish config for the layout-level right-sidebar; cleared on unmount. - useEffect(() => { - setChatSidebarConfig({ - workspace: ws, - context: conv?.context ?? {}, - refreshNonce: sidebarNonce, - }); - }, [ - ws, - conv?.context?.project, - conv?.context?.task, - conv?.context?.variant, - sidebarNonce, - setChatSidebarConfig, - ]); - useEffect(() => () => setChatSidebarConfig(null), [setChatSidebarConfig]); - const provider = conv ? (snap?.config?.providers.find((p) => p.id === conv.provider_id) ?? null) : null; @@ -326,7 +307,7 @@ export function ConversationDetailPage() { // or the message is gone with no signal. const [queueError, setQueueError] = useState(null); // Stream errors are held per conversation id; this one is not, so drop it on navigation. - useEffect(() => setQueueError(null), [id]); + useResetOnChange(id, () => setQueueError(null)); const settleQueue = useCallback( (p: Promise) => { p.then(() => setQueueError(null)) @@ -359,11 +340,15 @@ export function ConversationDetailPage() { } }, [conv, initialMsg, handleSend]); - useEffect(() => { + // Keyed so only a conversation change resets, before the positioning below. + const positionedKeyRef = useRef(scrollKey); + useLayoutEffect(() => { + if (positionedKeyRef.current === scrollKey) return; + positionedKeyRef.current = scrollKey; initialPositionedRef.current = false; userScrolledRef.current = false; lastLength.current = 0; - }, [ws, id]); + }, [scrollKey]); // Position synchronously before paint to avoid a top-then-bottom scroll flash. useLayoutEffect(() => { @@ -456,6 +441,13 @@ export function ConversationDetailPage() { )} + + +
diff --git a/apps/desktop/src/views/LogViewer.tsx b/apps/desktop/src/views/LogViewer.tsx index a5237be..4de7c56 100644 --- a/apps/desktop/src/views/LogViewer.tsx +++ b/apps/desktop/src/views/LogViewer.tsx @@ -9,23 +9,33 @@ export function LogViewer({ name }: { name: string }) { const [entries, setEntries] = useState([]); const scrollRef = useRef(null); const stickToBottom = useRef(true); + const tail = useRef<{ key: string; channel: Channel } | null>(null); useEffect(() => { if (!ws) return; - setEntries([]); - // One newline-terminated line per chunk from the engine's log-tail stream. - const channel = new Channel(); - channel.onmessage = (chunk) => { - const parsed = parseEvent(chunk.trimEnd()); - if (parsed.length === 0) return; - setEntries((prev) => [...prev, ...parsed]); - }; - invoke("log_stream", { workspaceId: ws, logName: name, onChunk: channel }).catch(() => { - // Stream ended or the engine went away; nothing to surface here. - }); - // Detaching the channel ends the tail on the engine without affecting the run. + const key = `${ws}/${name}`; + if (tail.current?.key !== key) { + // Detaching the channel ends the tail on the engine without affecting the run. + if (tail.current) tail.current.channel.onmessage = () => {}; + setEntries([]); + // One newline-terminated line per chunk from the engine's log-tail stream. + const channel = new Channel(); + channel.onmessage = (chunk) => { + const parsed = parseEvent(chunk.trimEnd()); + if (parsed.length === 0) return; + setEntries((prev) => [...prev, ...parsed]); + }; + invoke("log_stream", { workspaceId: ws, logName: name, onChunk: channel }).catch(() => { + // Stream ended or the engine went away; nothing to surface here. + }); + tail.current = { key, channel }; + } + const host = scrollRef.current; return () => { - channel.onmessage = () => {}; + // Still in the document means hidden, not unmounted: keep tailing. + if (host?.isConnected || !tail.current) return; + tail.current.channel.onmessage = () => {}; + tail.current = null; }; }, [ws, name]); diff --git a/apps/desktop/src/views/RootLayout.tsx b/apps/desktop/src/views/RootLayout.tsx index 522a94a..6487e49 100644 --- a/apps/desktop/src/views/RootLayout.tsx +++ b/apps/desktop/src/views/RootLayout.tsx @@ -1,41 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Outlet, useLocation } from "react-router-dom"; -import { ChatRightSidebar } from "../components/chat/ChatRightSidebar"; -import { RightPanel, RightPanelCard } from "../components/right-panel/RightPanel"; -import { SearchModal } from "../components/SearchModal"; -import { AsideCardProvider, AsideCardTarget } from "../contexts/AsideCardContext"; -import { ChatSidebarProvider, useChatSidebar } from "../contexts/ChatSidebarContext"; +import { Fragment, useEffect, useState } from "react"; +import { RouterProvider } from "react-router-dom"; +import { ChatSidebarProvider } from "../contexts/ChatSidebarContext"; import { ConversationStreamProvider } from "../contexts/ConversationStreamProvider"; -import { HeaderSlotProvider } from "../contexts/HeaderSlotContext"; -import { ModalPortalProvider } from "../contexts/ModalPortalContext"; -import { PipelineContext } from "../contexts/PipelineContext"; -import { RightPanelProvider } from "../contexts/RightPanelProvider"; -import { ShortcutsProvider, useShortcut } from "../contexts/ShortcutsContext"; -import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; -import { SnapshotProvider, useSnapshot } from "../contexts/SnapshotContext"; -import { ThemeProvider, useThemeContext } from "../contexts/ThemeContext"; +import { ShortcutsProvider } from "../contexts/ShortcutsContext"; +import { SidebarProvider } from "../contexts/SidebarContext"; +import { ThemeProvider } from "../contexts/ThemeContext"; import { ToastProvider } from "../contexts/ToastContext"; -import { WorkspaceContext } from "../contexts/WorkspaceContext"; +import { WorkspaceSwitcherContext } from "../contexts/WorkspaceContext"; import { useEngineEvents } from "../hooks/useEngineEvents"; import { useSuppressContextMenu } from "../hooks/useSuppressContextMenu"; import { useTitlebarDrag } from "../hooks/useTitlebarDrag"; import { useWorkspaceState } from "../hooks/useWorkspaceState"; -import { getPipeline } from "../lib/pipeline"; import { useLocalStorage } from "../lib/useLocalStorage"; -import { useSnapshotQuery } from "../queries/snapshot"; -import { windowButtons } from "../tauri/window"; -import { Header } from "./Header"; +import { workspaceRouter } from "../router"; import { Onboarding } from "./Onboarding"; -import { Sidebar } from "./Sidebar"; -import { Titlebar } from "./Titlebar"; -/** Top-level layout route. Owns the long-lived state (workspace + SSE stream) - * and exposes it to descendants via context. Always renders the Header and - * Sidebar; the outlet renders whichever child route matched. - * - * While the snapshot is loading we still render the Header and Sidebar so nav - * is usable; the body shows a "connecting…" hint until the first SSE message - * arrives. */ +/** App shell: the workspace selection plus the state every workspace shares. */ export function RootLayout() { // Native window drag + double-click-to-maximize for the overlay titlebar. // Mounted here, above both the onboarding and app trees, so every screen is @@ -82,134 +62,36 @@ function EngineEvents({ workspace }: { workspace: string }) { function AppRoot({ wsState }: { wsState: ReturnType }) { const { workspace, workspaces, setWorkspace, refreshWorkspaces } = wsState; - const snap = useSnapshotQuery(workspace).data ?? null; - const pipeline = useMemo(() => getPipeline(snap), [snap]); return ( - - - - - - - - - - - - - - + + + + + + + ); } -/** Body that runs INSIDE all the providers, so it can consume their context - * (snapshot, location) without wrapping the whole tree. */ -function RootLayoutBody({ - workspace, - workspaces, - setWorkspace, - refreshWorkspaces, -}: { - workspace: string; - workspaces: ReturnType["workspaces"]; - setWorkspace: (ws: string) => void; - refreshWorkspaces: () => void; -}) { - const { snap } = useSnapshot(); - const { glass } = useThemeContext(); - const location = useLocation(); - const { notifyRegionWidth } = useSidebarContext(); - const navRef = useRef(null); - const contentRef = useRef(null); - const [searchOpen, setSearchOpen] = useState(false); +/** Every visited workspace stays mounted under its own router; `WorkspaceLayout` + * hides the inactive ones. Each keeps its event watch so its cache stays current. */ +function WorkspaceViews({ active }: { active: string }) { + const [visited, setVisited] = useState([]); + if (active && !visited.includes(active)) setVisited([...visited, active]); - useShortcut("mod+k", () => setSearchOpen(true)); - - // Windows acrylic reads more solid than macOS vibrancy, so it needs less tint. - let plate = "bg-chrome"; - if (glass) plate = windowButtons() === "system" ? "bg-chrome/80" : "bg-chrome/70"; - - // Scroll to top on route change so deep-scrolled pages don't carry over. - useEffect(() => { - window.scrollTo(0, 0); - }, [location.pathname]); - - // Nav + content = window minus the drawers and aside card. Summing the two - // keeps it constant as the nav animates, so collapsing never feeds back in. - useEffect(() => { - const nav = navRef.current; - const content = contentRef.current; - if (!nav || !content) return; - const measure = () => notifyRegionWidth(nav.offsetWidth + content.offsetWidth); - const ro = new ResizeObserver(measure); - ro.observe(nav); - ro.observe(content); - measure(); - return () => ro.disconnect(); - }, [notifyRegionWidth]); - - return ( - // Base plate: title bar and sidebar sit flat on it, the content card above it. - - - -
- setSearchOpen(true)} - /> -
- -
-
-
- {snap ? ( - - ) : ( -
- connecting… -
- )} -
-
-
- - -
- - - {searchOpen && setSearchOpen(false)} />} -
-
-
- ); -} - -/** Chat right-sidebar as a layout-level sibling of the content column, like RightPanel. */ -function ChatRightSidebarHost() { - const { open, config } = useChatSidebar(); - if (!open || !config) return null; - return ( - - ); + return visited.map((ws) => ( + + + + + )); } diff --git a/apps/desktop/src/views/Sidebar.tsx b/apps/desktop/src/views/Sidebar.tsx index 19f2597..0c16070 100644 --- a/apps/desktop/src/views/Sidebar.tsx +++ b/apps/desktop/src/views/Sidebar.tsx @@ -15,7 +15,7 @@ import { Settings, Trash2, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; import { ConfirmModal } from "../components/ConfirmModal"; import { ContextMenu } from "../components/ContextMenu"; @@ -28,6 +28,7 @@ import { useStreamingConvIds } from "../contexts/ConversationStreamProvider"; import { useSidebarContext } from "../contexts/SidebarContext"; import { useSnapshot } from "../contexts/SnapshotContext"; import { useAppUpdate } from "../hooks/useAppUpdate"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { client } from "../services/client"; import type { ConversationSummary, WorkspaceInfo } from "../types"; @@ -92,9 +93,9 @@ function SidebarItem({ const [expanded, setExpanded] = useState(hasActiveChild || !!item.defaultOpen); - useEffect(() => { + useResetOnChange(hasActiveChild, () => { if (hasActiveChild) setExpanded(true); - }, [hasActiveChild]); + }); const indent = depth > 0 ? "pl-8 pr-3" : "px-3"; const rowLayout = sidebarOpen @@ -259,7 +260,7 @@ export function Sidebar({ onSwitchWorkspace: (ws: string) => void; onRefreshWorkspaces: () => void; onOpenSearch: () => void; - /** RootLayout measures the nav's live width for the auto-collapse decision. */ + /** WorkspaceLayout measures the nav's live width for the auto-collapse decision. */ ref?: React.Ref; }) { const { open } = useSidebarContext(); diff --git a/apps/desktop/src/views/TaskDetail.tsx b/apps/desktop/src/views/TaskDetail.tsx index acb4480..3da4af5 100644 --- a/apps/desktop/src/views/TaskDetail.tsx +++ b/apps/desktop/src/views/TaskDetail.tsx @@ -41,6 +41,7 @@ import { WorkspaceContext } from "../contexts/WorkspaceContext"; import { useFeedback } from "../hooks/useFeedback"; import { toneDotClasses } from "../lib/pipeline"; import { externalStatusTextClass, sourceLabel, variantStatusTone } from "../lib/task"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { useVariantPr } from "../queries/diff"; import { taskKeys, useTaskAgentSettings, useTaskBranches } from "../queries/task"; import { threadKeys, useThreads } from "../queries/thread"; @@ -180,9 +181,7 @@ export function TaskDetail({ const dirtyRef = useRef(dirty); dirtyRef.current = dirty; // Switching tasks always resets the form to the new task's values. - useEffect(() => { - setForm(taskToForm(task)); - }, [task.id]); + useResetOnChange(task.id, () => setForm(taskToForm(task))); // For same-id updates (snapshot reflecting our own save, or someone else's // edit), only sync if the form has no in-flight local edits. useEffect(() => { @@ -711,7 +710,7 @@ function AgentLoopSetting({ onSave: (amount: number) => void; }) { const [val, setVal] = useState(String(amount)); - useEffect(() => setVal(String(amount)), [amount]); + useResetOnChange(amount, () => setVal(String(amount))); const commit = () => { const n = Math.max(1, Math.min(100, parseInt(val, 10) || 1)); setVal(String(n)); @@ -1186,11 +1185,18 @@ function ThreadEntryView({ entry, taskId }: { entry: ThreadEntry; taskId: string el.style.height = `${el.scrollHeight}px`; }, [editing, draft]); - // On entering edit mode, focus with the caret at the end. + // On entering edit mode, focus with the caret at the end. Once per entry: + // effects also re-run when a hidden workspace is shown again. + const focused = useRef(false); useEffect(() => { - if (!editing) return; + if (!editing) { + focused.current = false; + return; + } + if (focused.current) return; const el = textareaRef.current; if (!el) return; + focused.current = true; el.focus(); el.setSelectionRange(el.value.length, el.value.length); }, [editing]); diff --git a/apps/desktop/src/views/Tasks.tsx b/apps/desktop/src/views/Tasks.tsx index 2cbf92b..9d7ef87 100644 --- a/apps/desktop/src/views/Tasks.tsx +++ b/apps/desktop/src/views/Tasks.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronRight, Download, Plus } from "lucide-react"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AiAssist, AiAssistTrigger } from "../components/AiAssist"; import { BaseModal } from "../components/BaseModal"; @@ -16,6 +16,7 @@ import { useSnapshot } from "../contexts/SnapshotContext"; import { WorkspaceContext } from "../contexts/WorkspaceContext"; import { pipelineLabel, pipelineStatusFor, pipelineTone, toneDotClasses } from "../lib/pipeline"; import { externalStatusTextClass, variantStatusTone } from "../lib/task"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { client, errorMessage } from "../services/client"; import type { Agent, RoadmapItem, Task } from "../types"; import { logPath } from "./Logs"; @@ -323,13 +324,13 @@ function NewTaskForm({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - useEffect(() => { + useResetOnChange(open, () => { if (open) { setTitle(""); setDescription(""); setError(null); } - }, [open]); + }); async function save() { if (!title.trim()) return; diff --git a/apps/desktop/src/views/Titlebar.tsx b/apps/desktop/src/views/Titlebar.tsx index ae90caa..92e088d 100644 --- a/apps/desktop/src/views/Titlebar.tsx +++ b/apps/desktop/src/views/Titlebar.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { NavigationType, useLocation, useNavigate, useNavigationType } from "react-router-dom"; import { IconButton } from "../components/IconButton"; import { WindowControls } from "../components/WindowControls"; import { useSidebarContext } from "../contexts/SidebarContext"; @@ -10,25 +11,17 @@ import { windowButtons } from "../tauri/window"; export function Titlebar() { const { open, toggle } = useSidebarContext(); const navigate = useNavigate(); - const location = useLocation(); - - // `location` is read so back/forward recompute on every navigation. - const historyIdx = (window.history.state?.idx as number | undefined) ?? 0; - void location; + const { canBack, canForward } = useHistoryBounds(); return ( // z-60 keeps the window buttons live above modals; pl clears the macOS lights.
- navigate(-1)} disabled={historyIdx <= 0} title="Back"> + navigate(-1)} disabled={!canBack} title="Back"> - navigate(1)} - disabled={historyIdx >= window.history.length - 1} - title="Forward" - > + navigate(1)} disabled={!canForward} title="Forward"> ); } + +/** Back/forward availability. The workspace's memory router keeps its history + * index private, so mirror the entry stack from location keys. */ +function useHistoryBounds() { + const { key } = useLocation(); + const action = useNavigationType(); + const [h, setH] = useState({ keys: [key], idx: 0 }); + if (h.keys[h.idx] !== key) { + if (action === NavigationType.Push) { + setH({ keys: [...h.keys.slice(0, h.idx + 1), key], idx: h.idx + 1 }); + } else if (action === NavigationType.Replace) { + setH({ keys: h.keys.map((k, i) => (i === h.idx ? key : k)), idx: h.idx }); + } else { + const idx = h.keys.indexOf(key); + setH(idx >= 0 ? { keys: h.keys, idx } : { keys: [key], idx: 0 }); + } + } + return { canBack: h.idx > 0, canForward: h.idx < h.keys.length - 1 }; +} diff --git a/apps/desktop/src/views/Wiki.tsx b/apps/desktop/src/views/Wiki.tsx index 32c3bc0..ffb7796 100644 --- a/apps/desktop/src/views/Wiki.tsx +++ b/apps/desktop/src/views/Wiki.tsx @@ -10,6 +10,7 @@ import { PromptModal } from "../components/PromptModal"; import { Spinner } from "../components/Spinner"; import { useShortcut } from "../contexts/ShortcutsContext"; import { WorkspaceContext } from "../contexts/WorkspaceContext"; +import { useResetOnChange } from "../lib/useResetOnChange"; import { useWikiFile, useWikiTree, wikiKeys } from "../queries/wiki"; import type { WikiFile, WikiFileBody, WikiNode } from "../services/client"; import { client, errorMessage } from "../services/client"; @@ -107,9 +108,7 @@ export function WikiView() { // Sync the editable draft from the server copy whenever it (re)loads, and // clear it when no file is selected (the query is disabled, `file` undefined). - useEffect(() => { - setDraft(file ? file.content : null); - }, [file]); + useResetOnChange(file, () => setDraft(file ? file.content : null)); // Reset selection on workspace switch. The ref guard keeps this off the mount // pass, where it would clear a page just deep-linked to. diff --git a/apps/desktop/src/views/WorkspaceLayout.tsx b/apps/desktop/src/views/WorkspaceLayout.tsx new file mode 100644 index 0000000..8863644 --- /dev/null +++ b/apps/desktop/src/views/WorkspaceLayout.tsx @@ -0,0 +1,144 @@ +import { Activity, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { RightPanel, RightPanelCard } from "../components/right-panel/RightPanel"; +import { SearchModal } from "../components/SearchModal"; +import { AsideCardProvider, AsideCardTarget } from "../contexts/AsideCardContext"; +import { ChatSidebarTarget, ChatSidebarTargetProvider } from "../contexts/ChatSidebarContext"; +import { HeaderSlotProvider } from "../contexts/HeaderSlotContext"; +import { ModalPortalProvider } from "../contexts/ModalPortalContext"; +import { PipelineContext } from "../contexts/PipelineContext"; +import { RightPanelProvider } from "../contexts/RightPanelProvider"; +import { useShortcut } from "../contexts/ShortcutsContext"; +import { useSidebarContext } from "../contexts/SidebarContext"; +import { SnapshotProvider, useSnapshot } from "../contexts/SnapshotContext"; +import { useThemeContext } from "../contexts/ThemeContext"; +import { WorkspaceContext, WorkspaceSwitcherContext } from "../contexts/WorkspaceContext"; +import { getPipeline } from "../lib/pipeline"; +import { useSnapshotQuery } from "../queries/snapshot"; +import { windowButtons } from "../tauri/window"; +import { Header } from "./Header"; +import { Sidebar } from "./Sidebar"; +import { Titlebar } from "./Titlebar"; + +/** Root route of a workspace's router. `` keeps a hidden workspace's + * DOM (scroll included) and state but tears down its effects, which re-run when + * it is shown again. It sits inside the router so a navigation fired while + * hidden still lands. */ +export function WorkspaceLayout({ workspace }: { workspace: string }) { + const { active } = useContext(WorkspaceSwitcherContext); + return ( + + + + ); +} + +/** While the snapshot is loading we still render the Header and Sidebar so nav + * is usable; the body shows a "connecting…" hint until the first fetch lands. */ +function WorkspaceProviders({ workspace }: { workspace: string }) { + const snap = useSnapshotQuery(workspace).data ?? null; + const pipeline = useMemo(() => getPipeline(snap), [snap]); + + return ( + + + + + + + + + + + + ); +} + +/** Body that runs INSIDE the workspace providers, so it can consume their + * context (snapshot, location) without wrapping the whole tree. */ +function WorkspaceLayoutBody({ workspace }: { workspace: string }) { + const { workspaces, setWorkspace, refreshWorkspaces } = useContext(WorkspaceSwitcherContext); + const { snap } = useSnapshot(); + const { glass } = useThemeContext(); + const location = useLocation(); + const { notifyRegionWidth } = useSidebarContext(); + const navRef = useRef(null); + const contentRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + + useShortcut("mod+k", () => setSearchOpen(true)); + + // Windows acrylic reads more solid than macOS vibrancy, so it needs less tint. + let plate = "bg-chrome"; + if (glass) plate = windowButtons() === "system" ? "bg-chrome/80" : "bg-chrome/70"; + + // Scroll to top on route change so deep-scrolled pages don't carry over. + useEffect(() => { + window.scrollTo(0, 0); + }, [location.pathname]); + + // Hiding the workspace blurs whatever it had focused; put focus back on return. + const lastFocused = useRef(null); + useLayoutEffect(() => { + if (lastFocused.current?.isConnected) lastFocused.current.focus({ preventScroll: true }); + }, []); + + // Nav + content = window minus the drawers and aside card. Summing the two + // keeps it constant as the nav animates, so collapsing never feeds back in. + useEffect(() => { + const nav = navRef.current; + const content = contentRef.current; + if (!nav || !content) return; + const measure = () => notifyRegionWidth(nav.offsetWidth + content.offsetWidth); + const ro = new ResizeObserver(measure); + ro.observe(nav); + ro.observe(content); + measure(); + return () => ro.disconnect(); + }, [notifyRegionWidth]); + + return ( + // Base plate: title bar and sidebar sit flat on it, the content card above it. + + + +
{ + if (!navRef.current?.contains(e.target)) lastFocused.current = e.target; + }} + > + setSearchOpen(true)} + /> +
+ +
+
+
+ {snap ? ( + + ) : ( +
+ connecting… +
+ )} +
+
+
+ + +
+ + + {searchOpen && setSearchOpen(false)} />} +
+
+
+ ); +}