fix: six UX bugs (peers auth, scroll, chat tabs, config persist, + visibility)
Six bugs reported from a live session — all shippable in one commit: 1. Peers tab 401 on local Docker. The /registry/:id/peers endpoint demands a workspace-scoped bearer token (validateDiscoveryCaller) which the canvas session doesn't hold. Added the same Tier-1b dev-mode fail-open hatch that AdminAuth and WorkspaceAuth already use — gated by MOLECULE_ENV=development + empty ADMIN_TOKEN, so SaaS production stays strict. Exported IsDevModeFailOpen from the middleware package for the handler layer to reuse. 2. Org Templates list unscrollable. OrgTemplatesSection was rendered in the TemplatePalette footer — a div without overflow — so when it expanded to 15+ entries the list extended past the viewport with no scroll. Moved it to the top of the flex-1 overflow-y-auto container. Tall lists now scroll naturally. 3. Chat tab: "My Chat" and "Agent Comms" rendered stacked instead of switching. HTML `hidden` attribute was being overridden by Tailwind's `flex` class (display: flex beats the attribute), so both tabpanels rendered concurrently. Swapped to a conditional Tailwind `hidden`/`flex` class so the inactive panel is display:none with proper CSS specificity. 4. Hermes Config form never persists. handleSave wrote config.yaml but name / tier / runtime / model all live on the workspace row (or the dedicated /workspaces/:id/model endpoint) — the form edited in-memory, the request returned 200, the next reload wiped everything back. Hermes + external runtimes manage their own config inside the container anyway, so writing config.yaml is a no-op for them; skip it. Always diff and PATCH the DB-backed fields that actually changed. 5. Channels "+ Connect" dropdown empty on first open. ChannelsTab's load() used Promise.all with a silent catch — if EITHER the channels or adapters fetch failed, both setters were skipped with no error visible. Switched to Promise.allSettled so each endpoint settles independently, and the adapters failure now surfaces via the top-level error state. 6. Plugin registry always "No plugins in registry". Same silent catch pattern in SkillsTab.tsx — load errors for /plugins, /plugins/sources, and /workspaces/:id/plugins swallowed without logging. Replaced the empty catches with console.warn so future failures are at least visible in devtools. Tests: 923 passing (unchanged). Go handler tests pass. Server rebuilt and running with the peers-auth + collapsed-persistence fixes (pid 15875). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4fd7f1e84c
commit
3f11df031c
@ -400,6 +400,11 @@ export function TemplatePalette() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{/* Org templates live INSIDE the scroll container so an
|
||||
* expanded list (15+ entries) is reachable instead of
|
||||
* overflowing the fixed footer below. */}
|
||||
<OrgTemplatesSection />
|
||||
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-zinc-500 text-center py-8">
|
||||
<Spinner />
|
||||
@ -467,7 +472,6 @@ export function TemplatePalette() {
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
|
||||
<OrgTemplatesSection />
|
||||
<ImportAgentButton onImported={loadTemplates} />
|
||||
<button
|
||||
onClick={loadTemplates}
|
||||
|
||||
@ -60,18 +60,27 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
const allowedUsersId = useId();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [chRes, adRes] = await Promise.all([
|
||||
api.get<Channel[]>(`/workspaces/${workspaceId}/channels`),
|
||||
api.get<ChannelAdapter[]>(`/channels/adapters`),
|
||||
]);
|
||||
setChannels(Array.isArray(chRes) ? chRes : []);
|
||||
setAdapters(Array.isArray(adRes) ? adRes : []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Fetch channels and adapters independently so a failure in one
|
||||
// doesn't blank the other. Previously a single Promise.all + silent
|
||||
// catch meant ANY request failing left both `channels` and
|
||||
// `adapters` empty — the user saw a "+ Connect" button with no
|
||||
// platform options, with no clue why.
|
||||
const [chResult, adResult] = await Promise.allSettled([
|
||||
api.get<Channel[]>(`/workspaces/${workspaceId}/channels`),
|
||||
api.get<ChannelAdapter[]>(`/channels/adapters`),
|
||||
]);
|
||||
if (chResult.status === "fulfilled") {
|
||||
setChannels(Array.isArray(chResult.value) ? chResult.value : []);
|
||||
} else {
|
||||
console.warn("ChannelsTab: channels load failed", chResult.reason);
|
||||
}
|
||||
if (adResult.status === "fulfilled") {
|
||||
setAdapters(Array.isArray(adResult.value) ? adResult.value : []);
|
||||
} else {
|
||||
console.warn("ChannelsTab: adapters load failed", adResult.reason);
|
||||
setError("Failed to load channel platforms — try refreshing");
|
||||
}
|
||||
setLoading(false);
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@ -144,12 +144,30 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{/* Content — both panels are always in the DOM so aria-controls targets exist.
|
||||
The inactive panel is hidden via the HTML `hidden` attribute (removed from
|
||||
display and accessibility tree, but present in the DOM for WCAG 4.1.2). */}
|
||||
<div id="chat-panel-my-chat" role="tabpanel" aria-labelledby="chat-tab-my-chat" hidden={subTab !== "my-chat"} className="flex-1 overflow-hidden flex flex-col">
|
||||
Inactive panel is hidden via a conditional `hidden` Tailwind class
|
||||
(display: none) because the native HTML `hidden` attribute is
|
||||
overridden by the panel's own `flex` utility — that's why both
|
||||
sections used to render stacked. */}
|
||||
<div
|
||||
id="chat-panel-my-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chat-tab-my-chat"
|
||||
aria-hidden={subTab !== "my-chat"}
|
||||
className={`flex-1 overflow-hidden flex-col ${
|
||||
subTab === "my-chat" ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
</div>
|
||||
<div id="chat-panel-agent-comms" role="tabpanel" aria-labelledby="chat-tab-agent-comms" hidden={subTab !== "agent-comms"} className="flex-1 overflow-hidden flex flex-col">
|
||||
<div
|
||||
id="chat-panel-agent-comms"
|
||||
role="tabpanel"
|
||||
aria-labelledby="chat-tab-agent-comms"
|
||||
aria-hidden={subTab !== "agent-comms"}
|
||||
className={`flex-1 overflow-hidden flex-col ${
|
||||
subTab === "agent-comms" ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -241,15 +241,53 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
setSuccess(false);
|
||||
try {
|
||||
const content = rawMode ? rawDraft : toYaml(config);
|
||||
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
|
||||
const runtimeManagesOwnConfig = RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "");
|
||||
// Only write the platform-managed config.yaml when the runtime
|
||||
// actually consumes it. Hermes + external runtimes manage their
|
||||
// own config file inside the container, so writing this one is a
|
||||
// no-op at best and can fail with 404 if config.yaml was never
|
||||
// created for this workspace.
|
||||
if (!runtimeManagesOwnConfig) {
|
||||
await api.put(`/workspaces/${workspaceId}/files/config.yaml`, { content });
|
||||
}
|
||||
|
||||
// If runtime changed, update it in the DB so restart uses the correct image
|
||||
const newRuntime = rawMode
|
||||
? (parseYaml(rawDraft).runtime as string || "")
|
||||
: (config.runtime || "");
|
||||
const oldRuntime = (parseYaml(originalYaml).runtime as string || "");
|
||||
if (newRuntime && newRuntime !== oldRuntime) {
|
||||
await api.patch(`/workspaces/${workspaceId}`, { runtime: newRuntime });
|
||||
// DB-backed fields (name, tier, runtime, model) live on the
|
||||
// workspace row, NOT in config.yaml. Fire separate PATCHes for
|
||||
// the ones that actually changed — otherwise a Hermes user edits
|
||||
// the form, hits Save, sees the request succeed, then watches the
|
||||
// values snap back on the next reload because the workspace row
|
||||
// never heard about the change.
|
||||
const oldParsed = parseYaml(originalYaml);
|
||||
const nextParsed = rawMode ? parseYaml(rawDraft) : null;
|
||||
const effective = nextParsed
|
||||
? { ...DEFAULT_CONFIG, ...nextParsed } as ConfigData
|
||||
: config;
|
||||
const dbPatch: Record<string, unknown> = {};
|
||||
if (effective.name && effective.name !== oldParsed.name) {
|
||||
dbPatch.name = effective.name;
|
||||
}
|
||||
if (effective.tier && effective.tier !== (oldParsed.tier ?? null)) {
|
||||
dbPatch.tier = effective.tier;
|
||||
}
|
||||
const oldRuntime = (oldParsed.runtime as string) || "";
|
||||
if (effective.runtime && effective.runtime !== oldRuntime) {
|
||||
dbPatch.runtime = effective.runtime;
|
||||
}
|
||||
if (Object.keys(dbPatch).length > 0) {
|
||||
await api.patch(`/workspaces/${workspaceId}`, dbPatch);
|
||||
}
|
||||
|
||||
// Model has its own endpoint (separate from the general workspace
|
||||
// PATCH) because the runtime may need to validate it against the
|
||||
// template's supported models list.
|
||||
const oldModel = (oldParsed.model as string) || "";
|
||||
if (effective.model && effective.model !== oldModel) {
|
||||
try {
|
||||
await api.put(`/workspaces/${workspaceId}/model`, { model: effective.model });
|
||||
} catch (e) {
|
||||
// Non-fatal — log and continue so the rest of the save commits.
|
||||
console.warn("ConfigTab: model PATCH failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
setOriginalYaml(content);
|
||||
|
||||
@ -68,22 +68,32 @@ export function SkillsTab({ data }: Props) {
|
||||
const loadInstalled = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>(`/workspaces/${workspaceId}/plugins`);
|
||||
if (mountedRef.current) setInstalled(result);
|
||||
} catch { /* ignore */ }
|
||||
if (mountedRef.current) setInstalled(Array.isArray(result) ? result : []);
|
||||
} catch (e) {
|
||||
console.warn("SkillsTab: installed plugins load failed", e);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const loadRegistry = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<PluginInfo[]>("/plugins");
|
||||
if (mountedRef.current) setRegistry(result);
|
||||
} catch { /* ignore */ }
|
||||
if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []);
|
||||
} catch (e) {
|
||||
// Registry is the AVAILABLE PLUGINS list. Silent failure here
|
||||
// left the user seeing "No plugins in registry" with no clue
|
||||
// it was a fetch error — log it so devtools shows the cause.
|
||||
console.warn("SkillsTab: registry load failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSourceSchemes = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.get<SourceSchemesResponse>("/plugins/sources");
|
||||
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
|
||||
} catch { /* ignore — falls back to "local only" UX */ }
|
||||
} catch (e) {
|
||||
console.warn("SkillsTab: plugin sources load failed", e);
|
||||
// Falls back to "local only" UX — non-fatal.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -330,6 +330,15 @@ func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID st
|
||||
if !hasLive {
|
||||
return nil // legacy / pre-upgrade
|
||||
}
|
||||
// Tier-1b dev-mode hatch — same escape hatch AdminAuth and
|
||||
// WorkspaceAuth apply on a local Docker setup. Without this, the
|
||||
// canvas Details tab can never load peers for a workspace that has
|
||||
// registered its live token, producing the 401 the user sees.
|
||||
// Gated by MOLECULE_ENV=development + empty ADMIN_TOKEN, so SaaS
|
||||
// production stays strict.
|
||||
if middleware.IsDevModeFailOpen() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try session cookie auth first (SaaS canvas path).
|
||||
// verifiedCPSession returns (valid, presented):
|
||||
|
||||
@ -54,3 +54,12 @@ func isDevModeFailOpen() bool {
|
||||
_, ok := devModeEnvValues[env]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsDevModeFailOpen exposes isDevModeFailOpen to packages outside the
|
||||
// middleware module (handlers, discovery, etc.) so they can apply the
|
||||
// same Tier-1b escape hatch their sibling AdminAuth / WorkspaceAuth
|
||||
// already do. Keep every call site audit-tagged so security review can
|
||||
// grep them.
|
||||
func IsDevModeFailOpen() bool {
|
||||
return isDevModeFailOpen()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user