diff --git a/canvas/src/components/A2ATopologyOverlay.tsx b/canvas/src/components/A2ATopologyOverlay.tsx index 4a35e638..efd4f0ff 100644 --- a/canvas/src/components/A2ATopologyOverlay.tsx +++ b/canvas/src/components/A2ATopologyOverlay.tsx @@ -74,7 +74,11 @@ export function buildA2AEdges( }); } - // 3. Build React Flow Edge objects + // 3. Build React Flow Edge objects. We tag every overlay edge with + // type: "a2a" so React Flow renders it via our custom A2AEdge + // component (canvas/A2AEdge.tsx). The custom component portals + // its label out of the SVG layer so it (a) doesn't get hidden + // behind workspace cards and (b) is clickable. return Array.from(map.values()).map(({ source, target, count, lastAt }) => { const isHot = now - lastAt < A2A_HOT_MS; const stroke = isHot ? "#8b5cf6" : "#3b82f6"; // violet-500 : blue-500 @@ -84,6 +88,7 @@ export function buildA2AEdges( return { id: `a2a-${source}-${target}`, + type: "a2a", source, target, animated: isHot, @@ -96,22 +101,22 @@ export function buildA2AEdges( style: { stroke, strokeWidth: 2, - // Non-blocking: label overlay never intercepts pointer events + // Path itself stays non-interactive so node drags through + // the line still work. The clickable target is the label + // pill, which sets pointerEvents: all on its own div. pointerEvents: "none" as React.CSSProperties["pointerEvents"], }, + // `label` keeps the same string for back-compat with any test + // that asserts on it (e.g. buildA2AEdges output shape). Custom + // edge reads the rich data from `data` so the label visual is + // not constrained to a string anymore. label, - labelStyle: { - fill: "#a1a1aa", // zinc-400 - fontSize: 10, - pointerEvents: "none" as React.CSSProperties["pointerEvents"], + data: { + count, + lastAt, + isHot, + label, }, - labelBgStyle: { - fill: "#18181b", // zinc-900 - fillOpacity: 0.9, - pointerEvents: "none" as React.CSSProperties["pointerEvents"], - }, - labelBgPadding: [4, 6] as [number, number], - labelBgBorderRadius: 4, }; }); } diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index c0e25d19..19c6dc41 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -36,11 +36,22 @@ import { DropTargetBadge } from "./canvas/DropTargetBadge"; import { useDragHandlers } from "./canvas/useDragHandlers"; import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts"; import { useCanvasViewport } from "./canvas/useCanvasViewport"; +import { A2AEdge } from "./canvas/A2AEdge"; const nodeTypes = { workspaceNode: WorkspaceNode, }; +// Custom edge types. The default React Flow edge renders its label +// inside the SVG group (always under nodes) with pointerEvents: none +// inherited from the path. A2AEdge portals the label to a sibling +// DOM layer so it renders above nodes and accepts clicks. Keep the +// reference stable (module-scope const) so React Flow doesn't see a +// new edgeTypes object on every render and warn about prop churn. +const edgeTypes = { + a2a: A2AEdge, +}; + const defaultEdgeOptions: Partial = { animated: true, style: { @@ -248,6 +259,7 @@ function CanvasInner() { onPaneClick={onPaneClick} onMoveEnd={onMoveEnd} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} defaultEdgeOptions={defaultEdgeOptions} defaultViewport={defaultViewport} fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1} diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx index 10964fd3..0e578972 100644 --- a/canvas/src/components/Legend.tsx +++ b/canvas/src/components/Legend.tsx @@ -1,19 +1,92 @@ "use client"; +import { useEffect, useState } from "react"; import { STATUS_CONFIG } from "@/lib/design-tokens"; import { useCanvasStore } from "@/store/canvas"; const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const; +// Persist the user's choice across sessions. Default is "open" so +// first-time users still see the symbol key; once dismissed we +// respect that until they explicitly reopen via the floating pill. +const STORAGE_KEY = "molecule.legend.open"; + +function readStoredOpen(): boolean { + if (typeof window === "undefined") return true; + try { + const v = window.localStorage.getItem(STORAGE_KEY); + if (v === null) return true; + return v === "1"; + } catch { + return true; + } +} + +function writeStoredOpen(open: boolean) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, open ? "1" : "0"); + } catch { + // localStorage can throw in private mode / quota / disabled + // contexts. Silent fallback — the in-memory state still works + // for the current session. + } +} + export function Legend() { // TemplatePalette (when open) is fixed top-0 left-0 w-[280px] — the // default bottom-6 left-4 position of this legend would sit under it. // Shift past the 280 px palette + a 16 px gap when the palette is open. const paletteOpen = useCanvasStore((s) => s.templatePaletteOpen); const leftClass = paletteOpen ? "left-[296px]" : "left-4"; + + // SSR-safe pattern: mount with the default (true) so first paint + // matches the server output, then hydrate the persisted value + // after mount. Avoids a hydration mismatch warning when the user + // had previously closed the legend. + const [open, setOpen] = useState(true); + useEffect(() => { + setOpen(readStoredOpen()); + }, []); + + const closeLegend = () => { + setOpen(false); + writeStoredOpen(false); + }; + const openLegend = () => { + setOpen(true); + writeStoredOpen(true); + }; + + if (!open) { + return ( + + ); + } + return (
-
Legend
+
+
Legend
+ +
{/* Status */}
diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index e80ab58a..318ecef7 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; import { api } from "@/lib/api"; import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight"; @@ -196,6 +197,12 @@ function ProviderPickerModal({ ); if (!open) return null; + // Portal to document.body for the same reason as + // OrgImportPreflightModal — several callers (TemplatePalette, + // EmptyState) render the modal inside their own fixed+filtered + // containers, which re-anchor the "fixed" positioning to the + // wrapper's bounds instead of the viewport. + if (typeof document === "undefined") return null; const allSaved = entries.length > 0 && entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); @@ -203,8 +210,14 @@ function ProviderPickerModal({ .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); - return ( -
+ return createPortal( + // z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50). + // Both can be on screen at once during an org import: the org- + // preflight is open while the user clicks a per-workspace deploy + // that triggers MissingKeys. Without the explicit z-order the + // backdrop click might dismiss the wrong modal depending on + // React's commit ordering. +
-
+
, + document.body, ); } @@ -474,6 +488,7 @@ function AllKeysModal({ }, [open]); if (!open) return null; + if (typeof document === "undefined") return null; const allSaved = entries.length > 0 && entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); @@ -481,8 +496,14 @@ function AllKeysModal({ .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); - return ( -
+ return createPortal( + // z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50). + // Both can be on screen at once during an org import: the org- + // preflight is open while the user clicks a per-workspace deploy + // that triggers MissingKeys. Without the explicit z-order the + // backdrop click might dismiss the wrong modal depending on + // React's commit ordering. +
-
+
, + document.body, ); } diff --git a/canvas/src/components/OrgImportPreflightModal.tsx b/canvas/src/components/OrgImportPreflightModal.tsx index a9d76e50..51d61c82 100644 --- a/canvas/src/components/OrgImportPreflightModal.tsx +++ b/canvas/src/components/OrgImportPreflightModal.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { createSecret } from "@/lib/api/secrets"; /** @@ -156,21 +157,39 @@ export function OrgImportPreflightModal({ ); const canProceed = missingRequired.length === 0; + // Synchronous in-flight gate. A ref (not state) so two clicks + // dispatched in the SAME microtask both see the gate flip — state + // commits don't help here because setState is async. The previous + // closure-based `current.saving` gate worked under React Testing + // Library's act() flushing but failed for true microtask-level + // double-fires (programmatic clicks, dblclick events, Enter-spam + // before React commits). Set is keyed by env var name so different + // rows can save concurrently. + const inFlightRef = useRef>(new Set()); + + // Latest-drafts ref so saveOne can read the current input value + // without taking `drafts` as a useCallback dep — that dep would + // re-create saveOne on every keystroke and re-bind every Save + // button's onClick handler, churn that scales with row count. + const draftsRef = useRef(drafts); + useEffect(() => { + draftsRef.current = drafts; + }, [drafts]); + const saveOne = useCallback( async (key: string) => { - // Functional setter throughout so two near-simultaneous saves - // don't have the second one's call see a stale snapshot captured - // before the first save's setState landed. Read the current - // value AND write the `saving` flag in a single transition - // rather than reading from closure-scoped `drafts`. - let startValue = ""; - setDrafts((d) => { - const current = d[key]; - if (!current || !current.value.trim()) return d; - startValue = current.value; - return { ...d, [key]: { ...current, saving: true, error: null } }; - }); - if (!startValue.trim()) return; + // Microtask-safe gate: claim the slot synchronously BEFORE any + // await so a second click in the same tick bounces immediately. + if (inFlightRef.current.has(key)) return; + const current = draftsRef.current[key]; + if (!current || !current.value.trim()) return; + inFlightRef.current.add(key); + + const startValue = current.value; + setDrafts((d) => ({ + ...d, + [key]: { ...d[key], saving: true, error: null }, + })); try { await createSecret("global", key, startValue); setDrafts((d) => ({ @@ -189,6 +208,8 @@ export function OrgImportPreflightModal({ error: e instanceof Error ? e.message : "Save failed", }, })); + } finally { + inFlightRef.current.delete(key); } }, [onSecretSaved], @@ -196,7 +217,21 @@ export function OrgImportPreflightModal({ if (!open) return null; - return ( + // Portal the dialog to document.body so it escapes any ancestor + // containing block. TemplatePalette renders this modal inside a + // sidebar whose `fixed` container plus backdrop-filter together + // re-anchor descendants' `position: fixed` to the sidebar's own + // bounds instead of the viewport — the modal ends up glued to the + // sidebar's scrollable region and only becomes visible after the + // user scrolls the sidebar. Portal dodges that class of issue + // once and for all, regardless of what future wrappers do. + // + // SSR-safe guard: `document` is undefined on the server. Since + // the modal is gated by `if (!open) return null` above, this + // effectively only runs after open flips true on the client. + if (typeof document === "undefined") return null; + + return createPortal(
e.stopPropagation()} >
@@ -280,7 +315,8 @@ export function OrgImportPreflightModal({
-
+
, + document.body, ); } diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 35ba5c8f..80fe37b8 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -29,7 +29,7 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "chat", label: "Chat", icon: "◈" }, { id: "activity", label: "Activity", icon: "⊙" }, { id: "details", label: "Details", icon: "◉" }, - { id: "skills", label: "Skills", icon: "✦" }, + { id: "skills", label: "Plugins", icon: "✦" }, { id: "terminal", label: "Terminal", icon: "▸" }, { id: "config", label: "Config", icon: "⚙" }, { id: "schedule", label: "Schedule", icon: "⏲" }, diff --git a/canvas/src/components/__tests__/A2ATopologyOverlay.test.tsx b/canvas/src/components/__tests__/A2ATopologyOverlay.test.tsx index ab0a3c4d..8d4dba00 100644 --- a/canvas/src/components/__tests__/A2ATopologyOverlay.test.tsx +++ b/canvas/src/components/__tests__/A2ATopologyOverlay.test.tsx @@ -175,9 +175,28 @@ describe("buildA2AEdges — edge properties", () => { expect((edge.style as React.CSSProperties).pointerEvents).toBe("none"); }); - it("sets pointerEvents: 'none' on labelStyle", () => { + it("tags the edge as type=a2a so React Flow renders the custom A2AEdge component", () => { + // The custom edge portals labels above the node layer and makes + // them clickable. Without type=a2a, RF falls back to the default + // edge whose label sits in the SVG group (hidden under nodes, + // pointerEvents:none). Regression guard for the hidden-label / + // unclickable-label bug observed 2026-04-25. const [edge] = buildA2AEdges([makeRow()], NOW); - expect((edge.labelStyle as React.CSSProperties).pointerEvents).toBe("none"); + expect(edge.type).toBe("a2a"); + }); + + it("populates edge.data with the fields the custom edge component reads", () => { + // A2AEdge reads count, lastAt, isHot, label from edge.data so the + // shape upstream must keep emitting them. A future buildA2AEdges + // refactor that drops any of these silently breaks the rendered + // pill (label disappears, hot/warm color swap fails, click handler + // can still fire but the label text vanishes). + const [edge] = buildA2AEdges([makeRow()], NOW); + const data = edge.data as Record; + expect(data.count).toBe(1); + expect(typeof data.lastAt).toBe("number"); + expect(typeof data.isHot).toBe("boolean"); + expect(data.label).toMatch(/^1 call ·/); }); it("label uses singular 'call' for count === 1", () => { diff --git a/canvas/src/components/__tests__/OrgImportPreflightModal.test.tsx b/canvas/src/components/__tests__/OrgImportPreflightModal.test.tsx new file mode 100644 index 00000000..73d62803 --- /dev/null +++ b/canvas/src/components/__tests__/OrgImportPreflightModal.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +// Regression tests for the OrgImportPreflightModal's save path and +// any-of group rendering. Guards two specific bugs caught in the +// UX A/B Lab rollout (2026-04-24): +// +// 1. saveOne early-returned because it tried to read a local +// `startValue` reassigned inside a functional setDrafts +// updater. React did not always evaluate the updater +// synchronously, so the gate read "" and bailed while +// `saving:true` committed at next render, wedging the +// button on "…" without ever calling createSecret. +// +// 2. Double-click / Enter-spam could race past the disabled- +// button UI gate, firing createSecret twice. The production +// endpoint is idempotent so no data hazard, but the extra +// PUT is wasteful and harder to reason about. + +const createSecretMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("@/lib/api/secrets", () => ({ + createSecret: (...args: unknown[]) => createSecretMock(...args), +})); + +import { OrgImportPreflightModal } from "../OrgImportPreflightModal"; + +beforeEach(() => { + createSecretMock.mockClear(); + createSecretMock.mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); +}); + +describe("OrgImportPreflightModal — saveOne", () => { + it("calls createSecret exactly once when Save is clicked on an any-of member", async () => { + render( + {}} + onProceed={() => {}} + onCancel={() => {}} + />, + ); + + // Both any-of members render their own input + Save. + const input = screen.getByLabelText(/Value for ANTHROPIC_API_KEY/i); + fireEvent.change(input, { target: { value: "test-secret-value" } }); + + // The Save button adjacent to the changed input. + const saveButtons = screen + .getAllByRole("button") + .filter((b) => b.textContent === "Save"); + // Two saves on screen (one per any-of member). First is ANTHROPIC. + fireEvent.click(saveButtons[0]); + + await waitFor(() => { + expect(createSecretMock).toHaveBeenCalledTimes(1); + }); + expect(createSecretMock).toHaveBeenCalledWith( + "global", + "ANTHROPIC_API_KEY", + "test-secret-value", + ); + }); + + it("synchronous double-click on Save fires createSecret exactly once", async () => { + // Pause the first save so we can fire a second click while the + // first is still mid-await. The two clicks happen in the SAME + // tick — fireEvent runs synchronously through React's event + // system — so any guard that depends on a committed setState + // (e.g. `disabled={drafts[key].saving}` or a closure read of + // `drafts[key].saving`) loses the race: the second click sees + // saving=false because React hasn't committed yet. The fix is + // a useRef-based gate that flips synchronously before any await. + let resolveCreate!: () => void; + createSecretMock.mockImplementationOnce( + () => new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + + render( + {}} + onProceed={() => {}} + onCancel={() => {}} + />, + ); + + const input = screen.getByLabelText(/Value for ANTHROPIC_API_KEY/i); + fireEvent.change(input, { target: { value: "test-secret-value" } }); + + const saveButtons = screen + .getAllByRole("button") + .filter((b) => b.textContent === "Save"); + // Pull the React-bound onClick once so both invocations close + // over the SAME callback — simulates a double-fire that happens + // before React reconciles between events. Without this, RTL + // flushes act() between fireEvent calls and the second click + // sees the post-commit state. + const saveBtn = saveButtons[0] as HTMLButtonElement; + saveBtn.click(); + saveBtn.click(); + + // Give React a tick to process any queued state updates. + await waitFor(() => { + expect(createSecretMock).toHaveBeenCalledTimes(1); + }); + + resolveCreate(); + await waitFor(() => { + // Post-save count must remain at exactly one. + expect(createSecretMock).toHaveBeenCalledTimes(1); + }); + }); + + it("does not call createSecret when value is empty", async () => { + render( + {}} + onProceed={() => {}} + onCancel={() => {}} + />, + ); + + // Button is disabled when value is empty — clicking a disabled + // button still dispatches onClick in RTL (since fireEvent + // bypasses the disabled attribute), so this asserts the code- + // level gate catches it, not just the UI. + const saveButtons = screen + .getAllByRole("button") + .filter((b) => b.textContent === "Save"); + fireEvent.click(saveButtons[0]); + + // Small async wait to let any state updates settle. + await new Promise((r) => setTimeout(r, 50)); + expect(createSecretMock).not.toHaveBeenCalled(); + }); +}); + +describe("OrgImportPreflightModal — any-of rendering", () => { + it("renders each any-of member as a separate input row", () => { + render( + {}} + onProceed={() => {}} + onCancel={() => {}} + />, + ); + + expect(screen.getByText("Configure any one")).toBeTruthy(); + expect(screen.getByLabelText(/Value for ANTHROPIC_API_KEY/i)).toBeTruthy(); + expect(screen.getByLabelText(/Value for CLAUDE_CODE_OAUTH_TOKEN/i)).toBeTruthy(); + }); + + it("shows satisfied indicator when any member is configured, and enables Import", () => { + render( + {}} + onProceed={() => {}} + onCancel={() => {}} + />, + ); + + // "✓ using CLAUDE_CODE_OAUTH_TOKEN" banner renders. Name appears + // twice (banner + member row) so use getAllByText. + expect(screen.getByText(/using/i)).toBeTruthy(); + expect(screen.getAllByText("CLAUDE_CODE_OAUTH_TOKEN").length).toBeGreaterThanOrEqual(1); + + const importBtn = screen.getByRole("button", { name: /^Import$/ }); + expect(importBtn.hasAttribute("disabled")).toBe(false); + }); + + it("keeps Import disabled when no any-of member is configured", () => { + render( + {}} + onProceed={() => {}} + onCancel={() => {}} + />, + ); + + const importBtn = screen.getByRole("button", { name: /^Import$/ }); + expect(importBtn.hasAttribute("disabled")).toBe(true); + }); +}); diff --git a/canvas/src/components/canvas/A2AEdge.tsx b/canvas/src/components/canvas/A2AEdge.tsx new file mode 100644 index 00000000..05900e7a --- /dev/null +++ b/canvas/src/components/canvas/A2AEdge.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { memo } from "react"; +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + type EdgeProps, +} from "@xyflow/react"; +import { useCanvasStore } from "@/store/canvas"; + +/** + * Custom edge for the A2A topology overlay. Solves two problems with the + * default React Flow edge label rendering: + * + * 1. **Z-order.** The default `label` prop renders inside the edge's + * SVG group, which always sits below node DOM in React Flow. When + * a label happened to land underneath a workspace card, it was + * hidden. EdgeLabelRenderer mounts label content in a separate + * portal layer that we can pin above nodes via z-index. + * + * 2. **Clickability.** Default labels inherit `pointerEvents: none` + * from the SVG path so the user can drag through them. The + * portaled label is a regular HTML element with its own pointer + * events — we set `pointerEvents: all` only on the label pill so + * drags on the edge line still pass through to the canvas. + * + * On click: selects the source workspace and switches its side panel + * to Activity, where the user can inspect the underlying delegations. + */ +interface A2AEdgeData { + count: number; + lastAt: number; + isHot: boolean; + /** Pre-formatted "5 calls · 2m ago" — built upstream by buildA2AEdges + * so the same string renders here and in any future tooltip layer. */ + label: string; +} + +function A2AEdgeImpl({ + id, + source, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + style = {}, +}: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const selectNode = useCanvasStore((s) => s.selectNode); + const setPanelTab = useCanvasStore((s) => s.setPanelTab); + + const edgeData = (data ?? {}) as Partial; + const labelText = edgeData.label ?? ""; + const isHot = edgeData.isHot ?? false; + const count = edgeData.count ?? 0; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + // Select the source (the agent that initiated the delegations). + // The user's mental model when clicking the edge is "show me the + // calls FROM here" — that's the source's activity feed. + // + // Preserve the current tab when the user re-clicks the same edge + // (or another edge whose source is already selected). Yanking + // them back to Activity every click would surprise — they may + // have intentionally switched to Chat / Memory while looking at + // this peer. The first click that lands a *different* selection + // still routes them to Activity, which is the discovery affordance. + const alreadySelected = + useCanvasStore.getState().selectedNodeId === source; + selectNode(source); + if (!alreadySelected) { + setPanelTab("activity"); + } + }; + + // The edge stroke color matches what buildA2AEdges sets on the SVG + // path style. Mirror it on the badge border so the visual identity + // (hot=violet vs warm=blue) carries to the clickable label. + const accent = isHot ? "border-violet-500/60" : "border-blue-500/60"; + const accentText = isHot ? "text-violet-200" : "text-blue-200"; + const ariaLabel = `${count} delegation${count === 1 ? "" : "s"} from ${ + edgeData.label?.split(" · ")[1] ?? "recent" + }. Click to inspect.`; + + return ( + <> + + {labelText && ( + +
+ +
+
+ )} + + ); +} + +export const A2AEdge = memo(A2AEdgeImpl); diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 03a26882..9b6dd1cd 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -43,23 +43,73 @@ interface A2AResponse { }; } +/** Detect activity-log rows that the workspace's own runtime fired + * against itself but were misclassified as canvas-source. The proper + * fix is the X-Workspace-ID header from `self_source_headers()` in + * workspace/platform_auth.py, which makes the platform record + * source_id = workspace_id. But three failure modes still leak a + * self-message into "My Chat": + * + * 1. Historical rows already in the DB with source_id=NULL. + * 2. Workspace containers running pre-fix heartbeat.py / main.py + * (the fix only takes effect after an image rebuild + redeploy). + * 3. Future internal triggers added without the helper. + * + * This client-side filter recognises the heartbeat trigger by its + * exact prefix — the heartbeat assembles + * + * "Delegation results are ready. Review them and take appropriate + * action:\n" + summary_lines + report_instruction + * + * in workspace/heartbeat.py. The prefix is template-fixed so a + * string match is reliable. If the heartbeat copy ever changes, + * update this constant in the same commit. + * + * This is a backstop, not the primary defence — the X-Workspace-ID + * header is. Filtering content is fragile to copy edits, so keep + * the list narrow. */ +const INTERNAL_SELF_MESSAGE_PREFIXES = [ + "Delegation results are ready. Review them and take appropriate action", +]; + +function isInternalSelfMessage(text: string): boolean { + return INTERNAL_SELF_MESSAGE_PREFIXES.some((p) => text.startsWith(p)); +} + // extractReplyText pulls the agent's text reply out of an A2A response. -// Mirrors the Go-side extractReplyText in workspace-server/internal/channels/manager.go. +// Concatenates ALL text parts (joined with "\n") rather than returning +// just the first. Claude Code and other runtimes commonly emit multi- +// part text replies for long content (markdown tables, code blocks), +// and the prior "first part wins" implementation silently truncated +// the rest — observed on a 15k-char Wave 1 brief that rendered only +// the table header. Mirrors extractTextsFromParts in message-parser.ts. +// +// Server-side counterpart in workspace-server/internal/channels/ +// manager.go has the same single-part bug; fix that too if/when a +// channel-delivered reply (Slack, Lark, etc.) gets truncated. function extractReplyText(resp: A2AResponse): string { + const collect = (parts: A2APart[] | undefined): string => { + if (!parts) return ""; + return parts + .filter((p) => p.kind === "text") + .map((p) => p.text ?? "") + .filter(Boolean) + .join("\n"); + }; const result = resp?.result; - if (result?.parts) { - for (const p of result.parts) { - if (p.kind === "text") return p.text ?? ""; - } - } + const collected: string[] = []; + const fromParts = collect(result?.parts); + if (fromParts) collected.push(fromParts); + // Walk artifacts even if parts had text — some producers (Hermes + // tool calls) emit a summary in parts AND details in artifacts. + // Returning early on parts dropped the artifact body silently. if (result?.artifacts) { for (const a of result.artifacts) { - for (const p of a.parts || []) { - if (p.kind === "text") return p.text ?? ""; - } + const t = collect(a.parts); + if (t) collected.push(t); } } - return ""; + return collected.join("\n"); } // Agent-returned files live on the same response shape as text — @@ -87,7 +137,7 @@ async function loadMessagesFromDB(workspaceId: string): Promise<{ messages: Chat for (const a of [...activities].reverse()) { // Extract user message from request_body const userText = extractRequestText(a.request_body); - if (userText) { + if (userText && !isInternalSelfMessage(userText)) { messages.push(createMessage("user", userText)); } @@ -477,6 +527,17 @@ function MyChatPanel({ workspaceId, data }: Props) { sendInFlightRef.current = false; }) .catch(() => { + // Same dedup guard as .then(): if a WS path (pendingAgentMsgs + // or ACTIVITY_LOGGED a2a_receive ok) already delivered the + // reply, sendingFromAPIRef is already false and there's + // nothing to roll back. Surfacing "Failed to send" here would + // contradict the agent reply the user is currently reading — + // exactly the false-positive observed when the HTTP request + // hung up (proxy idle / 502) after WS already won. + if (!sendingFromAPIRef.current) { + sendInFlightRef.current = false; + return; + } setSending(false); sendingFromAPIRef.current = false; sendInFlightRef.current = false; @@ -499,6 +560,82 @@ function MyChatPanel({ workspaceId, data }: Props) { const removePendingFile = (index: number) => setPendingFiles((prev) => prev.filter((_, i) => i !== index)); + // Monotonic counter so two paste events within the same wall-clock + // second still produce distinct filenames. Without this, on + // Firefox (where pasted images have an empty `file.name`), two + // pastes ~100ms apart could yield identical synthetic names AND + // identical sizes, collapsing into one attachment via the + // `name:size` dedup in onFilesPicked. + const pasteCounterRef = useRef(0); + + /** Paste-from-clipboard image attachment. + * + * Browser clipboard image items arrive as `File`s whose `name` is + * often a generic "image.png" (Chrome) or empty (Firefox/Safari), + * so two consecutive screenshot pastes collide on the name+size + * dedup the file-picker uses. Re-tag each pasted image with a + * per-paste unique name so dedup keeps them apart and the upload + * pipeline (which expects a non-empty filename) is happy. + * + * Falls through to onFilesPicked via direct File[] (NOT through + * the DataTransfer constructor — that throws on Safari < 14.1 + * and old Edge, silently aborting the paste). + * + * Only intercepts the paste when the clipboard has at least one + * image; text-only pastes fall through to the textarea's default + * behaviour. */ + const mimeToExt = (mime: string): string => { + // Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`, + // `"jpeg"`, `"webp"` etc. which produce ugly filenames and may + // trip server-side extension allowlists. Map known types + // explicitly; unknown falls back to a safe default. + if (mime === "image/svg+xml") return "svg"; + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/gif") return "gif"; + if (mime === "image/webp") return "webp"; + if (mime === "image/heic") return "heic"; + return "png"; + }; + + const onPasteIntoComposer = (e: React.ClipboardEvent) => { + if (!dropEnabled) return; + const items = e.clipboardData?.items; + if (!items || items.length === 0) return; + const imageFiles: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item.type.startsWith("image/")) continue; + const file = item.getAsFile(); + if (!file) continue; + const ext = mimeToExt(file.type); + const stamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + const seq = pasteCounterRef.current++; + const fname = `pasted-${stamp}-${seq}-${i}.${ext}`; + imageFiles.push(new File([file], fname, { type: file.type })); + } + if (imageFiles.length === 0) return; + e.preventDefault(); + // Reuse the picker path so file-size guards, dedup, and pending- + // list state all run through the same code. Build a synthetic + // FileList-like object to avoid the DataTransfer constructor — + // that's missing on Safari < 14.1 / old Edge and would silently + // throw, leaving the paste a no-op. + addPastedFiles(imageFiles); + }; + + // Variant of onFilesPicked that accepts a File[] directly, sidestepping + // the DataTransfer-FileList round-trip. Same dedup + state shape. + const addPastedFiles = (files: File[]) => { + setPendingFiles((prev) => { + const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`)); + return [...prev, ...files.filter((f) => !keyed.has(`${f.name}:${f.size}`))]; + }); + }; + // Drag-and-drop staging. dragDepthRef counts enter vs leave events so // the overlay doesn't flicker when the cursor crosses nested children // (textarea, buttons) — dragenter/dragleave fire for every boundary. @@ -719,7 +856,8 @@ function MyChatPanel({ workspaceId, data }: Props) { sendMessage(); } }} - placeholder={agentReachable ? "Send a message... (Shift+Enter for new line)" : `Agent is ${data.status}`} + onPaste={onPasteIntoComposer} + placeholder={agentReachable ? "Send a message... (Shift+Enter for new line, paste images to attach)" : `Agent is ${data.status}`} disabled={!agentReachable || sending} rows={1} className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-zinc-200 placeholder-zinc-500 focus:outline-none focus:border-blue-500 resize-none disabled:opacity-50" diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index d046f070..c144f301 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -57,6 +57,17 @@ export function SkillsTab({ data }: Props) { const reloadTimerRef = useRef>(undefined); useEffect(() => { + // Re-init `mountedRef.current = true` on every mount. React 18 + // StrictMode (Next.js dev) double-invokes effects: mount → + // cleanup → mount. Without this re-init, the first cleanup sets + // mountedRef.current = false, the re-mount runs the effect body + // again but never restores the flag, so every subsequent + // `if (mountedRef.current) setX(...)` guard skips and the + // component appears wedged: fetches complete, state never + // updates, "Loading…" sits forever. Production doesn't double- + // invoke so the bug only surfaces in dev — but dev is where we + // see it, and the cost of being explicit is one assignment. + mountedRef.current = true; return () => { mountedRef.current = false; clearTimeout(reloadTimerRef.current); @@ -65,24 +76,98 @@ export function SkillsTab({ data }: Props) { const workspaceId = data.id; + // Tracks whether loadInstalled has completed at least once (success + // or empty-array success — NOT failure). Without this the auto- + // expand effect below would fire on the initial render where + // `installed.length === 0` simply because the fetch hasn't returned + // yet, and worse, would also fire if the fetch throws (network + // blip, auth failure) — both cases falsely look like "no plugins + // installed". Gating on a separate "loaded" flag avoids the false + // positive. + const [installedLoaded, setInstalledLoaded] = useState(false); + const loadInstalled = useCallback(async () => { try { const result = await api.get(`/workspaces/${workspaceId}/plugins`); - if (mountedRef.current) setInstalled(Array.isArray(result) ? result : []); + if (mountedRef.current) { + setInstalled(Array.isArray(result) ? result : []); + setInstalledLoaded(true); + } } catch (e) { console.warn("SkillsTab: installed plugins load failed", e); } }, [workspaceId]); - const loadRegistry = useCallback(async () => { + // registry-load lifecycle so the UI can show "Loading…" / error / + // retry instead of an indistinguishable "No plugins in registry" + // banner whether the fetch is in-flight, errored, or genuinely + // returned []. The previous silent console.warn-only path made + // an auth failure or CORS blip look identical to an empty + // registry — exactly the diagnosis dead-end observed when the + // server returned 20 plugins via curl but the canvas showed 0. + const [registryLoading, setRegistryLoading] = useState(false); + const [registryError, setRegistryError] = useState(null); + + // Synchronous gate against concurrent loadRegistry runs. Refs survive + // Fast Refresh re-renders (ref objects persist across re-runs of + // the function body), so a previously-stranded fetch can pin this + // ref at true and block every subsequent loadRegistry call. The + // `force` parameter on loadRegistry below provides the user-driven + // escape hatch for that wedge. + const registryFetchInFlight = useRef(false); + + // Reset the in-flight gate on unmount so a Fast Refresh that + // tears down + recreates the component without a full page reload + // doesn't carry the stuck-true value into the new instance via + // dev-server-preserved module state. + useEffect(() => { + return () => { + registryFetchInFlight.current = false; + }; + }, []); + + const loadRegistry = useCallback(async (force = false) => { + // Default callers (mount effect, button while not loading) honour + // the gate. Explicit force=true callers (Retry button) bypass it + // — the user is signalling "forget whatever you thought was in + // flight, fetch again now". + if (!force && registryFetchInFlight.current) return; + registryFetchInFlight.current = true; + setRegistryLoading(true); + setRegistryError(null); try { - const result = await api.get("/plugins"); + // 10s timeout — tighter than the 15s default. Plugin registry + // is local-disk-backed on the platform host (server reads + // pluginsDir entries) so a 10s budget is generous. Without + // an explicit timeout the UI's "Loading registry…" can sit + // for the full 15s + any browser hop time when a Fast + // Refresh strands an in-flight promise. + const result = await api.get("/plugins", { timeoutMs: 10_000 }); if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []); } catch (e) { - // Registry is the AVAILABLE PLUGINS list. Silent failure here - // left the user seeing "No plugins in registry" with no clue - // it was a fetch error — log it so devtools shows the cause. console.warn("SkillsTab: registry load failed", e); + if (mountedRef.current) { + // Detect timeout/abort by DOMException.name first — that's + // the canonical signal across browsers. Fall back to a + // widened message regex covering Chromium's "signal timed + // out", Firefox's "The operation timed out.", Safari's + // "Aborted". The previous /timeout/ regex missed Chromium's + // "timed out" variant entirely. + const name = (e as { name?: string })?.name ?? ""; + const msg = e instanceof Error ? e.message : ""; + const isTimeoutLike = + name === "TimeoutError" || + name === "AbortError" || + /abort|time(d)?\s*out/i.test(msg); + setRegistryError( + isTimeoutLike + ? "Registry fetch timed out (10s). The platform server may be slow or unreachable." + : msg || "Failed to load registry", + ); + } + } finally { + registryFetchInFlight.current = false; + if (mountedRef.current) setRegistryLoading(false); } }, []); @@ -102,6 +187,21 @@ export function SkillsTab({ data }: Props) { loadSourceSchemes(); }, [loadInstalled, loadRegistry, loadSourceSchemes]); + // First-time experience: if the workspace has zero plugins + // installed but the platform's registry has options to choose + // from, expand the registry by default so the user sees what's + // available without an extra click. Once they install something + // (or explicitly toggle the registry off), the manual setting + // wins — we only auto-expand from the closed default state. + const hasAutoExpandedRef = useRef(false); + useEffect(() => { + if (hasAutoExpandedRef.current) return; + if (installedLoaded && installed.length === 0 && registry.length > 0) { + setShowRegistry(true); + hasAutoExpandedRef.current = true; + } + }, [installedLoaded, installed.length, registry.length]); + const installedNames = useMemo(() => new Set(installed.map((p) => p.name)), [installed]); // Install always goes through the source-based API. For registry @@ -264,9 +364,53 @@ export function SkillsTab({ data }: Props) { Local registry plugins below; paste any scheme URL above for GitHub or other sources. -
Available plugins
- {registry.length === 0 ? ( -
No plugins in registry
+
+
Available plugins
+ {/* Retry visible whenever registry is empty — including + the loading state — so a stuck fetch (Fast Refresh + stranded promise, slow server, browser quirk) has a + user-driven escape hatch. The button disables while + loading so a genuine in-flight fetch isn't double- + fired, but the user can see the affordance and act + the moment it un-disables. */} + {registry.length === 0 && ( + // Always enabled: the user clicking Retry signals + // "I don't trust the loading state, try again now", + // and force=true bypasses the in-flight gate so a + // stranded fetch from Fast Refresh / a stale + // ReadableStream / a never-resolving promise can be + // un-stuck without a full page reload. The visible + // label flips to "Loading…" while a fetch is + // in-flight so the user still sees the activity. + + )} +
+ {registryLoading && registry.length === 0 ? ( +
Loading registry…
+ ) : registryError ? ( +
+
+ Couldn't load the plugin registry +
+
{registryError}
+
+ Check the platform server is reachable at /plugins. The Retry button is in the header above. +
+
+ ) : registry.length === 0 ? ( +
+
Registry returned 0 plugins.
+
+ This usually means the platform's plugins/ directory is empty. + Run scripts/clone-manifest.sh to populate it from the standalone repos. +
+
) : (
{registry.map((p) => { diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index 7315e7be..30cdbd14 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -1,13 +1,16 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { api } from "@/lib/api"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { WS_URL } from "@/store/socket"; import { closeWebSocketGracefully } from "@/lib/ws-close"; +import { showToast } from "../../Toaster"; import { extractResponseText, extractRequestText } from "./message-parser"; -interface ActivityEntry { +export interface ActivityEntry { id: string; activity_type: string; source_id: string | null; @@ -22,11 +25,29 @@ interface ActivityEntry { interface CommMessage { id: string; - direction: "in" | "out"; + /** UI-facing flow from THIS workspace's point of view: + * + * "out" — this workspace either initiated the call (a2a_send) + * OR self-logged the reply from a peer it had called + * (a2a_receive with source_id == workspaceId). + * "in" — a peer initiated the call to us (a2a_receive with + * source_id != workspaceId). + * + * Distinct from activity_type because the agent runtime self- + * logs its outbound calls' replies as `a2a_receive` rows; without + * this normalisation the UI labels would render those as + * incoming ("← From X") and right-justify them on the wrong + * side, even though from the user's perspective the call WAS + * outgoing. See toCommMessage for the resolution rules. */ + flow: "in" | "out"; peerName: string; peerId: string; text: string; responseText: string | null; + /** "ok" | "error" — surfaces failed deliveries with their own + * visual treatment + recovery actions instead of an opaque + * "[A2A_ERROR]" body the user can't act on. */ + status: string; timestamp: string; } @@ -36,9 +57,31 @@ function resolveName(id: string): string { return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8); } -function toCommMessage(entry: ActivityEntry, workspaceId: string): CommMessage | null { - const isOutgoing = entry.activity_type === "a2a_send"; - const peerId = isOutgoing ? (entry.target_id || "") : (entry.source_id || ""); +export function toCommMessage(entry: ActivityEntry, workspaceId: string): CommMessage | null { + // a2a_receive activity rows come in two shapes: + // + // 1. Real incoming call (a peer called us): source_id = the peer, + // target_id = us. peerId is source_id, flow is "in". + // + // 2. Self-logged response to an outbound call (the workspace's own + // runtime calls report_activity("a2a_receive", ...) after + // delegating; see workspace/a2a_tools.py:181). source_id = + // our own workspace_id, target_id = the peer that replied. + // peerId must come from target_id (otherwise the peer-name + // resolves to "us" and Restart would target THIS workspace), + // and flow is "out" — from the user's perspective this row + // belongs to the outbound thread, not an incoming one. + // + // a2a_send rows are always outbound from us: source_id = us, + // target_id = the peer. + const isSendActivity = entry.activity_type === "a2a_send"; + const isSelfLoggedReceive = + entry.activity_type === "a2a_receive" && entry.source_id === workspaceId; + const flow: "in" | "out" = isSendActivity || isSelfLoggedReceive ? "out" : "in"; + const peerId = + isSendActivity || isSelfLoggedReceive + ? entry.target_id || "" + : entry.source_id || ""; if (!peerId) return null; const text = extractRequestText(entry.request_body) || entry.summary || ""; @@ -46,15 +89,56 @@ function toCommMessage(entry: ActivityEntry, workspaceId: string): CommMessage | return { id: entry.id, - direction: isOutgoing ? "out" : "in", + flow, peerName: resolveName(peerId), peerId, text, responseText, + status: entry.status || "ok", timestamp: entry.created_at, }; } +/** Strip the [A2A_ERROR] sentinel prefix the workspace runtime adds + * to failed delegation responses, so the UI can render the underlying + * message (or fall back to a generic explanation when the inner text + * is empty — currently common because httpx exceptions often + * stringify as ""). */ +const A2A_ERROR_PREFIX = "[A2A_ERROR]"; + +function unwrapErrorText(raw: string | null): string { + if (!raw) return ""; + const trimmed = raw.trim(); + if (trimmed.startsWith(A2A_ERROR_PREFIX)) { + return trimmed.slice(A2A_ERROR_PREFIX.length).trim(); + } + return trimmed; +} + +/** Best-effort cause hint based on what we can see in the error text. + * These map known runtime symptoms to operator-actionable language so + * the user isn't left staring at "[A2A_ERROR]" with no next step. */ +function inferCauseHint(errorText: string): string { + const t = errorText.toLowerCase(); + // "control request timeout" is the specific Claude Code SDK init + // wedge symptom. Don't pattern on bare "initialize" — too broad + // (a user task containing "failed to initialize database" would + // false-positive into the SDK-wedge hint). + if (t.includes("control request timeout")) { + return "The remote agent's Claude Code SDK is wedged on initialization (often after a long idle period or OAuth refresh). A workspace restart usually clears it."; + } + if (t.includes("deadline exceeded") || t.includes("timeout")) { + return "The remote agent didn't respond within the proxy timeout. It may be busy with a long-running task, or the runtime is stuck. Restart the workspace if this repeats."; + } + if (t.includes("agent error") || t.includes("exception")) { + return "The remote agent's runtime threw an exception. Check the workspace's container logs for the traceback. Restart usually clears transient runtime crashes."; + } + if (errorText === "") { + return "The remote agent returned no error detail (the underlying httpx exception had an empty message — typically a connection-reset or silent timeout). A workspace restart is the safe first move."; + } + return "The remote agent reported a delivery failure. Check the workspace logs or try restarting."; +} + export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); @@ -74,7 +158,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { for (const e of filtered) { const m = toCommMessage(e, workspaceId); if (m) { - const key = `${m.timestamp}:${m.direction}:${m.peerId}`; + const key = `${m.timestamp}:${m.flow}:${m.peerId}`; msgs.push(m); seenKeys.current.add(key); } @@ -115,7 +199,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { }; const m = toCommMessage(entry, workspaceId); if (m) { - const key = `${m.timestamp}:${m.direction}:${m.peerId}`; + const key = `${m.timestamp}:${m.flow}:${m.peerId}`; if (seenKeys.current.has(key)) return; seenKeys.current.add(key); setMessages((prev) => [...prev, m]); @@ -148,31 +232,177 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { return (
- {messages.map((msg) => ( -
-
-
- {msg.direction === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`} -
-
{msg.text || "(no message text)"}
- {msg.responseText && ( -
- {msg.responseText} -
- )} -
- {new Date(msg.timestamp).toLocaleTimeString()} -
-
-
- ))} + {messages.map((msg) => + msg.status === "error" ? ( + + ) : ( + + ), + )}
); } + +function NormalMessage({ msg }: { msg: CommMessage }) { + return ( +
+
+
+ {msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`} +
+ {msg.text ? ( + {msg.text} + ) : ( +
(no message text)
+ )} + {msg.responseText && ( + + {msg.responseText} + + )} +
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+
+ ); +} + +/** Failure-state row. Replaces the unactionable "X failed [A2A_ERROR]" + * bubble with: a clear banner naming the peer, the underlying + * error text (if any), an inferred cause hint, and recovery + * actions — Restart workspace, Open workspace. + * + * Recovery actions show on BOTH directions because both target the + * same peer (toCommMessage now resolves peerId to the peer in + * either case): an outbound delivery failure ("we called X and it + * errored"), an inbound runtime failure ("X called us and our + * reply errored" — rare), or the agent-self-logged "I called X and + * got an error back" pattern that is the most common shape. The + * user always wants to restart or inspect the failing peer. */ +function ErrorMessage({ msg }: { msg: CommMessage }) { + const selectNode = useCanvasStore((s) => s.selectNode); + const [restarting, setRestarting] = useState(false); + const errorText = unwrapErrorText(msg.responseText); + const hint = inferCauseHint(errorText); + + // Guard against acting on a peer whose workspace has been deleted + // since this row was logged. Without the guard, restart 404s + // surface as a generic toast and Open silently sets a dangling + // selection that renders nothing in the side panel. + const peerExists = (): boolean => { + return useCanvasStore.getState().nodes.some((n) => n.id === msg.peerId); + }; + + const handleRestart = async () => { + if (restarting) return; + if (!peerExists()) { + showToast(`${msg.peerName} no longer exists`, "error"); + return; + } + setRestarting(true); + try { + await api.post(`/workspaces/${msg.peerId}/restart`, {}); + showToast(`Restarting ${msg.peerName}…`, "success"); + } catch (e) { + showToast( + `Restart failed: ${e instanceof Error ? e.message : "unknown error"}`, + "error", + ); + } finally { + setRestarting(false); + } + }; + + const handleOpen = () => { + if (!peerExists()) { + showToast(`${msg.peerName} no longer exists`, "error"); + return; + } + selectNode(msg.peerId); + }; + + return ( +
+
+
+ + {msg.flow === "out" + ? `Failed to deliver to ${msg.peerName}` + : `${msg.peerName} returned an error`} +
+ + {msg.text && ( +
+ Task + {msg.text} +
+ )} + +
+
+ Underlying error +
+ + {errorText || "(no detail returned)"} + +
+ +

{hint}

+ + {msg.peerId && ( +
+ + +
+ )} + +
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+
+ ); +} + +/** Tiny markdown wrapper matching ChatTab's My Chat styling. Same + * remark-gfm pipeline (tables, strikethrough, task lists) plus the + * prose tweaks that keep paragraphs tight inside a small bubble. + * Code blocks get an `overflow-x-auto` so a long line of code doesn't + * blow out the bubble's max-width — agent-to-agent replies routinely + * ship code samples and JSON. */ +function MarkdownBody({ + children, + className, +}: { + children: string; + className?: string; +}) { + return ( +
p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`} + > + {children} +
+ ); +} diff --git a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts new file mode 100644 index 00000000..fc2eb70f --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; + +// Stub the canvas store before importing the SUT — toCommMessage calls +// useCanvasStore.getState() inside resolveName to look up peer names, +// which would otherwise hit the real Zustand store. +vi.mock("@/store/canvas", () => ({ + useCanvasStore: { + getState: () => ({ + nodes: [ + { id: "ws-self", data: { name: "Self" } }, + { id: "ws-peer", data: { name: "Peer Agent" } }, + ], + }), + }, +})); + +import { toCommMessage, type ActivityEntry } from "../AgentCommsPanel"; + +const SELF = "ws-self"; +const PEER = "ws-peer"; + +function makeEntry(overrides: Partial = {}): ActivityEntry { + return { + id: "act-1", + activity_type: "a2a_send", + source_id: SELF, + target_id: PEER, + method: "message/send", + summary: "Delegating to Peer Agent", + request_body: null, + response_body: null, + status: "ok", + created_at: "2026-04-25T18:00:00Z", + ...overrides, + }; +} + +describe("toCommMessage — flow derivation", () => { + it("a2a_send is always outbound (flow=out, peer=target)", () => { + const m = toCommMessage( + makeEntry({ activity_type: "a2a_send", source_id: SELF, target_id: PEER }), + SELF, + ); + expect(m).toBeTruthy(); + expect(m!.flow).toBe("out"); + expect(m!.peerId).toBe(PEER); + expect(m!.peerName).toBe("Peer Agent"); + }); + + it("a2a_receive from a peer (peer-initiated call) is inbound", () => { + // Real incoming call: source = peer, target = us. + const m = toCommMessage( + makeEntry({ + activity_type: "a2a_receive", + source_id: PEER, + target_id: SELF, + }), + SELF, + ); + expect(m!.flow).toBe("in"); + expect(m!.peerId).toBe(PEER); + expect(m!.peerName).toBe("Peer Agent"); + }); + + it("a2a_receive self-logged by our runtime AFTER an outbound call is OUTBOUND from the user's POV", () => { + // workspace/a2a_tools.py:181 self-logs an a2a_receive on the + // CALLER's workspace_id with source_id=us, target_id=peer. + // From the user's perspective this row belongs to the outbound + // delegation thread — render flow=out + peer=target so the + // bubble right-justifies under "Delegating to peer" and the + // Restart button targets the actual peer (NOT us). Regression + // for the bug where these rows rendered as "← From Self" with + // a Restart button that would have restarted the user's own + // workspace. + const m = toCommMessage( + makeEntry({ + activity_type: "a2a_receive", + source_id: SELF, + target_id: PEER, + summary: "Peer Agent failed", + status: "error", + }), + SELF, + ); + expect(m!.flow).toBe("out"); + expect(m!.peerId).toBe(PEER); + expect(m!.peerName).toBe("Peer Agent"); + expect(m!.status).toBe("error"); + }); + + it("returns null when no peer can be resolved", () => { + // a2a_receive with both ids null — discard rather than render a + // ghost bubble pointing at "Unknown". + const m = toCommMessage( + makeEntry({ + activity_type: "a2a_receive", + source_id: null, + target_id: null, + }), + SELF, + ); + expect(m).toBeNull(); + }); + + it("propagates status through to the message (drives error rendering)", () => { + const m = toCommMessage( + makeEntry({ status: "error", activity_type: "a2a_send" }), + SELF, + ); + expect(m!.status).toBe("error"); + }); +}); diff --git a/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts b/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts index 09a5fcea..809c37ab 100644 --- a/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/message-parser.test.ts @@ -100,6 +100,67 @@ describe("extractResponseText", () => { it("returns empty when result has no parts", () => { expect(extractResponseText({ result: { other: true } })).toBe(""); }); + + // Regression: Claude Code (and other long-reply runtimes) emits + // multi-part text replies. The previous implementation returned + // only the first part, silently truncating the rest. Observed + // 2026-04-25 on a 15k-char Wave 1 brief that rendered as just the + // markdown table header. + it("joins all text parts when result.parts has multiple", () => { + const body = { + result: { + parts: [ + { kind: "text", text: "# Header" }, + { kind: "text", text: "| Col |" }, + { kind: "text", text: "| --- |" }, + { kind: "text", text: "| Row |" }, + ], + }, + }; + expect(extractResponseText(body)).toBe("# Header\n| Col |\n| --- |\n| Row |"); + }); + + it("joins all text parts across multiple artifacts", () => { + const body = { + result: { + artifacts: [ + { parts: [{ kind: "text", text: "First artifact" }] }, + { parts: [{ kind: "text", text: "Second artifact" }] }, + ], + }, + }; + expect(extractResponseText(body)).toBe("First artifact\nSecond artifact"); + }); + + it("joins all .root.text variants when present", () => { + const body = { + result: { + parts: [ + { root: { text: "alpha" } }, + { root: { text: "beta" } }, + ], + }, + }; + expect(extractResponseText(body)).toBe("alpha\nbeta"); + }); + + // Regression: when a response carries BOTH parts and artifacts + // (Hermes tool-call replies do this — summary in parts, detail in + // artifacts), the early-return-on-parts implementation silently + // dropped the artifacts body. The collected-from-every-source + // implementation must surface both. + it("collects text from BOTH result.parts AND result.artifacts when both present", () => { + const body = { + result: { + parts: [{ kind: "text", text: "Summary" }], + artifacts: [ + { parts: [{ kind: "text", text: "Detail block one" }] }, + { parts: [{ kind: "text", text: "Detail block two" }] }, + ], + }, + }; + expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two"); + }); }); describe("extractTextsFromParts", () => { diff --git a/canvas/src/components/tabs/chat/message-parser.ts b/canvas/src/components/tabs/chat/message-parser.ts index 5e4bc3f2..54fa3a64 100644 --- a/canvas/src/components/tabs/chat/message-parser.ts +++ b/canvas/src/components/tabs/chat/message-parser.ts @@ -99,22 +99,54 @@ export function extractRequestText(body: Record | null): string return (parts?.[0]?.text as string) || ""; } -/** Extract text from an activity log response_body (multiple possible formats) */ +/** Extract text from an activity log response_body (multiple possible formats). + * + * Collects from EVERY source — top-level `parts[].text`, `parts[].root.text` + * (older nested shape), and `artifacts[].parts[].text` (task-shaped + * replies) — and joins them with "\n". Two reasons to collect rather + * than early-return: + * + * 1. Claude Code and other long-reply runtimes emit multiple text + * parts in a single `parts` array. Returning just the first + * silently truncates 15k-char briefs to their leading line + * (observed UX A/B Lab Wave 1, 2026-04-25). + * + * 2. Some producers emit a summary in `parts[].text` AND details in + * `artifacts[].parts[].text` (Hermes does this for tool calls). + * The previous "first source wins" returned only the summary; + * artifacts dropped silently. */ export function extractResponseText(body: Record): string { try { // {result: "text"} — from MCP server delegation logs if (typeof body.result === "string") return body.result; - // A2A JSON-RPC response: {result: {parts: [{kind: "text", text: "..."}]}} const result = body.result as Record | undefined; if (result) { + const collected: string[] = []; + + // A2A JSON-RPC: {result: {parts: [{kind: "text", text: "..."}]}} + const fromParts = extractTextsFromParts(result.parts); + if (fromParts) collected.push(fromParts); + + // Older nested shape: {parts: [{root: {text: "..."}}]} const parts = (result.parts || []) as Array>; + const rootTexts: string[] = []; for (const p of parts) { - const t = (p.text as string) || ""; - if (t) return t; const root = p.root as Record | undefined; - if (root?.text) return root.text as string; + if (root?.text) rootTexts.push(root.text as string); } + if (rootTexts.length > 0) collected.push(rootTexts.join("\n")); + + // Task shape: {result: {artifacts: [{parts: [...]}]}} + const artifacts = result.artifacts as Array> | undefined; + if (artifacts) { + for (const a of artifacts) { + const t = extractTextsFromParts(a.parts); + if (t) collected.push(t); + } + } + + if (collected.length > 0) return collected.join("\n"); } // {task: "text"} — request body format, shouldn't be in response but handle it diff --git a/canvas/src/store/__tests__/canvas-topology.test.ts b/canvas/src/store/__tests__/canvas-topology.test.ts index db046e80..4cee168d 100644 --- a/canvas/src/store/__tests__/canvas-topology.test.ts +++ b/canvas/src/store/__tests__/canvas-topology.test.ts @@ -149,6 +149,75 @@ describe("buildNodesAndEdges – parent + child workspaces", () => { }); }); +describe("buildNodesAndEdges – auto-rescue respects live grown parent size", () => { + // Regression: child the user dragged into a user-grown area was + // false-rescued by every periodic rehydrate (socket health check + // every 30s) because the rescue heuristic used the initial + // grid-derived parent bbox, not the currently-grown size. Result: + // child snapped to a stale grid slot, then settled back ~1 frame + // later when growParentsToFitChildren re-ran. Observed 2026-04-25 + // as "child jumps to weird location, then 30s later it's fine". + + it("does NOT rescue a child placed inside the user-grown parent area", () => { + // Parent's initial grid-derived size is small; user has since grown it + // to 800×600. Child sits at relative (700, 400) — inside the grown + // bbox but outside the initial bbox. Without currentParentSizes, + // the rescue would re-place the child into a default grid slot. + const parentAbs = { x: 100, y: 100 }; + const childAbs = { x: parentAbs.x + 700, y: parentAbs.y + 400 }; + const workspaces = [ + makeWS({ id: "parent", x: parentAbs.x, y: parentAbs.y }), + makeWS({ id: "child", parent_id: "parent", x: childAbs.x, y: childAbs.y }), + ]; + const grownDims = new Map([ + ["parent", { width: 800, height: 600 }], + ]); + + const { nodes } = buildNodesAndEdges(workspaces, new Map(), grownDims); + const child = nodes.find((n) => n.id === "child")!; + // Child's relative position should match what we passed in. + expect(child.position).toEqual({ x: 700, y: 400 }); + }); + + it("DOES rescue a child whose stored position is outside even the grown parent", () => { + // Same parent but child is way outside (relative 5000, 5000). + // The rescue must still fire — the heuristic isn't "always trust + // the user", it's "trust the user up to the current parent bbox". + const parentAbs = { x: 100, y: 100 }; + const childAbs = { x: parentAbs.x + 5000, y: parentAbs.y + 5000 }; + const workspaces = [ + makeWS({ id: "parent", x: parentAbs.x, y: parentAbs.y }), + makeWS({ id: "child", parent_id: "parent", x: childAbs.x, y: childAbs.y }), + ]; + const grownDims = new Map([ + ["parent", { width: 800, height: 600 }], + ]); + + const { nodes } = buildNodesAndEdges(workspaces, new Map(), grownDims); + const child = nodes.find((n) => n.id === "child")!; + // Rescued: NOT the original (5000, 5000); some grid slot instead. + expect(child.position.x).toBeLessThan(5000); + expect(child.position.y).toBeLessThan(5000); + }); + + it("falls back to initial-min bbox when no live size is provided (preserves legacy behavior)", () => { + // Empty currentParentSizes — first hydrate or test without store + // priming. Child outside the initial bbox should still be rescued. + const parentAbs = { x: 100, y: 100 }; + const childAbs = { x: parentAbs.x + 700, y: parentAbs.y + 400 }; + const workspaces = [ + makeWS({ id: "parent", x: parentAbs.x, y: parentAbs.y }), + makeWS({ id: "child", parent_id: "parent", x: childAbs.x, y: childAbs.y }), + ]; + + const { nodes } = buildNodesAndEdges(workspaces); + const child = nodes.find((n) => n.id === "child")!; + // Without a live size hint, the initial bbox applies — rescue + // fires, child gets a fresh slot, NOT the user-supplied (700,400). + expect(child.position).not.toEqual({ x: 700, y: 400 }); + }); +}); + describe("buildNodesAndEdges – deeply nested hierarchy", () => { it("handles three levels of nesting", () => { const workspaces = [ diff --git a/canvas/src/store/__tests__/socket.test.ts b/canvas/src/store/__tests__/socket.test.ts index caef6878..c0a7c523 100644 --- a/canvas/src/store/__tests__/socket.test.ts +++ b/canvas/src/store/__tests__/socket.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // --------------------------------------------------------------------------- -// Mock the canvas store before importing socket.ts +// Mock the canvas store and api before importing socket.ts // --------------------------------------------------------------------------- vi.mock("../canvas", () => ({ useCanvasStore: { @@ -13,6 +13,7 @@ vi.mock("../canvas", () => ({ }, })); + // --------------------------------------------------------------------------- // Mock WebSocket // --------------------------------------------------------------------------- @@ -76,7 +77,6 @@ function getLastWS(): MockWebSocket { beforeEach(() => { MockWebSocket.instances = []; vi.useFakeTimers(); - // Reset mocked store state vi.mocked(useCanvasStore.getState).mockReturnValue({ applyEvent: vi.fn(), @@ -328,3 +328,45 @@ describe("health check", () => { clearIntervalSpy.mockRestore(); }); }); + +// Rehydrate dedup logic itself is exercised by `RehydrateDedup` unit +// tests in this file (below). End-to-end coupling through the +// dynamic-imported `@/lib/api` was non-trivial under our existing +// fake-timer setup; isolating the gate in a pure helper keeps +// regression coverage without that mocking complexity. + +import { RehydrateDedup } from "../socket"; + +describe("RehydrateDedup", () => { + it("first call passes the gate (no prior fetch)", () => { + const d = new RehydrateDedup(1500); + expect(d.shouldSkip(0)).toBe(false); + }); + + it("blocks while a fetch is in flight", () => { + const d = new RehydrateDedup(1500); + d.beginFetch(); + expect(d.shouldSkip(100)).toBe(true); + }); + + it("blocks within the post-completion window", () => { + const d = new RehydrateDedup(1500); + d.beginFetch(); + d.completeFetch(1_000); + // 1100 - 1000 = 100 < 1500 → skip + expect(d.shouldSkip(1_100)).toBe(true); + // 2600 - 1000 = 1600 > 1500 → allow + expect(d.shouldSkip(2_600)).toBe(false); + }); + + it("a completed fetch followed by another beginFetch blocks for the new in-flight", () => { + const d = new RehydrateDedup(1500); + d.beginFetch(); + d.completeFetch(1_000); + // First wait out the dedup window + expect(d.shouldSkip(2_600)).toBe(false); + d.beginFetch(); + // Now a second fetch is in flight; further calls block again + expect(d.shouldSkip(2_700)).toBe(true); + }); +}); diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index 9c1cb25f..d53476f4 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -280,6 +280,15 @@ export function computeAutoLayout( * Accepts an optional layoutOverrides map (from computeAutoLayout) to override * positions for workspaces that were at 0,0. * + * `currentParentSizes` carries the LIVE measured/grown dimensions of parent + * nodes from the existing client store. The auto-rescue heuristic below + * (line ~445) compares each child's stored relative position against its + * parent's bbox; without the live size, the bbox is whatever the + * grid-derived initial min-size formula produced. That falsely rescued + * children dragged into the user-grown area on every periodic rehydrate + * (socket.ts:87 fires every 30s if no WS events seen) — observed + * 2026-04-25 as "child jumps to weird location, then settles 30s later". + * * Parent/child rendering model: every workspace is a first-class React Flow * node (full card). When a workspace has parent_id set, its RF `parentId` is * set to the parent's id and its position is stored RELATIVE to the parent @@ -290,7 +299,8 @@ export function computeAutoLayout( */ export function buildNodesAndEdges( workspaces: WorkspaceData[], - layoutOverrides: Map = new Map() + layoutOverrides: Map = new Map(), + currentParentSizes: Map = new Map(), ): { nodes: Node[]; edges: Edge[]; @@ -439,7 +449,23 @@ export function buildNodesAndEdges( // child.left = 500 < parent.right = 800 → overlaps → kept // legacy huge positive (position.x = 50000): // child.left = 50000 >= parent.right → no overlap → rescued - const psize = parentSize.get(ws.parent_id!)!; + const initialPsize = parentSize.get(ws.parent_id!)!; + // Use the larger of (initial min, currently grown) for the bbox + // test. Without this, a child the user dragged into the grown + // area appears "outside" the (smaller) initial bbox and the + // rescue below false-fires on every periodic rehydrate, jumping + // the child to a stale grid slot. Live grown dims arrive via + // currentParentSizes from hydrate(); on first load (empty + // store), the map is empty and we fall back to the initial min + // — preserving the original rescue semantics for genuinely + // detached legacy data. + const liveParentSize = currentParentSizes.get(ws.parent_id!); + const psize = liveParentSize + ? { + width: Math.max(initialPsize.width, liveParentSize.width), + height: Math.max(initialPsize.height, liveParentSize.height), + } + : initialPsize; const myW = subtreeSize.get(ws.id)?.width ?? CHILD_DEFAULT_WIDTH; const myH = subtreeSize.get(ws.id)?.height ?? CHILD_DEFAULT_HEIGHT; const overlapsX = diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index deb4d858..2971de39 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -791,7 +791,30 @@ export const useCanvasStore = create((set, get) => ({ hydrate: (workspaces: WorkspaceData[]) => { const layoutOverrides = computeAutoLayout(workspaces); - const { nodes, edges } = buildNodesAndEdges(workspaces, layoutOverrides); + // Carry the live measured/grown parent sizes from the existing + // store into the rebuild. buildNodesAndEdges runs an auto-rescue + // pass on each child to detach orphans whose stored relative + // position falls outside the parent bbox — without the live + // size, the bbox is the initial grid-derived minimum, which + // false-flags any child the user has dragged into the + // user-grown area. Periodic rehydrate (socket.ts health check, + // 30s) was reasserting the rescue against legitimate user + // placements, causing the "child jumps to weird location, then + // settles" symptom. + const current = get().nodes; + const currentParentSizes = new Map(); + for (const n of current) { + const w = (n.measured?.width ?? n.width) as number | undefined; + const h = (n.measured?.height ?? n.height) as number | undefined; + if (typeof w === "number" && typeof h === "number") { + currentParentSizes.set(n.id, { width: w, height: h }); + } + } + const { nodes, edges } = buildNodesAndEdges( + workspaces, + layoutOverrides, + currentParentSizes, + ); set({ nodes, edges }); for (const [nodeId, { x, y }] of layoutOverrides) { api.patch(`/workspaces/${nodeId}`, { x, y }).catch(() => {}); diff --git a/canvas/src/store/socket.ts b/canvas/src/store/socket.ts index 0c1b5a44..364c7ffb 100644 --- a/canvas/src/store/socket.ts +++ b/canvas/src/store/socket.ts @@ -12,6 +12,50 @@ export interface WSMessage { payload: Record; } +/** Window during which a freshly-completed rehydrate is reused + * instead of firing a new GET. Picked to absorb the connect→health- + * check sequence (rehydrate runs once on onopen, then the first + * health-check tick fires immediately after — both should share the + * same fetch) without holding back legitimately-spaced rehydrates + * triggered by genuine WS silence later. */ +const REHYDRATE_DEDUP_WINDOW_MS = 1_500; + +/** Pure dedup gate for rehydrate(). Tracks two states: + * + * - in-flight (between beginFetch and completeFetch): every + * shouldSkip returns true. + * - post-completion window (now < completedAt + windowMs): + * shouldSkip returns true. + * + * Extracted from ReconnectingSocket so the gate is unit-testable + * without mocking dynamic imports or fake timers. The class itself + * is stateful but tiny — instances are not shared across sockets. */ +export class RehydrateDedup { + private inFlight = false; + // -Infinity so the very first shouldSkip(now) call always passes + // (now - (-Infinity) > windowMs). Initializing to 0 would false- + // trip on test runs where now is also 0 (vi.useFakeTimers default + // clock) AND on real runs in the first 1.5s after epoch on + // clock-skewed systems. + private completedAt = Number.NEGATIVE_INFINITY; + constructor(private readonly windowMs: number) {} + + shouldSkip(now: number): boolean { + if (this.inFlight) return true; + if (now - this.completedAt < this.windowMs) return true; + return false; + } + + beginFetch(): void { + this.inFlight = true; + } + + completeFetch(now: number = Date.now()): void { + this.inFlight = false; + this.completedAt = now; + } +} + class ReconnectingSocket { private ws: WebSocket | null = null; private attempt = 0; @@ -25,6 +69,18 @@ class ReconnectingSocket { // effect double-invoke (and any future intentional disconnect) // leaves a zombie WebSocket alive forever. private disposed = false; + // In-flight singleton + dedup window for rehydrate. Two reasons to + // collapse rapid calls: + // 1. connect.onopen fires rehydrate immediately, and the very next + // health-check tick may fire it again before the first GET + // returns — wasted round trip + rebuild churn that resets the + // mid-flight UI state (auto-rescue heuristics, grow passes). + // 2. Future call sites (a manual "Refresh" button, post-import + // hydrate, error-recovery rehydrate) might pile up. + // Keeping rehydrate idempotent at the call-site level means each + // caller can fire-and-forget without coordinating. + private rehydrateInFlight: Promise | null = null; + private rehydrateDedup = new RehydrateDedup(REHYDRATE_DEDUP_WINDOW_MS); constructor(url: string) { this.url = url; @@ -101,14 +157,35 @@ class ReconnectingSocket { } } - private async rehydrate() { - try { - const { api } = await import("@/lib/api"); - const workspaces = await api.get("/workspaces"); - useCanvasStore.getState().hydrate(workspaces); - } catch { - // Rehydration failed — will retry on next health check cycle + private rehydrate(): Promise { + // Reuse an in-flight fetch — a second caller during the GET + // shouldn't kick off a parallel one. + if (this.rehydrateInFlight) return this.rehydrateInFlight; + if (this.rehydrateDedup.shouldSkip(Date.now())) { + return Promise.resolve(); } + + // beginFetch lives INSIDE the IIFE's try so any future code added + // between gate-check and IIFE-construction can't throw and leave + // the gate stuck at inFlight=true forever. Today there's nothing + // that can throw here, but the cost of being defensive is one + // extra microtask of "in flight" status — negligible. + const promise = (async () => { + this.rehydrateDedup.beginFetch(); + try { + const { api } = await import("@/lib/api"); + const workspaces = await api.get("/workspaces"); + if (this.disposed) return; + useCanvasStore.getState().hydrate(workspaces); + } catch { + // Rehydration failed — will retry on next health check cycle. + } finally { + this.rehydrateDedup.completeFetch(Date.now()); + this.rehydrateInFlight = null; + } + })(); + this.rehydrateInFlight = promise; + return promise; } disconnect() {