every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,16 +27,33 @@ export const ActivityScreen = () => {
|
||||
// Poll the registry (harness task files + announced detached jobs).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => get<Registry>('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {});
|
||||
const tick = () =>
|
||||
get<Registry>('/activity/tasks')
|
||||
.then((r) => {
|
||||
if (alive) {
|
||||
setReg(r);
|
||||
setRegLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
tick();
|
||||
const iv = setInterval(tick, POLL_MS);
|
||||
return () => { alive = false; clearInterval(iv); };
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The row backing the open id, and the stream query it implies. A string rather than the row object,
|
||||
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
|
||||
const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined;
|
||||
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`;
|
||||
const row = selectedId
|
||||
? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId))
|
||||
: undefined;
|
||||
const query = !row
|
||||
? null
|
||||
: row.source === 'harness'
|
||||
? `task=${encodeURIComponent(row.id)}`
|
||||
: `path=${encodeURIComponent(row.path)}`;
|
||||
|
||||
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
|
||||
useEffect(() => {
|
||||
@@ -50,13 +67,18 @@ export const ActivityScreen = () => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
|
||||
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
|
||||
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||
} catch { /* ignore */ }
|
||||
else if (d.kind === 'line' && typeof d.text === 'string')
|
||||
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return () => es.close();
|
||||
}, [query, token]);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
|
||||
}, [lines]);
|
||||
|
||||
const rowCls = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
|
||||
@@ -68,20 +90,36 @@ export const ActivityScreen = () => {
|
||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||
</div>
|
||||
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Background tasks
|
||||
</div>
|
||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||
{reg.tasks.map((t) => (
|
||||
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}>
|
||||
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||
className={rowCls(selectedId === t.id)}
|
||||
title={t.cwd}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
/>
|
||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{reg.detached.length > 0 && (
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Detached
|
||||
</div>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
<Link key={d.id} to={`/activity/${encodeURIComponent(d.id)}`} className={rowCls(selectedId === d.id)} title={d.path}>
|
||||
<Link
|
||||
key={d.id}
|
||||
to={`/activity/${encodeURIComponent(d.id)}`}
|
||||
className={rowCls(selectedId === d.id)}
|
||||
title={d.path}
|
||||
>
|
||||
<FileText size={13} className="shrink-0" />
|
||||
<span className="truncate">{d.id}</span>
|
||||
</Link>
|
||||
@@ -103,33 +141,54 @@ export const ActivityScreen = () => {
|
||||
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
|
||||
{progress.status ? ` (${progress.status})` : ''}
|
||||
</span>
|
||||
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
|
||||
<span className="shrink-0 pl-2">
|
||||
{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
|
||||
</span>
|
||||
</div>
|
||||
{typeof progress.pct === 'number' && (
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{query ? 'waiting for output…' : regLoaded ? (
|
||||
{query ? (
|
||||
'waiting for output…'
|
||||
) : regLoaded ? (
|
||||
<>
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or it never started.{' '}
|
||||
<Link to="/activity" className="underline">Back to the list</Link>
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||
it never started.{' '}
|
||||
<Link to="/activity" className="underline">
|
||||
Back to the list
|
||||
</Link>
|
||||
</>
|
||||
) : 'loading…'}
|
||||
) : (
|
||||
'loading…'
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-words">
|
||||
{l}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a task to follow its live output
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user