molecule-core/canvas/src/components/OrgImportPreflightModal.tsx
Hongming Wang 1d71b4e9e5 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>
2026-04-24 19:54:43 -07:00

541 lines
19 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { createSecret } from "@/lib/api/secrets";
/**
* One entry from the server's preflight `required_env` / `recommended_env`.
*
* - A plain string is a STRICT requirement: that exact env var must be
* configured.
* - A `{any_of: [...]}` object is an OR group: at least one member
* must be configured to satisfy it. Lets a template say "either
* ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN" without forcing
* both.
*
* Matches the Go `EnvRequirement` type's JSON shape (MarshalJSON in
* workspace-server/internal/handlers/org.go). The union is written so
* that a narrow check — `typeof e === "string"` — distinguishes cleanly.
*/
export type EnvRequirement = string | { any_of: string[] };
/** Flat member list for a requirement. */
export function envReqMembers(r: EnvRequirement): string[] {
return typeof r === "string" ? [r] : r.any_of;
}
/** True if any member is present in `configured`. */
export function envReqSatisfied(r: EnvRequirement, configured: Set<string>): boolean {
if (typeof r === "string") return configured.has(r);
return r.any_of.some((m) => configured.has(m));
}
/** Stable react-key / dedup key for a requirement. Sorted for groups so
* reordered-member variants still collapse to one entry. */
export function envReqKey(r: EnvRequirement): string {
if (typeof r === "string") return r;
return [...r.any_of].sort().join("|");
}
interface Props {
open: boolean;
/** Display name of the org template — headline only. */
orgName: string;
/** Total workspace count so the header can read "12 workspaces". */
workspaceCount: number;
/** Env vars the server has declared MUST be set as global secrets.
* Import is disabled until every entry here is configured. Entries
* are either a single key name or an any-of group. */
requiredEnv: EnvRequirement[];
/** Env vars the server suggests — import can proceed without them,
* but the user sees them listed so they can decide. Same union
* shape as `requiredEnv`. */
recommendedEnv: EnvRequirement[];
/** Names of env vars already configured globally. Used to strike
* through entries the user has already set up in another
* session. Passed in rather than queried inside the modal so the
* parent can refresh after each save without prop-driven effects. */
configuredKeys: Set<string>;
/** Called after a successful secret save so the parent can refresh
* `configuredKeys`. */
onSecretSaved: () => void;
/** User clicked Import with all required envs satisfied. */
onProceed: () => void;
/** User dismissed the modal. Import is NOT fired. */
onCancel: () => void;
}
interface DraftEntry {
key: string;
value: string;
saving: boolean;
error: string | null;
}
/**
* OrgImportPreflightModal
* -----------------------
* Two-tier env preflight before POST /org/import:
*
* - REQUIRED section (red, blocking) — every entry MUST be configured
* globally before the Import button enables. Matches the server-
* side preflight that would 412 the import anyway.
*
* - RECOMMENDED section (yellow, non-blocking) — listed so the user
* can add them if they want the full experience, but the Import
* button stays enabled regardless.
*
* Saving goes to the GLOBAL secrets endpoint (PUT /settings/secrets)
* because org-level templates deploy shared resources. Per-workspace
* overrides still work via the Config tab on an individual node
* after import. The modal does NOT enable Import the moment a key is
* typed — only after it saves successfully (so a half-entered token
* can't proceed and then fail at container-start time instead).
*/
export function OrgImportPreflightModal({
open,
orgName,
workspaceCount,
requiredEnv,
recommendedEnv,
configuredKeys,
onSecretSaved,
onProceed,
onCancel,
}: Props) {
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({});
// Flatten the union-shaped requirement lists to the set of every key
// that could ever appear as an input row. Used purely to seed the
// drafts map — satisfaction semantics still read from the grouped
// EnvRequirement entries (a group can be satisfied by any one
// member).
const allMemberKeys = useMemo(() => {
const keys: string[] = [];
for (const r of requiredEnv) keys.push(...envReqMembers(r));
for (const r of recommendedEnv) keys.push(...envReqMembers(r));
return keys;
}, [requiredEnv, recommendedEnv]);
// Seed a draft entry per declared key the first time the modal
// opens. Entries persist across `configuredKeys` changes so a mid-
// save recheck doesn't wipe what the user typed.
//
// Dep: derive a STABLE string from the env-name lists rather than
// the array refs themselves. The parent computes
// `preflight.org.required_env ?? []`, which produces a fresh []
// identity on every re-render (e.g. when refreshConfiguredKeys
// bumps state); depending on the array refs would re-fire the
// effect on every parent render and mask any future edit that
// drops the `if (!next[k])` guard as a silent input-reset bug.
const envKeysSignature = useMemo(
() => [...allMemberKeys].sort().join("|"),
[allMemberKeys],
);
useEffect(() => {
if (!open) return;
setDrafts((prev) => {
const next = { ...prev };
for (const k of allMemberKeys) {
if (!next[k]) {
next[k] = { key: k, value: "", saving: false, error: null };
}
}
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, envKeysSignature]);
const missingRequired = useMemo(
() => requiredEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
[requiredEnv, configuredKeys],
);
const missingRecommended = useMemo(
() => recommendedEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
[recommendedEnv, configuredKeys],
);
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) => {
// 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) => ({
...d,
[key]: { ...d[key], value: "", saving: false, error: null },
}));
// Let the parent refresh configuredKeys so the strike-through
// updates and canProceed recomputes.
onSecretSaved();
} catch (e) {
setDrafts((d) => ({
...d,
[key]: {
...d[key],
saving: false,
error: e instanceof Error ? e.message : "Save failed",
},
}));
} finally {
inFlightRef.current.delete(key);
}
},
[onSecretSaved],
);
if (!open) return null;
// 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"
aria-labelledby="org-preflight-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onCancel}
>
<div
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">
<h2 id="org-preflight-title" className="text-sm font-semibold text-zinc-100">
Deploy {orgName}
</h2>
<p className="mt-0.5 text-[11px] text-zinc-500">
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
Review the credentials needed before import.
</p>
</header>
<section className="p-5 space-y-5">
{requiredEnv.length > 0 && (
<EnvList
tone="required"
title="Required"
subtitle="Import is blocked until every key below is saved globally."
entries={requiredEnv}
configuredKeys={configuredKeys}
drafts={drafts}
onChange={(key, value) =>
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
}
onSave={saveOne}
/>
)}
{recommendedEnv.length > 0 && (
<EnvList
tone="recommended"
title="Recommended"
subtitle="Not required, but some features degrade without them. Add them now for the best experience."
entries={recommendedEnv}
configuredKeys={configuredKeys}
drafts={drafts}
onChange={(key, value) =>
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
}
onSave={saveOne}
/>
)}
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
<p className="text-[12px] text-zinc-400">
No additional credentials required for this template.
</p>
)}
</section>
<footer className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
>
Cancel
</button>
<div className="flex items-center gap-2">
{missingRecommended.length > 0 && canProceed && (
<span className="text-[10px] text-amber-400/90">
{missingRecommended.length} recommended key
{missingRecommended.length === 1 ? "" : "s"} still unset
</span>
)}
<button
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-blue-600 hover:bg-blue-500 text-white disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
>
Import
</button>
</div>
</footer>
</div>
</div>,
document.body,
);
}
interface EnvListProps {
tone: "required" | "recommended";
title: string;
subtitle: string;
entries: EnvRequirement[];
configuredKeys: Set<string>;
drafts: Record<string, DraftEntry>;
onChange: (key: string, value: string) => void;
onSave: (key: string) => void;
}
function EnvList({
tone,
title,
subtitle,
entries,
configuredKeys,
drafts,
onChange,
onSave,
}: EnvListProps) {
const accent =
tone === "required"
? "border-red-800/60 bg-red-950/20"
: "border-amber-800/50 bg-amber-950/15";
const headerColor =
tone === "required" ? "text-red-300" : "text-amber-300";
return (
<div className={`rounded-lg border ${accent} p-3`}>
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
{title}
</h3>
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
<ul className="space-y-2">
{entries.map((entry) =>
typeof entry === "string" ? (
<StrictEnvRow
key={envReqKey(entry)}
envKey={entry}
configured={configuredKeys.has(entry)}
draft={drafts[entry]}
onChange={onChange}
onSave={onSave}
/>
) : (
<AnyOfEnvGroup
key={envReqKey(entry)}
members={entry.any_of}
configuredKeys={configuredKeys}
drafts={drafts}
onChange={onChange}
onSave={onSave}
/>
),
)}
</ul>
</div>
);
}
interface StrictEnvRowProps {
envKey: string;
configured: boolean;
draft: DraftEntry | undefined;
onChange: (key: string, value: string) => void;
onSave: (key: string) => void;
}
function StrictEnvRow({
envKey,
configured,
draft: d,
onChange,
onSave,
}: StrictEnvRowProps) {
return (
<li className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5">
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-zinc-500 line-through" : "text-zinc-200"
}`}
>
{envKey}
</code>
{configured ? (
<span className="text-[10px] text-emerald-400"> set</span>
) : (
<>
<input
type="password"
aria-label={`Value for ${envKey}`}
placeholder="paste value"
value={d?.value ?? ""}
onChange={(e) => onChange(envKey, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSave(envKey);
}
}}
disabled={d?.saving}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
</>
)}
{d?.error && (
<span className="text-[9px] text-red-400 basis-full pl-1">
{d.error}
</span>
)}
</li>
);
}
interface AnyOfEnvGroupProps {
members: string[];
configuredKeys: Set<string>;
drafts: Record<string, DraftEntry>;
onChange: (key: string, value: string) => void;
onSave: (key: string) => void;
}
/**
* Renders an OR group: the user only needs to configure ONE of the
* members to satisfy the requirement. Once any member is configured
* the group shows a green banner identifying the satisfying key; the
* other inputs remain visible but muted so the user can still switch
* providers if they want (uncommon but cheap to support).
*/
function AnyOfEnvGroup({
members,
configuredKeys,
drafts,
onChange,
onSave,
}: AnyOfEnvGroupProps) {
const satisfiedBy = members.find((m) => configuredKeys.has(m));
return (
<li className="rounded border border-zinc-800 bg-zinc-900/50 px-2.5 py-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-wide text-zinc-400">
Configure any one
</span>
{satisfiedBy && (
<span className="text-[10px] text-emerald-400">
using <code className="font-mono">{satisfiedBy}</code>
</span>
)}
</div>
<ul className="space-y-1.5">
{members.map((m) => {
const isConfigured = configuredKeys.has(m);
const d = drafts[m];
const dimmed = !!satisfiedBy && !isConfigured;
return (
<li
key={m}
className={`flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1 ${
dimmed ? "opacity-50" : ""
}`}
>
<code
className={`text-[11px] font-mono flex-1 ${
isConfigured ? "text-zinc-500 line-through" : "text-zinc-200"
}`}
>
{m}
</code>
{isConfigured ? (
<span className="text-[10px] text-emerald-400"> set</span>
) : (
<>
<input
type="password"
aria-label={`Value for ${m}`}
placeholder="paste value"
value={d?.value ?? ""}
onChange={(e) => onChange(m, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSave(m);
}
}}
disabled={d?.saving}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
</>
)}
{d?.error && (
<span className="text-[9px] text-red-400 basis-full pl-1">
{d.error}
</span>
)}
</li>
);
})}
</ul>
</li>
);
}