From 94d9331c765e12c25f09579837c17c8a596d77e9 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 13:27:51 -0700 Subject: [PATCH 01/37] feat(canvas+platform): chat attachments, model selection, deploy/delete UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session's accumulated UX work across frontend and platform. Reviewable in four logical sections — diff is large but internally cohesive (each section fixes a gap the next one depends on). ## Chat attachments — user ↔ agent file round trip - New POST /workspaces/:id/chat/uploads (multipart, 50 MB total / 25 MB per file, UUID-prefixed storage under /workspace/.molecule/chat-uploads/). - New GET /workspaces/:id/chat/download with RFC 6266 filename escaping and binary-safe io.CopyN streaming. - Canvas: drag-and-drop onto chat pane, pending-file pills, per-message attachment chips with fetch+blob download (anchor navigation can't carry auth headers). - A2A flow carries FileParts end-to-end; hermes template executor now consumes attachments via platform helpers. ## Platform attachment helpers (workspace/executor_helpers.py) Every runtime's executor routes through the same helpers so future runtimes inherit attachment awareness for free: - extract_attached_files — resolve workspace:/file:///bare URIs, reject traversal, skip non-existent. - build_user_content_with_files — manifest for non-image files, multi-modal list (text + image_url) for images. Respects MOLECULE_DISABLE_IMAGE_INLINING for providers whose vision adapter hangs on base64 payloads (MiniMax M2.7). - collect_outbound_files — scans agent reply for /workspace/... paths, stages each into chat-uploads/ (download endpoint whitelist), emits as FileParts in the A2A response. - ensure_workspace_writable — called at molecule-runtime startup so non-root agents can write /workspace without each template having to chmod in its Dockerfile. Hermes template executor + langgraph (a2a_executor.py) + claude-code (claude_sdk_executor.py) all adopt the helpers. ## Model selection & related platform fixes - PUT /workspaces/:id/model — was 404'ing, so canvas "Save" silently lost the model choice. Stores into workspace_secrets (MODEL_PROVIDER), auto-restarts via RestartByID. - applyRuntimeModelEnv falls back to envVars["MODEL_PROVIDER"] so Restart propagates the stored model to HERMES_DEFAULT_MODEL without needing the caller to rehydrate payload.Model. - ConfigTab Tier dropdown now reads from workspaces row, not the (stale) config.yaml — fixes "badge shows T3, form shows T2". ## ChatTab & WebSocket UX fixes - Send button no longer locks after a dropped TASK_COMPLETE — `sending` no longer initializes from data.currentTask. - A2A POST timeout 15 s → 120 s. LLM turns routinely exceed 15 s; the previous default aborted fetches while the server was still replying, producing "agent may be unreachable" on success. - socket.ts: disposed flag + reconnectTimer cancellation + handler detachment fix zombie-WebSocket in React StrictMode. - Hermes Config tab: RUNTIMES_WITH_OWN_CONFIG drops 'hermes' — the adaptor's purpose IS the form, banner was contradictory. - workspace_provision.go auto-recovery: try -default AND bare for template path (hermes lives at the bare name). ## Org deploy/delete animation (theme-ready CSS) - styles/theme-tokens.css — design tokens (durations, easings, colors). Light theme overrides by setting only the deltas. - styles/org-deploy.css — animation classes + keyframes, every value references a token. prefers-reduced-motion respected. - Canvas projects node.draggable=false onto locked workspaces (deploying children AND actively-deleting ids) — RF's authoritative drag lock; useDragHandlers retains a belt-and- braces check. - Organ cancel button (red pulse pill on root during deploy) cascades via existing DELETE /workspaces/:id?confirm=true. - Auto fit-view after each arrival, debounced 500 ms so rapid sibling arrivals coalesce into one fit (previous per-event fit made the viewport lurch continuously). - Auto-fit respects user-pan — onMoveEnd stamps a user-pan timestamp only when event !== null (ignores programmatic fitView) so auto-fits don't self-cancel. - deletingIds store slice + useOrgDeployState merge gives the delete flow the same dim + non-draggable treatment as deploy. - Platform-level classNames.ts shared by canvas-events + useCanvasViewport (DRY'd 3 copies of split/filter/join). ## Server payload change - org_import.go WORKSPACE_PROVISIONING broadcast now includes parent_id + parent-RELATIVE x/y (slotX/slotY) so the canvas renders the child at the right parent-nested slot without doing any absolute-position walk. createWorkspaceTree signature gains relX, relY alongside absX, absY; both call sites updated. ## Tests - workspace/tests/test_executor_helpers.py — 11 new cases covering URI resolution (including traversal rejection), attached-file extraction (both Part shapes), manifest-only vs multi-modal content, large-image skip, outbound staging, dedup, and ensure_workspace_writable (chmod 777 + non-root tolerance). - workspace-server chat_files_test.go — upload validation, Content-Disposition escaping, filename sanitisation. - workspace-server secrets_test.go — SetModel upsert, empty clears, invalid UUID rejection. - tests/e2e/test_chat_attachments_e2e.sh — round-trip against a live hermes workspace. - tests/e2e/test_chat_attachments_multiruntime_e2e.sh — static plumbing check + round-trip across hermes/langgraph/claude-code. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/app/globals.css | 19 +- canvas/src/components/Canvas.tsx | 109 ++++- canvas/src/components/TemplatePalette.tsx | 36 +- canvas/src/components/WorkspaceNode.tsx | 19 + .../src/components/canvas/OrgCancelButton.tsx | 165 +++++++ .../components/canvas/useCanvasViewport.ts | 142 +++++- .../src/components/canvas/useDragHandlers.ts | 12 + .../components/canvas/useOrgDeployState.ts | 152 +++++++ canvas/src/components/tabs/ChatTab.tsx | 272 ++++++++++-- canvas/src/components/tabs/ConfigTab.tsx | 32 +- .../components/tabs/chat/AttachmentViews.tsx | 94 ++++ .../chat/__tests__/message-parser.test.ts | 69 +++ .../tabs/chat/__tests__/uploads.test.ts | 41 ++ .../components/tabs/chat/message-parser.ts | 58 +++ canvas/src/components/tabs/chat/types.ts | 42 +- canvas/src/components/tabs/chat/uploads.ts | 135 ++++++ canvas/src/store/canvas-events.ts | 242 ++++++++-- canvas/src/store/canvas.ts | 24 +- canvas/src/store/classNames.ts | 53 +++ canvas/src/store/socket.ts | 49 ++- canvas/src/styles/org-deploy.css | 151 +++++++ canvas/src/styles/theme-tokens.css | 69 +++ tests/e2e/test_chat_attachments_e2e.sh | 93 ++++ .../test_chat_attachments_multiruntime_e2e.sh | 149 +++++++ .../internal/handlers/chat_files.go | 415 ++++++++++++++++++ .../internal/handlers/chat_files_test.go | 194 ++++++++ workspace-server/internal/handlers/org.go | 3 +- .../internal/handlers/org_import.go | 31 +- workspace-server/internal/handlers/secrets.go | 67 +++ .../internal/handlers/secrets_test.go | 83 ++++ .../internal/handlers/workspace_provision.go | 48 +- workspace-server/internal/router/router.go | 9 + workspace/a2a_executor.py | 42 +- workspace/claude_sdk_executor.py | 35 +- workspace/executor_helpers.py | 279 ++++++++++++ workspace/main.py | 9 + workspace/tests/test_executor_helpers.py | 252 +++++++++++ 37 files changed, 3580 insertions(+), 114 deletions(-) create mode 100644 canvas/src/components/canvas/OrgCancelButton.tsx create mode 100644 canvas/src/components/canvas/useOrgDeployState.ts create mode 100644 canvas/src/components/tabs/chat/AttachmentViews.tsx create mode 100644 canvas/src/components/tabs/chat/__tests__/uploads.test.ts create mode 100644 canvas/src/components/tabs/chat/uploads.ts create mode 100644 canvas/src/store/classNames.ts create mode 100644 canvas/src/styles/org-deploy.css create mode 100644 canvas/src/styles/theme-tokens.css create mode 100755 tests/e2e/test_chat_attachments_e2e.sh create mode 100755 tests/e2e/test_chat_attachments_multiruntime_e2e.sh create mode 100644 workspace-server/internal/handlers/chat_files.go create mode 100644 workspace-server/internal/handlers/chat_files_test.go diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css index a88ce30a..ee39b125 100644 --- a/canvas/src/app/globals.css +++ b/canvas/src/app/globals.css @@ -1,5 +1,9 @@ @import "xterm/css/xterm.css"; +/* Theme tokens MUST load before any feature stylesheet that + references them so custom properties are in scope. */ +@import "../styles/theme-tokens.css"; @import "../styles/settings-panel.css"; +@import "../styles/org-deploy.css"; @tailwind base; @tailwind components; @@ -38,7 +42,20 @@ body { } .react-flow__node { - transition: box-shadow 0.2s ease; + /* Transform transition drives the "spawn from parent" motion — + org-deploy sets the node's initial position to the parent's + absolute coords, then repositions to the real slot, and this + transition interpolates the translate() in between. + Non-deploy workspace moves (drag, nest) get the same smoothing + for free. */ + transition: + box-shadow var(--mol-duration-fast) ease, + transform var(--mol-duration-spawn) var(--mol-easing-bounce-out); +} +/* Drag events must feel instant — React Flow adds this class + for the lifetime of the gesture. */ +.react-flow__node.dragging { + transition: box-shadow var(--mol-duration-fast) ease; } /* Scrollbar styling */ diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 16c299cb..c0e25d19 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -58,14 +58,95 @@ export function Canvas() { } function CanvasInner() { - const nodes = useCanvasStore((s) => s.nodes); + const rawNodes = useCanvasStore((s) => s.nodes); const edges = useCanvasStore((s) => s.edges); const a2aEdges = useCanvasStore((s) => s.a2aEdges); const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); + const deletingIds = useCanvasStore((s) => s.deletingIds); const allEdges = useMemo( () => (showA2AEdges ? [...edges, ...a2aEdges] : edges), [edges, a2aEdges, showA2AEdges], ); + // Drag-lock during a system-owned operation (deploy OR delete). + // React Flow respects Node.draggable, which stops the gesture + // before it starts — preventDefault() on the drag-start callback + // isn't authoritative in v12. We project `draggable: false` onto + // each locked node before handing the array to ReactFlow; the + // drag-start handler in useDragHandlers remains as a belt-and- + // braces check. + // + // Perf: short-circuit when nothing is provisioning so the memo + // passes rawNodes through unchanged (identity-stable → RF + // reconciles nothing). When a deploy IS active, build an O(n) + // root index once and re-use it. Critically, do NOT spread every + // node — only mutate the locked ones — so unmodified nodes keep + // their object identity and RF's per-node memo short-circuits. + const nodes = useMemo(() => { + const anyProvisioning = rawNodes.some((n) => n.data.status === "provisioning"); + const anyDeleting = deletingIds.size > 0; + if (!anyProvisioning && !anyDeleting) return rawNodes; + + const byId = new Map(); + for (const n of rawNodes) byId.set(n.id, n); + const rootOf = new Map(); + const resolveRoot = (id: string): string => { + // Iterative walk guards against a pathological cycle (hostile + // data) — recursion would hit the stack limit on a deep tree. + const visited = new Set(); + let cursor: string | null = id; + while (cursor) { + if (visited.has(cursor)) break; + visited.add(cursor); + const cached = rootOf.get(cursor); + if (cached) { + for (const seenId of visited) rootOf.set(seenId, cached); + return cached; + } + const n = byId.get(cursor); + if (!n) break; + if (!n.data.parentId) { + for (const seenId of visited) rootOf.set(seenId, cursor); + return cursor; + } + cursor = n.data.parentId; + } + return id; + }; + + const provisioningByRoot = new Map(); + for (const n of rawNodes) { + if (n.data.status !== "provisioning") continue; + const rootId = resolveRoot(n.id); + provisioningByRoot.set(rootId, (provisioningByRoot.get(rootId) ?? 0) + 1); + } + + let touched = false; + const next = rawNodes.map((n) => { + const rootId = resolveRoot(n.id); + const deployLocked = n.id !== rootId && (provisioningByRoot.get(rootId) ?? 0) > 0; + // Delete-locked: nothing in a subtree whose DELETE is in + // flight should be draggable, INCLUDING the root of that + // subtree (unlike deploy, there's no cancel — the delete + // is irrevocable at this point). + const deleteLocked = deletingIds.has(n.id); + const shouldLock = deployLocked || deleteLocked; + if (shouldLock && n.draggable !== false) { + touched = true; + return { ...n, draggable: false }; + } + if (!shouldLock && n.draggable === false) { + // Node was locked in a prior render; deploy cancelled / + // completed, or delete failed and was reverted. Restore + // default dragability. + touched = true; + const { draggable: _d, ...rest } = n; + void _d; + return rest as typeof n; + } + return n; // identity-preserved + }); + return touched ? next : rawNodes; + }, [rawNodes, deletingIds]); const onNodesChange = useCanvasStore((s) => s.onNodesChange); const selectNode = useCanvasStore((s) => s.selectNode); const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); @@ -96,10 +177,36 @@ function CanvasInner() { if (!pendingDelete) return; const { id } = pendingDelete; setPendingDelete(null); + // Compute the full subtree and mark it as "deleting" so every + // node in the chain renders dim + non-draggable during the + // network round-trip + the server-side cascade. Matches the + // deploy-lock UX: once a system-initiated operation owns this + // subtree, the user shouldn't be able to move its pieces + // around until it resolves. + const state = useCanvasStore.getState(); + const subtree = new Set(); + const stack = [id]; + while (stack.length) { + const nid = stack.pop()!; + subtree.add(nid); + for (const n of state.nodes) { + if (n.data.parentId === nid) stack.push(n.id); + } + } + state.beginDelete(subtree); try { await api.del(`/workspaces/${id}?confirm=true`); removeNode(id); + // Server-side cascade will emit WORKSPACE_REMOVED per node; + // handleCanvasEvent drops each from the store. Clear the + // deleting set in one shot once the DELETE resolves so any + // node that lags the WS (or is preserved locally, e.g. an + // external workspace) doesn't stay dimmed forever. + state.endDelete(subtree); } catch (e) { + // Network or server error — restore the subtree to normal + // interaction and surface the error. + state.endDelete(subtree); showToast(e instanceof Error ? e.message : "Delete failed", "error"); } }, [pendingDelete, setPendingDelete, removeNode]); diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index 35f75877..55065dcd 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -114,16 +114,32 @@ export function OrgTemplatesSection() { setError(null); try { await importOrgTemplate(org.dir); - // Refresh canvas inline — the WebSocket may be offline, in which case - // WORKSPACE_PROVISIONING broadcasts never arrive and the user sees - // no change from clicking "Import org". A direct fetch guarantees - // the new workspaces land on canvas regardless of WS state. - try { - const workspaces = await api.get("/workspaces"); - useCanvasStore.getState().hydrate(workspaces); - } catch { - // Rehydrate failure is non-fatal; WS (if alive) or the next - // health-check cycle will eventually pick the new workspaces up. + // Hydrate is the safety net for the "WS is offline" case — + // without live events the canvas stays empty. But calling it + // immediately wipes the org-deploy animation (hydrate rebuilds + // the node array from scratch, dropping the spawn / shimmer + // classes and position tweens). So: + // 1. If the number of nodes on the canvas already matches + // (or exceeds) the template's workspace count, WS + // delivered everything — skip hydrate. + // 2. Otherwise, wait a short window to let any in-flight WS + // events land, then hydrate only if still behind. + const expectedCount = org.workspaces; + // Nodes transition through WORKSPACE_REMOVED which physically + // drops them from the store — there is no "removed" status in + // WorkspaceNodeData — so a simple length check is enough here. + const hasAll = () => useCanvasStore.getState().nodes.length >= expectedCount; + if (!hasAll()) { + await new Promise((r) => setTimeout(r, 1500)); + } + if (!hasAll()) { + try { + const workspaces = await api.get("/workspaces"); + useCanvasStore.getState().hydrate(workspaces); + } catch { + // WS (if alive) or the next health-check cycle will + // eventually pick the new workspaces up. + } } showToast(`Imported "${org.name || org.dir}" (${org.workspaces} workspaces)`, "success"); } catch (e) { diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx index 61870fee..7a6e3f60 100644 --- a/canvas/src/components/WorkspaceNode.tsx +++ b/canvas/src/components/WorkspaceNode.tsx @@ -6,6 +6,8 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { showToast } from "@/components/Toaster"; import { Tooltip } from "@/components/Tooltip"; import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens"; +import { useOrgDeployState } from "@/components/canvas/useOrgDeployState"; +import { OrgCancelButton } from "@/components/canvas/OrgCancelButton"; /** Descendant count for the "N sub" badge — children are first-class nodes * rendered as full cards inside this one via React Flow's native parentId, @@ -35,6 +37,10 @@ function EjectIcon(props: React.SVGProps) { export function WorkspaceNode({ id, data }: NodeProps>) { const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline; const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" }; + // Org-deploy context — four derived flags off one store subscription. + // Drives the shimmer while provisioning, the dimmed/non-draggable + // treatment on locked descendants, and the Cancel pill on the root. + const deploy = useOrgDeployState(id); const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); const selectNode = useCanvasStore((s) => s.selectNode); const openContextMenu = useCanvasStore((s) => s.openContextMenu); @@ -138,8 +144,21 @@ export function WorkspaceNode({ id, data }: NodeProps>) } backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 + ${deploy.isActivelyProvisioning ? "mol-deploy-shimmer" : ""} + ${deploy.isLockedChild ? "mol-deploy-locked" : ""} `} > + {/* Cancel-deployment pill — rendered on the root of a deploying + org only. Positioned absolute inside the card so it moves + with drag; class="nodrag" on the button stops React Flow + from treating clicks as a drag start. */} + {deploy.isDeployingRoot && ( + + )} {/* Status gradient bar at top */}
diff --git a/canvas/src/components/canvas/OrgCancelButton.tsx b/canvas/src/components/canvas/OrgCancelButton.tsx new file mode 100644 index 00000000..402b92d8 --- /dev/null +++ b/canvas/src/components/canvas/OrgCancelButton.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/lib/api"; +import { useCanvasStore } from "@/store/canvas"; +import { showToast } from "@/components/Toaster"; + +interface Props { + /** Root workspace of the org being deployed. The cancel action + * cascades delete through workspace-server's existing recursive + * delete handler, so we only need the root id. */ + rootId: string; + rootName: string; + /** Count rendered in the pill label; updated live as children + * come online (the useOrgDeployState hook recomputes on every + * status change). */ + workspaceCount: number; +} + +/** + * Cancel-deployment pill attached to the root of a deploying org. + * One click → confirm dialog → DELETE /workspaces/:rootId?confirm=true + * which cascades through every descendant server-side. + * + * Rendered inside the root's WorkspaceNode card via an absolute- + * positioned overlay so it sits visually ON the card and moves with + * drag. `className="nodrag"` stops React Flow from interpreting + * clicks here as the start of a drag gesture. + * + * Deliberately uses only `.mol-deploy-cancel*` classes for styling — + * every color / easing comes from theme-tokens.css, so a future + * light-theme (or tenant-branded theme) inherits automatically. + */ +export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) { + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleCancel = async () => { + setSubmitting(true); + // Populate deletingIds with the subtree so every descendant + // (and the root) locks into the dim + non-draggable state for + // the duration of the network round-trip + server cascade — + // same treatment the regular delete gives. Otherwise the org + // looks interactive for the several seconds between click and + // the first WORKSPACE_REMOVED event. + const preState = useCanvasStore.getState(); + const subtreeIds = new Set(); + const walkStack = [rootId]; + while (walkStack.length) { + const nid = walkStack.pop()!; + subtreeIds.add(nid); + for (const n of preState.nodes) { + if (n.data.parentId === nid) walkStack.push(n.id); + } + } + preState.beginDelete(subtreeIds); + try { + await api.del<{ status: string }>( + `/workspaces/${rootId}?confirm=true`, + ); + showToast(`Cancelled deployment of "${rootName}"`, "success"); + // Optimistic local removal — workspace-server broadcasts + // WORKSPACE_REMOVED per node but the WS may lag; strip the + // subtree now so the user sees immediate feedback. Re-read + // the store AFTER the await: children may have landed (or + // already been removed by WS events) during the network + // round-trip. If the WS_REMOVED handler already dropped the + // root during the network call, bail out — the subtree walk + // would miss any now-orphaned descendants (handleCanvasEvent + // reparents children of a removed node upward, so they no + // longer share the original root's id as parentId). + const postDeleteState = useCanvasStore.getState(); + if (!postDeleteState.nodes.some((n) => n.id === rootId)) { + return; + } + const subtree = new Set(); + const stack = [rootId]; + while (stack.length) { + const id = stack.pop()!; + subtree.add(id); + for (const n of postDeleteState.nodes) { + if (n.data.parentId === id) stack.push(n.id); + } + } + useCanvasStore.setState({ + nodes: postDeleteState.nodes.filter((n) => !subtree.has(n.id)), + edges: postDeleteState.edges.filter( + (e) => !subtree.has(e.source) && !subtree.has(e.target), + ), + }); + } catch (e) { + // Undo the lock so the user can try again / interact with the + // still-deploying subtree. + useCanvasStore.getState().endDelete(subtreeIds); + showToast( + e instanceof Error ? `Cancel failed: ${e.message}` : "Cancel failed", + "error", + ); + } finally { + // Success path's endDelete is covered implicitly — every node + // in the subtree is stripped by the optimistic local removal + // above, and any stragglers are removed by WORKSPACE_REMOVED + // WS events whose handler is a no-op on already-missing ids. + // The deletingIds set will naturally empty as endDelete runs + // in both paths below. + useCanvasStore.getState().endDelete(subtreeIds); + setSubmitting(false); + setConfirming(false); + } + }; + + if (confirming) { + return ( +
e.stopPropagation()} + > + + Delete {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}? + + + +
+ ); + } + + return ( + + ); +} diff --git a/canvas/src/components/canvas/useCanvasViewport.ts b/canvas/src/components/canvas/useCanvasViewport.ts index 8ab916e5..4a5fecd7 100644 --- a/canvas/src/components/canvas/useCanvasViewport.ts +++ b/canvas/src/components/canvas/useCanvasViewport.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useReactFlow } from "@xyflow/react"; import { useCanvasStore } from "@/store/canvas"; +import { appendClass, removeClass } from "@/store/classNames"; import { CHILD_DEFAULT_HEIGHT, CHILD_DEFAULT_WIDTH, @@ -30,6 +31,13 @@ export function useCanvasViewport() { // render so we can detect the boundary when the last one finishes // and auto-fit the viewport around the whole tree. const hadProvisioningRef = useRef(false); + // Respect-user-pan gate for the deploy-time auto-fit: whenever the + // user moves the canvas (onMoveEnd stamps userPannedAtRef), we + // compare against the last auto-fit timestamp; if the user moved + // AFTER the last auto-fit, the auto-fit handler bails out for the + // rest of this deploy cycle. + const userPannedAtRef = useRef(null); + const lastAutoFitAtRef = useRef(0); useEffect(() => { return () => { @@ -55,6 +63,41 @@ export function useCanvasViewport() { hadProvisioningRef.current = hasProvisioning; if (wasProvisioning && !hasProvisioning && nodeCount > 0) { + // Root-complete moment — every root that has children just + // finished deploying. Pop + glow once (mol-deploy-root-complete) + // then auto-fit the viewport around the whole org. Leaf-only + // roots (single workspaces with no children) are skipped so the + // effect reads as "your org landed" not "random card flickered". + const state = useCanvasStore.getState(); + const rootsWithChildren = new Set(); + for (const n of state.nodes) { + if (n.data.parentId) continue; + if (state.nodes.some((c) => c.data.parentId === n.id)) { + rootsWithChildren.add(n.id); + } + } + if (rootsWithChildren.size > 0) { + useCanvasStore.setState({ + nodes: state.nodes.map((n) => + rootsWithChildren.has(n.id) + ? { ...n, className: appendClass(n.className, "mol-deploy-root-complete") } + : n, + ), + }); + // Strip the one-shot class after the keyframe ends so a later + // deploy on the same node can fire it again. + window.setTimeout(() => { + const s = useCanvasStore.getState(); + useCanvasStore.setState({ + nodes: s.nodes.map((n) => + rootsWithChildren.has(n.id) + ? { ...n, className: removeClass(n.className, "mol-deploy-root-complete") } + : n, + ), + }); + }, 800); + } + clearTimeout(autoFitTimerRef.current); // 1200ms settle delay: lets React Flow's DOM measurement pass // resize newly-online parents before we compute bounds. @@ -63,12 +106,16 @@ export function useCanvasViewport() { autoFitTimerRef.current = setTimeout(() => { fitView({ duration: 1200, - padding: 0.25, + // Match the deploy-time fit padding (0.45) so end-state + // and in-flight state use the same framing — otherwise + // the final zoom-out "jumps" relative to the intermediate + // fits and looks like a mis-layout. + padding: 0.45, // Cap zoom-in: a small tree (2-3 nodes) would otherwise end // up at the 2x maxZoom, visually implying "something is - // wrong". 0.8 reads like "here's your whole org" even when - // the tree is small. - maxZoom: 0.8, + // wrong". 0.65 reads like "here's your whole org" even when + // the tree is small — matches deploy-time cap. + maxZoom: 0.65, // Cap zoom-out: fitView would fall back to the component's // minZoom=0.1 on a sparse/outlier layout, leaving the user // staring at a postage-stamp canvas. 0.25 is the floor. @@ -92,6 +139,82 @@ export function useCanvasViewport() { return () => window.removeEventListener("molecule:pan-to-node", handler); }, [fitView]); + // Auto pan+zoom to the whole deploying org after each child + // arrival — DEBOUNCED. Firing fitView on every event with a + // 600ms animation meant rapid sibling arrivals (server paces 2s + // apart, HMR bursts can land faster) made the viewport lurch + // continuously, which the user read as "parent flashing around". + // We now wait until the arrivals GO QUIET for 500ms, then run + // exactly one fit. The rootId we captured on the most recent + // event drives the fit bounds. Respect-user-pan still short- + // circuits: if the user moved after our last auto-fit, we never + // fit again this deploy. + const pendingFitRootRef = useRef(null); + useEffect(() => { + const runFit = () => { + const rootCandidate = pendingFitRootRef.current; + pendingFitRootRef.current = null; + if (!rootCandidate) return; + if ( + userPannedAtRef.current !== null && + userPannedAtRef.current > lastAutoFitAtRef.current + ) { + return; + } + const state = useCanvasStore.getState(); + // Climb to the true root — the event's rootId is the just- + // landed child's direct parent, which may itself be nested. + let topId = rootCandidate; + let cursor = state.nodes.find((n) => n.id === topId); + while (cursor?.data.parentId) { + const up = state.nodes.find((n) => n.id === cursor!.data.parentId); + if (!up) break; + cursor = up; + topId = up.id; + } + const subtree: string[] = []; + const stack = [topId]; + while (stack.length) { + const id = stack.pop()!; + subtree.push(id); + for (const n of state.nodes) { + if (n.data.parentId === id) stack.push(n.id); + } + } + if (subtree.length === 0) return; + fitView({ + nodes: subtree.map((id) => ({ id })), + duration: 600, + // Generous padding so the right-hand Communications panel, + // bottom-left Legend, and bottom-right "New Workspace" + // button don't cover the outer cards. React Flow padding + // is a fraction of viewport dims, so 0.45 ≈ ~430px of + // margin on a 960-wide canvas — enough clearance for the + // two side panels (~300px + ~280px). + padding: 0.45, + // Lower maxZoom so small orgs (2-3 cards) still zoom out + // enough to show the parent frame + children clearly with + // the padded margins. 0.65 reads as "here's the whole org" + // without getting dragged to the maxZoom by fitView's + // "fill the viewport" default. + maxZoom: 0.65, + minZoom: 0.25, + }); + lastAutoFitAtRef.current = Date.now(); + }; + const handler = (e: Event) => { + const { rootId } = (e as CustomEvent<{ rootId: string }>).detail; + // Keep the most recently-requested root — if the user triggers + // imports on two different orgs back-to-back, the later one + // wins the viewport, which matches user intent. + pendingFitRootRef.current = rootId; + clearTimeout(autoFitTimerRef.current); + autoFitTimerRef.current = setTimeout(runFit, 500); + }; + window.addEventListener("molecule:fit-deploying-org", handler); + return () => window.removeEventListener("molecule:fit-deploying-org", handler); + }, [fitView]); + // Zoom to a team: fit the parent + its direct children in view. useEffect(() => { const handler = (e: Event) => { @@ -128,7 +251,16 @@ export function useCanvasViewport() { }, [fitBounds]); const onMoveEnd = useCallback( - (_event: unknown, vp: { x: number; y: number; zoom: number }) => { + (event: unknown, vp: { x: number; y: number; zoom: number }) => { + // Stamp user-pan timestamp only when the move was actually + // initiated by the user (mouse / trackpad / keyboard). React + // Flow also fires onMoveEnd for programmatic fitView() calls + // — `event` is null in that case, which would otherwise + // defeat the respect-user-pan gate by making every auto-fit + // look like a user move. + if (event !== null) { + userPannedAtRef.current = Date.now(); + } clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { saveViewport(vp.x, vp.y, vp.zoom); diff --git a/canvas/src/components/canvas/useDragHandlers.ts b/canvas/src/components/canvas/useDragHandlers.ts index a0a38e77..aa8fa82c 100644 --- a/canvas/src/components/canvas/useDragHandlers.ts +++ b/canvas/src/components/canvas/useDragHandlers.ts @@ -113,6 +113,18 @@ export function useDragHandlers(): DragHandlers { const onNodeDragStart: OnNodeDrag = useCallback( (event, node) => { + // Belt-and-braces drag-lock: the primary mechanism is the + // `draggable: false` projection in Canvas.tsx — React Flow + // won't invoke this callback for locked nodes. But a future + // change to the projection that forgets a locked subtree + // would silently allow dragging, and locked drags mid-deploy + // corrupt the spawn animation. Fall through to a state-based + // check here so the invariant stays enforced in both places. + if (node.draggable === false) { + dragStartStateRef.current = null; + return; + } + dragModifiersRef.current = { alt: event.altKey, meta: event.metaKey || event.ctrlKey, diff --git a/canvas/src/components/canvas/useOrgDeployState.ts b/canvas/src/components/canvas/useOrgDeployState.ts new file mode 100644 index 00000000..587643df --- /dev/null +++ b/canvas/src/components/canvas/useOrgDeployState.ts @@ -0,0 +1,152 @@ +"use client"; + +import { useMemo } from "react"; +import { useCanvasStore } from "@/store/canvas"; + +/** + * Org-deploy state for a single workspace node. Computed from the + * current canvas store snapshot — no per-org status field on the + * backend is required (a root "is deploying" iff any descendant in + * its subtree still reports status === "provisioning"). + * + * Performance note: the first version of this hook walked the entire + * nodes array per node render — O(n²) for a 50-node org. The current + * implementation computes ONE map of derived state for the whole + * canvas per nodes-array change, then each call site looks up its + * own id. The map is built inside useMemo against a cheap projection + * (id + parentId + status tuples via useShallow) so unrelated store + * mutations (drag, selection, viewport) don't re-run the walk. + */ +export interface OrgDeployState { + isActivelyProvisioning: boolean; + isDeployingRoot: boolean; + isLockedChild: boolean; + descendantProvisioningCount: number; +} + +const EMPTY: OrgDeployState = { + isActivelyProvisioning: false, + isDeployingRoot: false, + isLockedChild: false, + descendantProvisioningCount: 0, +}; + +/** Projection used to drive the deploy-state computation. Shallow- + * compared so re-renders only happen when one of these fields + * actually changes across any node. */ +interface NodeProjection { + id: string; + parentId: string | null; + status: string; +} + +function buildDeployMap( + projections: NodeProjection[], + deletingIds: ReadonlySet, +): Map { + const byId = new Map(); + const childrenBy = new Map(); + for (const p of projections) { + byId.set(p.id, p); + if (p.parentId) { + const arr = childrenBy.get(p.parentId) ?? []; + arr.push(p.id); + childrenBy.set(p.parentId, arr); + } + } + + // Walk once from each node up to its root, memoising the root id. + // `rootOf.get(id)` short-circuits further walks on the same chain. + const rootOf = new Map(); + const findRoot = (id: string): string => { + const cached = rootOf.get(id); + if (cached) return cached; + let cursor: NodeProjection | undefined = byId.get(id); + let rootId = id; + while (cursor && cursor.parentId) { + const parent = byId.get(cursor.parentId); + if (!parent) break; + cursor = parent; + rootId = parent.id; + const alreadyKnown = rootOf.get(rootId); + if (alreadyKnown) { + rootId = alreadyKnown; + break; + } + } + rootOf.set(id, rootId); + return rootId; + }; + + // Count provisioning descendants per node. Also walk once per root + // using an iterative DFS so we don't stack-overflow on deep trees. + const countProvisioning = (rootId: string): number => { + let count = 0; + const stack = [rootId]; + while (stack.length) { + const id = stack.pop()!; + const node = byId.get(id); + if (!node) continue; + if (node.status === "provisioning") count++; + const kids = childrenBy.get(id); + if (kids) stack.push(...kids); + } + return count; + }; + + // Per-root cache of subtree count so every descendant resolves in O(1). + const rootCount = new Map(); + + const out = new Map(); + for (const p of projections) { + const rootId = findRoot(p.id); + let provCount = rootCount.get(rootId); + if (provCount === undefined) { + provCount = countProvisioning(rootId); + rootCount.set(rootId, provCount); + } + const rootIsDeploying = provCount > 0; + // A node being deleted gets the same visual + interaction lock + // as a deploying child. "The system owns this node right now, + // don't touch it" is the shared semantic — the user only cares + // that the card is dim and won't drag; they don't need to know + // whether it's coming up or going down. + const deleting = deletingIds.has(p.id); + out.set(p.id, { + isActivelyProvisioning: p.status === "provisioning", + isDeployingRoot: p.id === rootId && rootIsDeploying, + isLockedChild: deleting || (p.id !== rootId && rootIsDeploying), + descendantProvisioningCount: + p.id === rootId ? provCount : 0, // only roots display the count + }); + } + return out; +} + +/** Store-wide derived map. Recomputed whenever the `nodes` array + * reference changes — which is on every store mutation that touches + * nodes, including pure position tweens. The map build is O(n) so + * a 50-node canvas costs ~50μs per tween frame; that's cheap enough + * to not need a projection layer. (An earlier attempt to narrow the + * subscription via `useShallow((s) => s.nodes.map(...))` triggered + * React 18's "getSnapshot should be cached" loop because the + * projection creates fresh object references each call — shallow + * equality always sees "changed", which re-renders, which re-runs + * the selector, ad infinitum.) */ +function useDeployMap(): Map { + const nodes = useCanvasStore((s) => s.nodes); + const deletingIds = useCanvasStore((s) => s.deletingIds); + return useMemo(() => { + const projections = nodes.map((n) => ({ + id: n.id, + parentId: n.data.parentId, + status: n.data.status, + })); + return buildDeployMap(projections, deletingIds); + }, [nodes, deletingIds]); +} + +export function useOrgDeployState(nodeId: string): OrgDeployState { + const map = useDeployMap(); + return map.get(nodeId) ?? EMPTY; +} diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 3762ffdc..03a26882 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -7,8 +7,10 @@ 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 { type ChatMessage, createMessage, appendMessageDeduped } from "./chat/types"; -import { extractResponseText, extractRequestText } from "./chat/message-parser"; +import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types"; +import { uploadChatFiles, downloadChatFile } from "./chat/uploads"; +import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews"; +import { extractResponseText, extractRequestText, extractFilesFromTask } from "./chat/message-parser"; import { AgentCommsPanel } from "./chat/AgentCommsPanel"; import { runtimeDisplayName } from "@/lib/runtime-names"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -21,10 +23,18 @@ interface Props { type ChatSubTab = "my-chat" | "agent-comms"; // A2A response shape (subset). The full schema is in @a2a-js/sdk but we only -// need parts/artifacts text extraction for the synchronous fallback path. +// need parts/artifacts text + file extraction for the synchronous fallback. +interface A2AFileRef { + name?: string; + mimeType?: string; + uri?: string; + bytes?: string; + size?: number; +} interface A2APart { kind: string; - text: string; + text?: string; + file?: A2AFileRef; } interface A2AResponse { result?: { @@ -39,19 +49,25 @@ function extractReplyText(resp: A2AResponse): string { const result = resp?.result; if (result?.parts) { for (const p of result.parts) { - if (p.kind === "text") return p.text; + if (p.kind === "text") return p.text ?? ""; } } if (result?.artifacts) { for (const a of result.artifacts) { for (const p of a.parts || []) { - if (p.kind === "text") return p.text; + if (p.kind === "text") return p.text ?? ""; } } } return ""; } +// Agent-returned files live on the same response shape as text — +// delegated to extractFilesFromTask in message-parser.ts, which also +// walks status.message.parts (that ChatTab's legacy text extractor +// doesn't). Single source of truth for file-part parsing across +// live chat, activity log replay, and any future consumers. + /** * Load chat history from the activity_logs database via the platform API. * Uses source=canvas to only get user-initiated messages (not agent-to-agent). @@ -75,12 +91,19 @@ async function loadMessagesFromDB(workspaceId: string): Promise<{ messages: Chat messages.push(createMessage("user", userText)); } - // Extract agent response + // Extract agent response — text AND any file attachments so a + // chat reload surfaces historical download chips, not just plain + // text. `result` is nested on successful A2A responses; some + // older rows stored the raw `result` payload at the top level, + // so fall back to the body itself when `.result` is absent. if (a.response_body) { const text = extractResponseText(a.response_body); - if (text) { + const attachments = extractFilesFromTask( + (a.response_body.result ?? a.response_body) as Record, + ); + if (text || attachments.length > 0) { const role = a.status === "error" || text.toLowerCase().startsWith("agent error") ? "system" : "agent"; - messages.push({ ...createMessage(role, text), timestamp: a.created_at }); + messages.push({ ...createMessage(role, text, attachments), timestamp: a.created_at }); } } } @@ -178,7 +201,16 @@ export function ChatTab({ workspaceId, data }: Props) { function MyChatPanel({ workspaceId, data }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); - const [sending, setSending] = useState(!!data.currentTask); + // `sending` is strictly the "this tab kicked off a send and hasn't + // seen the reply yet" signal. Previously this was initialized from + // data.currentTask to pick up in-flight agent work on mount, but + // that conflated agent-busy (workspace heartbeat) with user- + // in-flight (local send): when the WS dropped a TASK_COMPLETE event, + // currentTask lingered, the component re-mounted with sending=true, + // and the Send button stayed disabled forever even though nothing + // local was in flight. For the "agent is busy, show spinner" UX, + // use data.currentTask directly in the render path. + const [sending, setSending] = useState(false); const [thinkingElapsed, setThinkingElapsed] = useState(0); const [activityLog, setActivityLog] = useState([]); const [loading, setLoading] = useState(true); @@ -189,6 +221,17 @@ function MyChatPanel({ workspaceId, data }: Props) { const [error, setError] = useState(null); const [confirmRestart, setConfirmRestart] = useState(false); const bottomRef = useRef(null); + // Files the user has picked but not yet sent. Cleared on send + // (upload success) or by the × on each pill. + const [pendingFiles, setPendingFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + // Guard against a double-click during the upload phase: React + // state updates from the click that started the upload haven't + // flushed yet, so the disabled-button logic sees `uploading=false` + // from the closure and lets a second `sendMessage` enter. A ref + // observes the latest value synchronously. + const sendInFlightRef = useRef(false); // Load chat history from database on mount useEffect(() => { @@ -231,8 +274,10 @@ function MyChatPanel({ workspaceId, data }: Props) { // Dedupe in case the agent proactively pushed the same text the // HTTP /a2a response already delivered (observed with the Hermes // runtime, which emits both a reply body and a send_message_to_user - // push for the same content). - setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content))); + // push for the same content). Attachments ride along with the + // message so files returned by the A2A_RESPONSE WS path render + // their download chips. + setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content, m.attachments))); } if (sendingFromAPIRef.current && msgs.length > 0) { setSending(false); @@ -339,10 +384,35 @@ function MyChatPanel({ workspaceId, data }: Props) { const sendMessage = async () => { const text = input.trim(); - if (!text || !agentReachable || sending) return; + const filesToSend = pendingFiles; + // Allow sending if EITHER text OR attachments are present — a user + // can drop a file with no text and the agent still receives it. + if ((!text && filesToSend.length === 0) || !agentReachable || sending || uploading) return; + // Synchronous re-entry guard — see sendInFlightRef comment. + if (sendInFlightRef.current) return; + sendInFlightRef.current = true; + + // Upload attachments first so we can include URIs in the A2A + // message parts. Sequential-before-send: a message with references + // to files not yet staged would fail agent-side; staging happens + // synchronously via /chat/uploads before message/send dispatch. + let uploaded: ChatAttachment[] = []; + if (filesToSend.length > 0) { + setUploading(true); + try { + uploaded = await uploadChatFiles(workspaceId, filesToSend); + } catch (e) { + setUploading(false); + sendInFlightRef.current = false; + setError(e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed"); + return; + } + setUploading(false); + } setInput(""); - setMessages((prev) => [...prev, createMessage("user", text)]); + setPendingFiles([]); + setMessages((prev) => [...prev, createMessage("user", text, uploaded)]); setSending(true); sendingFromAPIRef.current = true; setError(null); @@ -356,40 +426,141 @@ function MyChatPanel({ workspaceId, data }: Props) { parts: [{ kind: "text", text: m.content }], })); + // A2A parts: text part (if any) + file parts (per attachment). The + // agent sees both in a single turn, matching the A2A spec shape. + const parts: A2APart[] = []; + if (text) parts.push({ kind: "text", text }); + for (const att of uploaded) { + parts.push({ + kind: "file", + file: { + name: att.name, + mimeType: att.mimeType, + uri: att.uri, + size: att.size, + }, + }); + } + + // A2A calls can legitimately take minutes — LLM latency + + // multi-turn tool use is common on slower providers (Hermes+minimax, + // Claude Code invoking bash/file tools, etc.). The 15s default + // would silently abort the fetch here, leaving the server to + // complete the reply and the user staring at + // "agent may be unreachable". Match the upload timeout (60s × 2) + // for the happy-path ceiling; anything longer is genuinely stuck. api.post(`/workspaces/${workspaceId}/a2a`, { method: "message/send", params: { message: { role: "user", messageId: crypto.randomUUID(), - parts: [{ kind: "text", text }], + parts, }, metadata: { history }, }, - }) + }, { timeoutMs: 120_000 }) .then((resp) => { // Skip if the WS A2A_RESPONSE event already handled this response. // Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears // it first wins, the other becomes a no-op (no duplicate messages). if (!sendingFromAPIRef.current) return; const replyText = extractReplyText(resp); - if (replyText) { - setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", replyText))); + const replyFiles = extractFilesFromTask((resp?.result ?? {}) as Record); + if (replyText || replyFiles.length > 0) { + setMessages((prev) => + appendMessageDeduped(prev, createMessage("agent", replyText, replyFiles)), + ); } setSending(false); sendingFromAPIRef.current = false; + sendInFlightRef.current = false; }) .catch(() => { setSending(false); sendingFromAPIRef.current = false; + sendInFlightRef.current = false; setError("Failed to send message — agent may be unreachable"); }); }; + const onFilesPicked = (fileList: FileList | null) => { + if (!fileList) return; + const picked = Array.from(fileList); + // Deduplicate against current pending set by name+size — user + // picking the same file twice shouldn't append it. + setPendingFiles((prev) => { + const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`)); + return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))]; + }); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const removePendingFile = (index: number) => + setPendingFiles((prev) => prev.filter((_, i) => i !== index)); + + // 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. + const [dragOver, setDragOver] = useState(false); + const dragDepthRef = useRef(0); + const dropEnabled = agentReachable && !sending && !uploading; + const isFileDrag = (e: React.DragEvent) => + Array.from(e.dataTransfer.types || []).includes("Files"); + + const onDragEnter = (e: React.DragEvent) => { + if (!dropEnabled || !isFileDrag(e)) return; + e.preventDefault(); + dragDepthRef.current += 1; + setDragOver(true); + }; + const onDragOver = (e: React.DragEvent) => { + if (!dropEnabled || !isFileDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }; + const onDragLeave = (e: React.DragEvent) => { + if (!dropEnabled || !isFileDrag(e)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setDragOver(false); + }; + const onDrop = (e: React.DragEvent) => { + if (!dropEnabled || !isFileDrag(e)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setDragOver(false); + onFilesPicked(e.dataTransfer.files); + }; + + const downloadAttachment = (att: ChatAttachment) => { + // Errors here are rare but user-visible (401 on a revoked token, + // 404 if the agent deleted the file). Surface via the inline + // error banner — the message list itself stays untouched. + downloadChatFile(workspaceId, att).catch((e) => { + setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed"); + }); + }; + const isOnline = data.status === "online" || data.status === "degraded"; return ( -
+
+ {dragOver && ( +
+
+ Drop to attach +
+
+ )} {/* Messages */}
{loading && ( @@ -435,9 +606,23 @@ function MyChatPanel({ workspaceId, data }: Props) { : "bg-zinc-800/80 text-zinc-200 border border-zinc-700/30" }`} > -
- {msg.content} -
+ {msg.content && ( +
+ {msg.content} +
+ )} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att, i) => ( + + ))} +
+ )}
{new Date(msg.timestamp).toLocaleTimeString()}
@@ -445,8 +630,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
))} - {/* Thinking indicator */} - {sending && ( + {/* Thinking indicator — shows when this tab is awaiting a reply + OR when the workspace heartbeat reports an in-flight task + (covers the "agent is already busy when I open the tab" case + without locking the Send button on a stale currentTask). */} + {(sending || !!data.currentTask) && (
@@ -490,7 +678,37 @@ function MyChatPanel({ workspaceId, data }: Props) { {/* Input */}
-
+ {pendingFiles.length > 0 && ( +
+ {pendingFiles.map((f, i) => ( + removePendingFile(i)} + /> + ))} +
+ )} +
+ onFilesPicked(e.target.files)} + aria-hidden="true" + /> +