diff --git a/.github/workflows/auto-tag-runtime.yml b/.github/workflows/auto-tag-runtime.yml new file mode 100644 index 00000000..2b9070bc --- /dev/null +++ b/.github/workflows/auto-tag-runtime.yml @@ -0,0 +1,113 @@ +name: auto-tag-runtime + +# Auto-tag runtime releases on every merge to main that touches workspace/. +# This is the entry point of the runtime CD chain: +# +# merge PR → auto-tag-runtime (this) → publish-runtime → cascade → template +# image rebuilds → repull on hosts. +# +# Default bump is patch. Override via PR label `release:minor` or +# `release:major` BEFORE merging — the label is read off the merged PR +# associated with the push commit. +# +# Skips when: +# - The push isn't to main (other branches don't auto-release). +# - The merge commit message contains `[skip-release]` (escape hatch +# for cleanup PRs that touch workspace/ but shouldn't ship). + +on: + push: + branches: [main] + paths: + - "workspace/**" + - "scripts/build_runtime_package.py" + - ".github/workflows/auto-tag-runtime.yml" + - ".github/workflows/publish-runtime.yml" + +permissions: + contents: write # to push the new tag + pull-requests: read # to read labels off the merged PR + +concurrency: + # Serialize tag bumps so two near-simultaneous merges can't both think + # they're 0.1.6 and race to push the same tag. + group: auto-tag-runtime + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full tag history for `git describe` / sort + + - name: Skip when commit asks + id: skip + run: | + MSG=$(git log -1 --format=%B "${{ github.sha }}") + if echo "$MSG" | grep -qiE '\[skip-release\]|\[no-release\]'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Commit message contains [skip-release] — no tag will be created." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Determine bump kind from PR label + id: bump + if: steps.skip.outputs.skip != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + # The merged PR for this push commit. `gh pr list --search` finds + # closed PRs whose merge commit matches; we take the first. + PR=$(gh pr list --state merged --search "${{ github.sha }}" --json number,labels --jq '.[0]' 2>/dev/null || echo "") + if [ -z "$PR" ] || [ "$PR" = "null" ]; then + echo "No merged PR found for ${{ github.sha }} — defaulting to patch bump." + echo "kind=patch" >> "$GITHUB_OUTPUT" + exit 0 + fi + LABELS=$(echo "$PR" | jq -r '.labels[].name') + if echo "$LABELS" | grep -qx 'release:major'; then + echo "kind=major" >> "$GITHUB_OUTPUT" + elif echo "$LABELS" | grep -qx 'release:minor'; then + echo "kind=minor" >> "$GITHUB_OUTPUT" + else + echo "kind=patch" >> "$GITHUB_OUTPUT" + fi + + - name: Compute next version from latest runtime-v* tag + id: version + if: steps.skip.outputs.skip != 'true' + run: | + # Find the highest runtime-vX.Y.Z tag. `sort -V` handles semver + # ordering; `grep` filters to the right tag prefix. + LATEST=$(git tag --list 'runtime-v*' | sort -V | tail -1) + if [ -z "$LATEST" ]; then + # No prior tag — start the runtime line at 0.1.0. + CURRENT="0.0.0" + else + CURRENT="${LATEST#runtime-v}" + fi + MAJOR=$(echo "$CURRENT" | cut -d. -f1) + MINOR=$(echo "$CURRENT" | cut -d. -f2) + PATCH=$(echo "$CURRENT" | cut -d. -f3) + case "${{ steps.bump.outputs.kind }}" in + major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0;; + minor) MINOR=$((MINOR+1)); PATCH=0;; + patch) PATCH=$((PATCH+1));; + esac + NEW="$MAJOR.$MINOR.$PATCH" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "new=$NEW" >> "$GITHUB_OUTPUT" + echo "Bumping runtime $CURRENT → $NEW (${{ steps.bump.outputs.kind }})" + + - name: Push new tag + if: steps.skip.outputs.skip != 'true' + run: | + NEW_TAG="runtime-v${{ steps.version.outputs.new }}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$NEW_TAG" -m "runtime $NEW_TAG (auto-bump from ${{ steps.bump.outputs.kind }})" + git push origin "$NEW_TAG" + echo "Pushed $NEW_TAG — publish-runtime workflow will fire on the tag." diff --git a/.github/workflows/publish-runtime.yml b/.github/workflows/publish-runtime.yml new file mode 100644 index 00000000..61054f8a --- /dev/null +++ b/.github/workflows/publish-runtime.yml @@ -0,0 +1,161 @@ +name: publish-runtime + +# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/. +# Monorepo workspace/ is the only source-of-truth for runtime code; this +# workflow is the bridge from monorepo edits to the PyPI artifact that +# the 8 workspace-template-* repos depend on. +# +# Triggered by: +# - Pushing a tag matching `runtime-vX.Y.Z` (the version is derived from +# the tag — `runtime-v0.1.6` publishes `0.1.6`). +# - Manual workflow_dispatch with an explicit `version` input (useful for +# dev/test releases without tagging the repo). +# +# The workflow: +# 1. Runs scripts/build_runtime_package.py to copy workspace/ → +# build/molecule_runtime/ with imports rewritten (`a2a_client` → +# `molecule_runtime.a2a_client`). +# 2. Builds wheel + sdist with `python -m build`. +# 3. Publishes to PyPI via twine + repo secret PYPI_TOKEN. +# +# After publish: the 8 template repos pick up the new version on their +# next image rebuild (their requirements.txt pin +# `molecule-ai-workspace-runtime>=0.1.0`, so any new release is eligible). +# To force-pull immediately, bump the pin in each template repo's +# requirements.txt and merge — that triggers their own publish-image.yml. + +on: + push: + tags: + - "runtime-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.1.6). Required for manual dispatch." + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi-publish + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Derive version from tag or input + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + else + # Tag is `runtime-vX.Y.Z` — strip the prefix. + VERSION="${GITHUB_REF_NAME#runtime-v}" + fi + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.dev[0-9]+|rc[0-9]+|a[0-9]+|b[0-9]+|\.post[0-9]+)?$'; then + echo "::error::version $VERSION does not match PEP 440" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Publishing molecule-ai-workspace-runtime $VERSION" + + - name: Install build tooling + run: pip install build twine + + - name: Build package from workspace/ + run: | + python scripts/build_runtime_package.py \ + --version "${{ steps.version.outputs.version }}" \ + --out "${{ runner.temp }}/runtime-build" + + - name: Build wheel + sdist + working-directory: ${{ runner.temp }}/runtime-build + run: python -m build + + - name: Verify package contents (sanity) + working-directory: ${{ runner.temp }}/runtime-build + run: | + python -m twine check dist/* + # Smoke-import the built wheel to catch import-rewrite mistakes + # before they hit PyPI. The package depends on a2a-sdk + httpx + # via pyproject; install those so the smoke import resolves. + python -m venv /tmp/smoke + /tmp/smoke/bin/pip install --quiet dist/*.whl + WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \ + PLATFORM_URL=http://localhost:8080 \ + /tmp/smoke/bin/python -c " + from molecule_runtime import a2a_client, a2a_tools + from molecule_runtime.builtin_tools import memory + from molecule_runtime.adapters import get_adapter, BaseAdapter, AdapterConfig + assert a2a_client._A2A_QUEUED_PREFIX, 'queued prefix missing — chat-leak fix not in build' + print('✓ smoke import passed') + " + + - name: Publish to PyPI + working-directory: ${{ runner.temp }}/runtime-build + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: python -m twine upload dist/* + + cascade: + # After PyPI accepts the upload, fan out a repository_dispatch to each + # template repo so they rebuild their image against the new runtime. + # Each template's `runtime-published.yml` receiver picks up the event, + # pulls the new PyPI version (their requirements.txt pin is `>=`), and + # republishes ghcr.io/molecule-ai/workspace-template-:latest. + # + # Soft-fail per repo: if one template's dispatch fails (perms missing, + # repo archived, etc.) we still try the others and surface the failures + # in the workflow summary instead of aborting the whole cascade. + needs: publish + runs-on: ubuntu-latest + steps: + - name: Fan out repository_dispatch + env: + # Fine-grained PAT with `actions:write` on the 8 template repos. + # GITHUB_TOKEN can't fire dispatches across repos — needs an explicit + # token. Stored as a repo secret; rotate per the standard schedule. + DISPATCH_TOKEN: ${{ secrets.TEMPLATE_DISPATCH_TOKEN }} + RUNTIME_VERSION: ${{ needs.publish.outputs.version || steps.version.outputs.version }} + run: | + set +e # don't abort on a single repo failure — collect them all + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::warning::TEMPLATE_DISPATCH_TOKEN secret not set — skipping cascade. PyPI was published; templates will pick up the new version on their own next rebuild." + exit 0 + fi + # Re-derive version from the tag here too (in case publish job + # didn't expose an output the previous step's reference reads). + VERSION="${GITHUB_REF_NAME#runtime-v}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + fi + TEMPLATES="claude-code langgraph crewai autogen deepagents hermes gemini-cli openclaw" + FAILED="" + for tpl in $TEMPLATES; do + REPO="Molecule-AI/molecule-ai-workspace-template-$tpl" + STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \ + -X POST "https://api.github.com/repos/$REPO/dispatches" \ + -H "Authorization: Bearer $DISPATCH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -d "{\"event_type\":\"runtime-published\",\"client_payload\":{\"runtime_version\":\"$VERSION\"}}") + if [ "$STATUS" = "204" ]; then + echo "✓ dispatched $tpl ($VERSION)" + else + echo "::warning::✗ failed to dispatch $tpl: HTTP $STATUS — $(cat /tmp/dispatch.out)" + FAILED="$FAILED $tpl" + fi + done + if [ -n "$FAILED" ]; then + echo "::warning::Cascade incomplete. Failed templates:$FAILED" + # Don't fail the whole job — PyPI publish already succeeded; + # operators can retry the failed templates manually. + fi diff --git a/.github/workflows/sweep-cf-orphans.yml b/.github/workflows/sweep-cf-orphans.yml index 0e825256..7fb35328 100644 --- a/.github/workflows/sweep-cf-orphans.yml +++ b/.github/workflows/sweep-cf-orphans.yml @@ -40,10 +40,14 @@ on: description: "Override safety gate (default 50, set higher only for major cleanup)" required: false default: "50" - # Required-check support: scheduled-only today, but include merge_group - # so a future branch-protection wire-in doesn't need a workflow edit. - merge_group: - types: [checks_requested] + # No `merge_group:` trigger on purpose. This is a janitor — it doesn't + # need to gate merges, and including it as written before #2088 fired + # the full sweep job (or its secret-check) on every PR going through + # the merge queue, generating one red CI run per merge-queue eval. If + # this workflow is ever wired up as a required check, re-add + # merge_group: { types: [checks_requested] } + # AND gate the sweep step with `if: github.event_name != 'merge_group'` + # so merge-queue evals report success without actually running. # Don't let two sweeps race the same zone. workflow_dispatch during a # scheduled run would otherwise issue duplicate DELETE calls. @@ -77,9 +81,12 @@ jobs: - uses: actions/checkout@v4 - name: Verify required secrets present - # Fail fast and loud if a secret is unset — sweep-cf-orphans.sh - # also checks via `need`, but we want a single distinct error - # in the workflow log instead of script-level multi-line noise. + id: verify + # Soft skip when secrets aren't configured. The 6 secrets have + # to be set on the repo manually before this workflow can do + # real work; until they are, the schedule is a no-op rather + # than a recurring red CI run. workflow_dispatch surfaces a + # warning so an operator running it ad-hoc sees the gap. run: | missing=() for var in CF_API_TOKEN CF_ZONE_ID CP_PROD_ADMIN_TOKEN CP_STAGING_ADMIN_TOKEN AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do @@ -88,12 +95,15 @@ jobs: fi done if [ ${#missing[@]} -gt 0 ]; then - echo "::error::missing required secret(s): ${missing[*]}" - exit 2 + echo "::warning::skipping sweep — secrets not yet configured: ${missing[*]}" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 fi echo "All required secrets present ✓" + echo "skip=false" >> "$GITHUB_OUTPUT" - name: Run sweep + if: steps.verify.outputs.skip != 'true' # Schedule-vs-dispatch dry-run asymmetry (intentional): # - Scheduled runs: github.event.inputs.dry_run is empty → # defaults to "false" below → script runs with --execute diff --git a/canvas/e2e/staging-setup.ts b/canvas/e2e/staging-setup.ts index 963f9ccb..5fc39225 100644 --- a/canvas/e2e/staging-setup.ts +++ b/canvas/e2e/staging-setup.ts @@ -46,7 +46,17 @@ const TENANT_DOMAIN = process.env.STAGING_TENANT_DOMAIN || "staging.moleculesai. // were blocking staging→main syncs on 2026-04-24. const PROVISION_TIMEOUT_MS = 20 * 60 * 1000; const WORKSPACE_ONLINE_TIMEOUT_MS = 20 * 60 * 1000; -const TLS_TIMEOUT_MS = 3 * 60 * 1000; + +// TLS readiness depends on (1) Cloudflare DNS propagation through the +// edge, (2) the tenant's CF Tunnel registering the new hostname, (3) +// CF's edge ACME cert provisioning + cache. Each of these layers can +// add 1-3 min on its own under heavy staging load. Bumped 10→15 min +// after a burst of canary failures correlated with CP changes (#2090). +// Stays below the 20-min PROVISION_TIMEOUT envelope so a genuinely- +// stuck tenant fails-loud at the provision step rather than +// masquerading as a TLS issue. Kept aligned with +// tests/e2e/test_staging_full_saas.sh. +const TLS_TIMEOUT_MS = 15 * 60 * 1000; async function jsonFetch( url: string, diff --git a/canvas/next.config.ts b/canvas/next.config.ts index 68a6c64d..079e21c2 100644 --- a/canvas/next.config.ts +++ b/canvas/next.config.ts @@ -1,7 +1,100 @@ import type { NextConfig } from "next"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// Load NEXT_PUBLIC_* vars from the monorepo root .env so a fresh +// `pnpm dev` works without a per-developer canvas/.env.local. Next.js +// only auto-loads .env from the project root by default — but our +// canonical config (NEXT_PUBLIC_PLATFORM_URL, NEXT_PUBLIC_WS_URL, +// MOLECULE_ENV, etc.) lives at the monorepo root, gitignored, shared +// by the Go platform binary. Without this, the canvas falls back to +// `window.location` (`ws://localhost:3000/ws`) and the WS pill stays +// "Reconnecting" forever because Next.js dev doesn't serve /ws. +// +// Mirrors workspace-server/cmd/server/dotenv.go's monorepo-rooted .env +// loader. Both processes look for the SAME marker (`workspace-server/ +// go.mod`) so a developer renaming or relocating the repo only has to +// update one heuristic. Production is unaffected: `output: "standalone"` +// bakes resolved env into the build, and the marker file isn't shipped. +loadMonorepoEnv(); const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig; + +function loadMonorepoEnv() { + const root = findMonorepoRoot(__dirname); + if (!root) return; + const envPath = join(root, ".env"); + if (!existsSync(envPath)) return; + const body = readFileSync(envPath, "utf8"); + let loaded = 0; + let skipped = 0; + for (const line of body.split(/\r?\n/)) { + const kv = parseLine(line); + if (!kv) continue; + const [k, v] = kv; + // Existing env wins. NOTE: an explicitly-set empty string + // (`KEY=` exported from a parent shell, where Node represents it + // as `""` not `undefined`) counts as "set" — we keep the empty + // value rather than backfilling from the file. Matches Go's + // os.LookupEnv check in workspace-server/cmd/server/dotenv.go so + // both processes treat the same input identically. Operators who + // want the file value to win must `unset KEY` in the launching + // shell. + if (process.env[k] !== undefined) { + skipped++; + continue; + } + process.env[k] = v; + loaded++; + } + // eslint-disable-next-line no-console + console.log( + `[next.config] loaded ${loaded} vars from ${envPath} (${skipped} already set in env)`, + ); +} + +function findMonorepoRoot(start: string): string | null { + let dir = start; + for (let i = 0; i < 6; i++) { + if (existsSync(join(dir, "workspace-server", "go.mod"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +// Mirror of workspace-server/cmd/server/dotenv.go's parseDotEnvLine +// — same rules so the two loaders agree on every line in the shared +// .env. If you change one parser, change the other. +function parseLine(raw: string): [string, string] | null { + let line = raw.replace(/^/, "").trim(); + if (line === "" || line.startsWith("#")) return null; + // `export ` prefix uses a literal space — `export\tFOO=bar` with a + // tab is intentionally rejected, matching the Go mirror in + // workspace-server/cmd/server/dotenv.go. Shells emit the prefix + // with a space; tabs would only appear in hand-mangled files. + if (line.startsWith("export ")) line = line.slice("export ".length).trimStart(); + const eq = line.indexOf("="); + if (eq <= 0) return null; + const k = line.slice(0, eq).trim(); + let v = line.slice(eq + 1).replace(/^[ \t]+/, ""); + if (v.length >= 2 && (v[0] === '"' || v[0] === "'")) { + const quote = v[0]; + const end = v.indexOf(quote, 1); + if (end >= 0) return [k, v.slice(1, end)]; + // unterminated — fall through to bare-value handling + } + for (let i = 0; i < v.length; i++) { + if (v[i] !== "#") continue; + if (i === 0 || v[i - 1] === " " || v[i - 1] === "\t") { + v = v.slice(0, i); + break; + } + } + return [k, v.trim()]; +} 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/app/page.tsx b/canvas/src/app/page.tsx index 8b79ef83..666923eb 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -7,13 +7,19 @@ import { CommunicationOverlay } from "@/components/CommunicationOverlay"; import { Spinner } from "@/components/Spinner"; import { connectSocket, disconnectSocket } from "@/store/socket"; import { useCanvasStore } from "@/store/canvas"; -import { api } from "@/lib/api"; +import { api, PlatformUnavailableError } from "@/lib/api"; import type { WorkspaceData } from "@/store/socket"; export default function Home() { const hydrationError = useCanvasStore((s) => s.hydrationError); const setHydrationError = useCanvasStore((s) => s.setHydrationError); const [hydrating, setHydrating] = useState(true); + // Distinct from hydrationError: platform-down is its own UX path + // (different copy, different action — the user's next step is to + // check local services, not to retry the API call). Tracked + // separately rather than encoded into hydrationError so the + // generic-error branch can stay simple. + const [platformDown, setPlatformDown] = useState(false); useEffect(() => { connectSocket(); @@ -28,8 +34,11 @@ export default function Home() { useCanvasStore.getState().setViewport(viewport); } }).catch((err) => { - // Initial hydration failed — show error banner to user console.error("Canvas: initial hydration failed", err); + if (err instanceof PlatformUnavailableError) { + setPlatformDown(true); + return; + } useCanvasStore.getState().setHydrationError( err instanceof Error && err.message ? err.message : "Failed to load canvas" ); @@ -53,6 +62,10 @@ export default function Home() { ); } + if (platformDown) { + return ; + } + return ( <> @@ -83,3 +96,43 @@ export default function Home() { ); } + +/** + * Dedicated diagnostic for the case where the platform reported its + * datastore (Postgres / Redis) is unreachable. Distinct from the + * generic API-error overlay: the user's next action is to check + * local services, not to retry the API call. Includes the exact + * commands for the common dev-host setup. + */ +function PlatformDownDiagnostic() { + return ( +
+
+ Platform infrastructure unreachable +
+

