The locale picker's labels are now AUTONYMS computed from Intl -- each language named in its own language, so "Português europeu" reads as Portuguese whichever locale the UI is in. That is the convention for a language picker and the useful behaviour: somebody who cannot read the current language still has to find theirs. CapabilityPage interpolated the capability KIND into six strings by lowercasing it in code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now. What is left is two strings, both deliberate: see the next commit message.
201 lines
8.0 KiB
TypeScript
201 lines
8.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Link, useParams } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
|
|
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
|
|
type Registry = { tasks: TaskRow[]; detached: DetachedRow[] };
|
|
type ProgressLine = { phase?: string; status?: string; pct?: number; detail?: string; cap?: string; job?: string };
|
|
|
|
const POLL_MS = 3000;
|
|
const MAX_LINES = 600;
|
|
|
|
// Which run you are following is `/activity/:id`. The two row kinds stream through different query
|
|
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
|
|
// the screen re-derives the param from the registry row — one address shape for both kinds.
|
|
export const ActivityScreen = () => {
|
|
const { t } = useTranslation();
|
|
const { token, get } = useClient();
|
|
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
|
const [regLoaded, setRegLoaded] = useState(false);
|
|
const selectedId = useParams<{ id: string }>().id ?? null;
|
|
const [lines, setLines] = useState<string[]>([]);
|
|
const [progress, setProgress] = useState<ProgressLine | null>(null);
|
|
const esRef = useRef<EventSource | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// 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(() => {});
|
|
tick();
|
|
const iv = setInterval(tick, POLL_MS);
|
|
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)}`;
|
|
|
|
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
|
|
useEffect(() => {
|
|
esRef.current?.close();
|
|
setLines([]);
|
|
setProgress(null);
|
|
if (!query) return;
|
|
const es = new EventSource(`/api/activity/stream?${query}&token=${encodeURIComponent(token ?? '')}`);
|
|
esRef.current = es;
|
|
es.onmessage = (ev) => {
|
|
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 */
|
|
}
|
|
};
|
|
return () => es.close();
|
|
}, [query, token]);
|
|
|
|
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'}`;
|
|
|
|
return (
|
|
<div className="flex h-full w-full">
|
|
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
|
|
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
|
|
</div>
|
|
|
|
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
{t('activity.backgroundTasks')}
|
|
</div>
|
|
{reg.tasks.length === 0 && (
|
|
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
|
|
)}
|
|
{reg.tasks.map((task) => (
|
|
<Link
|
|
key={task.id}
|
|
to={`/activity/${encodeURIComponent(task.id)}`}
|
|
className={rowCls(selectedId === task.id)}
|
|
title={task.cwd}
|
|
>
|
|
<span
|
|
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
|
/>
|
|
<span className="truncate font-mono text-xs">{task.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">
|
|
{t('activity.detached')}
|
|
</div>
|
|
)}
|
|
{reg.detached.map((d) => (
|
|
<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>
|
|
))}
|
|
</aside>
|
|
|
|
<main className="flex min-w-0 flex-1 flex-col">
|
|
{selectedId ? (
|
|
<>
|
|
<div className="shrink-0 border-b border-border p-3">
|
|
<div className="flex items-center gap-2 text-sm text-foreground">
|
|
<Radio size={14} className="text-primary" />
|
|
<span className="truncate font-mono">{selectedId}</span>
|
|
</div>
|
|
{progress && (
|
|
<div className="mt-2">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span className="truncate">
|
|
{[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>
|
|
</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>
|
|
)}
|
|
</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"
|
|
>
|
|
{lines.length === 0 ? (
|
|
<span className="text-muted-foreground">
|
|
{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">
|
|
{t('activity.backToList')}
|
|
</Link>
|
|
</>
|
|
) : (
|
|
'loading…'
|
|
)}
|
|
</span>
|
|
) : (
|
|
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">
|
|
{t('activity.selectTask')}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|