molecule-core/canvas/src/styles/org-deploy.css
Hongming Wang 94d9331c76 feat(canvas+platform): chat attachments, model selection, deploy/delete UX
Session's accumulated UX work across frontend and platform. Reviewable
in four logical sections — diff is large but internally cohesive
(each section fixes a gap the next one depends on).

## Chat attachments — user ↔ agent file round trip

- New POST /workspaces/:id/chat/uploads (multipart, 50 MB total /
  25 MB per file, UUID-prefixed storage under
  /workspace/.molecule/chat-uploads/).
- New GET /workspaces/:id/chat/download with RFC 6266 filename
  escaping and binary-safe io.CopyN streaming.
- Canvas: drag-and-drop onto chat pane, pending-file pills,
  per-message attachment chips with fetch+blob download (anchor
  navigation can't carry auth headers).
- A2A flow carries FileParts end-to-end; hermes template executor
  now consumes attachments via platform helpers.

## Platform attachment helpers (workspace/executor_helpers.py)

Every runtime's executor routes through the same helpers so future
runtimes inherit attachment awareness for free:
- extract_attached_files — resolve workspace:/file:///bare URIs,
  reject traversal, skip non-existent.
- build_user_content_with_files — manifest for non-image files,
  multi-modal list (text + image_url) for images. Respects
  MOLECULE_DISABLE_IMAGE_INLINING for providers whose vision
  adapter hangs on base64 payloads (MiniMax M2.7).
- collect_outbound_files — scans agent reply for /workspace/...
  paths, stages each into chat-uploads/ (download endpoint
  whitelist), emits as FileParts in the A2A response.
- ensure_workspace_writable — called at molecule-runtime startup
  so non-root agents can write /workspace without each template
  having to chmod in its Dockerfile.

Hermes template executor + langgraph (a2a_executor.py) + claude-code
(claude_sdk_executor.py) all adopt the helpers.

## Model selection & related platform fixes

- PUT /workspaces/:id/model — was 404'ing, so canvas "Save"
  silently lost the model choice. Stores into workspace_secrets
  (MODEL_PROVIDER), auto-restarts via RestartByID.
- applyRuntimeModelEnv falls back to envVars["MODEL_PROVIDER"]
  so Restart propagates the stored model to HERMES_DEFAULT_MODEL
  without needing the caller to rehydrate payload.Model.
- ConfigTab Tier dropdown now reads from workspaces row, not the
  (stale) config.yaml — fixes "badge shows T3, form shows T2".

## ChatTab & WebSocket UX fixes

- Send button no longer locks after a dropped TASK_COMPLETE —
  `sending` no longer initializes from data.currentTask.
- A2A POST timeout 15 s → 120 s. LLM turns routinely exceed 15 s;
  the previous default aborted fetches while the server was still
  replying, producing "agent may be unreachable" on success.
- socket.ts: disposed flag + reconnectTimer cancellation + handler
  detachment fix zombie-WebSocket in React StrictMode.
- Hermes Config tab: RUNTIMES_WITH_OWN_CONFIG drops 'hermes' —
  the adaptor's purpose IS the form, banner was contradictory.
- workspace_provision.go auto-recovery: try <runtime>-default AND
  bare <runtime> for template path (hermes lives at the bare name).

## Org deploy/delete animation (theme-ready CSS)

- styles/theme-tokens.css — design tokens (durations, easings,
  colors). Light theme overrides by setting only the deltas.
- styles/org-deploy.css — animation classes + keyframes, every
  value references a token. prefers-reduced-motion respected.
- Canvas projects node.draggable=false onto locked workspaces
  (deploying children AND actively-deleting ids) — RF's
  authoritative drag lock; useDragHandlers retains a belt-and-
  braces check.
- Organ cancel button (red pulse pill on root during deploy)
  cascades via existing DELETE /workspaces/:id?confirm=true.
- Auto fit-view after each arrival, debounced 500 ms so rapid
  sibling arrivals coalesce into one fit (previous per-event
  fit made the viewport lurch continuously).
- Auto-fit respects user-pan — onMoveEnd stamps a user-pan
  timestamp only when event !== null (ignores programmatic
  fitView) so auto-fits don't self-cancel.
- deletingIds store slice + useOrgDeployState merge gives the
  delete flow the same dim + non-draggable treatment as deploy.
- Platform-level classNames.ts shared by canvas-events +
  useCanvasViewport (DRY'd 3 copies of split/filter/join).

## Server payload change

- org_import.go WORKSPACE_PROVISIONING broadcast now includes
  parent_id + parent-RELATIVE x/y (slotX/slotY) so the canvas
  renders the child at the right parent-nested slot without doing
  any absolute-position walk. createWorkspaceTree signature gains
  relX, relY alongside absX, absY; both call sites updated.

## Tests

- workspace/tests/test_executor_helpers.py — 11 new cases
  covering URI resolution (including traversal rejection),
  attached-file extraction (both Part shapes), manifest-only
  vs multi-modal content, large-image skip, outbound staging,
  dedup, and ensure_workspace_writable (chmod 777 + non-root
  tolerance).
- workspace-server chat_files_test.go — upload validation,
  Content-Disposition escaping, filename sanitisation.
- workspace-server secrets_test.go — SetModel upsert, empty
  clears, invalid UUID rejection.
- tests/e2e/test_chat_attachments_e2e.sh — round-trip against
  a live hermes workspace.
- tests/e2e/test_chat_attachments_multiruntime_e2e.sh — static
  plumbing check + round-trip across hermes/langgraph/claude-code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:27:51 -07:00

152 lines
7.8 KiB
CSS

/**
* Org-deploy animation module.
*
* Loaded globally (see app/globals.css). All values come from
* theme-tokens.css so a theme swap needs zero edits here.
*
* Component contract — canvas/src/components/canvas code adds
* these classes to the React Flow node / edge wrappers:
*
* .mol-deploy-spawn One-shot entry animation on a
* node that just arrived. Applied
* by canvas-events.ts for 600 ms
* then removed.
* .mol-deploy-shimmer Persistent border shimmer while
* a node's status === "provisioning".
* Removed when status flips to
* "online" / "failed".
* .mol-deploy-parent-pulse One-shot acknowledgement pulse
* on the parent when a child lands.
* Applied for parent-pulse duration
* then removed.
* .mol-deploy-locked Applied to every non-root node
* inside a deploying org so it dims
* and the cursor signals un-
* draggable.
* .mol-deploy-root-complete One-shot pop + glow on the root
* when the last child comes online.
*
* Edges use React Flow edge data to pick styling — see the
* selectors below the node keyframes.
*
* Reduced motion is handled at the bottom via the same guard
* globals.css already installs for other animations.
*/
/* ────────────────────────────────────────────────────────
Keyframes — kept terse; values come from variables so
duplication across themes is nil.
──────────────────────────────────────────────────────── */
@keyframes mol-deploy-spawn {
/* Gentle fade-in-place. The earlier "spring from parent" motion
collided with the server-computed grid positions (parent and
child used different coord origins once the parent was placed
on the client's grid instead of the template's absolute
coords), which landed children in wrong slots. Keeping the
animation to a simple opacity+scale lets the server's layout
win — and reads as "node arrived" without the over-engineered
spring. */
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
/* mol-deploy-parent-pulse keyframe removed with the effect — the
box-shadow expanding ring made the parent card visibly "flash" on
every child arrival when the grow pass also bumped width/height.
Kept as a deliberate non-class so the theme-tokens vars can drop
with it on the next theme pass. */
@keyframes mol-deploy-root-complete {
0% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
40% { transform: scale(var(--mol-deploy-root-scale-peak)); box-shadow: var(--mol-deploy-root-glow); }
100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
}
/* (mol-deploy-edge-draw keyframe removed with the edge effects.) */
@keyframes mol-deploy-cancel-pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--mol-deploy-cancel-ring); }
50% { box-shadow: 0 0 0 10px transparent; }
}
/* ────────────────────────────────────────────────────────
Node classes
──────────────────────────────────────────────────────── */
/* Qualify with .react-flow__node so this rule beats the default
`node-appear` animation defined later in globals.css. Without
the qualifier, CSS source-order wins and the standard
node-appear overrides our scale/opacity keyframe, visually
dropping the "spawn from parent" motion. */
.react-flow__node.mol-deploy-spawn {
animation:
mol-deploy-spawn var(--mol-duration-spawn) var(--mol-easing-bounce-out) both;
}
/* Provisioning signal — the earlier rotating conic-gradient border
read as distracting "spinner" clutter during a 15-child org
import (dozens of them spinning simultaneously). A static dim
(reduced opacity + saturation) communicates "this one is still
coming online" without the motion noise. The locked-child style
already uses the same pattern — we reuse the filter values so
a provisioning ROOT node and a locked CHILD look consistent. */
.mol-deploy-shimmer {
filter: saturate(var(--mol-deploy-locked-saturation)) opacity(var(--mol-deploy-locked-opacity));
transition: filter var(--mol-duration-base) var(--mol-easing-standard);
}
.mol-deploy-locked {
filter: saturate(var(--mol-deploy-locked-saturation)) opacity(var(--mol-deploy-locked-opacity));
cursor: not-allowed !important;
transition: filter var(--mol-duration-base) var(--mol-easing-standard);
}
.react-flow__node.mol-deploy-root-complete {
animation: mol-deploy-root-complete var(--mol-duration-root-complete) var(--mol-easing-emphasize) both;
}
/* ────────────────────────────────────────────────────────
Edge classes — intentionally inert.
Earlier revisions painted incoming edges with a dashed-blueprint
→ animated-laser-trace effect as the child landed. User feedback
on the first demo was "remove connection line effects" — the
moving dashes read as noise during a multi-child deploy. Keeping
the class hooks so canvas-events.ts event handlers can still
apply/strip them without blowing up, but the styling is a no-op
(edges fall through to the default styling in globals.css).
If a future demo wants the effect back, wire the rules below.
──────────────────────────────────────────────────────── */
/* ────────────────────────────────────────────────────────
Cancel-deployment pill — rendered by OrgCancelButton.tsx
attached to the root node during deploy. Class `.mol-deploy-cancel`
is always applied; the pulse is additive.
──────────────────────────────────────────────────────── */
.mol-deploy-cancel {
background: var(--mol-deploy-cancel-bg);
color: var(--mol-deploy-cancel-text);
transition: background var(--mol-duration-fast) var(--mol-easing-standard);
}
.mol-deploy-cancel:hover {
background: var(--mol-deploy-cancel-bg-hover);
}
.mol-deploy-cancel-pulse {
animation: mol-deploy-cancel-pulse var(--mol-duration-parent-pulse) var(--mol-easing-standard) infinite;
}
/* ────────────────────────────────────────────────────────
Reduced-motion guard — mirror globals.css's policy so this
module stays WCAG 2.3.3 compliant without relying on the
global file being loaded first.
──────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.react-flow__node.mol-deploy-spawn,
.react-flow__node.mol-deploy-root-complete,
.mol-deploy-cancel-pulse {
animation: none !important;
}
/* Dim-light signal is already static; no override needed. */
}