From a288e0a996fae98bbd7080d0be55440f884d72bf Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Mon, 8 Jun 2026 00:55:18 +0800 Subject: [PATCH 1/2] enhance(studio): group sidebar workspaces by remote server The sidebar previously only showed workspaces from the active server, forcing users to round-trip through Settings to see what was on other servers. Now every configured remote renders as its own section so the full inventory is visible in one glance and a single click switches both server and workspace. - api: rawFetch helper splits transport from singleton resolution so the new fetchJsonAt(remoteId, ...) can target any remote without disturbing the active one - useAllRemoteWorkspaces hook parallel-fetches /workspaces from every remote via useQueries; unauthenticated and unreachable states are surfaced so the sidebar can render Sign In / error affordances inline - useResetOnRemoteChange clears the react-query cache when the active remote flips so workspace-scoped queries don't return stale data from the previous server - Sidebar renders one section per remote with Notion-style heading (Laptop / Globe icon + name); clicking a workspace from a non-active remote silently switches active before navigating - ListRow gains stripeClassName / stripeDim so the row can advertise which remote it belongs to via a 2px left-edge bar that coexists with the active indicator - Details affordance moved from - - )} - - {collapsed && ( -
- -
- )} + {remoteResults.map(result => ( + + ))} {activeWorkspaceId && ( @@ -196,9 +184,6 @@ export function Sidebar({ ) : ( <> - {/* Empty flex spacer reserves the left of the utility row - for a future user / account avatar; without it the - icons would drift to centre when account is absent. */}
@@ -218,6 +203,278 @@ export function Sidebar({ ) } +function RemoteSection({ + result, + collapsed, + activeWorkspaceId, + activeRemoteId, + onSelectWorkspace, + onOpenDetails, + onOpenAdd, + onSignIn, +}: { + result: RemoteWorkspacesResult + collapsed: boolean + activeWorkspaceId: string | null + activeRemoteId: string + onSelectWorkspace: (remote: RemoteSummary, workspaceId: string) => void + onOpenDetails: (workspaceId: string) => void + onOpenAdd: (remote: RemoteSummary) => void + onSignIn: (remote: RemoteSummary) => void +}) { + const { remote, state } = result + const isActiveRemote = remote.id === activeRemoteId + const stripe = remoteStripeClass(remote) + const Icon = remote.isLocal ? Laptop : Globe + + return ( +
+ {!collapsed && ( +
+
+ + + {remote.name} + +
+ {state.kind === 'ok' && ( + + )} +
+ )} + + {collapsed && ( +
+ + +
+ + {remote.name} + +
+ )} + + +
+ ) +} + +function RemoteContent({ + state, + remote, + stripe, + collapsed, + isActiveRemote, + activeWorkspaceId, + onSelectWorkspace, + onOpenDetails, + onOpenAdd, + onSignIn, +}: { + state: RemoteWorkspacesResult['state'] + remote: RemoteSummary + stripe: string + collapsed: boolean + isActiveRemote: boolean + activeWorkspaceId: string | null + onSelectWorkspace: (remote: RemoteSummary, workspaceId: string) => void + onOpenDetails: (workspaceId: string) => void + onOpenAdd: (remote: RemoteSummary) => void + onSignIn: (remote: RemoteSummary) => void +}) { + if (state.kind === 'loading') { + if (collapsed) + return null + return ( +
Loading…
+ ) + } + + if (state.kind === 'unauthenticated') { + if (collapsed) { + return ( +
+ + + + + {`Sign in to ${remote.name}`} + +
+ ) + } + return ( + + ) + } + + if (state.kind === 'error') { + if (collapsed) + return null + return ( +
+ Unreachable +
+ ) + } + + const workspaces = state.workspaces + if (workspaces.length === 0) { + if (collapsed) { + return ( +
+ + + + + {`Open workspace on ${remote.name}`} + +
+ ) + } + return ( +
No workspace yet.
+ ) + } + + return ( +
    + {workspaces.map(ws => ( + onSelectWorkspace(remote, ws.id)} + onOpenDetails={() => onOpenDetails(ws.id)} + /> + ))} +
+ ) +} + +function WorkspaceRow({ + workspace, + remote, + stripe, + collapsed, + isActiveRemote, + active, + onClick, + onOpenDetails, +}: { + workspace: Workspace + remote: RemoteSummary + stripe: string + collapsed: boolean + isActiveRemote: boolean + active: boolean + onClick: () => void + onOpenDetails: () => void +}) { + // Inactive remotes get a dimmer stripe so the active server still + // reads first while server identity stays visible across all rows. + return ( + + {collapsed + ? ( + isActiveRemote + ? ( + + ) + : ( + + ) + ) + : ( + <> + + {workspace.id} + {isActiveRemote && } + { + e.stopPropagation() + onOpenDetails() + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation() + onOpenDetails() + } + }} + className="ml-1 hidden rounded p-0.5 text-sidebar-foreground/40 hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:inline-flex" + title="Details" + > + ⋯ + + + )} + + ) +} + function SidebarIconButton({ onClick, title, children }: { onClick: () => void title: string @@ -248,25 +505,6 @@ function ThemeToggle() { ) } -function ActiveServerLabel({ collapsed }: { collapsed: boolean }) { - const remotes = useRemotes() - const activeId = useActiveRemoteId() - if (remotes.length === 0 || collapsed) - return null - const isLocal = activeId === LOCAL_REMOTE_ID - const activeName = isLocal - ? 'Local' - : remotes.find(r => r.id === activeId)?.name ?? 'Local' - const Icon = isLocal ? Laptop : Globe - return ( -
- - Server: - {activeName} -
- ) -} - function HereSection({ workspaceId, activeSurface, @@ -286,21 +524,11 @@ function HereSection({ const { data: skills } = useSkills(workspaceId) const pendingProposals = proposals?.items.length ?? 0 const pendingClarify = clarify?.items.length ?? 0 - // HITL tabs are hidden when the viewer can't act on them. The server - // 403-gates the same routes, so devtools tampering changes nothing. const canSeeProposals = policy.can('proposal.read') const canSeeClarify = policy.can('clarify.read') - // Actions tab is visible when the viewer can run at least one - // workspace skill. Owners/maintainers trivially can; guests get the - // tab only when a skill's allowedRoles or their per-member override - // gives them something runnable (e.g. braid-ask in the default - // catalog). const canRunActions = (skills?.items ?? []).some(s => !s.frontmatter.braid.hidden && policy.can('skill.run', { skill: s.frontmatter, skillId: s.id }), ) - // History page exposes Tag / Restore actions which are owner-only. - // Even though the server 403s, showing the buttons to guests is - // misleading; hide the whole tab for them. const canSeeHistory = policy.effectiveRole !== null && policy.effectiveRole !== 'guest' return ( @@ -424,9 +652,6 @@ function HereRow({ collapsed, icon: Icon, label, active, count = 0, shortcut, on ) : ( <> - {/* size-5 wrapper so the icon column matches the workspace - swatch column above (both 20px wide), keeping label - x-positions aligned across both sections. */}
@@ -454,13 +679,6 @@ function HereRow({ collapsed, icon: Icon, label, active, count = 0, shortcut, on } function WorkspaceBadges({ workspaceId }: { workspaceId: string }) { - // For the active workspace, useWorkspaceEvents keeps these query keys - // live. For inactive workspaces the counts are slightly stale until - // the user opens it; acceptable cost to avoid N concurrent SSEs. - // Three kinds (in-flight runs, pending clarifies, pending proposals) - // aggregate into a single number; the active workspace's HERE - // section is where the per-surface breakdown lives. A tooltip keeps - // the breakdown a hover away for the inactive-workspace case. const { data: proposals } = usePendingProposals(workspaceId) const { data: clarify } = usePendingClarify(workspaceId) const { data: runs } = useRuns(workspaceId) diff --git a/packages/studio/src/lib/api.ts b/packages/studio/src/lib/api.ts index 2220994..3d3b76c 100644 --- a/packages/studio/src/lib/api.ts +++ b/packages/studio/src/lib/api.ts @@ -34,7 +34,8 @@ import type { } from '@braidhq/schema' import { getAuthToken } from './authToken.js' import { getCurrentUserId } from './currentUser.js' -import { getServerUrl } from './serverUrl.js' +import { getTokenFor } from './remotes.js' +import { getServerUrl, getServerUrlFor } from './serverUrl.js' export function workspaceEventsUrl(workspaceId: string): string { return `${getServerUrl()}/workspaces/${workspaceId}/events` @@ -126,9 +127,8 @@ export class ApiError extends Error { } } -async function fetchJson(path: string, init?: RequestInit): Promise { - const token = getAuthToken() - const response = await fetch(`${getServerUrl()}${path}`, { +async function rawFetch(baseUrl: string, token: string | null, path: string, init?: RequestInit): Promise { + const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', @@ -158,6 +158,19 @@ async function fetchJson(path: string, init?: RequestInit): Promise { return response.json() as Promise } +async function fetchJson(path: string, init?: RequestInit): Promise { + return rawFetch(getServerUrl(), getAuthToken(), path, init) +} + +/** + * Like `fetchJson` but targets an explicit remote regardless of the active + * one. The sidebar uses this to enumerate workspaces across every + * configured server without disturbing the active singleton. + */ +async function fetchJsonAt(remoteId: string, path: string, init?: RequestInit): Promise { + return rawFetch(getServerUrlFor(remoteId), getTokenFor(remoteId), path, init) +} + export interface AuthConfig { googleEnabled: boolean studioUrl: string @@ -224,6 +237,8 @@ export const api = { }), listWorkspaces: () => fetchJson>('/workspaces'), + listWorkspacesAt: (remoteId: string) => + fetchJsonAt>(remoteId, '/workspaces'), getWorkspace: (workspaceId: string) => fetchJson(`/workspaces/${workspaceId}`), registerWorkspace: (rootPath: string) => diff --git a/packages/studio/src/lib/useRemoteWorkspaces.ts b/packages/studio/src/lib/useRemoteWorkspaces.ts new file mode 100644 index 0000000..f3d3f33 --- /dev/null +++ b/packages/studio/src/lib/useRemoteWorkspaces.ts @@ -0,0 +1,100 @@ +import type { Workspace } from '@braidhq/schema' +import { useQueries, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { api, ApiError } from './api' +import { getTokenFor, LOCAL_REMOTE_ID, useActiveRemoteId, useRemotes } from './remotes' +import { getServerUrlFor } from './serverUrl' + +export interface RemoteSummary { + id: string + name: string + url: string + isLocal: boolean +} + +export type RemoteWorkspacesState = + | { kind: 'loading' } + | { kind: 'ok', workspaces: Workspace[] } + | { kind: 'unauthenticated' } + | { kind: 'error', message: string } + +export interface RemoteWorkspacesResult { + remote: RemoteSummary + state: RemoteWorkspacesState +} + +export interface ClassifyInput { + hasToken: boolean + isPending: boolean + error: unknown + data: { items: Workspace[] } | undefined +} + +/** + * Pure classifier extracted so the state-machine for "no token / loading + * / 401 / network / ok" can be tested without standing up react-query. + * A 401 collapses to `unauthenticated` because the user's recourse is + * the same as having no token at all: sign in. + */ +export function classifyRemoteResult(remote: RemoteSummary, input: ClassifyInput): RemoteWorkspacesResult { + if (!remote.isLocal && !input.hasToken) + return { remote, state: { kind: 'unauthenticated' } } + if (input.isPending) + return { remote, state: { kind: 'loading' } } + if (input.error) { + if (input.error instanceof ApiError && input.error.status === 401) + return { remote, state: { kind: 'unauthenticated' } } + const message = input.error instanceof Error ? input.error.message : 'Unreachable' + return { remote, state: { kind: 'error', message } } + } + return { remote, state: { kind: 'ok', workspaces: input.data?.items ?? [] } } +} + +/** + * Fetch `/workspaces` from every configured remote in parallel. Local is + * always queried (X-Braid-User fallback covers the no-token sidecar + * case); remotes without a stored token short-circuit to + * `unauthenticated` so the sidebar can render a Sign in affordance + * without ever issuing a doomed request. + */ +export function useAllRemoteWorkspaces(): RemoteWorkspacesResult[] { + const remotes = useRemotes() + const all: RemoteSummary[] = [ + { id: LOCAL_REMOTE_ID, name: 'Local', url: getServerUrlFor(LOCAL_REMOTE_ID), isLocal: true }, + ...remotes.map(r => ({ id: r.id, name: r.name, url: r.url, isLocal: false })), + ] + const queries = useQueries({ + queries: all.map(remote => ({ + queryKey: ['workspaces-at', remote.id] as const, + queryFn: () => api.listWorkspacesAt(remote.id), + retry: false, + enabled: remote.isLocal || getTokenFor(remote.id) != null, + })), + }) + return all.map((remote, i) => { + const query = queries[i]! + return classifyRemoteResult(remote, { + hasToken: getTokenFor(remote.id) != null, + isPending: query.isPending, + error: query.error, + data: query.data, + }) + }) +} + +/** + * Clears the react-query cache whenever the active remote flips. Without + * this every workspace-scoped query (proposals, clarify, history…) would + * return stale data from the previous server until its own TTL expired. + */ +export function useResetOnRemoteChange(): void { + const queryClient = useQueryClient() + const activeId = useActiveRemoteId() + const prevRef = useRef(activeId) + useEffect(() => { + if (prevRef.current !== activeId) { + queryClient.clear() + prevRef.current = activeId + } + }, [activeId, queryClient]) +} diff --git a/packages/studio/test/lib/useRemoteWorkspaces.test.ts b/packages/studio/test/lib/useRemoteWorkspaces.test.ts new file mode 100644 index 0000000..09ea1fc --- /dev/null +++ b/packages/studio/test/lib/useRemoteWorkspaces.test.ts @@ -0,0 +1,83 @@ +import type { Workspace } from '@braidhq/schema' +import { describe, expect, it } from 'vitest' +import { ApiError } from '../../src/lib/api' +import { classifyRemoteResult, type RemoteSummary } from '../../src/lib/useRemoteWorkspaces' + +const LOCAL: RemoteSummary = { id: 'local', name: 'Local', url: 'http://localhost:4321', isLocal: true } +const REMOTE: RemoteSummary = { id: 'r-team', name: 'team', url: 'https://team.example.com', isLocal: false } + +function ws(id: string): Workspace { + return { id, rootPath: '/tmp', members: [] } as unknown as Workspace +} + +describe('classifyRemoteResult', () => { + it('skips remotes that have no stored token before considering the query state', () => { + const result = classifyRemoteResult(REMOTE, { + hasToken: false, + isPending: false, + error: undefined, + data: undefined, + }) + expect(result.state.kind).toBe('unauthenticated') + }) + + it('does not skip Local when there is no token, since X-Braid-User covers local trust mode', () => { + const result = classifyRemoteResult(LOCAL, { + hasToken: false, + isPending: false, + error: undefined, + data: { items: [ws('braid')] }, + }) + expect(result.state).toEqual({ kind: 'ok', workspaces: [ws('braid')] }) + }) + + it('reports loading before the first response settles', () => { + const result = classifyRemoteResult(LOCAL, { + hasToken: true, + isPending: true, + error: undefined, + data: undefined, + }) + expect(result.state.kind).toBe('loading') + }) + + it('collapses 401 to unauthenticated so the sidebar prompts a re-sign-in', () => { + const result = classifyRemoteResult(REMOTE, { + hasToken: true, + isPending: false, + error: new ApiError('Unauthorized', 401), + data: undefined, + }) + expect(result.state.kind).toBe('unauthenticated') + }) + + it('surfaces non-401 errors with their message so the user can see what is wrong', () => { + const result = classifyRemoteResult(REMOTE, { + hasToken: true, + isPending: false, + error: new Error('Network unreachable'), + data: undefined, + }) + expect(result.state).toEqual({ kind: 'error', message: 'Network unreachable' }) + }) + + it('returns the workspace list on success', () => { + const result = classifyRemoteResult(REMOTE, { + hasToken: true, + isPending: false, + error: undefined, + data: { items: [ws('alpha'), ws('beta')] }, + }) + expect(result.state).toEqual({ kind: 'ok', workspaces: [ws('alpha'), ws('beta')] }) + }) + + it('treats missing data as an empty workspace list rather than crashing', () => { + const result = classifyRemoteResult(REMOTE, { + hasToken: true, + isPending: false, + error: undefined, + data: undefined, + }) + expect(result.state).toEqual({ kind: 'ok', workspaces: [] }) + }) +}) From 3fad62c29fe3e7ad3e8e4aa94ad1b7e4921436de Mon Sep 17 00:00:00 2001 From: mroops0111 Date: Mon, 8 Jun 2026 11:39:21 +0800 Subject: [PATCH 2/2] fix(server): tolerate stale registry entries in workspace list list() now skips unreadable rootPaths with a warn-level log so one ghost entry doesn't block boot. load() unchanged. Also drops the unused api.registerWorkspace shim in Studio whose server endpoint was removed earlier. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../infrastructure/fs/FsWorkspaceRepository.ts | 15 +++++++++++++-- .../fs/FsWorkspaceRepository.test.ts | 14 ++++++++++++++ packages/studio/src/lib/api.ts | 2 -- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/server/src/infrastructure/fs/FsWorkspaceRepository.ts b/packages/server/src/infrastructure/fs/FsWorkspaceRepository.ts index 140f9f9..7aa732b 100644 --- a/packages/server/src/infrastructure/fs/FsWorkspaceRepository.ts +++ b/packages/server/src/infrastructure/fs/FsWorkspaceRepository.ts @@ -2,11 +2,13 @@ import type { Workspace as WorkspaceData } from '@braidhq/schema' import type { WorkspaceRegistryFile } from './WorkspaceRegistryFile.js' import { readFile, stat } from 'node:fs/promises' import { resolve } from 'node:path' -import { NotFoundError, ValidationError, Workspace, type WorkspaceRepository } from '@braidhq/core' +import { createLogger, NotFoundError, ValidationError, Workspace, type WorkspaceRepository } from '@braidhq/core' import { AbsolutePath, ProductManifest, WorkspaceId } from '@braidhq/schema' import { parseMarkdownFrontmatter } from './frontmatter.js' import { workspaceProductManifestPath } from './paths.js' +const log = createLogger('server').child({ mod: 'workspace-repo' }) + export interface FsWorkspaceRepositoryDeps { readonly registry: WorkspaceRegistryFile } @@ -16,11 +18,20 @@ export class FsWorkspaceRepository implements WorkspaceRepository { constructor(private readonly deps: FsWorkspaceRepositoryDeps) {} + // One unreadable rootPath (deleted dir, broken PRODUCT.md, ...) + // must not starve the rest. The asymmetry with `load()` is intentional: + // aggregate views recover, single-entity lookups distinguish present + // from absent. async list(): Promise { const rootPaths = await this.deps.registry.list() const workspaces: Workspace[] = [] for (const rootPath of rootPaths) { - workspaces.push(await this.load(rootPath)) + try { + workspaces.push(await this.load(rootPath)) + } + catch (err) { + log.warn({ err, rootPath }, 'skipping unreadable registry entry') + } } return workspaces } diff --git a/packages/server/test/infrastructure/fs/FsWorkspaceRepository.test.ts b/packages/server/test/infrastructure/fs/FsWorkspaceRepository.test.ts index 6305932..5f60d90 100644 --- a/packages/server/test/infrastructure/fs/FsWorkspaceRepository.test.ts +++ b/packages/server/test/infrastructure/fs/FsWorkspaceRepository.test.ts @@ -78,6 +78,20 @@ describe('FsWorkspaceRepository', () => { await expect(repository.load(rootPath)).rejects.toThrow(NotFoundError) }) + it('list skips registry entries whose workspace directory is missing', async () => { + const liveRoot = AbsolutePath.parse(await createWorkspaceDir({ name: 'alive' })) + const ghostRoot = AbsolutePath.parse('/tmp/braid-ghost-workspace-does-not-exist') + const registry = await makeRegistry() + // Stamp the stale entry directly; load+save would throw before saving. + await registry.add(ghostRoot) + await registry.add(liveRoot) + + const repository = new FsWorkspaceRepository({ registry }) + const all = await repository.list() + + expect(all.map(w => w.productManifest.name)).toEqual(['alive']) + }) + it('throws ValidationError when frontmatter invalid', async () => { const dir = await mkdtemp(join(tmpdir(), 'braid-ws-')) await writeFile(join(dir, 'PRODUCT.md'), '---\nname: ""\n---\n', 'utf-8') diff --git a/packages/studio/src/lib/api.ts b/packages/studio/src/lib/api.ts index 3d3b76c..4ea4d95 100644 --- a/packages/studio/src/lib/api.ts +++ b/packages/studio/src/lib/api.ts @@ -241,8 +241,6 @@ export const api = { fetchJsonAt>(remoteId, '/workspaces'), getWorkspace: (workspaceId: string) => fetchJson(`/workspaces/${workspaceId}`), - registerWorkspace: (rootPath: string) => - fetchJson('/workspaces', { method: 'POST', body: JSON.stringify({ rootPath }) }), scaffoldWorkspace: (name: string, manifest: ProductManifestDraft) => fetchJson('/workspaces/scaffold', { method: 'POST',