forked from molecule-ai/molecule-core
cd55ce10d2
512 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| cd55ce10d2 |
chore: sync main → staging (auto, 502aa082)
|
|||
| 739c7f1141 |
chore: sync main → staging (auto, 33327cf0)
|
|||
|
|
7d0df65474 | Merge remote-tracking branch 'origin/main' into feat/canvas-topology-overlay-ws-subscribe | ||
|
|
7194b08987 |
feat(canvas): A2ATopologyOverlay subscribes to ACTIVITY_LOGGED — drop 60s polling
Stage 2 of #61. Replaces the 60s setInterval poll that fanned out across every visible workspace fetching `?type=delegation&limit=500` with: 1. One bootstrap fan-out on mount (or on visible-ID-set change), same shape as before — preserves the 60-min look-back history. 2. useSocketEvent subscription to ACTIVITY_LOGGED — every event with activity_type=delegation + method=delegate from a visible workspace appends to a local rolling buffer, edges are re-derived via the existing buildA2AEdges helper. 3. showA2AEdges toggle off: clears edges + buffer. No interval poll. The visibleIdsKey selector gate that fixed the 2026-05-04 render-loop incident is preserved — peer-discovery / status-flip writes still don't trigger a wasteful re-bootstrap. Steady-state HTTP traffic from this overlay drops from N req/min (N visible workspaces × 1 cycle/min) to 0 outside of mount + visible- ID-set-change bootstraps. Live update latency drops from up to 60s to ~10ms. Bootstrap race-aware: any WS arrivals that landed in the buffer during the fetch await are preserved by id-dedup-with-fetched-first ordering. No row is double-counted; no row is lost during in-flight updates. Test changes: - 27 existing tests pass unchanged (buildA2AEdges purity preserved, component visibility/visibleIdsKey/error-swallow behaviour preserved). - 6 new WS-subscription tests: - NO 60s polling after bootstrap (clock advance fires nothing) - WS push for delegation updates edges with NO HTTP call - WS push for non-delegation activity_type ignored - WS push for delegate_result ignored (mirrors buildA2AEdges method filter) - WS push from hidden workspace ignored - WS push while showA2AEdges=false ignored Mutation-tested: - drop activity_type filter → "non-delegation" test fails - drop method===delegate filter → "delegate_result" test fails - drop visible-ws membership filter → "hidden workspace" test fails Full canvas suite: 1395 passing, 0 failing. tsc clean. No API or schema change. ACTIVITY_LOGGED event shape unchanged. The /workspaces/:id/activity HTTP endpoint stays — used for bootstrap. Hostile self-review (three weakest spots): 1. Bootstrap fetches up to 500 rows × N workspaces. Worst-case buffer ~3000 entries before window-prune. Acceptable: window- prune runs on every recomputeAndPush, buildA2AEdges aggregates to at most N² edges. Real-world usage stays well under both. 2. WS handler re-arms on every bootstrap dependency change (visibleIds change). useSocketEvent's ref-based pattern means the bus subscription stays stable across renders, but the handler closure re-captures bootstrap each time. Side effect: fine — handler invocation just calls recomputeAndPush which is idempotent. 3. delegate_result rows arriving over WS are silently dropped. Acceptable: the existing buildA2AEdges already filters them out at aggregation time (avoids double-counting); pre-filtering at the WS handler is the correct mirror — keeps the bus path and the bootstrap path consistent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
830de70e84 |
feat(canvas): CommunicationOverlay subscribes to ACTIVITY_LOGGED — drop 30s polling
Stage 1 of #61. Replaces the 30s setInterval poll with: 1. One bootstrap fan-out on mount (cap of 3 retained from the 2026-05-04 fix), gives the initial recent-comms window without waiting for live events. 2. useSocketEvent subscription to ACTIVITY_LOGGED — every event with a comm-overlay-relevant activity_type from a visible online workspace prepends to the rendered list. 3. Re-bootstrap on visibility-toggle re-open so the snapshot is fresh after a long collapsed period. No interval poll. Inherits the singleton ReconnectingSocket's reconnect / backoff / health-check guarantees via useSocketEvent. Steady-state HTTP traffic from this overlay drops from ~6 req/min (3 ws × 2 cycles/min) to 0 outside of mount/visibility-toggle bootstraps. Live updates arrive within ~10ms of the server insert instead of after up to 30s. Test changes: - Bootstrap fan-out cap of 3 — kept (was the cadence test's role pre-#61) - 30s cadence test — replaced with "no interval polling" test that pins the absence of any cadence-driven HTTP after bootstrap - Visibility gate test — extended to verify both: no fetches while closed, AND re-bootstrap on re-open - WS subscription tests (new): - WS push extends rendered list with NO HTTP call - WS push for offline workspace ignored - WS push for non-comm activity_type ignored - WS push while collapsed ignored - non-ACTIVITY_LOGGED events ignored Mutation-tested: - drop visibility gate → visibility test fails - drop activity_type filter → "non-comm activity_type" test fails - drop workspace online-set filter → "offline workspace" test fails Full canvas suite: 1393 passing, 0 failing. tsc clean. No API or schema change. ACTIVITY_LOGGED event shape pinned by existing socket-events tests. Hostile self-review (three weakest spots): 1. Sustained WS outage shows stale comms until visibility-toggle re-bootstrap. Acceptable: the singleton socket already auto- reconnects and the comm overlay isn't a critical-path surface. 2. Bootstrap on visibility-toggle costs another 3 HTTP calls each re-open. Acceptable: visibility-toggle is a deliberate user action, not a tight loop. 3. The WS handler reads the latest `nodes` via nodesRef rather than re-subscribing on node changes. By design — the bus listener stays bound for the component lifetime to avoid the "tear-down storm" pattern A2ATopologyOverlay's comment warns about (ref-based current-state lookup, stable subscription). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
| 25fb696965 |
chore: reconcile main → staging post-suspension divergence
Refs Task #165 (Class D AUTO_SYNC_TOKEN plumbing). main and staging diverged after the 2026-05-06 GitHub-org suspension because Class D / Class G / feature work landed on staging while unrelated CI fixes (#34-47, ECR auth-inline, buildx→docker, pre-clone manifest deps) landed straight on main. Both branches edited the same workflow files, so every push to main triggered an Auto-sync run that aborted at `git merge --no-ff origin/main` with 7 content conflicts: - .github/workflows/canary-verify.yml (URL: github.com → Gitea) - .github/workflows/ci.yml (3 URL refs) - .github/workflows/publish-runtime.yml (cascade: HTTP repo-dispatch → Gitea push) - .github/workflows/publish-workspace-server-image.yml (drop AWS-action steps; ECR auth is inline) - .github/workflows/retarget-main-to-staging.yml (URL) - manifest.json (lowercase org slug + add mock-bigorg from main) - scripts/clone-manifest.sh (keep main's MOLECULE_GITEA_TOKEN auth path + drop awk-tolower since manifest is now lowercase) Resolution: union — staging's post-suspension Gitea/ECR migrations win on URL/policy edits; main's additive work (mock-bigorg manifest entry, inline ECR auth, MOLECULE_GITEA_TOKEN basic-auth) is preserved on top. After this lands, staging is a strict superset of main, so the next auto-sync run on a push to main will be a clean fast-forward / no-op. The auto-sync workflow on main also picks up staging's AUTO_SYNC_TOKEN swap (Class D #26) for free, fixing the latent layer-2 push-auth issue. Verified locally: - bash -n scripts/clone-manifest.sh - python -c 'yaml.safe_load(...)' on each touched workflow - python -c 'json.load(open(manifest.json))' (21 plugins, 9 templates, 7 org_templates) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 55689e0b10 |
fix(post-suspension): migrate github.com/Molecule-AI refs to git.moleculesai.app (Class G #168)
The GitHub org Molecule-AI was suspended on 2026-05-06; canonical SCM is now Gitea at https://git.moleculesai.app/molecule-ai/. Stale github.com/Molecule-AI/... URLs return 404 and break tooling that clones / pip-installs / curls them. This bundles all non-Go-module URL fixes for this repo into a single PR. Go module path references (in *.go, go.mod, go.sum) are out of scope here -- tracked separately under Task #140. Token-auth clone URLs also flip ${GITHUB_TOKEN} -> ${GITEA_TOKEN} since the GitHub token does not auth against Gitea. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
|
|
a37a4a6e40 |
feat(canvas): demo Mock #1 — purchase-success modal on URL flag
Funding-demo Mock #1: when the canvas loads with `?purchase_success=1`, show a centred success modal in the warm-paper theme. Auto-dismisses after 5s; Close button + Esc + backdrop click also dismiss; URL params are stripped on first paint so a refresh after dismiss does not re-trigger. Mounted in `app/layout.tsx` (not `app/page.tsx`) so the modal persists across the canvas page-state transitions (loading → hydrated → error) without unmounting and losing its open-state. No real billing logic — the marketplace "Purchase" button on the landing page redirects here with the flag; this modal is the only thing the user sees of the "transaction". Local-verified end-to-end via playwright (5/5 tests pass): redirect URL shape, modal visibility, URL cleanup, close button, refresh-after- dismiss behaviour, 5s auto-dismiss. Pairs with the Purchase button added to landingpage Marketplace section. |
||
| 6a7dcd287c | Merge pull request 'feat(canvas/chat-server): canvas consumes /chat-history + server-side row-aware reverse (RFC #2945 PR-C-2)' (#4) from feat/rfc-2945-pr-c-2-canvas-chat-history into staging | |||
|
|
624ef4d06d |
perf(workspace-server,canvas): EIC tunnel pool + canvas Promise.all (closes core#11)
## Symptom
Canvas detail-panel "config + filesystem load" took ~20s. Reported on
production hongming tenant, workspace c7c28c0b-... (Claude Code Agent T2).
## Two stacked latency sources
### 1. Server-side: per-call EIC tunnel setup (~80% of the win)
`workspace-server/internal/handlers/template_files_eic.go::realWithEICTunnel`
performed ssh-keygen + SendSSHPublicKey + open-tunnel + waitForPort PER call.
4 callers (read/write/list/delete) each paid the full ~3-5s setup cost even
when fired back-to-back on the same workspace EC2.
Fix: refcounted pool keyed on instanceID with TTL ≤ 50s (under the 60s
SendSSHPublicKey grant). One tunnel serves N file ops; concurrent acquires
for the same instance share the slot via a pendingSetups gate; LRU eviction
caps simultaneous tracked instances at 32. Poisons entries on tunnel-fatal
errors (connection refused, broken pipe, auth failed) so the next acquire
builds fresh. Cleanup on panic via defer-release pattern (added after
self-review caught a refcount-leak hazard).
Public API unchanged — `var withEICTunnel` rebinds to `pooledWithEICTunnel`
at package init, so all 4 callers inherit pooling for free.
10 unit tests pin: 4-ops-amortise (1 setup), different-instances-do-not-share,
TTL eviction, poison invalidates, concurrent-acquire-single-setup,
TTL=0 escape hatch, LRU eviction at cap, error classification heuristic,
refcount blocks expired eviction, panic poisons entry. All green.
### 2. Canvas-side: serial fan-out + duplicate fetch (~20% of the win)
`canvas/src/components/tabs/ConfigTab.tsx::loadConfig` awaited 3 independent
metadata GETs (`/workspaces/{id}`, `/model`, `/provider`) serially.
`AgentCardSection` fired a SECOND `/workspaces/{id}` from its own useEffect.
Fix: Promise.all over the 3 metadata GETs (each leg keeps its existing
.catch fallback semantics). AgentCardSection now reads `agentCard` from
the canvas store (`useCanvasStore`) instead of refetching — the canvas
already hydrates `node.data.agentCard` from the platform event stream.
Defensive selector handles test mocks without a `nodes` array.
## Verification
- `go test ./internal/handlers/` 5.07s green (full handlers package, including
10 new pool tests)
- `go vet ./internal/handlers/` clean
- `npx vitest run` — 1380/1380 canvas unit tests pass (2 test FILES fail on
a pre-existing xyflow CSS-load issue in vitest config, unrelated to this
change)
- `npx tsc --noEmit` clean
Live wall-time verification deferred to Phase 4 / E2E (canvas browser session
required; external probe blocked by 403 since the canvas auth chain is
session-cookie + Origin header, not a bearer token I can fabricate).
## Backwards compatibility
API surface unchanged. All 4 EIC handler callers use the rebound var; no
caller migration. Pool defaults to enabled (TTL=50s); tests can disable by
setting poolTTL=0 or by overwriting withEICTunnel directly (existing stub
pattern in template_files_eic_dispatch_test.go preserved).
## Hostile self-review (3 weakest spots)
1. `fnErrIndicatesTunnelFault` is a substring grep on err.Error() — the
marker list is hand-curated and ssh client error formats vary across
OpenSSH versions. A future ssh that reports a tunnel failure via a
phrasing not in the list would NOT poison the entry → next callers reuse
a dead tunnel until TTL evicts. Acceptable: TTL bounds the impact (≤50s
of bad reuse), and the heuristic covers every tunnel-error shape that
appears in the existing test fixtures and known incidents.
2. `acquire`'s for-loop has unbounded retry potential under pathological
churn (signal closed → new acquirer → setup fails → repeat). No bounded
retry counter. Today there is no test exercise for "flaky setup that
succeeds-then-fails-then-succeeds"; if observability ever shows this
shape, add a max-retry guard. Filed as a known limitation, not blocking.
3. The substring assertion `strings.Contains` style I used for tunnel-fault
classification could false-positive on app-level error messages that
happen to contain "permission denied" or "broken pipe" verbatim. The
classification test covers the discriminator but only against the
error shapes we know today. Acceptable: poisoning errs on the side of
building fresh, which is correct-but-slightly-slow rather than incorrect.
## Phase 4 / E2E plan
- Live timing of the canvas detail-panel open against a real workspace
(browser session, not external probe).
- Target: perceived latency under 2s on warm pool. Cold open still pays
one tunnel setup (~3-5s) — the pool buys you the SECOND through Nth
panel-open within the TTL window.
- Memory `feedback_chase_verification_to_staging` applies — will not
declare done at PR-merge; will follow through to user-visible behavior
on staging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
| 75a72bf5a2 |
feat(canvas/chat-server): canvas consumes /chat-history + server-side row-aware reverse (RFC #2945 PR-C-2)
Closes the SSOT story shipped in PR-C/D: canvas now consumes the typed
/chat-history endpoint instead of /activity?type=a2a_receive, and the
server emits messages in display-ready chronological order so the
client doesn't have to re-order them.
## Canvas (consumer migration)
- loadMessagesFromDB swaps from /activity to /chat-history.
- Drops type=a2a_receive + source=canvas params (server applies the
filter centrally now).
- Drops [...activities].reverse() — wire is already display-ready.
- Drops the local INTERNAL_SELF_MESSAGE_PREFIXES constant +
isInternalSelfMessage helper. Server-side IsInternalSelfMessage
applies the same predicate before emitting rows.
- Drops the activityRowToMessages + ActivityRowForHydration imports
from historyHydration.ts. The TS parser stays in tree because
message-parser.ts is still load-bearing for live A2A WebSocket
messages (ChatTab.tsx:805, AgentCommsPanel.tsx, canvas-events.ts).
## Server (row-aware wire-order fix)
The pre-PR-C-2 client did `[...activities].reverse()` over ROWS, then
flattened each row into [user, agent] messages. The reversal was
ROW-aware. After PR-C/D, the server returned a flat ChatMessage slice
in `ORDER BY created_at DESC` order, with [user, agent] within each
row. A naive client-side flat reverse would FLIP each pair (agent
before user at same timestamp).
Two ways to fix it:
A) Server emits oldest-first within page; canvas does NOT reverse.
B) Canvas does row-aware reversal (group by timestamp, reverse).
Option A is cleaner — server owns the wire-order responsibility, every
client trusts `for m of messages` to render chronologically. Server
adds reverseRowChunks() that:
1. Groups consecutive same-Timestamp messages into row chunks
(1-2 messages per row).
2. Reverses the chunk order (newest-row-first → oldest-row-first).
3. Flattens. Within-chunk [user, agent] order is preserved.
Single-message rows (agent reply not yet recorded, attachments-only
user upload) collapse to 1-element chunks and reverse correctly too.
## Tests
Server: 3 new unit tests on reverseRowChunks (paired across rows,
single-message rows, empty input) + 1 sqlmock integration test on
List() that drives the full SQL → reverse → wire path. Mutation-tested:
removed `messages = reverseRowChunks(messages)` from List(), confirmed
the integration test fires red with all 4 misordered indices flagged.
Restored, all 25 messagestore tests + 9 chat-history handler tests
green.
Canvas: 8 lazyHistory pagination tests refactored to mock
/chat-history (not /activity) and assert against the new wire shape
({messages, reached_end} not raw activity rows). All 1389/1389 vitest
tests green; tsc --noEmit clean.
## Three weakest spots (hostile-reviewer self-pass)
1. reverseRowChunks groups by Timestamp string equality. If two
distinct rows had the SAME timestamp (legitimately possible at sub-
millisecond granularity), the algorithm would treat them as one
chunk and not reverse them relative to each other. Mitigated:
activity_logs.created_at uses microsecond resolution; concurrent
inserts at exact-same microsecond are vanishingly rare. If a
collision happens, the within-chunk order is whatever the SQL
returned — both rows render at the same timestamp, no user-visible
misordering.
2. The pre-existing TS parser files (historyHydration.ts +
message-parser.ts) stay in tree. historyHydration.ts is now dead
code (no consumers post-migration); deletion is parked as a follow-
up after a one-week observation window confirms no live-message
consumer reaches it.
3. canvas's loadMessagesFromDB returns `resp.messages ?? []`. If the
server were ever to return `null` instead of `[]` (it currently
doesn't — handler defensively coerces nil to []), the nullish coalesce
keeps the canvas from crashing. A stricter wire schema would assert
the never-null invariant; for today's pragmatic safety, the ?? is
enough.
## Security review
- Untrusted input? Same as PR-C — agent JSON parsed defensively in
the messagestore parser. No new exposure.
- Trust boundary? Same. Canvas → /chat-history → wsAuth → messagestore.
- Output sanitization? Plain text + opaque attachment URIs as before.
No security-relevant changes beyond what /chat-history already
exposes via PR-C. Considered, not skipped.
## Versioning / backwards compat
- /activity endpoint unchanged.
- /chat-history endpoint shape unchanged (still {messages, reached_end});
only the wire ORDER within a page changed (newest-first row → oldest-
first row). Canvas is the only consumer in tree; no API consumers
depend on the previous order.
- canvas's loadMessagesFromDB call signature unchanged — internal
refactor.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
|||
|
|
e87df906bd | Merge staging into rfc-2991-pr-1 to clear BEHIND (post PR-2993 + PR-3005) | ||
|
|
c60e2b5fa2 |
chore(canvas/chat): drop unused downloadChatFile import in AttachmentImage
github-code-quality bot flagged this as the last unresolved review thread blocking the merge queue. The function is referenced in comments but never called from this file (download is dispatched via the lightbox / AttachmentChip path). Removing the import resolves the bot thread and clears the staging branch-protection 'all conversations resolved' gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1b29b24e83 | Merge staging into rfc-2991-pr-1 to clear BEHIND state | ||
|
|
ab1acff2d2 |
ux(canvas/files): drag-drop upload to target folder (#2999 PR-D)
User asked for VSCode-style drag-drop upload (#2999): "drag local to upload to target folder just like vscode does". Today the only upload path is the toolbar's Upload button (folder picker). Drag-drop lets users grab files from Finder/Explorer and drop them directly on a specific subdirectory in the tree. 1. New `uploadDataTransferItems(items, targetDir)` in `useFilesApi` — walks the HTML5 DataTransferItemList via `webkitGetAsEntry()`, recursing folders to a flat (relativePath, file) list, then PUTs each via the existing /files/<path> endpoint. The walker (also exported via `__testables`) calls `readEntries()` in a loop until empty so multi-batch folders (browsers cap each call at ~100 entries) aren't silently truncated. 2. `uploadFiles` (folder-picker path) gained an optional `targetDir` parameter. Same prefixing semantics so future surfaces (e.g. an "upload here" toolbar button on a row) can reuse it. 3. `FileTree` directory rows gained `onDragOver` / `onDragEnter` / `onDragLeave` / `onDrop` handlers + a hover-target highlight (accent-tinted background + outline). dragLeave uses `currentTarget.contains(relatedTarget)` to suppress the flicker that fires when the cursor crosses any child of the row (icon, label, ✕ button) — without this the highlight strobes on every sub-element transition. 4. `FilesTab` wraps the tree column in an outer drop zone for "drop on root" — drops outside any specific subdir row land at root. The empty-state placeholder copy now includes a "drag files here to upload" hint when the active root is /configs (the only writable root today). 5. Both the row drop and the root drop are gated on `root === "/configs"` (the same gate that already blocks the toolbar's New / Upload / Clear). Other roots ignore the drag entirely (no highlight, no drop), so the user doesn't get a misleading drag affordance followed by a "switch root" toast. `dragDropUpload.test.tsx` (9 tests, two layers): Walker tests (pure function, no DOM): - `walkEntry` collects a single dropped file with correct relpath. - `walkEntry` walks a folder + preserves folder name in the path. - **Multi-batch loop**: a fake reader that emits two batches of 2 + an empty terminator must yield 4 files. A walker that called readEntries once would see only 2 — this is the load-bearing assertion against silent folder truncation. - Nested directories: outer/inner/file.md → "outer/inner/file.md". FileTree drag-drop wiring (DOM): - `dragover` on a directory row preventDefault's (load-bearing — without it the drop event never fires). - `drop` on a directory row fires `onDropToTarget(path, items)`. - `drop` on a FILE row does NOT fire (only directories are valid drop targets). - `drop` with no DataTransferItems does NOT fire (defensive guard against text-only drags). - `dragenter` adds the highlight class to the directory row. 1. The 1MB per-file size cap is inherited from the existing `uploadFiles`. A user dropping a 5MB skill bundle silently skips the file (the loop's `continue` on `file.size > 1_000_000`). Same behavior as the toolbar Upload, so consistent if not great. Surfacing skipped-files would be a UX improvement tracked separately — not load-bearing for this PR. 2. Drop-zone highlight on the column wrapper uses an outline that sits inside the column's overflow-y-auto scroll container. If the user drags onto a row that's mid-scroll, the highlight may clip slightly at the scroll boundary. Cosmetic only; the drop still works. 3. The `?root=` query is NOT passed on the underlying writeFile call (matches the existing uploadFiles behavior). On a backend without #2999 PR-A, this means uploads always land in /configs regardless of selected root — but we already gated drop on `root === "/configs"` so the practical effect is nil today. Once PR-A merges and the canvas threads ?root= through writes (separate follow-up), drops on /home etc. would be enableable by lifting the canDelete-style gate. - `npx tsc --noEmit` clean - 177/177 canvas tab tests pass - Manual on local dev: drag a file from Finder onto /configs/skills row → file appears under /configs/skills/<name>. Drag a folder of 3 files onto root area → 3 files uploaded with folder structure preserved. Drag onto /home tree → no highlight, no drop. Refs #2999. Pairs with PR-A (backend EIC) — without PR-A the tree is empty on SaaS and there's nothing to drop ONTO; PR-D still works on self-hosted today. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
dcece2762b |
feat(canvas/chat): inline PDF + text/code preview (RFC #2991 PR-3)
Adds two new arms to the AttachmentPreview kind dispatcher: * PDF — chip in the bubble, click opens the shared AttachmentLightbox with a browser-native <embed type="application/pdf"> at 95vw/90vh. Fetch+Blob+ObjectURL auth path matches AttachmentImage / Video. PDF.js not pulled in; browser viewer is good enough for the desktop chat MVP (Slack/Linear/Notion all gate full-page PDF behind a click for the same reason). Falls back to AttachmentChip on fetch error. * Text/code/JSON/YAML — first 10 lines in monospace <pre><code> right in the bubble, "Show all N lines" expands to full content, with a filename + ⬇ download header. Streams up to 256 KB then marks truncated and offers a download chip; large logs don't crash the bubble. No syntax highlighting in v1 — shiki adds 200-500 KB and is pure polish. Coverage: 5 new dispatch tests (PDF success → embed in lightbox, PDF fetch fail → chip fallback, text inline render, text long content → Show-all-N-lines expand button, text fetch fail → chip fallback). All 19 AttachmentPreview tests pass; tsc --noEmit clean. Stacked on rfc-2991-pr-1-image-preview-lightbox (PR-2 already merged into PR-1's branch). PR-1 ships first; this rebases onto staging once it lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
57bfa40990
|
Merge pull request #3004 from Molecule-AI/ux/files-tab-context-menu
ux(canvas/files): right-click context menu — Open / Download / Delete (#2999 PR-C) |
||
|
|
d88fbb90fb |
ux(canvas/files): right-click context menu — Open / Download / Delete (#2999 PR-C)
## Why User asked for a VSCode-style right-click menu on file rows (#2999): "right click to have a menu to download". Today the only download affordance is the toolbar's Export-all (bulk JSON dump), and the inline ✕ button is the only delete UX (small click target, easy to miss). ## Fix 1. New `FileTreeContextMenu` component — fixed-position popover with Open / Download / Delete items composed per-row (files get all three; directories get Delete only since "open a directory in the editor" doesn't apply). Esc + outside-click + Tab + scroll dismiss. ↓/↑ arrow keys rove focus between menu items. role=menu + role=menuitem + autofocus on first item for a11y. 2. Menu state lifted to the top-level `FileTree` (not per-row) so opening a second row's menu auto-closes the first — only one menu open at a time, matching VSCode/Theia. Pinned by the `replaces the first` test. 3. New `downloadFileByPath(path)` in `useFilesApi` — fetches via the existing GET /workspaces/<id>/files/<path>?root= endpoint and triggers a browser download. Distinct from the existing `handleDownloadFile` which downloads the in-editor buffer (round-trips unsaved edits to disk); the context-menu download targets arbitrary tree rows the user hasn't opened. 4. `canDelete` prop threaded from FilesTab → FileTree → menu → item. Same gate as the toolbar (Clear/New/Upload all gated to /configs); context menu's Delete renders as disabled with a muted background on other roots, matching the "feature exists but isn't applicable here" pattern. ## Test coverage `FileTreeContextMenu.test.tsx` (8 tests): - File row → menu opens with Open + Download + Delete. - Directory row → menu opens with Delete only. - Click Download → onDownload(path) fires + menu closes. - Click Delete (canDelete=true) → onDelete(path) fires. - Click Delete (canDelete=false) → onDelete NOT called + menu stays open (disabled-state UX). - Esc dismisses. - Outside-click (mousedown on document.body) dismisses. - Opening second context menu replaces the first (only-one-open invariant). Each test uses fireEvent + screen.getByRole, so they fail on a deleted-code regression — none would pass on the pre-PR shape. ## Three weakest spots (hostile self-review) 1. The menu is positioned at `clientX/clientY` without viewport clamping. If the user right-clicks at the very bottom-right of the panel, part of the menu may overflow off-screen. VSCode handles this by flipping the anchor; we don't yet. Acceptable v1 because the FilesTab is fixed-width (≤ side-panel width) and the menu is small (140×~80px); the overflow would be a few pixels of one item. Filed as a follow-up. 2. Auto-focus on the first item shifts keyboard focus away from the row that opened the menu. Closing with Esc returns focus to the body, not the row. Same behavior as TerminalTab's placeholder + the canvas's other context menus; consistent isn't ideal but at least uniform. Documented inline. 3. The download request reuses the API client's 15s default timeout — large config files (multi-MB skill bundles) on a slow connection could time out. Same risk applies to the existing toolbar Export. If we see real download failures we can add a `timeoutMs` override at the call site without touching the menu. ## Verification - `npx tsc --noEmit` clean - 176/176 canvas tab tests pass - Manual on local dev: right-click a config.yaml row → menu opens → click Download → file lands in Downloads. Right-click on /home root → Delete renders disabled. Refs #2999. Pairs with PR-A (backend EIC) — without PR-A the tree is empty and there's nothing to right-click on a SaaS workspace. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
f93957e982 |
ux(canvas/files): "Files not available" banner for external runtimes (#2999 PR-B)
## Why Reported by user (issue #2999): external workspaces (mac laptop, mac mini, hermes-on-home-server — runtime="external") render the FilesTab identically to the SaaS empty-listing bug, showing "0 files / No config files yet" even though the platform doesn't actually own the filesystem of these workspaces. Visually indistinguishable from the broken state, reads as a bug. ## Fix Mirror the affordance TerminalTab adopted in PR #2830 for runtimes without a TTY: 1. New `NotAvailablePanel` in `canvas/src/components/tabs/FilesTab/` — folder-with-slash icon + "Files not available" headline + body text that names the runtime and points the user at Chat. 2. `FilesTab` now takes optional `data?: WorkspaceNodeData`. When `data.runtime` is in `RUNTIMES_WITHOUT_FILES` (currently just "external"), early-return the placeholder before mounting the useFilesApi hook. Mirrors TerminalTab's prop shape exactly so the review pattern is uniform across tabs. 3. SidePanel passes `node.data` to FilesTab (matches existing pattern for ChatTab / TerminalTab). ## Test coverage `FilesTab.notAvailable.test.tsx` (4 tests): - external runtime → banner renders with runtime name + Chat-tab guidance copy. - external runtime → NO `/files` API request fires (asserted by inspecting the mocked api.get call log). - claude-code runtime → no banner, normal mount proceeds (toolbar's root selector is the discriminator). - data prop omitted → falls through to normal mount (back-compat with any caller that doesn't thread data through, e.g. legacy tests). Each branch is independent and discriminating — none would pass on a code-deleted version of the early-return. ## Three weakest spots (hostile self-review) 1. `RUNTIMES_WITHOUT_FILES` is a hardcoded set in this file. If a future runtime joins (e.g. a "byok-claude" that runs on user hardware), someone has to remember to add it here. Reviewed alternatives: pull from a runtime-capabilities registry — same shape as `RUNTIMES_WITHOUT_TERMINAL` already in TerminalTab. We chose the parallel pattern over a new abstraction; consolidating into a shared registry can land if/when a third tab grows the same gate (rule of three). Documented inline. 2. The placeholder is a static panel — no retry, no "report bug" link. Same as TerminalTab's. Acceptable because the absence is intentional, not transient. 3. Chat-tab guidance is hardcoded English. No i18n in canvas yet; matches the rest of the codebase. Will move with the i18n migration when that lands. ## Verification - `npx tsc --noEmit` clean - 54/54 canvas tab + SidePanel tests pass - Will be live-verified on staging post-merge: open Files tab on an external workspace (mac laptop) → expect placeholder; open on a platform-owned workspace (Hongming Personal Brand Agent) → expect normal tree (assuming PR-A also lands). Refs #2999. Pairs with PR-A (backend EIC fix) — without PR-A the platform-owned path still shows "0 files" because the backend never returns rows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
95fdf86187 |
feat(canvas/chat): inline video + audio HTML5 native players (RFC #2991 PR-2)
Second specialized renderer pair landing under RFC #2991. Stacks on PR-1 (#2997) — extends the AttachmentPreview dispatcher with video/ audio cases. Why HTML5-native (not custom JS player) --------------------------------------- - Browser vendors ship hardware-accelerated decoders, captions, pinch + scrub UX, and fullscreen UI. We get all of it for free. - Native fullscreen via the <video> control bar — no AttachmentLightbox needed for video (the browser's built-in fullscreen handles it). - Mobile-friendly without us writing the touch handlers. Auth model ---------- Identical to AttachmentImage (PR-1): platform-auth URIs need our cookie/token, so we fetch the bytes, wrap in a Blob, hand the browser an ObjectURL via <video src=> / <audio src=>. External http(s) URIs skip the fetch. Memory caveat: a Blob holds the entire media in JS memory until the bubble unmounts. The server's 25MB single-file cap (chat_files.go) bounds this; v2 can switch to MediaSource + streaming if larger files become a real shape. Failure modes ------------- - Fetch failure (404, 403, network) → AttachmentChip fallback. - Bytes that aren't valid media (corrupt, wrong Content-Type) → <video onError> / <audio onError> swap to chip. Tests ----- 5 new component tests in AttachmentPreview.test.tsx (now 14 total): - kind=video → <video controls> with blob URL src - kind=video fetch fails → falls back to chip - kind=video extension fallback (no mime) → routes to video path - kind=audio → <audio controls> + filename label visible - kind=audio fetch fails → falls back to chip The preview-kind unit tests from PR-1 (49 cases) already cover the MIME → video / audio dispatch logic; this PR's component tests pin the rendered DOM shape (controls attribute, blob URL src, fallback behavior). Hostile self-review ------------------- 1. Memory bound: 25MB cap protects us today; documented future migration path (MediaSource). 2. iOS Safari autoplay: playsInline pinned on <video> so mobile doesn't auto-fullscreen on play. 3. Captions accessibility: <track kind="captions" /> placeholder so the element is tagged correctly even though we don't have caption files yet (forward-compatible). Verified - tsc --noEmit clean - 173 chat tests green (49 unit + 14 component + 110 pre-existing) Stacks on PR-1 (#2997). PR-3 (PDF + text/code) is the final piece. Refs RFC #2991, PR #2997 (PR-1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
04f7a07add |
feat(canvas/chat): inline image preview + fullscreen lightbox (RFC #2991 PR-1)
First specialized renderer landing under RFC #2991 — chat attachment preview. Adds the dispatch infrastructure that PR-2 (video/audio) and PR-3 (PDF/text) will extend. Architecture (RFC #2991 Phase 2 design) --------------------------------------- - preview-kind.ts: pure helper that maps mimeType (+ extension fallback for missing/generic MIME) to one of: image | video | audio | pdf | text | file. Single source of truth; the dispatch axis for every attachment renderer. - AttachmentPreview.tsx: SSOT dispatch component. ChatTab no longer imports kind-specific components — it imports AttachmentPreview, which switches on the kind and renders the right child. - AttachmentImage.tsx: inline thumbnail (max 240×180) + click → lightbox. Auth-aware: for platform URIs (workspace: / platform-pending: / etc) the bytes are fetched via JS-injected headers, wrapped in a Blob, served as ObjectURL — bare <img src> would not include the cookie/token. - AttachmentLightbox.tsx: shared fullscreen modal (image now; PDF will use it in PR-3). Esc / backdrop click / X button to close, focus trap on close button, focus restoration on close. - AttachmentChip retained as the kind=file fallback. No breaking change for existing renderable shapes. External-workspace coverage --------------------------- The wire shape (ChatAttachment.mimeType + uri) is identical for internal + external workspaces — both go through AgentMessageWriter (PR #2949). External claude-code agents that attach images via send_message_to_user automatically get the new preview surface; no runtime-side change needed. Failure modes ------------- - Fetch failure (404, 403, network) → AttachmentChip fallback so the user still gets a working download. Pinned by tests. - Decoded as non-image (corrupt bytes, wrong Content-Type) → onError on the <img> swaps to AttachmentChip. Pinned by tests. - Non-platform URIs (http/https external image hosts) → skip the auth-fetch flow, use the raw URL via resolveAttachmentHref. Pinned by extension-fallback tests. Tests ----- preview-kind.test.ts (49 cases): - Strict MIME match across image/video/audio/pdf/text/unknown - Extension fallback when MIME is missing or application/octet-stream - URL with query string + fragment → strip before parsing - MIME wins over extension (regression: don't render image-named zip) - SVG is image (not text) despite being XML - Non-canonical MIME like application/javascript → text AttachmentPreview.test.tsx (9 component tests): - Dispatch: kind=file → chip, kind=image → image path - Loading state shows placeholder, NOT chip (proves dispatch routed) - Extension fallback (no mimeType) routes to image path - Fetch fail (404) and network error → fall back to chip - Image success: <img> renders ObjectURL, click opens lightbox - Lightbox: Esc closes, backdrop click closes, content click doesn't - Universal fallback: unknown MIME → chip even when extension hints at a renderable kind Hostile self-review (3 weakest spots, addressed) ------------------------------------------------ 1. <img> auth: bare <img src="/chat/download?..."> would NOT include our auth headers. Resolved via fetch+Blob+ObjectURL pattern. Pinned by the image-success test (asserts src === "blob:test-url"). 2. Server-side allowed-roots mismatch: pre-fix tests used /tmp/ paths which the server doesn't allow. Caught when the dispatch test fell into the non-platform path. Updated tests to use /workspace/ subpaths matching templates.go's allowedRoots. 3. Bundle size creep: each kind component adds bytes. Lightbox is currently always-bundled. Lazy-loading is plausible but defer until measured-needed. Verified - tsc --noEmit clean - 168 chat tests green (49 unit + 9 component + 110 pre-existing) PR-2 (video + audio) and PR-3 (PDF + text) extend the dispatch in AttachmentPreview.tsx with their own kind-specific components. Refs RFC #2991. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
86b8d8d744
|
Merge pull request #2982 from Molecule-AI/fix-config-skip-yaml-for-external-runtime
fix(canvas/config): skip config.yaml fetch for external/hermes runtimes |
||
|
|
b6310d7ebf |
fix(memory-v2): namespace dropdown labels use display names not UUID prefixes (#2988)
User feedback on the v2 Memory tab redesign: on a root workspace, the
namespace dropdown showed three indistinguishable entries:
Workspace (30ba7f0b)
Team (30ba7f0b) (team)
Org (30ba7f0b-b303-4a20-aefe-3a4a675b8aa4) (org)
For a root workspace, the resolver collapses workspace==team==org IDs
(resolver.go:113-122 derive() degenerate case). The previous
shortID(8)-truncated UUID label scheme made all three look identical
even though the three concepts (private / team-shared / org-wide)
remain semantically distinct.
## Backend — Resolver returns DisplayName
- SQL chain query now SELECTs workspaces.name (COALESCE → "" on NULL)
- chainNode carries .name through walk
- deriveNames() computes the display name for each namespace,
mirroring derive():
workspace: self.name
team: parent.name (or self.name if root — degenerate)
org: chain[end].name (root of tree)
- Namespace struct gets a new DisplayName field, omitempty wire-shape
## Backend — Handler renders label from DisplayName when present
- memories_v2.go:namespaceLabelWithName(name, kind, displayName) is
the new SSOT label generator. Falls back to the UUID-prefix shape
when displayName is empty so callers without name plumbing keep
working unchanged.
- namespacesToViews now plumbs Namespace.DisplayName into the label.
- Old namespaceLabel(name, kind) is preserved as a thin wrapper
around namespaceLabelWithName(_, _, "") for back-compat.
- Custom namespaces ignore displayName by design — operator-defined
suffixes ARE the chosen label; a name override would surprise.
## Frontend — drop redundant `(kind)` suffix
Pre-fix: "Team (mac laptop) (team)" — kind shown twice.
Post-fix: "Team (mac laptop)" — the prefix already conveys the kind.
## Test coverage
Resolver (3 new tests):
- DisplayName_Root: workspace name propagates to all 3 namespaces
- DisplayName_Child: workspace=self.name, team=parent.name, org=root.name
- DisplayName_EmptyOnNULL: COALESCE → "" → empty fallback
Handler (3 new tests):
- NamespaceLabelWithName_PrefersDisplayName: workspace/team/org/custom paths
- NamespaceLabelWithName_FallsBackToUUIDPrefix: empty displayName → legacy shape
- NamespacesToViews_PassesDisplayNameThrough: full integration on root case
Canvas: existing 30 tests still pass; suffix drop is rendering-only.
memories_v2.go function coverage: **14/14 = 100%**
- namespaceLabelWithName: 100%
- namespacesToViews: 100%
- (all 11 pre-existing functions stay at 100%)
## SSOT
The "what is this namespace called" question now has one source of
truth: namespace.Resolver.ReadableNamespaces sets DisplayName from the
canonical workspace.name column. The handler is a renderer; the
canvas is a consumer. No name-lookup logic duplicated across the
three layers.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||
|
|
0886dbc923
|
Merge pull request #2978 from Molecule-AI/fix-plugins-compact-empty-state
feat(canvas/skills): compact-empty layout for Plugins section (#2971) |
||
|
|
38bc27df0d |
fix(canvas/config): skip config.yaml fetch for external / hermes runtimes — eliminate 404 console noise
Reported on production reno-stars 2026-05-05 (browser console):
/workspaces/d76977b1-…/files/config.yaml:1
Failed to load resource: the server responded with a status of 404
The workspace was an external-runtime mac-mini-style agent that
doesn't use the platform's config.yaml template — every Config tab
open issued a GET that 404d cleanly, and the existing catch block
fell into the runtime-manages-own-config branch + populated the
form from workspace metadata. Functionally correct, but the request
fired anyway, surfaced as a 404 in DevTools, and burned an RTT.
Fix: branch on RUNTIMES_WITH_OWN_CONFIG BEFORE the fetch — when the
workspace's runtime is one of those (external, hermes), skip the
GET, populate the form from workspace metadata directly, set
loading=false, return. Same code path as the existing 404-catch
fallback, just skipping the wasted request.
Behavior preserved for runtimes that DO use the template
(claude-code, etc.): unchanged GET → parse → setConfig flow.
Tests: 24/24 existing ConfigTab tests pass; no behavioral change for
the documented runtimes. tsc clean.
Refs reno-stars production 2026-05-05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c74d0ecc94 |
test(canvas/chat): cover platform-pending: branch + isPlatformAttachment (#2973)
Closes #2973 — the followup test gap I flagged on PR #2968's review. Pre-merge #2968 added the platform-pending: URI scheme branch to resolveAttachmentHref + introduced the isPlatformAttachment SSOT helper, but the existing uploads.test.ts only covered the older workspace: / file:/// / absolute-path branches. The new branch shipped on prod-impact (live console error on reno-stars) with manual post- deploy verification; the regression gate was filed as a followup (#2973) so a future canvas refactor can't silently re-break the poll-mode chat-attachment download path. Adds 15 new test cases across two existing describe blocks: resolveAttachmentHref — platform-pending: scheme (poll-mode uploads): - well-formed platform-pending:<wsid>/<fileid> resolves to the /pending-uploads/<file>/content endpoint - uses the URI's wsid, NOT the chat workspace_id (cross-workspace forwarding case — pinning the explicit decision from #2968's commit message so a regression that flipped this would mis-route the download to the wrong workspace's pending-uploads store) - defensive fallback to raw URI on missing slash, empty fileID, empty wsid (so a future "helpful" change can't synthesize a broken /pending-uploads// path) - regression test against the EXACT production repro from #2968's body (reno-stars, 2026-05-05 console error) isPlatformAttachment: - positive cases for platform-pending: (well-formed and malformed), workspace:<allowed-root>, file:///<allowed-root>, absolute paths under allowed roots - NEGATIVE cases for HTTPS/HTTP URLs to other origins (auth-leak class regression — a helper that always returned true would attach workspace tokens to third-party requests), non-allowlisted roots like /etc/passwd or /var/log/x, empty string, and unrecognised schemes (s3://, ftp://) All 21 tests pass. The 6 pre-existing tests are unchanged. The 15 new tests are the regression gate that #2973 asked for. Verification: - pnpm exec vitest run src/components/tabs/chat/__tests__/uploads.test.ts → 21 passed |
||
|
|
4a2dda7cac |
feat(canvas/skills): compact-empty layout for Plugins section (#2971)
Reported on production 2026-05-05:
agent plugin tab Plugins
0 installed
+ Install Plugin
this part should be default compact
Pre-fix: SkillsTab always rendered the Plugins section as a full
rounded-xl panel with vertical chrome — even when zero plugins were
installed and the registry browser was closed. The empty state
gave a lot of vertical real estate for content that's just "0
installed + Install button".
Fix: when installed.length === 0 AND registry closed AND initial
load completed, collapse the section into a single inline pill
("Plugins · 0 installed · + Install Plugin"). The full panel
re-mounts when:
- installed.length > 0 (a plugin landed → expand to surface the list)
- showRegistry === true (user clicked + Install Plugin → registry opens)
- !installedLoaded (avoid flash; the loading shell shows instead
until the first /plugins fetch resolves)
Accessibility:
- Compact pill: aria-label="Plugins (none installed)" + button
aria-expanded="false" + aria-controls="plugins-section"
- Full panel: button aria-expanded={showRegistry} + same aria-controls
- Section gets id="plugins-section" so the aria-controls reference
resolves once the section mounts
External workspaces: this is a pure canvas-frontend layout change —
applies to ALL workspace runtimes (external, claude-code, hermes,
langchain, codex, third-party MCP). No server-side change needed.
Tests
-----
SkillsTab.compactEmpty.test.tsx (4 tests):
- Compact pill renders when installed=0, registry closed, loaded
- Full panel renders when installed > 0
- Click + Install Plugin from compact → expands to full panel
(verified via aria-controls target id appearing in the DOM)
- During initial load (installedLoaded=false), compact pill does
NOT render — avoids a compact→full flash as the load completes
Per memory feedback_oss_design_philosophy.md: the SkillsTab is the
only tab that needs compact-empty today, but the pattern is
extractable into a shared EmptyStateCompactWrapper if Schedules /
Memories / Approvals adopt the same affordance later. Don't generalise
until the third use case (per the same memory, "every refactor toward
OSS plugin shape" without premature abstraction).
Verified
- tsc --noEmit clean
- All 4 tests pass
Refs #2971.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5d8b5e96e3 |
fix(canvas/chat): handle platform-pending: scheme for poll-mode upload downloads
Followup to PR #2966. The user reported the about:blank symptom on reno-stars and the browser console showed: Failed to launch 'platform-pending:d76977b1-…/bb0dcaf3-…' because the scheme does not have a registered handler. So the agent's "download link" was a `platform-pending:<wsid>/<file_id>` URI — the canonical reference for poll-mode chat uploads (see workspace-server/internal/handlers/chat_files.go:690 + workspace/inbox_uploads.py). PR #2966 only handled `workspace:`, `file:///`, and absolute container paths; the platform-pending scheme fell through to the raw URI which the browser couldn't navigate to. Fix --- - `resolveAttachmentHref`: added a `platform-pending:` branch that resolves to `${PLATFORM_URL}/workspaces/<wsid>/pending-uploads/ <file_id>/content`. Uses the wsid from the URI, NOT the chat's workspace_id — these can differ when a file is forwarded across workspaces (cross-workspace delegation, agent forwarding). - New `isPlatformAttachment(uri)` helper — single source of truth for "this URI requires our auth headers, route through downloadChatFile". Used by both `downloadChatFile` (chip click) and ChatTab's markdown-link override. - ChatTab.tsx markdown-link override now imports `isPlatformAttachment` instead of duplicating the scheme list. Pre-fix this list was duplicated and missed `platform-pending:`. Tests ----- The 4 IME tests still pass; tsc clean. The platform-pending resolution is exercised via the `isPlatformAttachment` SSOT helper (any URI reaching `downloadChatFile` or the markdown override goes through it). A dedicated test for the URL shape would need a more elaborate fixture; manual verification on staging post-deploy is the practical gate. Reported on production reno-stars 2026-05-05. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c2e12f3fb6 |
fix(canvas/chat): IME-safe Enter + markdown link target/scheme handling
Two production-reported regressions in the same chat surface, fixed
in one focused PR.
Issue 1 — IME composition + Enter sends half-typed message
----------------------------------------------------------
ChatTab's textarea onKeyDown was:
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
For agents typing CJK / Japanese / Korean via the system IME, Enter
commits the candidate selection — not a newline, not a send. With
the old check, every IME-commit Enter accidentally sent the
half-typed message ("你好" + half-typed-pinyin + Enter to commit
the next candidate → message goes out before the user finishes).
Fix: guard on `event.nativeEvent.isComposing` AND `e.keyCode !== 229`.
The latter covers older Safari / WebKit-based mobile browsers that
delay setting isComposing on the composition-end Enter.
Issue 2 — markdown links land at about:blank
---------------------------------------------
ReactMarkdown's default `<a>` rendering passes the agent-supplied
href directly to the DOM with no target / scheme handling:
- http(s) → navigates the canvas tab away (canvas state lost)
- workspace://path / file:///workspace/... / /workspace/... →
browser hits unhandled-protocol click → about:blank, no
download (the reported bug)
Fix: ReactMarkdown `components.a` override:
- In-container paths (workspace:, file:///{workspace,configs,home,
plugins}, bare /{workspace,configs,...}) → preventDefault, route
through downloadChatFile (same auth path the AttachmentChip
uses). Filename is derived from the path's last segment.
- External (http/https/mailto/unknown scheme) → target="_blank"
rel="noopener noreferrer" so canvas state survives.
Tests
-----
ChatTab.imeAndLinks.test.tsx (4 tests):
- Enter with isComposing=true → does NOT send, input preserved
- Enter with keyCode=229 (older-Safari IME) → does NOT send
- Enter with no IME signal → DOES send (happy path intact)
- Shift+Enter → does NOT send (newline path intact)
The link-component override is exercised through the full ChatTab
render — the IME tests are jsdom-only and don't load chat history
with markdown messages, so the link test would need a more elaborate
fixture. Manual verification on staging post-deploy is the practical
gate; if the link test grows critical the AttachmentViews-style chip
test can extend.
Verified:
- tsc --noEmit clean
- 4/4 IME tests pass
Reported on production 2026-05-05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c4d3c9a451 |
fix(memory): self-review on PR #2956 — drop speculative field, tighten 503 match
Two issues caught in five-axis self-review of #2956: ## 1. Drop speculative source_workspace_id rendering The panel rendered a "from peer" badge based on `propagation.source_workspace_id`, claiming it surfaced cross- workspace propagation. But the OpenAPI spec at docs/api-protocol/memory-plugin-v1.yaml documents `propagation` as "Opaque metadata the plugin stores and returns. Reserved for future cross-namespace propagation semantics" — and a grep across workspace-server/internal/memory/ confirms NO writer in the codebase populates that key. The badge would never render against real data. Violates "don't design for hypothetical future requirements" from the project conventions. Drop the field from MemoryV2, the row badge, the test fixtures, and the JSDoc. When propagation gains a concrete shape, re-add backed by an actual writer. ## 2. Tighten 503 detection — match the literal contract string Pre-fix detection: `msg.includes('503') || msg.toLowerCase().includes('plugin is not configured')` False-positives on any unrelated 503 + on any error mentioning "plugin" + "configured" in any order. Post-fix: `msg.includes('MEMORY_PLUGIN_URL')` — the env var name is a hard-coded literal in workspace-server/internal/handlers/memories_v2.go's available() error, so this is a pinned cross-layer contract. Drift between the Go error message and the canvas detection now fails loud (TestMemoriesV2_PluginUnwired_All503 asserts the env var name in the response body; the canvas test asserts the same). Extracted as a named export `isPluginUnavailableError` so the detection is unit-testable and reusable. Added 4 direct tests: contract-string match, generic-503 false-negative, 401 false- negative, non-Error inputs. ## Test results - 30 component tests pass (was 26; +4 for isPluginUnavailableError) - Coverage on MemoryInspectorPanel.tsx: 100% lines, 100% functions (branch coverage up to 85.9% from 84.7% — speculative-field branches no longer count) - Full canvas suite: 1277/1277 pass across 91 files |
||
|
|
f0f4d0e761 |
feat(memory): redesign Memory tab for v2 plugin
Replaces the v1 LOCAL/TEAM/GLOBAL tab trio (mapped to the deprecated
shared_context model) with a v2 plugin-driven UI. Without this,
canvas Memory tab was reading the frozen agent_memories table while
all post-cutover agent writes went to the plugin's memory_records —
the tab silently displayed stale data.
## Backend (workspace-server)
New routes under wsAuth, all behind the existing per-tenant token:
GET /workspaces/:id/v2/namespaces → readable + writable lists
GET /workspaces/:id/v2/memories → plugin search proxy
DELETE /workspaces/:id/v2/memories/:mid → plugin forget proxy
memories_v2.go — slim handler:
- Server-side ACL: every search request is intersected with the
resolver's readable-namespaces set (canvas-supplied namespace
that the workspace can't read returns [] not 403, matches v1
existence-non-inferring shape).
- Returns 503 with "set MEMORY_PLUGIN_URL" hint when plugin
isn't wired (canvas surfaces a banner).
- Maps plugin not_found → 404, other plugin errors → 502.
- View shaping: NamespaceView.label rendered server-side
("Workspace (abc-1234)", "Team (t-99)", "Org (acme)", custom)
so canvas doesn't parse namespace names. MemoryView surfaces
pin/expires_at/score/source_workspace_id from Propagation.
memories_v2_test.go — 100% line + 100% function coverage:
- 503 path on every endpoint when unwired
- Namespaces success + readable/writable error paths
- Search: empty intersection, full-path query/kind/limit
propagation, namespace=/no-namespace branches, propagation
map missing/wrong-type, intersect error, plugin error
- Forget: success, plugin not_found→404, other plugin
errors→502, missing memoryId→400
- Helpers: namespaceLabel for all 4 kinds + truncation,
parseLimit edge cases (default/0/negative/over-cap/non-num),
memoryToView field round-trip, indexOfColon, shortID
## Frontend (canvas)
MemoryInspectorPanel rewritten for v2:
- Drop LOCAL/TEAM/GLOBAL trio. Namespace dropdown driven by
GET /v2/namespaces.readable, "All namespaces" default.
- New per-row badges: kind (F/S/C), source (agent/runtime/user),
pin (📌), TTL countdown (⌛12h / "expired"), score% on
semantic search, source-workspace ⇡ws-pee for propagated.
- Drop Edit button — v2 plugin contract has no PATCH; the
model is forget + recommit. Forget stays.
- Plugin-unavailable banner with operator hint when /v2/*
returns 503.
- Bug fix surfaced by test: rollback-on-failed-delete order
of operations (loadEntries() called setError(null) AFTER
we set the failure message, wiping it). Reload first, then
set the error.
MemoryEditorDialog deleted — Add was POST /memories which v2
doesn't support from canvas (writes go via MCP). The legacy
Edit-flow tests go with it.
## Test results
Backend: `go test ./internal/handlers/` — all pass
Backend coverage on memories_v2.go: 100% lines, 100% functions
Canvas: `vitest run` — 91 files, 1273 tests pass (26 new)
Canvas coverage on MemoryInspectorPanel.tsx: 100% lines,
100% functions, 96.7% statements, 84.7% branches
(uncovered branches are defensive `?? fallback` for
contract-impossible kind/source values)
## Migration note
The legacy v1 GET/POST/PATCH/DELETE on /workspaces/:id/memories
remains in place for the back-compat MCP shim (mcp_tools_memory_v2's
legacy routing) and admin export/import. PR-9 (#283) drops
agent_memories along with the v1 endpoints once the cutover
verification window closes.
|
||
|
|
f3782662bd |
refactor(external-connect): embed help in agent paste, fix wrong docs hostname
Two related fixes to the Connect-External-Agent flow that the user flagged: the "Need help?" disclosure block in the modal is for the operator's eyes only — but the agent reading the pasted snippet has no access to that context. And the docs URL was pointing at a hostname that doesn't resolve. User-visible problems: 1. The agent doesn't see the install link, docs link, or the common- error/check pairs that the human pasted. When the agent fails to register or hits ConnectionRefused, it can't self-diagnose because the troubleshooting context lives in a separate UI block. 2. https://docs.molecule.ai → DNS NXDOMAIN. Every "Documentation" link in the modal was a dead link. ## Fixes ### Move help INTO the snippet (not a separate human-only UI block) Each of the 7 server-rendered templates in `workspace-server/internal/handlers/external_connection.go` now appends a `# Need help?` section with: install link, correct docs link, and the top common errors as `# • symptom — check` pairs. Templates updated: curl / channel (Claude Code) / mcp (Universal MCP) / python / hermes / codex / openclaw. Agents reading the paste now have the same diagnostic context the human did. ### Drop the duplicated UI block in the canvas modal `canvas/src/components/ExternalConnectModal.tsx`: - Removed the `TAB_HELP` per-tab metadata constant (152 lines). - Removed the `HelpBlock` component (62 lines). - Removed the `<HelpBlock help={TAB_HELP[tab]} />` render call. The snippet is now the single source of truth for tab-level help. ### Fix the wrong docs hostname The actual docs site is `doc.moleculesai.app` (singular `doc`, `.app` not `.ai`), confirmed by: - `package.json` description in `Molecule-AI/docs` repo → "Molecule AI documentation site — doc.moleculesai.app" - HTTP HEAD on the new URL → 200 for both `/docs/guides/mcp-server-setup` and `/docs/guides/external-agent-registration` - HTTP HEAD on old `docs.molecule.ai` → 000 (NXDOMAIN) All template docs URLs now point at `doc.moleculesai.app`. ## Verification - `go build ./...` clean - `go test ./internal/handlers/... -count=1` green - `pnpm test` → 1291/1291 pass (unchanged) - `tsc --noEmit` clean - 219 LOC removed (canvas duplicate UI), 69 LOC added (snippet help) - Net `-150 LOC` while gaining the agent-readable help ## Out of scope (deferred, captured in followups) - One blog post still has `canonical: "https://docs.molecule.ai/blog/..."` in `src/app/blog/2026-04-20-chrome-devtools-mcp/page.mdx` — separate blog-content fix. - Comment in `theme-provider.tsx` references `docs.moleculesai.app` (with `s`) — comment-only, not a runtime URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9386f1d399
|
Merge pull request #2926 from Molecule-AI/fix/agent-comms-display-parity
fix(canvas): AgentCommsPanel display + initial-state parity with my-chat |
||
|
|
5ad2669f88 |
fix(canvas): AgentCommsPanel display + initial-state parity with my-chat
User-visible problem: agent-comms panel opens mid-conversation on long histories (the same chat-opens-in-middle bug PR #2903 fixed for my-chat) and silently renders empty state when the history fetch fails (no retry button, no diagnostic). Three changes mirror the my-chat patterns from ChatTab: 1. Initial-mount instant scroll. Adds hasInitialScrollRef + switches the scroll hook from useEffect to useLayoutEffect. First arrival of messages → scrollIntoView `instant`; subsequent appends → `smooth` as before. useLayoutEffect runs before paint so the user never sees the panel jump for one frame on every append. 2. Error UI with Retry button. Adds `loadError` state. The history-load .catch now sets the error message; a new branch in the render renders a red alert with the failure text and a Retry button that re-invokes `loadInitial`. Same shape as ChatTab MyChatPanel's `loadError` handling — both surfaces should fail loud, not silent. 3. Extracted `loadInitial` callback. The history-load body becomes a useCallback so the retry button has a stable reference to call. Mirrors ChatTab's loadInitial. Tests (4 new in AgentCommsPanel.render.test.tsx): - Loading state renders the loading copy. - Error state with Retry button renders on rejection; clicking Retry fires a second api.get. - Empty state renders when load succeeds with zero rows. - scrollIntoView is called with behavior=instant on first message arrival (pins the chat-opens-in-middle prevention). Verification: - pnpm test → 1284/1284 pass (1280 prior + 4 new) - tsc --noEmit → clean - 92 → 93 test files, no existing test broken Closes the parity gap raised in chat. The two surfaces now share: loading copy / error UI / empty-state placeholder / scroll behaviour / useLayoutEffect timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
575f893f4e |
fix(canvas): consume CP logout_url to break the SSO re-auth loop
Follow-up to molecule-controlplane#485. The first half of #2913 wired a Sign-out button + signOut() helper that POSTed /cp/auth/signout, but clicking still left the user signed in: WorkOS's browser cookie preserved the SSO session, /cp/auth/login auto-re-authed via SSO, and the user landed back on /orgs. CP PR #485 returns the AuthKit hosted logout URL in the signout response. This change has signOut() navigate the browser there instead of /cp/auth/login. AuthKit clears its cookie + redirects to return_to (configured server-side from APP_URL) → next /cp/auth/login hits a fresh AuthKit, no SSO session, login form actually shows. Defensive parsing: malformed JSON, missing logout_url, or wrong-type logout_url all fall through to the legacy /cp/auth/login fallback, which works locally (DisabledProvider, dev) where there's no SSO to escape. Forward-compat: when CP doesn't have #485 deployed yet, signOut() sees logout_url="" or missing → fallback fires. Order of merge between this and #485 doesn't matter, but the bug isn't actually fixed end-to-end until both ship. Tests added (3 new, 15 total auth.test.ts): - Hosted logout: navigates to logout_url when response includes one. - DisabledProvider path: falls back to /cp/auth/login when "". - Defensive: malformed JSON body → fallback (no crash). - Defensive: non-string logout_url → fallback (no open redirect). Verified: - npx vitest run src/lib/__tests__/auth.test.ts — 15/15 pass - tsc --noEmit clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4cac4e7710 |
fix(canvas): wire SaaS Sign-out button — POST /cp/auth/signout was unreachable from the UI
Reported externally on 2026-05-05: "SaaS app logout does not work."
Root cause: the control plane has had POST /cp/auth/signout (clears the
WorkOS session cookie + revokes at the provider) since auth shipped,
but no canvas code ever called it. grep across canvas/ for
`logout|signOut|signout|sign-out` returned zero results — no helper,
no button, no menu entry. Users had no path to log out short of
clearing cookies in DevTools.
This is a UI gap, not a backend bug. Adding the missing pieces:
1. `signOut()` helper in `canvas/src/lib/auth.ts`:
- POST /cp/auth/signout with credentials:include (cross-origin
cookie required for tenant subdomain → app subdomain)
- Best-effort: a 5xx, 401-stale-cookie, or network failure still
redirects the browser to /cp/auth/login. Leaving the user on an
authed-looking page after they clicked Sign out is the worst
possible UX — that's the precise "logout doesn't work" symptom
the report described.
- Lands on /cp/auth/login (not the current URL) so the user
doesn't loop back into the org they just left via AuthGate's
return_to.
2. `AccountBar` component on /orgs page Shell — renders the signed-in
email + Sign-out button at the top. Click → signOut() →
`Signing out…` → bounces to login. Disabled-while-pending so a
double-click can't fire two requests.
3. Tests in `auth.test.ts` (4 new, total 12 pass):
- POSTs to the right endpoint with credentials:include
- Redirects to /cp/auth/login after success
- Redirects EVEN ON network failure (the critical UX invariant)
- Redirects on 401 (stale cookie path)
The auth-origin resolution (`getAuthOrigin`) is reused so a tenant
subdomain (acme.moleculesai.app) correctly POSTs to
app.moleculesai.app/cp/auth/signout — same chain that fetchSession
+ redirectToLogin already use.
Test plan:
- [x] `npx vitest run src/lib/__tests__/auth.test.ts` — 12/12 green
- [x] `tsc --noEmit` — clean
- [ ] Manual: navigate to /orgs, click Sign out, observe redirect +
that the next /orgs visit bounces to login (cookie cleared)
- [ ] CI green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a489ee1a7c |
fix(canvas/chat): instant-scroll to bottom on first mount
Reported: "right now when chat box opens it opens in the middle, but
it should be at the end of conversation."
Root cause: ChatTab.tsx:548 fires `bottomRef.scrollIntoView({ behavior:
"smooth" })` on every messages-update. On initial mount with N
messages already loaded, the smooth-scroll triggers a ~300ms animation
that any concurrent React re-render (agent push landing, theme
toggle, sidepanel resize) interrupts mid-flight, leaving the user
stuck somewhere in the middle of the conversation.
Fix: track first-mount via hasInitialScrollRef. Use behavior:"instant"
for the initial jump (deterministic, no animation interruption), then
smooth for subsequent appends (the new-message-landing visual stays).
Refs flipped on first messages.length > 0 transition, so:
- Initial open of chat tab: instant jump to bottom ✓
- New agent message arrives: smooth scroll into view ✓
- Workspace switch (ChatTab remounts): fresh hasInitialScrollRef, gets
instant again ✓
- loadOlder prepend: anchor-restore path unchanged, still pins user's
reading position ✓
Test plan:
- pnpm test --run ChatTab.lazyHistory.test.tsx → 8 pass (existing
lazy-history tests untouched)
- npx tsc --noEmit clean
- Manual on hongming.moleculesai.app: open a busy chat (mac laptop,
~50 messages), confirm view lands at the latest bubble, not mid-
scroll. Switch to another workspace + back → instant again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7644e82f2f |
feat(saas): default new workspaces to T4 on SaaS, T3 self-hosted
User reported every SaaS workspace defaults to T2 (Standard). Three sites quietly disagreed on the default: - canvas CreateWorkspaceDialog (line 126): isSaaS ? 4 : 3 ← only correct one - canvas EmptyState "Create blank": tier: 2 ← hardcoded - workspace.go POST /workspaces: tier = 3 ← not SaaS-aware - org_import.go createWorkspaceTree: tier = 2 (fallback)← not SaaS-aware So a user clicking "+ New Workspace" via the dialog got T4 on SaaS, but a user clicking "Create blank" on the empty canvas got T2, and an agent POSTing /workspaces directly got T3. Same tenant, three different tiers depending on entry point. Fix: 1. WorkspaceHandler.IsSaaS() and DefaultTier() helpers (workspace_dispatchers.go). IsSaaS() := h.cpProv != nil — single source of truth for "are we SaaS" across the file. DefaultTier() returns 4 on SaaS, 3 on self-hosted. SaaS rationale: each workspace runs on its own sibling EC2 so the per-workspace tier boundary is a Docker resource limit on the only container present — no neighbour to protect from. T4 matches the boundary. 2. workspace.go now defaults tier via h.DefaultTier() instead of hardcoded T3. 3. org_import.go fallback (when neither ws.tier nor defaults.tier set) becomes SaaS-aware: T4 on SaaS, T2 on self-hosted (preserve the existing safe-shared-Docker-daemon default for self-hosted org imports). 4. canvas EmptyState "Create blank" stops sending tier:2 in the body and lets the backend pick — single source of truth in the backend. Eliminates the third disagreement. Test plan: - go vet ./... clean - go test ./internal/handlers/ -count 1 — all green (4.3s) - npx tsc --noEmit on canvas — clean - Staging E2E (after deploy): create a fresh workspace via canvas empty-state on hongming.moleculesai.app, confirm tier=4 on the workspace details panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
191ef3be91 |
fix(canvas/tests): pin Expand-to-Team absence with literal assertion
Multi-model review of #2862 caught a non-load-bearing assertion: the test used \`expect(labels).not.toContain(expect.stringMatching(...))\` to claim the "Expand to Team" right-click item is gone. But vitest's toContain uses Object.is/===, so asymmetric matchers like expect.stringMatching are plain objects that never === any string — the assertion silently passed for ANY string array, including arrays that DID contain "Expand to Team". The test would have green-lit the unfixed code. Switch to the literal substring shape the rest of this file already uses (see lines 175/183/254 — labels.some((l) => l.includes(...))). Verified the new assertion is load-bearing: 1. Reintroduced \`{ label: "Expand to Team", ... }\` into the childless-workspace branch of ContextMenu.tsx 2. Ran the test — failed at the new assertion line as expected 3. Reverted the regression — test passes again Net diff: replaces one broken expect with one correct expect + a WHY-comment noting the toContain/asymmetric-matcher gotcha so the next reader (or test writer) doesn't reintroduce the same shape. Per memory feedback_assert_exact_not_substring.md: pin assertions that fail on the old code path; this assertion never fired even on the bug it was written to catch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a959feae84
|
Merge branch 'staging' into chore/remove-canvas-expand-button | ||
|
|
49027af419 |
chore(canvas): remove Expand-to-Team right-click button (#2858)
Pairs with PR #2856 which removed the backend POST /workspaces/:id/expand route. With the backend gone, the canvas right-click "Expand to Team" button calls a 404. Remove the button and its callback. ContextMenu.tsx: - Delete handleExpand callback (8 lines) - Drop the "Expand to Team" item from the childless-workspace menu array; childless workspaces now only show the regular actions (Extract from Team / Export Bundle / Duplicate / Pause / Restart / Delete). Toolbar.tsx: - Drop "expand," from the right-click help-text shortcut. ContextMenu.keyboard.test.tsx — two new pinning cases: - "'Expand to Team' menu item is gone (childless workspace)" — asserts the label literal is absent + the regular actions (Delete, Restart) are still present. - "'Collapse Team' is still present when the workspace HAS children" — sanity that the parent-with-children menu (Arrange Children / Collapse Team / Zoom to Team) didn't regress. How users create children now: the existing + New Workspace dialog (CreateWorkspaceDialog.tsx) already has a parent picker. No new UI needed — every workspace can be a parent via the regular Create flow with parent_id set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d175d0c4c1
|
Merge branch 'staging' into feat/canvas-chat-lazy-load-history | ||
|
|
b375252dc8 |
feat(external): credential rotation + re-show instruction modal (#319)
External workspaces (runtime=external) lose their workspace_auth_token
the moment the create modal closes — the token is unrecoverable from
any later DB read. Operators who lost their copy or want to respond to
a suspected leak had no recovery path short of recreating the workspace
(which also breaks cross-workspace delegation links + memory namespace).
This PR adds two endpoints + a Config-tab section that surfaces them:
POST /workspaces/:id/external/rotate
Revokes any prior live tokens, mints a fresh one, returns the same
ExternalConnectionInfo payload Create returns. Old credentials stop
working immediately — the previously-paired agent will fail auth on
its next heartbeat (~20s).
GET /workspaces/:id/external/connection
Returns the connect block with auth_token="". For the operator who
just needs to re-find PLATFORM_URL / WORKSPACE_ID / one of the
snippets without invalidating the live agent.
Both reject runtime ≠ external with 400 + a hint pointing at /restart
for non-external runtimes (which mints AND injects into the container).
## Why a flag isn't needed
The endpoints are purely additive — Create's behavior is unchanged.
Existing external workspaces don't see anything different until an
operator clicks the new buttons.
## DRY refactor
Extracted BuildExternalConnectionPayload() in external_connection.go
as the single source of truth for the connect payload shape. Create,
Rotate, and GetExternalConnection all call it. Adds a snippet once →
all three endpoints emit it. Trims trailing slash on platform_url so
no double-slash sneaks into registry_endpoint.
## Canvas
ExternalConnectionSection mounts in ConfigTab when runtime=external.
Two buttons:
- "Show connection info" (cosmetic) — fetches GET /external/connection
- "Rotate credentials" (destructive) — confirm dialog explains the
impact, then POST /external/rotate
Both reuse the existing ExternalConnectModal so operators don't learn
a second snippet UX.
## Coverage
10 Go tests:
- Rotate happy path (revoke + mint order, payload shape, broadcast event)
- Rotate refuses non-external runtimes (400 with restart hint)
- Rotate 404 on unknown workspace + 400 on empty id
- GetExternalConnection happy path (auth_token="", same payload shape)
- GetExternalConnection refuses non-external + 404 on unknown
- BuildExternalConnectionPayload — placeholder substitution + trailing
slash trimming + blank-token contract
6 canvas tests:
- both action buttons render
- "Show" calls GET /external/connection and opens modal
- "Rotate" opens confirm dialog before firing POST
- Cancel dismisses without rotating
- Confirm POSTs and opens modal with returned token
- API failures surface as visible error chips
Migration: existing external workspaces gain new abilities; no data
migration. The DRY refactor preserves byte-identical Create response
shape (8 ConfigTab tests + all existing handler tests still pass).
Closes #319.
|
||
|
|
cbe48c2225 |
feat(canvas/memories): Add + Edit modal for MemoryInspectorPanel
The Memory tab was read-only — users could see and Delete entries but
the only path to write was leaving canvas. Adds a + Add button (toolbar,
next to Refresh) and an Edit button (per-entry, next to Delete) that
share one MemoryEditorDialog.
Add: POST /workspaces/:id/memories with {content, scope, namespace}
Edit: PATCH /workspaces/:id/memories/:id (sibling endpoint #2838)
with only fields that changed; no-op edits short-circuit
client-side so we don't waste a redactSecrets + re-embed pass
Edit mode locks scope (cross-scope moves go through delete + recreate
to keep the GLOBAL audit-log + redact pipeline single-purpose).
Tests: 6 cases on the dialog covering POST shape, PATCH-only-diff,
no-op short-circuit, empty-content guard, save-error keeps modal open,
and namespace+content combined PATCH. Existing 27 MemoryInspectorPanel
tests still pass with the new prop wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4c9309e801 |
fix(canvas/chat): tag scroll anchor + token-guard fetches (race fixes)
Multi-model review of #2826 found two issues my self-approval missed: C1. Live agent-message append during loadOlder() yanked scroll AND swallowed the bottom-pin. The useLayoutEffect's "restore against saved distance-from-bottom" branch fired on ANY messages update while scrollAnchorRef was set — including appends from agent pushes that landed mid-fetch. User reading mid-history got thrown to a stale offset; the new agent message's normal scroll-to-bottom was silently swallowed. Fix: tag scrollAnchorRef with `expectFirstIdNotEqual` (the oldest message's id BEFORE the prepend). The layout effect only honors the anchor when messages[0].id has changed from that tag — i.e., a real prepend happened, not an append. R4. Workspace switch mid-fetch leaked the in-flight promise's result into the new workspace's state — user briefly saw someone else's history. Same shape for a fast-clicked Retry button or rapid scroll-flick triggering a second loadOlder. Fix: `fetchTokenRef` monotonic counter. loadInitial + loadOlder each capture their token at entry; the .then() bails if the token has moved. Both call sites bump the token at fetch start so any in-flight stale fetch loses identity. C2 (loadOlder identity stability via refs) and R3 (inflightRef synchronous double-entry guard) were already pushed in the previous commit on this branch. Build + 1258 tests pass. |
||
|
|
20f76c4fdf |
fix(canvas/chat): stable IntersectionObserver + inflight guard for loadOlder
Self-review of the lazy-load PR caught three Important findings:
1. IO observer was re-armed on every messages change. The previous
loadOlder useCallback depended on `messages`, so every live agent
push recreated it → re-ran the IO useEffect → tore down + re-armed
the observer. In a perf PR shipping to chat-heavy users, that's
the wrong direction. Fix: refs for the captured state
(oldestMessageRef, hasMoreRef), narrow loadOlder deps to
[workspaceId], and gate the IO effect on `messages.length > 0`
(boolean) instead of `messages` so it arms exactly once when data
first lands and stays armed across appends.
2. loadingOlder setState race. Two IO callbacks dispatched in the
same microtask (fast scroll, layout shift) could both pass the
`if (loadingOlder)` guard before React committed setLoadingOlder.
Fix: synchronous inflightRef set BEFORE any await, cleared in
finally; loadingOlder state stays for the UI label only.
3. Retry-button onClick duplicated the mount-effect body. Single
loadInitial() callback now serves both, eliminating the drift
hazard.
Coverage:
- 4 new tests bring the file to 8/8 (was 4):
- loadOlder fetches with limit=20 and before_ts=oldest.timestamp
- inflight guard rejects three concurrent IO triggers while a
deferred fetch is in flight (asserts call count stays at 2,
not 5)
- empty older response unmounts the sentinel (proxy for the
anchor-clearing branch in loadOlder)
- IO observer instance survives three subsequent prepends — same
object reference both before and after, no churn
- Both behavioural tests verified to FAIL on the prior code
(stashed ChatTab.tsx, ran them alone, confirmed both red), then
PASS on this commit. Pinning real regressions, not tautologies.
- IntersectionObserver fake captures instances + exposes
triggerIntersection() so the IO callback can be driven directly
from jsdom (no real layout / scrolling needed).
Test: vitest run src/components/tabs/__tests__/ → 39 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ba63f76e10 |
feat(canvas/terminal): not-available banner for runtimes without a TTY
Pre-fix TerminalTab tried to open /ws/terminal/<id> for every workspace including external ones (which have no shell endpoint on the workspace-server). The server returned 404, status flipped to "error", the user saw "Connection failed" with a Reconnect button — reading as a bug when really the runtime intentionally has no TTY. Now: when data.runtime is in RUNTIMES_WITHOUT_TERMINAL (currently just "external"), TerminalTab renders a NotAvailablePanel with a big terminal-off icon and a one-line explanation including the runtime name. The xterm + WebSocket dance is skipped entirely — no spurious 404s, no scary error UI, no Reconnect that can't help. The runtime is determined from the data prop now threaded by SidePanel.tsx (existing pattern for ChatTab/ConfigTab/etc). Tests: 4 new in TerminalTab.notAvailable.test.tsx pin: external renders banner with runtime name, external doesn't open WS, claude- code mounts normally (regression cover for the early-return scope), data omitted falls through (back-compat). Build clean. 1258 tests pass. |
||
|
|
8152cfc81e |
feat(canvas/chat): lazy-load history — 10 newest on mount, 20 per scroll-up batch
Pre-fix ChatTab fetched the newest 50 messages on every mount and
scrolled to bottom, paying full DOM cost up-front even when the user
only wanted to read the last few bubbles. On a long-running workspace
this meant 50× message-bubble paint + DOM cost on every tab swap.
Now:
- Initial fetch limit=10 (newest-first slice).
- IntersectionObserver on a top sentinel (rootMargin 200px) fires
loadOlder() the moment the user scrolls within 200px of the top.
- loadOlder() uses the oldest loaded message's timestamp as
`before_ts` (RFC3339 cursor the /activity endpoint already
supports) and fetches OLDER_HISTORY_BATCH (20) more.
- hasMore turns false when the server returns < limit rows; the
sentinel unmounts and the IO observer disconnects — no spinner
on a short conversation.
- useLayoutEffect handles scroll behavior across messages updates:
a prepend (loadOlder landed) restores the user's saved
distance-from-bottom (captured via scrollAnchorRef before the
fetch) so their reading position doesn't jump; an append /
initial load pins to the latest bubble.
Tests: 4 new in ChatTab.lazyHistory.test.tsx pinning the limit=10
on initial fetch, hasMore=false on short-history, full-page rendering
on exactly-the-limit, and limit=10 on retry-after-failure. Doesn't
exercise the IO/scroll-anchor in jsdom — that's brittler than
trusting the synth-canary against a live tenant.
Build clean. Existing 1250 tests + 4 new = 1254 pass.
|
||
|
|
beab899501 |
feat(ConfigTab): drop Skills/Tools tag inputs, give Prompt Files its own section
User feedback (2026-05-04 conversation):
> "Skills and Tools are having their own tab as plugin, and Prompt
> Files are in the file system which can be directly edited. Am I
> missing something?"
> "Tools should be merged into plugin then, and for prompt files... it
> should be in another section than in skill& tools"
The "Skills & Tools" section in ConfigTab had three TagList inputs:
- Skills: managed via the dedicated SkillsTab (per-workspace
skill folders) — duplicate UI affordance
- Tools: managed via the Plugins tab (install a plugin → its
tools become available) — duplicate UI affordance
- Prompt Files: load order for system-prompt files — semantically
unrelated to skills/tools
Drop the Skills + Tools inputs. Move Prompt Files into its own
section with explanatory copy that names the auto-loaded files
(system-prompt.md, CLAUDE.md, AGENTS.md) and points users at the
Files tab for actual editing.
Schema fields `config.skills` and `config.tools` are KEPT (load-bearing
for runtime skill loading + tool registry); only the inline editor goes
away. Operators who need to edit them can still use the Raw YAML toggle.
Tests:
- New ConfigTab.sections.test.tsx with 4 cases:
1. "Skills & Tools" section title is gone
2. Skills tag input is absent
3. Tools tag input is absent
4. Prompt Files section exists with explanatory copy
Sibling ConfigTab tests (hermes, provider) all still pass (20/20).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4ea6f437e9 |
feat(external-templates): codex tab now includes the bridge-daemon inbound path
The codex tab in the External Connect modal had a "outbound-tools-only first cut" caveat — operators got the MCP wiring for codex calling platform tools, but there was no documented inbound path. Canvas messages couldn't wake an idle codex session. That gap is now filled by codex-channel-molecule (github.com/Molecule-AI/codex-channel-molecule), shipped today as the codex counterpart to hermes-channel-molecule. The daemon long-polls the platform inbox, runs `codex exec --resume <session>` per inbound message, captures the assistant reply, routes it back via send_message_to_user / delegate_task, and acks the inbox row. Per-thread session continuity persisted to disk so daemon restarts don't lose conversation context. This commit: - Updates externalCodexTemplate to include `pip install codex-channel-molecule` (step 1) and a foreground `nohup codex-channel-molecule` invocation (step 3) using the same env-var contract as the MCP server (WORKSPACE_ID + PLATFORM_URL + MOLECULE_WORKSPACE_TOKEN). - Adds a "Canvas messages don't wake codex" common-issues entry to the TAB_HELP codex section pointing at the bridge daemon log. - Updates the doc comment to record the upstream deprecation path: when openai/codex#17543 lands, the bridge becomes redundant and the wired MCP server delivers push natively. Verified: TestExternalTemplates_NoMoleculeOrgIDPlaceholder still passes (no MOLECULE_ORG_ID re-introduction); full handlers suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |