forked from molecule-ai/molecule-core
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>
54 lines
1.9 KiB
TypeScript
54 lines
1.9 KiB
TypeScript
/**
|
|
* React Flow className helpers shared across the store and canvas
|
|
* hooks. React Flow's Node.className / Edge.className is a single
|
|
* space-separated string, so every call site was previously doing
|
|
* the same `.split/.filter/.join` dance — centralise it here so
|
|
* any future class manipulation follows one policy.
|
|
*/
|
|
|
|
/** Add `cls` to the existing className, de-duplicating. Returns
|
|
* the (possibly new) string; undefined/empty input → just `cls`. */
|
|
export function appendClass(existing: string | undefined, cls: string): string {
|
|
if (!existing) return cls;
|
|
const parts = existing.split(/\s+/).filter(Boolean);
|
|
if (parts.includes(cls)) return existing;
|
|
parts.push(cls);
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/** Remove `cls` if present. Returns the (possibly empty) string. */
|
|
export function removeClass(existing: string | undefined, cls: string): string {
|
|
if (!existing) return "";
|
|
return existing
|
|
.split(/\s+/)
|
|
.filter((c) => c && c !== cls)
|
|
.join(" ");
|
|
}
|
|
|
|
/** Schedule `removeClass(nodeId, cls)` on the `nodes` slice after
|
|
* `delayMs`. The callers used to inline this twice — once for
|
|
* parent-pulse cleanup, once for spawn-class cleanup — and now
|
|
* share the same impl so future one-shot animation classes land
|
|
* consistently.
|
|
*
|
|
* No-ops when `window` is undefined (SSR). Accepts the store's
|
|
* get/set pair directly rather than a store reference so it
|
|
* composes with the existing handleCanvasEvent signature. */
|
|
export function scheduleNodeClassRemoval(
|
|
nodeId: string,
|
|
cls: string,
|
|
delayMs: number,
|
|
get: () => { nodes: Array<{ id: string; className?: string }> },
|
|
set: (partial: Record<string, unknown>) => void,
|
|
): void {
|
|
if (typeof window === "undefined") return;
|
|
window.setTimeout(() => {
|
|
const state = get();
|
|
set({
|
|
nodes: state.nodes.map((n) =>
|
|
n.id === nodeId ? { ...n, className: removeClass(n.className, cls) } : n,
|
|
),
|
|
});
|
|
}, delayMs);
|
|
}
|