+ The platform server returned 503 platform_unavailable. + That means it can't reach Postgres or Redis to validate your session. + Most common cause on a dev host: one of those services stopped. +

+
+
Try first
+
{`brew services start postgresql@14
+brew services start redis`}
+
+

+ If both are running, check /tmp/molecule-server.log for + the underlying error. If you're on hosted SaaS, this is a platform incident — try again in a moment. +

+ +
+ ); +} diff --git a/canvas/src/components/A2ATopologyOverlay.tsx b/canvas/src/components/A2ATopologyOverlay.tsx index 4a35e638..efd4f0ff 100644 --- a/canvas/src/components/A2ATopologyOverlay.tsx +++ b/canvas/src/components/A2ATopologyOverlay.tsx @@ -74,7 +74,11 @@ export function buildA2AEdges( }); } - // 3. Build React Flow Edge objects + // 3. Build React Flow Edge objects. We tag every overlay edge with + // type: "a2a" so React Flow renders it via our custom A2AEdge + // component (canvas/A2AEdge.tsx). The custom component portals + // its label out of the SVG layer so it (a) doesn't get hidden + // behind workspace cards and (b) is clickable. return Array.from(map.values()).map(({ source, target, count, lastAt }) => { const isHot = now - lastAt < A2A_HOT_MS; const stroke = isHot ? "#8b5cf6" : "#3b82f6"; // violet-500 : blue-500 @@ -84,6 +88,7 @@ export function buildA2AEdges( return { id: `a2a-${source}-${target}`, + type: "a2a", source, target, animated: isHot, @@ -96,22 +101,22 @@ export function buildA2AEdges( style: { stroke, strokeWidth: 2, - // Non-blocking: label overlay never intercepts pointer events + // Path itself stays non-interactive so node drags through + // the line still work. The clickable target is the label + // pill, which sets pointerEvents: all on its own div. pointerEvents: "none" as React.CSSProperties["pointerEvents"], }, + // `label` keeps the same string for back-compat with any test + // that asserts on it (e.g. buildA2AEdges output shape). Custom + // edge reads the rich data from `data` so the label visual is + // not constrained to a string anymore. label, - labelStyle: { - fill: "#a1a1aa", // zinc-400 - fontSize: 10, - pointerEvents: "none" as React.CSSProperties["pointerEvents"], + data: { + count, + lastAt, + isHot, + label, }, - labelBgStyle: { - fill: "#18181b", // zinc-900 - fillOpacity: 0.9, - pointerEvents: "none" as React.CSSProperties["pointerEvents"], - }, - labelBgPadding: [4, 6] as [number, number], - labelBgBorderRadius: 4, }; }); } diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 16c299cb..0d793df5 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -36,11 +36,22 @@ import { DropTargetBadge } from "./canvas/DropTargetBadge"; import { useDragHandlers } from "./canvas/useDragHandlers"; import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts"; import { useCanvasViewport } from "./canvas/useCanvasViewport"; +import { A2AEdge } from "./canvas/A2AEdge"; const nodeTypes = { workspaceNode: WorkspaceNode, }; +// Custom edge types. The default React Flow edge renders its label +// inside the SVG group (always under nodes) with pointerEvents: none +// inherited from the path. A2AEdge portals the label to a sibling +// DOM layer so it renders above nodes and accepts clicks. Keep the +// reference stable (module-scope const) so React Flow doesn't see a +// new edgeTypes object on every render and warn about prop churn. +const edgeTypes = { + a2a: A2AEdge, +}; + const defaultEdgeOptions: Partial = { animated: true, style: { @@ -58,14 +69,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); @@ -91,18 +183,45 @@ function CanvasInner() { // outside-click handler. const pendingDelete = useCanvasStore((s) => s.pendingDelete); const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); - const removeNode = useCanvasStore((s) => s.removeNode); + const removeSubtree = useCanvasStore((s) => s.removeSubtree); const confirmDelete = useCallback(async () => { 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); + // Mirror the server-side cascade locally — drop the parent AND + // every descendant in one atomic update. The per-descendant + // WORKSPACE_REMOVED WS events still arrive (and are no-ops + // because the nodes are already gone), but we no longer depend + // on them: a wedged WS used to leave orphan child cards on the + // canvas until the user refreshed the page. + removeSubtree(id); + 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]); + }, [pendingDelete, setPendingDelete, removeSubtree]); const onPaneClick = useCallback(() => { selectNode(null); @@ -141,6 +260,7 @@ function CanvasInner() { onPaneClick={onPaneClick} onMoveEnd={onMoveEnd} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} defaultEdgeOptions={defaultEdgeOptions} defaultViewport={defaultViewport} fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1} diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index bca64869..43e66665 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -1,27 +1,19 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { OrgTemplatesSection } from "./TemplatePalette"; +import { type Template } from "@/lib/deploy-preflight"; +import { useTemplateDeploy } from "@/hooks/useTemplateDeploy"; import { Spinner } from "./Spinner"; import { TIER_CONFIG } from "@/lib/design-tokens"; -interface Template { - id: string; - name: string; - description: string; - tier: number; - model: string; - skills: string[]; - skill_count: number; -} - export function EmptyState() { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); - const [deploying, setDeploying] = useState(null); - const [error, setError] = useState(null); + const [blankCreating, setBlankCreating] = useState(false); + const [blankError, setBlankError] = useState(null); useEffect(() => { api @@ -31,48 +23,56 @@ export function EmptyState() { .finally(() => setLoading(false)); }, []); - const deploy = async (template: Template) => { - setDeploying(template.id); - setError(null); - try { - const ws = await api.post<{ id: string }>("/workspaces", { - name: template.name, - template: template.id, - tier: template.tier, - canvas: { x: 200, y: 150 }, - }); - // Auto-select the new workspace and open chat - setTimeout(() => { - useCanvasStore.getState().selectNode(ws.id); - useCanvasStore.getState().setPanelTab("chat"); - }, 500); - } catch (e) { - setError(e instanceof Error ? e.message : "Deploy failed"); - } finally { - setDeploying(null); - } - }; + // Canvas fills in a visible "center-ish" spot on a fresh tenant so + // the user doesn't have to pan to find their new workspace. Fixed + // (200, 150) instead of the sidebar's random placement because the + // canvas is guaranteed empty when this component mounts. + const firstDeployCoords = useCallback(() => ({ x: 200, y: 150 }), []); + // After the POST succeeds, auto-select the new workspace and flip + // the panel to Chat. This is a UX flourish that only makes sense + // on first deploy (the canvas is empty so the selection can't + // surprise anyone); the sidebar intentionally skips this step. + // 500 ms delay so React Flow has a frame to render the new node + // before it receives focus. + const handleDeployed = useCallback((workspaceId: string) => { + setTimeout(() => { + useCanvasStore.getState().selectNode(workspaceId); + useCanvasStore.getState().setPanelTab("chat"); + }, 500); + }, []); + + const { deploy, deploying, error, modal } = useTemplateDeploy({ + canvasCoords: firstDeployCoords, + onDeployed: handleDeployed, + }); + + // "Create blank" bypasses templates entirely — no preflight, no + // modal, just POST /workspaces with a default name and tier. + // Deliberately NOT routed through useTemplateDeploy because it + // has no `template.id` to deploy against. const createBlank = async () => { - setDeploying("blank"); - setError(null); + setBlankCreating(true); + setBlankError(null); try { const ws = await api.post<{ id: string }>("/workspaces", { name: "My First Agent", tier: 2, - canvas: { x: 200, y: 150 }, + canvas: firstDeployCoords(), }); - setTimeout(() => { - useCanvasStore.getState().selectNode(ws.id); - useCanvasStore.getState().setPanelTab("chat"); - }, 500); + handleDeployed(ws.id); } catch (e) { - setError(e instanceof Error ? e.message : "Create failed"); + setBlankError(e instanceof Error ? e.message : "Create failed"); } finally { - setDeploying(null); + setBlankCreating(false); } }; + // Any active gesture locks every button so the user can't fire a + // second POST while the first is still in flight. + const anyDeploying = !!deploying || blankCreating; + const displayError = error ?? blankError; + return (
@@ -112,8 +112,8 @@ export function EmptyState() { {/* Org templates — instantiate a whole team in one click */} @@ -154,12 +154,17 @@ export function EmptyState() {
- {error && ( + {displayError && (
- {error} + {displayError}
)} + {/* Missing-keys preflight modal — owned by useTemplateDeploy, + shared with TemplatePalette. Rendered inline here so it + overlays this card naturally. */} + {modal} + {/* Tips */}
diff --git a/canvas/src/components/Legend.tsx b/canvas/src/components/Legend.tsx index 10964fd3..0e578972 100644 --- a/canvas/src/components/Legend.tsx +++ b/canvas/src/components/Legend.tsx @@ -1,19 +1,92 @@ "use client"; +import { useEffect, useState } from "react"; import { STATUS_CONFIG } from "@/lib/design-tokens"; import { useCanvasStore } from "@/store/canvas"; const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const; +// Persist the user's choice across sessions. Default is "open" so +// first-time users still see the symbol key; once dismissed we +// respect that until they explicitly reopen via the floating pill. +const STORAGE_KEY = "molecule.legend.open"; + +function readStoredOpen(): boolean { + if (typeof window === "undefined") return true; + try { + const v = window.localStorage.getItem(STORAGE_KEY); + if (v === null) return true; + return v === "1"; + } catch { + return true; + } +} + +function writeStoredOpen(open: boolean) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, open ? "1" : "0"); + } catch { + // localStorage can throw in private mode / quota / disabled + // contexts. Silent fallback — the in-memory state still works + // for the current session. + } +} + export function Legend() { // TemplatePalette (when open) is fixed top-0 left-0 w-[280px] — the // default bottom-6 left-4 position of this legend would sit under it. // Shift past the 280 px palette + a 16 px gap when the palette is open. const paletteOpen = useCanvasStore((s) => s.templatePaletteOpen); const leftClass = paletteOpen ? "left-[296px]" : "left-4"; + + // SSR-safe pattern: mount with the default (true) so first paint + // matches the server output, then hydrate the persisted value + // after mount. Avoids a hydration mismatch warning when the user + // had previously closed the legend. + const [open, setOpen] = useState(true); + useEffect(() => { + setOpen(readStoredOpen()); + }, []); + + const closeLegend = () => { + setOpen(false); + writeStoredOpen(false); + }; + const openLegend = () => { + setOpen(true); + writeStoredOpen(true); + }; + + if (!open) { + return ( + + ); + } + return (
-
Legend
+
+
Legend
+ +
{/* Status */}
diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index e80ab58a..318ecef7 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; import { api } from "@/lib/api"; import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight"; @@ -196,6 +197,12 @@ function ProviderPickerModal({ ); if (!open) return null; + // Portal to document.body for the same reason as + // OrgImportPreflightModal — several callers (TemplatePalette, + // EmptyState) render the modal inside their own fixed+filtered + // containers, which re-anchor the "fixed" positioning to the + // wrapper's bounds instead of the viewport. + if (typeof document === "undefined") return null; const allSaved = entries.length > 0 && entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); @@ -203,8 +210,14 @@ function ProviderPickerModal({ .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); - return ( -
+ return createPortal( + // z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50). + // Both can be on screen at once during an org import: the org- + // preflight is open while the user clicks a per-workspace deploy + // that triggers MissingKeys. Without the explicit z-order the + // backdrop click might dismiss the wrong modal depending on + // React's commit ordering. +
-
+
, + document.body, ); } @@ -474,6 +488,7 @@ function AllKeysModal({ }, [open]); if (!open) return null; + if (typeof document === "undefined") return null; const allSaved = entries.length > 0 && entries.every((e) => e.saved); const anySaving = entries.some((e) => e.saving); @@ -481,8 +496,14 @@ function AllKeysModal({ .replace(/[-_]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); - return ( -
+ return createPortal( + // z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50). + // Both can be on screen at once during an org import: the org- + // preflight is open while the user clicks a per-workspace deploy + // that triggers MissingKeys. Without the explicit z-order the + // backdrop click might dismiss the wrong modal depending on + // React's commit ordering. +
-
+
, + document.body, ); } diff --git a/canvas/src/components/OrgImportPreflightModal.tsx b/canvas/src/components/OrgImportPreflightModal.tsx new file mode 100644 index 00000000..51d61c82 --- /dev/null +++ b/canvas/src/components/OrgImportPreflightModal.tsx @@ -0,0 +1,540 @@ +"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): 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; + /** 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>({}); + + // 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>(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( +
+
e.stopPropagation()} + > +
+

+ Deploy {orgName} +

+

+ {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}. + Review the credentials needed before import. +

+
+ +
+ {requiredEnv.length > 0 && ( + + setDrafts((d) => ({ ...d, [key]: { ...d[key], value } })) + } + onSave={saveOne} + /> + )} + {recommendedEnv.length > 0 && ( + + setDrafts((d) => ({ ...d, [key]: { ...d[key], value } })) + } + onSave={saveOne} + /> + )} + {requiredEnv.length === 0 && recommendedEnv.length === 0 && ( +

+ No additional credentials required for this template. +

+ )} +
+ +
+ +
+ {missingRecommended.length > 0 && canProceed && ( + + {missingRecommended.length} recommended key + {missingRecommended.length === 1 ? "" : "s"} still unset + + )} + +
+
+
+
, + document.body, + ); +} + +interface EnvListProps { + tone: "required" | "recommended"; + title: string; + subtitle: string; + entries: EnvRequirement[]; + configuredKeys: Set; + drafts: Record; + 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 ( +
+

+ {title} +

+

{subtitle}

+
    + {entries.map((entry) => + typeof entry === "string" ? ( + + ) : ( + + ), + )} +
+
+ ); +} + +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 ( +
  • + + {envKey} + + {configured ? ( + ✓ set + ) : ( + <> + 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" + /> + + + )} + {d?.error && ( + + {d.error} + + )} +
  • + ); +} + +interface AnyOfEnvGroupProps { + members: string[]; + configuredKeys: Set; + drafts: Record; + 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 ( +
  • +
    + + Configure any one + + {satisfiedBy && ( + + ✓ using {satisfiedBy} + + )} +
    +
      + {members.map((m) => { + const isConfigured = configuredKeys.has(m); + const d = drafts[m]; + const dimmed = !!satisfiedBy && !isConfigured; + return ( +
    • + + {m} + + {isConfigured ? ( + ✓ set + ) : ( + <> + 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" + /> + + + )} + {d?.error && ( + + {d.error} + + )} +
    • + ); + })} +
    +
  • + ); +} diff --git a/canvas/src/components/ProvisioningTimeout.tsx b/canvas/src/components/ProvisioningTimeout.tsx index 2f2ee564..92af73f0 100644 --- a/canvas/src/components/ProvisioningTimeout.tsx +++ b/canvas/src/components/ProvisioningTimeout.tsx @@ -65,6 +65,12 @@ export function ProvisioningTimeout({ // banner even if they stay in provisioning. Cleared when the // workspace leaves provisioning (status changes). const [dismissed, setDismissed] = useState>(new Set()); + // Watch the live WS health. While it's not "connected", local node + // status reflects the last event we received before the drop — + // workspaces may have actually transitioned to online minutes ago. + // Suppress the banner until WS recovers + rehydrate confirms each + // workspace is genuinely still provisioning. + const wsStatus = useCanvasStore((s) => s.wsStatus); // Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render // (filter+map creates new array reference on every store update). @@ -273,8 +279,11 @@ export function ProvisioningTimeout({ }, []); const visibleTimedOut = useMemo( - () => timedOut.filter((e) => !dismissed.has(e.workspaceId)), - [timedOut, dismissed], + () => + wsStatus === "connected" + ? timedOut.filter((e) => !dismissed.has(e.workspaceId)) + : [], + [timedOut, dismissed, wsStatus], ); if (visibleTimedOut.length === 0) return null; diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 35ba5c8f..44a32940 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -29,7 +29,7 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "chat", label: "Chat", icon: "◈" }, { id: "activity", label: "Activity", icon: "⊙" }, { id: "details", label: "Details", icon: "◉" }, - { id: "skills", label: "Skills", icon: "✦" }, + { id: "skills", label: "Plugins", icon: "✦" }, { id: "terminal", label: "Terminal", icon: "▸" }, { id: "config", label: "Config", icon: "⚙" }, { id: "schedule", label: "Schedule", icon: "⏲" }, @@ -280,7 +280,7 @@ export function SidePanel() { className="flex-1 overflow-y-auto focus:outline-none" > {panelTab === "details" && } - {panelTab === "skills" && } + {panelTab === "skills" && } {panelTab === "activity" && } {panelTab === "chat" && } {panelTab === "terminal" && } diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index 48b94156..f3b9044b 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -1,35 +1,48 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; +import { flushSync } from "react-dom"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import type { WorkspaceData } from "@/store/socket"; -import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight"; -import { MissingKeysModal } from "./MissingKeysModal"; +import { type Template } from "@/lib/deploy-preflight"; +import { useTemplateDeploy } from "@/hooks/useTemplateDeploy"; +import { + OrgImportPreflightModal, + type EnvRequirement, +} from "./OrgImportPreflightModal"; import { ConfirmDialog } from "./ConfirmDialog"; import { Spinner } from "./Spinner"; import { showToast } from "./Toaster"; import { TIER_CONFIG } from "@/lib/design-tokens"; +import { listSecrets } from "@/lib/api/secrets"; -interface Template { - id: string; - name: string; - description: string; - tier: number; - runtime?: string; - model: string; - models?: ModelSpec[]; - /** AND-required env vars declared at runtime_config.required_env. */ - required_env?: string[]; - skills: string[]; - skill_count: number; -} - +// `Template` type and `resolveRuntime` helper now live in +// `@/lib/deploy-preflight` so EmptyState can import the same ones. Was +// redeclared here + a narrower redeclaration in EmptyState; the +// narrower one dropped `runtime`, `models`, `required_env`, which is +// exactly the data the preflight needs. See reviewer's "runtime +// fallback drift" note — single source of truth closes the drift. export interface OrgTemplate { dir: string; name: string; description: string; workspaces: number; + /** Env vars that MUST be set as global secrets before the org can + * import. Server refuses the import with 412 if any are missing; + * the canvas preflights against /secrets/list to avoid the round + * trip. Aggregated from org-level + every workspace in the tree. + * + * Each entry is either a key name (strict) or an `{any_of: [...]}` + * group (any one of the listed members satisfies the requirement — + * e.g. `ANTHROPIC_API_KEY` OR `CLAUDE_CODE_OAUTH_TOKEN`). */ + required_env?: EnvRequirement[]; + /** "Nice-to-have" tier. Import proceeds without them but features + * may degrade — a channel's webhook posts get dropped, a fallback + * LLM isn't available, etc. Surfaced to the user as a non-blocking + * warning with an "add now" affordance. Same union shape as + * `required_env`. */ + recommended_env?: EnvRequirement[]; } /** Fetch the list of org templates from the platform. Returns [] on error @@ -91,6 +104,14 @@ export function OrgTemplatesSection() { const [loading, setLoading] = useState(false); const [importing, setImporting] = useState(null); const [error, setError] = useState(null); + // Preflight modal state. `preflight` is non-null when the user + // clicked Import on an org with declared required/recommended envs + // and we're waiting for them to confirm; null otherwise (direct + // import path for orgs with zero env requirements). + const [preflight, setPreflight] = useState<{ + org: OrgTemplate; + configuredKeys: Set; + } | null>(null); // Collapsed by default — org templates are multi-workspace imports // that most new users don't reach for first. Keeping them // expand-on-demand frees ~400 px of vertical space for the @@ -109,21 +130,55 @@ export function OrgTemplatesSection() { loadOrgs(); }, [loadOrgs]); - const handleImport = async (org: OrgTemplate) => { + /** Fetch the set of global secret KEYS that are already configured. + * Used to strike through already-set entries in the preflight modal + * and to decide whether the import needs the modal at all. */ + const loadConfiguredKeys = useCallback(async (): Promise> => { + try { + const secrets = await listSecrets("global"); + return new Set(secrets.map((s) => s.name)); + } catch { + // Secrets endpoint unreachable → assume nothing configured. + // The server will refuse the import with 412 and the user + // retries; safer than letting the import fly blind. + return new Set(); + } + }, []); + + /** Actually run the import. Split out so both the "no preflight + * needed" fast path and the "preflight modal approved" path can + * share the fetch + hydrate + toast sequence. */ + const doImport = useCallback(async (org: OrgTemplate) => { setImporting(org.dir); 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) { @@ -133,7 +188,45 @@ export function OrgTemplatesSection() { } finally { setImporting(null); } - }; + }, []); + + /** Entry point for the Import button. Two paths: + * + * 1. No env declared by the template (required_env + recommended_env + * both empty) → fire doImport directly. Matches the pre-preflight + * behaviour for existing templates. + * + * 2. Any env declared → load the configured-keys set and open the + * preflight modal. doImport runs only when the user clicks + * Import inside the modal, which is gated to "required envs all + * configured" by the modal itself. */ + const handleImport = useCallback(async (org: OrgTemplate) => { + const hasEnvDeclarations = + (org.required_env && org.required_env.length > 0) || + (org.recommended_env && org.recommended_env.length > 0); + if (!hasEnvDeclarations) { + void doImport(org); + return; + } + // Flip the button to its "Importing…" state while the secrets + // lookup runs — on a tenant with 500+ global secrets the round + // trip can be > 200 ms and the user otherwise gets zero visual + // feedback after clicking. Cleared on modal close / error. + setImporting(org.dir); + try { + const configuredKeys = await loadConfiguredKeys(); + setPreflight({ org, configuredKeys }); + } finally { + setImporting(null); + } + }, [doImport, loadConfiguredKeys]); + + /** Called by the preflight modal after a successful key save so the + * strike-through re-renders and canProceed recomputes. */ + const refreshConfiguredKeys = useCallback(async () => { + const keys = await loadConfiguredKeys(); + setPreflight((prev) => (prev ? { ...prev, configuredKeys: keys } : prev)); + }, [loadConfiguredKeys]); return (
    @@ -222,6 +315,35 @@ export function OrgTemplatesSection() { })}
    )} + + {preflight && ( + { + const org = preflight.org; + // flushSync guarantees the modal unmounts BEFORE we kick + // off the import network call. Without it, React batches + // setPreflight(null) with the setImporting(...) from + // doImport's synchronous prefix, both commit at the end + // of this handler, AND the await import() POST may yield + // a microtask before React schedules the paint. Net + // effect: the modal backdrop sat over the canvas during + // the first wave of WORKSPACE_PROVISIONING WS events, + // hiding the spawn animation. Force the close to land + // first so the user sees the canvas reveal + agents + // popping into place. + flushSync(() => setPreflight(null)); + void doImport(org); + }} + onCancel={() => setPreflight(null)} + /> + )}
    ); } @@ -319,14 +441,6 @@ export function TemplatePalette() { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(false); - const [creating, setCreating] = useState(null); - const [error, setError] = useState(null); - - // Missing keys modal state - const [missingKeysInfo, setMissingKeysInfo] = useState<{ - template: Template; - preflight: PreflightResult; - } | null>(null); const loadTemplates = useCallback(async () => { setLoading(true); @@ -344,65 +458,15 @@ export function TemplatePalette() { if (open) loadTemplates(); }, [open, loadTemplates]); - /** Resolve runtime from template ID (e.g., "langgraph", "claude-code-default" → "claude-code") */ - const resolveRuntime = (templateId: string): string => { - const runtimeMap: Record = { - langgraph: "langgraph", - "claude-code-default": "claude-code", - openclaw: "openclaw", - deepagents: "deepagents", - crewai: "crewai", - autogen: "autogen", - }; - return runtimeMap[templateId] ?? templateId.replace(/-default$/, ""); - }; - - /** Actually execute the deploy API call */ - const executeDeploy = useCallback(async (template: Template) => { - setCreating(template.id); - setError(null); - try { - await api.post("/workspaces", { - name: template.name, - template: template.id, - tier: template.tier, - canvas: { - x: Math.random() * 400 + 100, - y: Math.random() * 300 + 100, - }, - }); - setCreating(null); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to deploy"); - setCreating(null); - } - }, []); - - /** Pre-deploy check: validate secrets before deploying */ - const handleDeploy = async (template: Template) => { - setCreating(template.id); - setError(null); - - // Prefer the runtime the Go /templates endpoint returned verbatim — - // resolveRuntime() is a legacy id→runtime fallback for installs whose - // template summary predates the `runtime` field. - const runtime = template.runtime ?? resolveRuntime(template.id); - const preflight = await checkDeploySecrets({ - runtime, - models: template.models, - required_env: template.required_env, - }); - - if (!preflight.ok) { - // Missing keys — show the modal instead of deploying - setMissingKeysInfo({ template, preflight }); - setCreating(null); - return; - } - - // All keys present — deploy directly - await executeDeploy(template); - }; + // Preflight + POST + modal wiring moved into useTemplateDeploy so + // this component and EmptyState use one implementation. The sidebar + // uses the hook's default random canvas placement (no override) — + // an already-populated canvas shouldn't have new deploys stacking on + // a single fixed point. No post-deploy side effect either: the + // palette is operator-triggered, so auto-selecting would yank + // focus off whatever the user was already looking at. + const { deploy: handleDeploy, deploying: creating, error, modal } = + useTemplateDeploy(); return ( <> @@ -426,21 +490,9 @@ export function TemplatePalette() { - {/* Missing Keys Modal */} - { - if (missingKeysInfo) { - const template = missingKeysInfo.template; - setMissingKeysInfo(null); - executeDeploy(template); - } - }} - onCancel={() => setMissingKeysInfo(null)} - /> + {/* Missing-keys modal — rendered by the shared hook. Same + instance shape used by EmptyState. */} + {modal} {/* Sidebar */} {open && ( @@ -483,7 +535,7 @@ export function TemplatePalette() { +
    + + )} + + ); +} + +export const A2AEdge = memo(A2AEdgeImpl); 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/__tests__/useCanvasViewport.test.ts b/canvas/src/components/canvas/__tests__/useCanvasViewport.test.ts new file mode 100644 index 00000000..4d21ea91 --- /dev/null +++ b/canvas/src/components/canvas/__tests__/useCanvasViewport.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { shouldFitGrowing } from "../useCanvasViewport"; + +// Tests cover the auto-fit gate in isolation. The hook itself is +// effects + refs + React Flow handles, awkward to exercise directly — +// extracting the pure decision into shouldFitGrowing(...) lets us +// pin down the regression-prone logic with unit tests instead. + +describe("shouldFitGrowing", () => { + it("fits the very first time (no prior snapshot)", () => { + expect(shouldFitGrowing(["a"], undefined, null, 0)).toBe(true); + }); + + it("fits when the prior snapshot is empty", () => { + expect(shouldFitGrowing(["a", "b"], new Set(), null, 0)).toBe(true); + }); + + it("fits when a brand-new id has been added since the last fit", () => { + const prev = new Set(["root", "a", "b"]); + expect(shouldFitGrowing(["root", "a", "b", "c"], prev, null, 0)).toBe(true); + }); + + it("respects user pan when the subtree hasn't grown", () => { + const prev = new Set(["root", "a", "b"]); + // Status update on existing node — same membership. + expect(shouldFitGrowing(["root", "a", "b"], prev, 5_000, 1_000)).toBe(false); + }); + + it("fits when the subtree hasn't grown but the user never panned", () => { + const prev = new Set(["root", "a", "b"]); + expect(shouldFitGrowing(["root", "a", "b"], prev, null, 1_000)).toBe(true); + }); + + it("fits when the subtree hasn't grown and the user panned BEFORE the last fit", () => { + const prev = new Set(["root", "a", "b"]); + expect(shouldFitGrowing(["root", "a", "b"], prev, 500, 1_000)).toBe(true); + }); + + it("forces fit on delete-then-add even when the count is unchanged", () => { + // Subtree was [root, a, b, c, d]. Then `d` got removed and a + // sibling `e` arrived. Same length, different membership — a + // length-only check would skip the fit and leave `e` off-screen. + const prev = new Set(["root", "a", "b", "c", "d"]); + expect( + shouldFitGrowing(["root", "a", "b", "c", "e"], prev, 5_000, 1_000), + ).toBe(true); + }); + + it("does NOT fit on shrink-only when the user has panned (deletion alone shouldn't override exploration)", () => { + const prev = new Set(["root", "a", "b", "c"]); + expect(shouldFitGrowing(["root", "a", "b"], prev, 5_000, 1_000)).toBe(false); + }); +}); diff --git a/canvas/src/components/canvas/useCanvasViewport.ts b/canvas/src/components/canvas/useCanvasViewport.ts index 8ab916e5..db49cd20 100644 --- a/canvas/src/components/canvas/useCanvasViewport.ts +++ b/canvas/src/components/canvas/useCanvasViewport.ts @@ -3,11 +3,43 @@ 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, } from "@/store/canvas-topology"; +/** + * Decide whether the deploy-time auto-fit should run. Pure function so + * the gate logic is unit-testable in isolation — the surrounding + * useEffect tangle of refs, timers, and React Flow handles is awkward + * to exercise directly. + * + * Returns true when the auto-fit SHOULD fire: + * - the subtree contains an id that wasn't in the previous snapshot + * (a new node arrived → user has lost context, force the fit + * through regardless of any user-pan in between), OR + * - the user has not panned since the last successful fit (so the + * auto-fit isn't fighting their override). + * + * `prevSubtreeIds === undefined` means no fit has ever run for this + * root — treat every id as "new" and fit. `userPannedAt === null` + * means the user has never panned at all in this session — fit. + */ +export function shouldFitGrowing( + currentSubtreeIds: readonly string[], + prevSubtreeIds: ReadonlySet | undefined, + userPannedAt: number | null, + lastAutoFitAt: number, +): boolean { + if (!prevSubtreeIds || prevSubtreeIds.size === 0) return true; + for (const id of currentSubtreeIds) { + if (!prevSubtreeIds.has(id)) return true; + } + if (userPannedAt === null) return true; + return userPannedAt <= lastAutoFitAt; +} + /** * Wires the two canvas-wide CustomEvent listeners and the viewport * save/restore bookkeeping so Canvas.tsx doesn't have to. @@ -25,17 +57,79 @@ export function useCanvasViewport() { const saveViewport = useCanvasStore((s) => s.saveViewport); const saveTimerRef = useRef>(undefined); const panTimerRef = useRef>(undefined); - const autoFitTimerRef = useRef>(undefined); + // Two distinct fit timers — DO NOT collapse to one. + // - settleFitTimerRef: 1200ms one-shot run by the + // "transition from any-provisioning to none" effect (the deploy + // just finished — settle on the whole org once). + // - trackingFitTimerRef: 500ms debounced by the per-arrival + // molecule:fit-deploying-org event handler (track the org's + // bounds as children land during the deploy). + // They MUST NOT share a ref: the two effects fire interleaved + // (every WS event during a deploy resets the tracking timer; the + // settle timer arms the moment provisioning hits zero), and a + // shared ref made each effect silently clearTimeout the other's + // pending fit. Today's behavior happened to land in the right + // order out of luck; splitting the refs makes ordering independent + // of fire sequence. + const settleFitTimerRef = useRef>(undefined); + const trackingFitTimerRef = useRef>(undefined); // Tracks whether any workspace was provisioning on the previous // 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. Earlier + // revisions tried to detect user pans via `onMoveEnd`, but React + // Flow v12 fires that callback with a truthy event at the END of + // a programmatic fitView animation — so the first auto-fit we + // triggered would immediately look like a user pan and block + // every subsequent fit for the rest of the deploy, leaving the + // viewport stuck wherever the first fit landed. Now we stamp + // this ref ONLY on wheel / pointerdown / touchstart on the + // React Flow pane itself (see the effect below), which are + // unambiguous user-gesture signals. + const userPannedAtRef = useRef(null); + const lastAutoFitAtRef = useRef(0); useEffect(() => { return () => { clearTimeout(saveTimerRef.current); clearTimeout(panTimerRef.current); - clearTimeout(autoFitTimerRef.current); + clearTimeout(settleFitTimerRef.current); + clearTimeout(trackingFitTimerRef.current); + }; + }, []); + + // User-gesture listeners for the respect-user-pan gate. Listens on + // `document` with capture phase and filters to events whose target + // lies inside the React Flow pane — this avoids a mount-order race + // (`.react-flow__pane` may not exist when the hook first runs if + // RF is behind a Suspense boundary) AND keeps clicks on the + // toolbar / modals / side panel from stamping user-pan-intent. + // Capture phase runs before target-phase `stopPropagation` so a + // handler elsewhere can't swallow the signal. + // + // Wheel only — NOT pointerdown. A pointerdown on the pane fires for + // ordinary clicks (deselect, click-near-a-card, modal-close-bubble) + // as well as the start of a drag-pan. Treating every pointerdown as + // "user wants to override auto-fit" meant a single accidental click + // before/during an org import locked out every subsequent fit, so + // the viewport stuck at whatever the first fit landed on while + // children kept materialising off-screen. Wheel is the canonical + // unambiguous gesture: scroll-to-pan and pinch-zoom both surface as + // wheel events. Drag-pans without an accompanying wheel are rare + // enough that letting them be overridden by a follow-up auto-fit is + // the right tradeoff. + useEffect(() => { + if (typeof window === "undefined") return; + const stamp = (e: Event) => { + const target = e.target as HTMLElement | null; + if (!target?.closest?.(".react-flow__pane")) return; + userPannedAtRef.current = Date.now(); + }; + const opts: AddEventListenerOptions = { passive: true, capture: true }; + document.addEventListener("wheel", stamp, opts); + return () => { + document.removeEventListener("wheel", stamp, opts); }; }, []); @@ -55,20 +149,64 @@ export function useCanvasViewport() { hadProvisioningRef.current = hasProvisioning; if (wasProvisioning && !hasProvisioning && nodeCount > 0) { - clearTimeout(autoFitTimerRef.current); + // 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(settleFitTimerRef.current); // 1200ms settle delay: lets React Flow's DOM measurement pass // resize newly-online parents before we compute bounds. // Measuring too early gives us the pre-render skeleton bbox and // fitView zooms to that smaller-than-real rectangle. - autoFitTimerRef.current = setTimeout(() => { + settleFitTimerRef.current = setTimeout(() => { fitView({ + // Deliberately SLOWER than the in-flight tracking fits + // (400ms). The asymmetry reads as "settling" on the + // finished org rather than "tracking" another arrival, + // which is the intended UX for the "deploy done" moment. + // Don't normalize these two durations to the same value. 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 +230,115 @@ 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); + // Membership snapshot of the subtree at the moment of the last + // successful auto-fit, keyed by root id. When a new event arrives, + // we compute growth as "any id in the current subtree that wasn't + // in the snapshot". An id-set rather than just a count handles the + // delete-then-add case correctly: subtree of 6 → delete one → 5 → + // a different child arrives → 6 again. A length-only comparison + // would call this "no growth" and skip the fit even though a + // brand-new node landed off-screen. The id-set sees the new id + // wasn't in the snapshot and forces the fit. + // + // Map is keyed by root id and never pruned. Acceptable today because + // org roots are UUIDs (no collisions on retry / template re-import), + // canvas sessions are per-tab, and entries are tiny. Worth a sweep + // if long-lived sessions ever start importing hundreds of orgs. + const lastFitSubtreeIdsRef = useRef>>(new Map()); + useEffect(() => { + const runFit = () => { + const rootCandidate = pendingFitRootRef.current; + pendingFitRootRef.current = null; + if (!rootCandidate) 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; + + // Growth check: did any id in the current subtree NOT appear + // in the snapshot from the last fit? If yes, fit through + // regardless of the user-pan timestamp — the user has lost + // context, the new arrival is off-screen, and the deploy is + // the primary thing they want to watch. If no, fall back to + // the user-pan respect gate so post-deploy exploration isn't + // yanked back. + if (!shouldFitGrowing( + subtree, + lastFitSubtreeIdsRef.current.get(topId), + userPannedAtRef.current, + lastAutoFitAtRef.current, + )) { + return; + } + fitView({ + nodes: subtree.map((id) => ({ id })), + // Short animation — server paces children ~2s apart, so a + // 400ms fit animation reads as "smoothly tracked" rather + // than "constantly lurching". Longer durations (the earlier + // 600ms) start to overlap if the user re-triggers deploys. + duration: 400, + // 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(); + lastFitSubtreeIdsRef.current.set(topId, new Set(subtree)); + }; + const handler = (e: Event) => { + const { rootId } = (e as CustomEvent<{ rootId: string }>).detail; + // Keep the most recently-requested root. Back-to-back imports + // on two different orgs (rare — user would have to click + // Import twice within 500ms) "later wins" the viewport rather + // than ping-ponging between them. If this becomes a real + // pattern we'd flush the pending fit synchronously when + // `rootId` changes, rather than resetting the timer. + pendingFitRootRef.current = rootId; + clearTimeout(trackingFitTimerRef.current); + trackingFitTimerRef.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) => { @@ -129,6 +376,11 @@ export function useCanvasViewport() { const onMoveEnd = useCallback( (_event: unknown, vp: { x: number; y: number; zoom: number }) => { + // User-pan detection moved to the wheel/pointerdown listener + // above — onMoveEnd fires for programmatic fitView too, which + // made this callback an unreliable source for user-intent + // tracking. This now only handles the debounced viewport + // save so a reload lands the user back where they were. 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/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx index fc857842..d0e31630 100644 --- a/canvas/src/components/tabs/ActivityTab.tsx +++ b/canvas/src/components/tabs/ActivityTab.tsx @@ -5,6 +5,7 @@ import { api } from "@/lib/api"; import { ConversationTraceModal } from "@/components/ConversationTraceModal"; import { type ActivityEntry } from "@/types/activity"; import { useWorkspaceName } from "@/hooks/useWorkspaceName"; +import { inferA2AErrorHint } from "./chat/a2aErrorHint"; interface Props { workspaceId: string; @@ -286,6 +287,26 @@ function ActivityRow({ ); } +const A2A_ERROR_PREFIX = "[A2A_ERROR]"; + +/** Render a [A2A_ERROR]-prefixed response as a structured error block + * with a stripped detail line + a cause hint. The previous raw render + * ("[A2A_ERROR] " literal in the response area) gave the user no + * signal to act on. */ +function A2AErrorPreview({ label, raw }: { label: string; raw: string }) { + const detail = raw.slice(A2A_ERROR_PREFIX.length).trim() || "(no detail provided)"; + const hint = inferA2AErrorHint(detail); + return ( +
    +
    {label} — delivery failed
    +
    +
    {detail}
    +
    {hint}
    +
    +
    + ); +} + /** Extract human-readable text from A2A request/response JSON */ function MessagePreview({ label, body }: { label: string; body: Record }) { // Try to extract text from A2A message parts @@ -295,6 +316,14 @@ function MessagePreview({ label, body }: { label: string; body: Record; + } return (
    {label}
    diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 3762ffdc..68734f11 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -7,9 +7,12 @@ 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 { appendActivityLine } from "./chat/activityLog"; import { runtimeDisplayName } from "@/lib/runtime-names"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -21,10 +24,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?: { @@ -33,25 +44,81 @@ 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 — +// 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). @@ -71,16 +138,23 @@ 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)); } - // 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 +252,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 +272,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 +325,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); @@ -277,12 +373,21 @@ function MyChatPanel({ workspaceId, data }: Props) { try { const msg = JSON.parse(event.data); if (msg.event === "ACTIVITY_LOGGED") { + // Filter to events for THIS workspace. The platform's + // BroadcastOnly fires to every connected client, and + // without this guard a sibling workspace's a2a_send would + // surface as "→ Delegating to X..." inside the wrong + // chat panel. (workspace_id on the WS envelope is the + // workspace whose activity_log row we just wrote.) + if (msg.workspace_id !== workspaceId) return; + const p = msg.payload || {}; const type = p.activity_type as string; const method = (p.method as string) || ""; const status = (p.status as string) || ""; const targetId = (p.target_id as string) || ""; const durationMs = p.duration_ms as number | undefined; + const summary = (p.summary as string) || ""; let line = ""; if (type === "a2a_receive" && method === "message/send") { @@ -313,17 +418,23 @@ function MyChatPanel({ workspaceId, data }: Props) { const targetName = resolveWorkspaceName(targetId); line = `→ Delegating to ${targetName}...`; } else if (type === "task_update") { - const summary = (p.summary as string) || ""; if (summary) line = `⟳ ${summary}`; + } else if (type === "agent_log") { + // Per-tool-use telemetry from claude_sdk_executor's + // _report_tool_use. The summary already carries an icon + // + human-readable args (📄 Read /path, ⚡ Bash: …) + // so we render it verbatim. No icon prefix here — the + // emoji at the start of summary is the visual marker. + if (summary) line = summary; } if (line) { - setActivityLog((prev) => [...prev.slice(-8), line]); + setActivityLog((prev) => appendActivityLine(prev, line)); } } else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) { const task = (msg.payload?.current_task as string) || ""; if (task) { - setActivityLog((prev) => [...prev.slice(-8), `⟳ ${task}`]); + setActivityLog((prev) => appendActivityLine(prev, `⟳ ${task}`)); } } // A2A_RESPONSE is already consumed by the store and its text is @@ -339,10 +450,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 +492,228 @@ 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(() => { + // 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; 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)); + + // Monotonic counter so two paste events within the same wall-clock + // second still produce distinct filenames. Without this, on + // Firefox (where pasted images have an empty `file.name`), two + // pastes ~100ms apart could yield identical synthetic names AND + // identical sizes, collapsing into one attachment via the + // `name:size` dedup in onFilesPicked. + const pasteCounterRef = useRef(0); + + /** Paste-from-clipboard image attachment. + * + * Browser clipboard image items arrive as `File`s whose `name` is + * often a generic "image.png" (Chrome) or empty (Firefox/Safari), + * so two consecutive screenshot pastes collide on the name+size + * dedup the file-picker uses. Re-tag each pasted image with a + * per-paste unique name so dedup keeps them apart and the upload + * pipeline (which expects a non-empty filename) is happy. + * + * Falls through to onFilesPicked via direct File[] (NOT through + * the DataTransfer constructor — that throws on Safari < 14.1 + * and old Edge, silently aborting the paste). + * + * Only intercepts the paste when the clipboard has at least one + * image; text-only pastes fall through to the textarea's default + * behaviour. */ + const mimeToExt = (mime: string): string => { + // Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`, + // `"jpeg"`, `"webp"` etc. which produce ugly filenames and may + // trip server-side extension allowlists. Map known types + // explicitly; unknown falls back to a safe default. + if (mime === "image/svg+xml") return "svg"; + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/png") return "png"; + if (mime === "image/gif") return "gif"; + if (mime === "image/webp") return "webp"; + if (mime === "image/heic") return "heic"; + return "png"; + }; + + const onPasteIntoComposer = (e: React.ClipboardEvent) => { + if (!dropEnabled) return; + const items = e.clipboardData?.items; + if (!items || items.length === 0) return; + const imageFiles: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item.type.startsWith("image/")) continue; + const file = item.getAsFile(); + if (!file) continue; + const ext = mimeToExt(file.type); + const stamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + const seq = pasteCounterRef.current++; + const fname = `pasted-${stamp}-${seq}-${i}.${ext}`; + imageFiles.push(new File([file], fname, { type: file.type })); + } + if (imageFiles.length === 0) return; + e.preventDefault(); + // Reuse the picker path so file-size guards, dedup, and pending- + // list state all run through the same code. Build a synthetic + // FileList-like object to avoid the DataTransfer constructor — + // that's missing on Safari < 14.1 / old Edge and would silently + // throw, leaving the paste a no-op. + addPastedFiles(imageFiles); + }; + + // Variant of onFilesPicked that accepts a File[] directly, sidestepping + // the DataTransfer-FileList round-trip. Same dedup + state shape. + const addPastedFiles = (files: File[]) => { + setPendingFiles((prev) => { + const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`)); + return [...prev, ...files.filter((f) => !keyed.has(`${f.name}:${f.size}`))]; + }); + }; + + // Drag-and-drop staging. dragDepthRef counts enter vs leave events so + // the overlay doesn't flicker when the cursor crosses nested children + // (textarea, buttons) — dragenter/dragleave fire for every boundary. + 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 +759,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 +783,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 +831,37 @@ function MyChatPanel({ workspaceId, data }: Props) { {/* Input */}
    -
    + {pendingFiles.length > 0 && ( +
    + {pendingFiles.map((f, i) => ( + removePendingFile(i)} + /> + ))} +
    + )} +
    + onFilesPicked(e.target.files)} + aria-hidden="true" + /> +