File size: 7,216 Bytes
5710dd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | /**
* Workspace Services view — the workspace-scope Service reflection as a
* standalone rail view. Same Postman-style three-pane layout
* (`ScopePanelsScrollspy`) as App Services, plus a left sidebar holding the
* workspace picker: a server-side directory browser (`WorkspaceDirBrowser`,
* over the App-scope `IHostFolderBrowser`) that marks already-registered
* workspaces and registers a picked folder on demand. The proxies resolve on
* the `/workspace/:id` route, so a workspace must be selected before any
* Service is callable. Picking one materializes its handler on demand
* server-side (`IWorkspaceLifecycleService.handlerFor` is create-or-get), no
* manual join needed.
*/
import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useConnection } from '../connection';
import { fetchWorkspaceSnapshot } from '../snapshots/api';
import { Badge, ErrorLine } from '../ui';
import { WorkspaceDirBrowser } from './WorkspaceDirBrowser';
export function WorkspaceServicesView() {
const { klient, baseUrl } = useConnection();
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
const workspaces = useQuery({
queryKey: ['workspaces', klient.baseUrl],
queryFn: () => klient.core(IWorkspaceService).list(),
});
const snapshot = useQuery({
queryKey: ['workspace-snapshot', klient.baseUrl, workspaceId],
queryFn: () => fetchWorkspaceSnapshot(klient, workspaceId as string),
enabled: workspaceId !== null,
refetchInterval: 1_000,
});
useEffect(() => {
setWorkspaceId(null);
}, [baseUrl]);
const selected = (workspaces.data ?? []).find((ws) => ws.id === workspaceId);
const data = snapshot.data;
return (
<div className="flex min-h-0 min-w-0 flex-1">
<aside className="flex w-72 shrink-0 flex-col border-r border-neutral-800">
<div className="border-b border-neutral-800 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
Workspace
</div>
<div title={selected?.root} className="truncate font-mono text-[11px] text-neutral-300">
{selected === undefined ? (
<span className="text-neutral-600 italic">none selected</span>
) : (
`${selected.name} — ${selected.root}`
)}
</div>
{workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
</div>
<WorkspaceDirBrowser
klient={klient}
workspaces={workspaces.data}
onSelect={(workspace) => setWorkspaceId(workspace.id)}
/>
</aside>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4">
{workspaceId === null ? (
<div className="flex h-full items-center justify-center text-[12px] text-neutral-600 italic">
select a workspace to inspect
</div>
) : snapshot.isError ? (
<ErrorLine error={snapshot.error} />
) : data === undefined ? (
<div className="text-[12px] text-neutral-600">loading workspace snapshot…</div>
) : (
<div className="space-y-4">
<SnapshotPanel title="Workspace">
<SnapshotRow label="id" value={data.metadata.id} />
<SnapshotRow label="name" value={data.metadata.name} />
<SnapshotRow label="root" value={data.metadata.root} />
<SnapshotRow label="lifecycle" value={<Badge tone="sky">{data.lifecycle}</Badge>} />
</SnapshotPanel>
<SnapshotPanel title="Program">
<SnapshotRow label="binding" value={`${data.program.binding.workspaceId} / ${data.program.binding.runtimeId}`} />
<SnapshotRow label="status" value={<Badge tone={data.program.status === 'ready' ? 'green' : 'neutral'}>{data.program.status}</Badge>} />
<SnapshotRow label="ready" value={String(data.program.ready)} />
<SnapshotRow label="generation" value={data.program.generation ?? 'unavailable'} />
<SnapshotRow label="trusted" value={data.program.trusted === undefined ? 'unknown' : String(data.program.trusted)} />
<SnapshotRow label="skills" value={`${data.program.catalog.skills.total} total / ${data.program.catalog.skills.invocable} invocable / ${data.program.catalog.skills.skipped} skipped`} />
<SnapshotRow label="agent profiles" value={String(data.program.catalog.agentProfiles)} />
<SnapshotRow label="MCP servers" value={String(data.program.catalog.mcpServers)} />
</SnapshotPanel>
<SnapshotPanel title="Runtimes">
{data.runtimes.runtimes.length === 0 ? (
<div className="text-[11px] text-neutral-600">no current generations</div>
) : data.runtimes.runtimes.map((runtime) => (
<div key={`${runtime.runtimeId}:${runtime.generation}`} className="rounded border border-neutral-800 bg-neutral-950/40 p-2">
<div className="mb-1 flex items-center gap-2">
<span className="font-mono text-[12px] text-neutral-200">{runtime.runtimeId}</span>
<Badge tone={runtime.status === 'ready' ? 'green' : 'neutral'}>{runtime.status}</Badge>
</div>
<SnapshotRow label="generation" value={runtime.generation} />
<SnapshotRow label="capabilities" value={runtime.capabilities.join(', ') || 'none'} />
</div>
))}
</SnapshotPanel>
<SnapshotPanel title="Source provenance">
<SnapshotRow label="skills" value={data.program.sources.skills.map((source) => `${source.source}:${source.count}`).join(', ') || 'none'} />
<SnapshotRow label="skill roots" value={data.program.sources.skillRoots.join(', ') || 'none'} />
<SnapshotRow label="agent profiles" value={data.program.sources.agentProfiles.map((source) => `${source.sourceId}:${source.profiles.join('|')}`).join(', ') || 'none'} />
<SnapshotRow label="instructions" value={data.program.sources.instructionPaths.join(', ') || 'none'} />
<SnapshotRow label="MCP" value={data.program.sources.mcpServers.join(', ') || 'none'} />
</SnapshotPanel>
</div>
)}
</div>
</div>
);
}
function SnapshotPanel({ title, children }: { readonly title: string; readonly children: ReactNode }) {
return (
<section className="rounded border border-neutral-800 bg-neutral-900/30 p-3">
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-400">{title}</h2>
<div className="space-y-1">{children}</div>
</section>
);
}
function SnapshotRow({ label, value }: { readonly label: string; readonly value: ReactNode }) {
return (
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2 text-[11px]">
<span className="text-neutral-600">{label}</span>
<span className="min-w-0 break-all font-mono text-neutral-300">{value}</span>
</div>
);
}
|