put the open task log and the followed run in the url
task-logs was a clean move — the detail fetch already keyed off the id, so only its source changed. activity needed one decision: its two row kinds stream through different query params, so the url carries the id and the screen derives task= or path= from the registry row. the sse effect now depends on that derived string rather than a fresh object, so the 3s poll cannot re-open the stream. an id that has left the registry says so instead of waiting for output forever. /activity also had no page-title rule and read 'Officer'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ export function App() {
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
|
||||
<Route path="/skills" element={<Dashboard.Skills />} />
|
||||
@@ -91,6 +92,7 @@ export function App() {
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/processes/:dirName" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/task-logs/:id" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/jobs" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -10,10 +11,14 @@ type ProgressLine = { phase?: string; status?: string; pct?: number; detail?: st
|
||||
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 { token, get } = useClient();
|
||||
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
||||
const [selected, setSelected] = useState<{ label: string; query: string } | null>(null);
|
||||
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);
|
||||
@@ -22,19 +27,24 @@ export const ActivityScreen = () => {
|
||||
// Poll the registry (harness task files + announced detached jobs).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => get<Registry>('/activity/tasks').then((r) => alive && setReg(r)).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); };
|
||||
}, []);
|
||||
|
||||
// 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 (!selected) return;
|
||||
const es = new EventSource(`/api/activity/stream?${selected.query}&token=${encodeURIComponent(token ?? '')}`);
|
||||
if (!query) return;
|
||||
const es = new EventSource(`/api/activity/stream?${query}&token=${encodeURIComponent(token ?? '')}`);
|
||||
esRef.current = es;
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
@@ -44,7 +54,7 @@ export const ActivityScreen = () => {
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
return () => es.close();
|
||||
}, [selected, token]);
|
||||
}, [query, token]);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
|
||||
|
||||
@@ -61,30 +71,30 @@ export const ActivityScreen = () => {
|
||||
<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) => (
|
||||
<button key={t.id} type="button" onClick={() => setSelected({ label: t.id, query: `task=${encodeURIComponent(t.id)}` })} className={rowCls(selected?.label === t.id)} title={t.cwd}>
|
||||
<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>
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
<button key={d.id} type="button" onClick={() => setSelected({ label: d.id, query: `path=${encodeURIComponent(d.path)}` })} className={rowCls(selected?.label === 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>
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{selected ? (
|
||||
{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">{selected.label}</span>
|
||||
<span className="truncate font-mono">{selectedId}</span>
|
||||
</div>
|
||||
{progress && (
|
||||
<div className="mt-2">
|
||||
@@ -105,7 +115,14 @@ export const ActivityScreen = () => {
|
||||
</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">waiting for output…</span>
|
||||
<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">Back to the list</Link>
|
||||
</>
|
||||
) : 'loading…'}
|
||||
</span>
|
||||
) : (
|
||||
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
@@ -34,11 +35,12 @@ const ProviderBadge = ({ provider }: { provider: string }) => (
|
||||
</span>
|
||||
);
|
||||
|
||||
// Which run is open is `/task-logs/:id`. No redirect guard — the bare route is the list with nothing
|
||||
// open, and an id that no longer exists gets the empty pane rather than a rewritten address.
|
||||
export const TaskLogs = () => {
|
||||
const client = useClient();
|
||||
const [logs, setLogs] = useState<LogMetadata[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const selectedId = useParams<{ id: string }>().id ?? null;
|
||||
const [selectedLog, setSelectedLog] = useState<FullLog | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -79,7 +81,7 @@ export const TaskLogs = () => {
|
||||
<div className="flex h-full p-3 md:p-6 gap-4">
|
||||
{/* Left panel: list */}
|
||||
<Card
|
||||
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${selectedId ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
||||
>
|
||||
<div className="p-3 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
@@ -102,13 +104,10 @@ export const TaskLogs = () => {
|
||||
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
|
||||
)}
|
||||
{filtered.map((log) => (
|
||||
<button
|
||||
<Link
|
||||
key={log.id}
|
||||
onClick={() => {
|
||||
setSelectedId(log.id);
|
||||
setShowDetail(true);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedId === log.id ? 'bg-duck-teal/10' : ''}`}
|
||||
to={`/task-logs/${log.id}`}
|
||||
className={`block w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedId === String(log.id) ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{log.isError ? (
|
||||
@@ -125,31 +124,28 @@ export const TaskLogs = () => {
|
||||
<ProviderBadge provider={log.provider} />
|
||||
</div>
|
||||
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right panel: log viewer */}
|
||||
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
||||
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${selectedId ? 'flex' : 'hidden md:flex'}`}>
|
||||
{!selectedLog && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
|
||||
Select a log to view
|
||||
<button onClick={() => setShowDetail(false)} className="md:hidden text-duck-teal text-xs cursor-pointer">
|
||||
<Link to="/task-logs" className="md:hidden text-duck-teal text-xs cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 inline mr-1" />
|
||||
Back to list
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog && (
|
||||
<>
|
||||
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowDetail(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<Link to="/task-logs" className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
|
||||
|
||||
@@ -28,6 +28,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
||||
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
|
||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
|
||||
|
||||
Reference in New Issue
Block a user