fix(canvas): bundle of UX hardening — modals, position stability, error UX, paste
Single-themed bundle of fixes accumulated while polishing the canvas
chat / agent-comms / plugins / position flows. Each piece is small;
the connective tissue is "things observable from the canvas right
panel and the org-deploy flow that surprised real users".
UI / composer
- Legend: add close X + persisted-localStorage state + reopener
pill; default open for first-time users.
- SidePanel: rename "Skills" tab label → "Plugins" (single-line;
internal panelTab enum value, component name, and store keys
unchanged).
- SkillsTab: registry tri-state UI (loading / error / empty) with
actionable Retry button + 10s explicit fetch timeout. Handle
AbortSignal.timeout's DOMException by name (TimeoutError /
AbortError) — Chromium's "signal timed out" message wouldn't
match the prior naive /timeout/ regex. Reset mountedRef on every
mount: pre-existing StrictMode dev-mode bug where cleanup-only
`current = false` was never re-set, permanently wedging every
`if (mountedRef.current) setX(...)` guard and producing a
"Loading…" panel that never resolved on hard refresh.
- ChatTab: paste-image-from-clipboard via onPaste handler; unique
monotonic-counter filenames so same-second pastes don't collide
on name+size dedup. mime→ext map avoids `image/svg+xml`-style
raw extensions on synthesised filenames. Bypasses the
DataTransfer constructor so Safari < 14.1 / older Edge work.
- ChatTab: drop stuck error toast when the WS path already
delivered the agent reply but the HTTP path errored late
(sendingFromAPIRef gate now covers the .catch() handler).
- ChatTab: filter heartbeat-style internal self-messages from the
My Chat tab so historical rows with source_id=NULL don't
surface as user-typed input.
- Modal portals: OrgImportPreflightModal + MissingKeysModal
(ProviderPickerModal + AllKeysModal) now createPortal to
document.body and clamp max-h to 80vh. Escapes the ancestor
containing block (TemplatePalette's fixed+filtered sidebar
re-anchored descendants' position:fixed to itself, hiding
modals behind workspace cards). MissingKeysModal bumped to
z-[60] for stack ordering when both modals are open.
- OrgImportPreflightModal saveOne: ref-based microtask-safe
in-flight gate replaces the brittle "set startValue inside a
setState updater and read on the next line" pattern (React 18
doesn't guarantee functional updaters run synchronously; that
path strands `saving:true` and never calls createSecret). Same
useRef pattern guards SkillsTab.loadRegistry against concurrent
fires and Fast-Refresh-stranded promises; force=true parameter
on retry click bypasses the gate.
Agent comms
- AgentCommsPanel: derive UI-facing `flow` field instead of using
activity_type-derived direction. Self-logged a2a_receive rows
(source_id == workspace_id, what the agent runtime writes to log
its own outbound delegation replies) now correctly render as
OUTBOUND with → arrow + right-justified bubble. Previously they
rendered "← From Self" with Restart pointing at THIS workspace.
- AgentCommsPanel: error rows replace the unactionable
"X failed [A2A_ERROR]" body with banner + underlying-error
code-block + cause-hint (matched on Claude Code SDK init wedge,
deadline-exceeded, agent-thrown exception, empty-error) +
Restart [peer] / Open [peer] action buttons.
- AgentCommsPanel: render text bodies through ReactMarkdown +
remark-gfm so multi-part replies (tables, code) render properly.
Multi-part text extractor
- extractReplyText (live A2A response in ChatTab) and
extractResponseText (chat history loader in message-parser):
now COLLECT from every source — top-level parts, parts.root.text,
and artifacts — joined with "\n". Previous "first source wins"
silently dropped multi-part replies (Hermes summary+detail,
Claude Code long-form table). Tests cover joined-from-parts,
joined-from-artifacts, joined-from-both.
Position stability
- canvas-topology.buildNodesAndEdges: auto-rescue heuristic now
accepts currentParentSizes map; uses max(initial min, currently
grown) for the bbox check. Fixes "child jumps to weird location
after 30s" — the periodic socket health-check rehydrate
(silenceSec > 30) was rebuilding nodes from scratch, and the
rescue's reliance on grid-derived initial size false-flagged
children the user dragged into the user-grown area.
- canvas.hydrate: pass live measured dimensions from the existing
store into buildNodesAndEdges.
- socket.RehydrateDedup: pure exported helper class that gates
rehydrate calls. Two states — in-flight (in-flight Promise reused
by concurrent callers) + post-completion window (1.5s, returns
Promise.resolve()). Initialised with -Infinity so first call
always passes the gate. Wired into ReconnectingSocket.rehydrate.
A2A edges
- New A2AEdge custom React Flow edge component portals its label
out of the SVG layer via EdgeLabelRenderer so labels (a) render
above workspace cards instead of being hidden behind them and
(b) accept clicks. Click selects source + switches panel to
Activity, but only on a NEW selection (preserves current tab on
re-click of an already-selected source).
- buildA2AEdges output tagged type:"a2a"; edgeTypes wired in
Canvas.tsx.
Tests
- 14 new vitest cases across 4 files (964 → 978 passing):
OrgImportPreflightModal saveOne single-fire / double-click,
any-of rendering; AgentCommsPanel toCommMessage flow derivation
in all four shapes; canvas-topology rescue respects-grown /
rescues-genuine-drift / fallback-without-live-size; socket
RehydrateDedup gate behaviour; message-parser multi-part
response extraction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65b531acf6
commit
1d71b4e9e5
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<Edge> = {
|
||||
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}
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLegend}
|
||||
aria-label="Show legend"
|
||||
title="Show legend"
|
||||
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-zinc-900/95 border border-zinc-700/50 px-3 py-1.5 text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-zinc-200 hover:border-zinc-600 transition-[left,colors] duration-200`}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[10px]">ⓘ</span>
|
||||
Legend
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 ${leftClass} z-30 bg-zinc-900/95 border border-zinc-700/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}>
|
||||
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">Legend</div>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Legend</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeLegend}
|
||||
aria-label="Hide legend"
|
||||
title="Hide legend"
|
||||
className="-mt-0.5 -mr-1 px-1.5 text-[14px] leading-none text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-2">
|
||||
|
||||
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
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.
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
@ -215,7 +228,7 @@ function ProviderPickerModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 overflow-hidden"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@ -360,7 +373,8 @@ function ProviderPickerModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
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.
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
@ -493,7 +514,7 @@ function AllKeysModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@ -608,6 +629,7 @@ function AllKeysModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Set<string>>(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(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@ -205,7 +240,7 @@ export function OrgImportPreflightModal({
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-h-[85vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
|
||||
className="w-[560px] max-h-[80vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 py-4 border-b border-zinc-800">
|
||||
@ -280,7 +315,8 @@ export function OrgImportPreflightModal({
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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: "⏲" },
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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", () => {
|
||||
|
||||
225
canvas/src/components/__tests__/OrgImportPreflightModal.test.tsx
Normal file
225
canvas/src/components/__tests__/OrgImportPreflightModal.test.tsx
Normal file
@ -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(
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName="UX A/B Lab"
|
||||
workspaceCount={7}
|
||||
requiredEnv={[{ any_of: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }]}
|
||||
recommendedEnv={[]}
|
||||
configuredKeys={new Set()}
|
||||
onSecretSaved={() => {}}
|
||||
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<void>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName="UX A/B Lab"
|
||||
workspaceCount={7}
|
||||
requiredEnv={[{ any_of: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }]}
|
||||
recommendedEnv={[]}
|
||||
configuredKeys={new Set()}
|
||||
onSecretSaved={() => {}}
|
||||
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(
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName="UX A/B Lab"
|
||||
workspaceCount={7}
|
||||
requiredEnv={[{ any_of: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }]}
|
||||
recommendedEnv={[]}
|
||||
configuredKeys={new Set()}
|
||||
onSecretSaved={() => {}}
|
||||
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(
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName="UX A/B Lab"
|
||||
workspaceCount={7}
|
||||
requiredEnv={[{ any_of: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }]}
|
||||
recommendedEnv={[]}
|
||||
configuredKeys={new Set()}
|
||||
onSecretSaved={() => {}}
|
||||
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(
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName="UX A/B Lab"
|
||||
workspaceCount={7}
|
||||
requiredEnv={[{ any_of: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }]}
|
||||
recommendedEnv={[]}
|
||||
configuredKeys={new Set(["CLAUDE_CODE_OAUTH_TOKEN"])}
|
||||
onSecretSaved={() => {}}
|
||||
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(
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName="UX A/B Lab"
|
||||
workspaceCount={7}
|
||||
requiredEnv={[{ any_of: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }]}
|
||||
recommendedEnv={[]}
|
||||
configuredKeys={new Set()}
|
||||
onSecretSaved={() => {}}
|
||||
onProceed={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const importBtn = screen.getByRole("button", { name: /^Import$/ });
|
||||
expect(importBtn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
});
|
||||
133
canvas/src/components/canvas/A2AEdge.tsx
Normal file
133
canvas/src/components/canvas/A2AEdge.tsx
Normal file
@ -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<A2AEdgeData>;
|
||||
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 (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} markerEnd="url(#a2a-arrow)" />
|
||||
{labelText && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
// The label sits in a portal at the canvas root. position:
|
||||
// absolute + the (labelX, labelY) translate places it at
|
||||
// the edge midpoint. zIndex 5 wins against React Flow's
|
||||
// node layer (default z=0) without fighting the controls
|
||||
// strip (z=10).
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: "all",
|
||||
zIndex: 5,
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={ariaLabel}
|
||||
title="Open source workspace's activity feed"
|
||||
className={`px-2 py-0.5 rounded-full bg-zinc-900/95 border ${accent} ${accentText} text-[10px] font-medium shadow-md shadow-black/40 backdrop-blur-sm hover:bg-zinc-800 hover:border-opacity-100 transition-colors cursor-pointer`}
|
||||
>
|
||||
{labelText}
|
||||
</button>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const A2AEdge = memo(A2AEdgeImpl);
|
||||
@ -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<HTMLTextAreaElement>) => {
|
||||
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"
|
||||
|
||||
@ -57,6 +57,17 @@ export function SkillsTab({ data }: Props) {
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout>>(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<PluginInfo[]>(`/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<string | null>(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<PluginInfo[]>("/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<PluginInfo[]>("/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.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-600 mb-2">Available plugins</div>
|
||||
{registry.length === 0 ? (
|
||||
<div className="text-[10px] text-zinc-600">No plugins in registry</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-600">Available plugins</div>
|
||||
{/* 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.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadRegistry(true)}
|
||||
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline"
|
||||
>
|
||||
{registryLoading ? "Loading… click to retry" : "Retry"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{registryLoading && registry.length === 0 ? (
|
||||
<div className="text-[10px] text-zinc-500">Loading registry…</div>
|
||||
) : registryError ? (
|
||||
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
|
||||
<div className="text-[10px] text-red-300 font-semibold mb-0.5">
|
||||
Couldn't load the plugin registry
|
||||
</div>
|
||||
<div className="text-[10px] text-red-400/80">{registryError}</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
Check the platform server is reachable at /plugins. The Retry button is in the header above.
|
||||
</div>
|
||||
</div>
|
||||
) : registry.length === 0 ? (
|
||||
<div className="rounded-lg border border-zinc-800/40 bg-zinc-950/40 px-2 py-1.5">
|
||||
<div className="text-[10px] text-zinc-400 mb-0.5">Registry returned 0 plugins.</div>
|
||||
<div className="text-[10px] text-zinc-600">
|
||||
This usually means the platform's plugins/ directory is empty.
|
||||
Run scripts/clone-manifest.sh to populate it from the standalone repos.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{registry.map((p) => {
|
||||
|
||||
@ -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<CommMessage[]>([]);
|
||||
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 (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.direction === "out" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
msg.direction === "out"
|
||||
? "bg-cyan-900/30 text-cyan-100 border border-cyan-700/20"
|
||||
: "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[9px] text-zinc-500 mb-1">
|
||||
{msg.direction === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
||||
</div>
|
||||
<div className="text-zinc-300">{msg.text || "(no message text)"}</div>
|
||||
{msg.responseText && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/30 text-zinc-400">
|
||||
{msg.responseText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[9px] text-zinc-500 mt-1">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{messages.map((msg) =>
|
||||
msg.status === "error" ? (
|
||||
<ErrorMessage key={msg.id} msg={msg} />
|
||||
) : (
|
||||
<NormalMessage key={msg.id} msg={msg} />
|
||||
),
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NormalMessage({ msg }: { msg: CommMessage }) {
|
||||
return (
|
||||
<div className={`flex ${msg.flow === "out" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
msg.flow === "out"
|
||||
? "bg-cyan-900/30 text-cyan-100 border border-cyan-700/20"
|
||||
: "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[9px] text-zinc-500 mb-1">
|
||||
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
||||
</div>
|
||||
{msg.text ? (
|
||||
<MarkdownBody className="text-zinc-300">{msg.text}</MarkdownBody>
|
||||
) : (
|
||||
<div className="text-zinc-300">(no message text)</div>
|
||||
)}
|
||||
{msg.responseText && (
|
||||
<MarkdownBody className="mt-1.5 pt-1.5 border-t border-zinc-700/30 text-zinc-400">
|
||||
{msg.responseText}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
<div className="text-[9px] text-zinc-500 mt-1">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={`flex ${msg.flow === "out" ? "justify-end" : "justify-start"}`}>
|
||||
<div className="max-w-[85%] rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-red-300 font-semibold uppercase tracking-wide mb-1.5">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
{msg.flow === "out"
|
||||
? `Failed to deliver to ${msg.peerName}`
|
||||
: `${msg.peerName} returned an error`}
|
||||
</div>
|
||||
|
||||
{msg.text && (
|
||||
<div className="text-[10px] text-zinc-500 mb-1.5">
|
||||
<span className="uppercase tracking-wide">Task</span>
|
||||
<MarkdownBody className="text-zinc-400">{msg.text}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded bg-zinc-950/60 border border-red-900/40 px-2 py-1.5 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wide text-red-400 mb-0.5">
|
||||
Underlying error
|
||||
</div>
|
||||
<code className="text-[11px] font-mono text-red-200 whitespace-pre-wrap break-words">
|
||||
{errorText || "(no detail returned)"}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-zinc-400 leading-snug mb-2">{hint}</p>
|
||||
|
||||
{msg.peerId && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-800/60 border border-red-700/40 text-[10px] text-red-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{restarting ? "Restarting…" : `Restart ${msg.peerName}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="px-2 py-0.5 rounded bg-zinc-800 hover:bg-zinc-700 border border-zinc-700/50 text-[10px] text-zinc-300 transition-colors"
|
||||
>
|
||||
Open {msg.peerName}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[9px] text-zinc-500 mt-1.5">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
className={`prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
@ -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", () => {
|
||||
|
||||
@ -99,22 +99,54 @@ export function extractRequestText(body: Record<string, unknown> | 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, unknown>): 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<string, unknown> | 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<Record<string, unknown>>;
|
||||
const rootTexts: string[] = [];
|
||||
for (const p of parts) {
|
||||
const t = (p.text as string) || "";
|
||||
if (t) return t;
|
||||
const root = p.root as Record<string, unknown> | 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<Record<string, unknown>> | 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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, { x: number; y: number }> = new Map()
|
||||
layoutOverrides: Map<string, { x: number; y: number }> = new Map(),
|
||||
currentParentSizes: Map<string, { width: number; height: number }> = new Map(),
|
||||
): {
|
||||
nodes: Node<WorkspaceNodeData>[];
|
||||
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 =
|
||||
|
||||
@ -791,7 +791,30 @@ export const useCanvasStore = create<CanvasState>((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<string, { width: number; height: number }>();
|
||||
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(() => {});
|
||||
|
||||
@ -12,6 +12,50 @@ export interface WSMessage {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<void> | 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<WorkspaceData[]>("/workspaces");
|
||||
useCanvasStore.getState().hydrate(workspaces);
|
||||
} catch {
|
||||
// Rehydration failed — will retry on next health check cycle
|
||||
private rehydrate(): Promise<void> {
|
||||
// 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<WorkspaceData[]>("/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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user