## 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)
## 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)
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)
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>
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
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>
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>
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>
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
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.
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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.
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>
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.
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.
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>
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>
Parent → child knowledge sharing previously lived behind a `shared_context`
list in config.yaml: at boot, every child workspace HTTP-fetched its parent's
listed files via GET /workspaces/:id/shared-context and prepended them as
a "## Parent Context" block. That paid the full transfer cost on every
boot regardless of whether the agent needed it, single-parent SPOF, no team
or org scope, and broken if the parent was unreachable.
Replace with memory v2's team:<id> namespace: agents call recall_memory
on demand. For large blob-shaped artefacts see RFC #2789 (platform-owned
shared file storage).
Removed:
- workspace/coordinator.py: get_parent_context()
- workspace/prompt.py: parent_context arg + injection block
- workspace/adapter_base.py: import + call + arg pass
- workspace/config.py: shared_context field + parser entry
- workspace-server/internal/handlers/templates.go: SharedContext handler
- workspace-server/internal/router/router.go: GET /shared-context route
- canvas/src/components/tabs/ConfigTab.tsx: Shared Context tag input
- canvas/src/components/tabs/config/form-inputs.tsx: schema field + default
- canvas/src/components/tabs/config/yaml-utils.ts: serializer entry
- 6 tests pinning the removed behavior; 5 doc references
Added regression gates so any reintroduction is loud:
- workspace/tests/test_prompt.py: build_system_prompt must NOT emit
"## Parent Context"
- workspace/tests/test_config.py: legacy YAML key loads cleanly but
shared_context attr must NOT exist on WorkspaceConfig
- tests/e2e/test_staging_full_saas.sh §9d: GET /shared-context must NOT
return 200 against a live tenant
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Memory tab supported only Add+Delete. Correcting an entry meant
deleting and re-adding, losing the row's version counter and any
concurrent-write guard the agent depends on.
Now: per-row Edit button reveals an inline editor (value textarea +
TTL). Save POSTs to the existing /memory upsert endpoint with
if_match_version pinned to the entry's current version. On 409 the
UI surfaces a retry hint and reloads.
Tests:
- 11 vitest cases covering pre-fill (JSON vs string), payload shape
(parsed JSON, fallback to plain text, TTL inclusion/omission),
cancel, 409 retry path, generic error path, and the no-version
back-compat case.
- E2E gate 9c in test_staging_full_saas.sh: seed → GET version →
conditional update → assert new value → stale-version POST must
409. Pins the optimistic-locking contract end-to-end on staging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes molecule-controlplane#467 (issue filed against CP, but resolution
landed canvas-side because the workspace-server ALREADY returns the
agent_card JSONB blob with configuration_status / configuration_error
fields populated by molecule-core PR #2756). No CP-side change needed —
the gap was the canvas's blindness to those fields.
Before this PR, a workspace whose adapter.setup() failed (typically
missing/rotated LLM credential) appeared identical to a healthy one in
the canvas tile: green "Online" status, no error indication. The
operator had to dig into workspace logs to discover the env var to set.
This PR surfaces the state via the existing status-pill UX:
1. STATUS_CONFIG gains a "not_configured" entry — amber dot/glow,
"Not configured" label. Distinct from "online" (emerald) and
"failed" (red) — the workspace is reachable, it just needs config.
2. canvas-topology exposes getConfigurationStatus / getConfigurationError
helpers — strict equality on the JSONB field so unknown values
pass through as null instead of crashing the tile renderer.
3. WorkspaceNode derives an `effectiveStatus` that overrides
data.status with "not_configured" when (status === "online" AND
agent_card.configuration_status === "not_configured"). The override
only applies on top of "online" — a genuinely offline / failed /
provisioning workspace keeps its existing treatment.
4. The configuration_error string surfaces in two places: the tile's
aria-label (screen reader access) + a truncated preview row at the
bottom of the tile (same visual as the existing "degraded error
preview" — mirrors the established pattern for in-tile error
surfacing).
Test coverage: 11 new in canvas-topology-configuration-status.test.ts.
Each helper covered for the happy path, missing fields, defensive
ignores of unknown values, and an end-to-end "stale ready overrides
old error" guard.
Once this lands + canvas redeploys, operators see "Not configured:
Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set" right on the
workspace tile instead of a confused-looking green "online" workspace
that silently 503s every JSON-RPC request.
Pairs with: molecule-core PR #2756 (decouple agent-card from setup),
#2775 (boot_routes pin), #2778 (secret_redactor)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-review on PR #2723 caught a coverage gap: the existing
"visibility gate" describe block actually tested cadence (10s/30s
timing), not the gate itself. If a refactor dropped the
`if (!visible) return` line, the cadence test would still pass
because the effect would still fire every 30s — the regression would
silently ship.
New test renders with comms-returning mock so the panel renders, clicks
the close button, advances 60s, asserts no further fetches occur.
Discipline-verified: removed `if (!visible) return` from the source,
test fails as expected. Restored, test passes.
Same failure mode as PR #434 (test asserted broken behavior) — pin
what you claim to fix, not the easy substring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report 2026-05-04: 8+ workspace tenant (Design Director + 6 sub-agents
+ 3 standalones) saw sustained 429s in canvas console hitting
/workspaces/<id>/activity?limit=5. Server-side rate limit is 600 req/min/IP.
Three compounding issues in CommunicationOverlay:
1. Polled regardless of visibility — collapsed panel still hammered the API
2. 10s cadence — 6 req every 10s = 36 req/min from this overlay alone
3. Fan-out cap of 6 workspaces — scaled linearly with workspace count
Fix:
- Gate setInterval on `visible` (effect re-runs when collapsed/expanded)
- Cadence 10s → 30s
- Fan-out cap 6 → 3
Combined: ~36 req/min worst case → 6 req/min worst case (6x reduction),
0 req/min when collapsed.
Tests:
- Fan-out cap: 6 online nodes mounted → exactly 3 fetches (was 6)
- Offline gate: offline workspace never polled
- Cadence: timer at 10s = no new fetch; timer at 30s = next batch fires
Each test would fail if the corresponding dial regressed.
Follow-up (out of scope): structurally right fix is to consume the
WORKSPACE_ACTIVITY WS broadcast instead of polling per-workspace. Server
already publishes the events; canvas just isn't subscribing yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selector instability caused fetchAndUpdate to recreate on every Zustand
nodes[] mutation (status flips, position drags, peer-discovery writes,
heartbeats — typically ~5/sec). Each recreation invalidated the
useEffect deps so the 60s polling fan-out fired on every update,
hammering /workspaces/<id>/activity?type=delegation 5×N requests/sec
until the edge rate-limit returned 429. User-reported via browser
console showing infinite uE→ux→uE→ux render loop and 429s repeating
across every visible workspace ID.
Root cause:
const nodes = useCanvasStore((s) => s.nodes);
const visibleIds = useMemo(() => nodes.filter(...).map(...), [nodes]);
// useMemo dep recreates on every store update, even when ID set unchanged
Fix: select a STABLE STRING KEY (sorted CSV of visible IDs) from
Zustand. The selector's shallow-equal short-circuit prevents re-renders
when the actual visible-ID set is unchanged, so visibleIds reference
stays stable, fetchAndUpdate keeps its identity, and the useEffect
only re-fires when the visible-ID-set genuinely changes.
Tests:
- New regression test "does not re-fetch when nodes[] reference
changes but visible IDs are the same"
- Discipline-verified: pre-fix code emits 4 fetches (2 mount + 2
re-fetch storm), post-fix emits exactly 2
- Companion test "re-fetches when the visible ID set actually changes"
pins the desired behavior so future "stabilization" doesn't suppress
legitimate updates
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweep on the workspace-creation dialog — same patterns shipped on every
other surface.
- 2× bg-accent-strong hover:bg-accent (FAB + Create) hovered LIGHTER
on white text → bg-accent hover:bg-accent-strong + focus-visible
rings.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
elevated + focus-visible ring.
- 4× placeholder-zinc-500/600 hardcoded → placeholder-ink-soft so
placeholders flip with theme.
- FAB shadow tinting (shadow-blue-600/20 + shadow-blue-500/30) was
hardcoded blue with no theme variant; switched to shadow-accent so
the glow tint matches the brand mint accent in both modes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OrgImportPreflightModal:
- 3× bg-accent-strong hover:bg-accent (Import + 2 add-key buttons) —
accent is the LIGHTER variant, drops below AA on white text →
bg-accent hover:bg-accent-strong.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
elevated + focus-visible ring.
SkillsTab:
- Custom-source input had focus:border-violet-600 but no
focus-visible ring — keyboard users only got a 1px border swap.
Added focus-visible:ring-violet-600/50 (kept the violet to match
the surrounding "custom install" UI's brand).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six button fixes — same trap patterns shipped on every other tab:
DetailsTab:
- Save button: bg-accent-strong hover:bg-accent (LIGHTER on white text,
AA drop) → bg-accent hover:bg-accent-strong + focus-visible ring.
- Confirm Delete: bg-red-600 hover:bg-red-500 (LIGHTER on white text,
AA drop) → bg-red-700 + focus-visible danger ring.
- Cancel: bg-surface-card hover:bg-surface-card (no-op) →
surface-elevated.
ConfigTab:
- 2× Save buttons: same accent-LIGHTER trap → flipped + focus rings.
- Cancel: same no-op → surface-elevated.
ActivityTab:
- Refresh: same no-op → surface-elevated + focus-visible ring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three matched fixes — same patterns shipped on OnboardingWizard,
ConfirmDialog, ApprovalBanner.
1. 4× bg-accent-strong hover:bg-accent (Save, Add, two Show buttons)
hovered LIGHTER on white text — accent is the lighter variant, so
contrast dropped below AA on hover. Flipped: bg-accent
hover:bg-accent-strong.
2. 4× bg-surface-card hover:bg-surface-card no-op hovers (Collapse,
Open, Hide-Advanced, Refresh, Cancel). Lift to surface-elevated
so the buttons visibly respond.
3. Delete row button: text-bad hover:text-bad was a no-op. Switched
to a light hover bg + focus-visible danger ring so the destructive
action visibly responds and keyboard users see focus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three matched fixes for the inline Delete-All and Delete-File confirm
banners — same patterns shipped on ConfirmDialog/ApprovalBanner/
DeleteCascade:
1. Delete buttons hovered LIGHTER (bg-red-500 over bg-red-600). On
white text drops below AA contrast. Flipped to bg-red-700.
2. Cancel buttons hover was a no-op (bg-surface-card on top of
itself). Lift to surface-elevated, matching the Cancel pattern in
ConfirmDialog.
3. None of the four buttons had focus-visible rings. Added danger
ring on Delete, accent ring on Cancel, with ring-offset-surface
so the offset color matches the inline banner backdrop.
4. Wrapped both confirm banners in role="alertdialog" + aria-
labelledby pointing to the prompt text — SR users hear the
destructive prompt immediately instead of as ambient text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UIUX fixes for the workspace Traces tab — same pattern
shipped on EventsTab.
1. Status dots were hardcoded bg-red-400 / bg-emerald-400 — semantic-
token misses. Switched to bg-bad / bg-good so they pin to the
canvas-wide ramp instead of Tailwind raw tones.
2. Trace expander rows had no aria-expanded — SR users heard a
generic "button" with no toggle indication. Added aria-expanded
+ aria-controls pointing to the detail panel id.
3. Refresh + each expander button now carry focus-visible:ring-accent
so keyboard users see where focus lands. Both were hover-only
before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UIUX fixes for the workspace terminal status bar.
1. Status dots were hardcoded bg-green-500 / bg-yellow-500 /
bg-red-500 / bg-zinc-500 — semantic-token misses. Switched to
bg-good / bg-warm / bg-bad / bg-ink-soft so the colors flip with
the canvas-wide ramp instead of pinning Tailwind raw values.
2. Reconnect button used hardcoded text-blue-400 / hover:text-blue-300
with no focus ring. Switched to text-accent / hover:text-accent-strong
for theme parity, and added focus-visible:ring-accent/60 so
keyboard users see where focus lands on a recovery action.
3. Error banner used text-red-400 — switched to text-bad to match the
semantic ramp.
Status-bar bg/border kept as zinc (terminal body stays dark
unconditionally per the Canvas v4 design rule); only the chrome's
foreground tokens needed semanticisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four UIUX fixes for the workspace Events tab.
1. Hardcoded text-yellow-400 (DEGRADED) and text-purple-400
(AGENT_CARD_UPDATED) didn't theme-flip — read fine in dark mode,
washed out in warm-paper light. Switched DEGRADED → text-warm
(the semantic warm/amber token) and AGENT_CARD_UPDATED → text-
accent (informational metadata, accent is the right semantic).
2. Refresh button hover was a no-op (bg-surface-card on top of itself).
Lift to surface-elevated, matching the Cancel pattern from
ConfirmDialog. Added focus-visible ring.
3. Event expander rows had no aria-expanded — screen readers heard a
generic "button" with no indication it toggled. Added
aria-expanded + aria-controls pointing to the payload panel id.
4. Added focus-visible ring on each expander button. Hover bg added
too so the active row visibly responds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five fixes for the first-time-user wizard. Every new user sees this,
so visual bugs here have outsized impact.
1. Action button hovered LIGHTER: bg-accent-strong/90 hover:bg-accent.
accent is the LIGHTER variant — hovering to it on white text drops
contrast below AA. Flipped the direction: bg-accent
hover:bg-accent-strong, matching the same trap fixed in
ConfirmDialog and ApprovalBanner.
2. "Next" button hover was a no-op (bg-surface-card on top of itself).
Lift to surface-elevated, matching the Cancel pattern in
ConfirmDialog.
3. Progress bar gradient was hardcoded from-blue-500 to-sky-400 —
neither tone exists in the warm-paper light theme, so the bar lost
brand color in light mode. Switched to the accent ramp so it stays
brand-tinted in both.
4. Step indicator was hardcoded text-sky-400/80, same theme-flip
issue. Switched to text-accent.
5. All three buttons (Skip / Action / Next) had no focus-visible
rings. Added the accent ring pattern used across the rest of
the canvas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five fixes for the terms-acceptance modal:
1. CRITICAL: aria-hidden="true" on the modal's wrapper hid the dialog
AND its descendants from screen readers. The entire ToS-acceptance
flow was invisible to AT users. Removed the false aria-hidden — the
wrapper is just a backdrop, the dialog inside still has role=dialog
aria-modal=true so AT recognises it correctly.
2. Added focus management: when the modal opens, focus moves to the
"I agree" button (WCAG 2.4.3). Hard gate so no focus-trap loop or
Esc-dismiss — the user must accept or close the page.
3. "I agree" button hovered LIGHTER (bg-emerald-500 over bg-emerald-600).
On white text that drops below AA — same trap fixed in ApprovalBanner
and ConfirmDialog. Flipped to bg-emerald-700.
4. Added focus-visible ring on the "I agree" button. Was relying on
browser default outline only.
5. Privacy/Terms links: hardcoded text-sky-400 → text-accent (theme-
aware) + hover:text-accent-strong (was hover:text-sky-400, no-op
same color) + focus-visible ring. Added aria-describedby pointing
to the body div so SR can read the description with the title.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>