@schlessera/brain-ui-react 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/activity/activity-page.d.ts +11 -0
- package/dist/components/activity/activity-page.d.ts.map +1 -0
- package/dist/components/activity/activity-page.js +208 -0
- package/dist/components/activity/activity-page.js.map +1 -0
- package/dist/components/activity/digest-card.d.ts +10 -0
- package/dist/components/activity/digest-card.d.ts.map +1 -0
- package/dist/components/activity/digest-card.js +51 -0
- package/dist/components/activity/digest-card.js.map +1 -0
- package/dist/components/activity/push-toggle.d.ts +2 -0
- package/dist/components/activity/push-toggle.d.ts.map +1 -0
- package/dist/components/activity/push-toggle.js +82 -0
- package/dist/components/activity/push-toggle.js.map +1 -0
- package/dist/components/activity/span-bits.d.ts +28 -0
- package/dist/components/activity/span-bits.d.ts.map +1 -0
- package/dist/components/activity/span-bits.js +43 -0
- package/dist/components/activity/span-bits.js.map +1 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +4 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +1 -15
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/subagent-view.d.ts +18 -0
- package/dist/components/chat/subagent-view.d.ts.map +1 -0
- package/dist/components/chat/subagent-view.js +79 -0
- package/dist/components/chat/subagent-view.js.map +1 -0
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +40 -4
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/chat/tool-views.d.ts +4 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -1
- package/dist/components/chat/tool-views.js +26 -0
- package/dist/components/chat/tool-views.js.map +1 -1
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
- package/dist/components/layout/mobile-tab-bar.js +10 -4
- package/dist/components/layout/mobile-tab-bar.js.map +1 -1
- package/dist/components/layout/side-rail.d.ts.map +1 -1
- package/dist/components/layout/side-rail.js +7 -4
- package/dist/components/layout/side-rail.js.map +1 -1
- package/dist/hooks/use-now.d.ts +8 -0
- package/dist/hooks/use-now.d.ts.map +1 -0
- package/dist/hooks/use-now.js +32 -0
- package/dist/hooks/use-now.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +46 -0
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api-client.d.ts +40 -1
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +28 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/stores/activity-store.d.ts +93 -0
- package/dist/stores/activity-store.d.ts.map +1 -0
- package/dist/stores/activity-store.js +310 -0
- package/dist/stores/activity-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +9 -1
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/stores/ui-store.js +3 -0
- package/dist/stores/ui-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +495 -0
- package/src/components/activity/digest-card.tsx +101 -0
- package/src/components/activity/push-toggle.tsx +128 -0
- package/src/components/activity/span-bits.tsx +71 -0
- package/src/components/chat/chat-page.tsx +17 -0
- package/src/components/chat/session-drawer.tsx +1 -12
- package/src/components/chat/subagent-view.tsx +229 -0
- package/src/components/chat/tool-call-timeline.tsx +61 -3
- package/src/components/chat/tool-views.tsx +22 -0
- package/src/components/layout/mobile-tab-bar.tsx +25 -5
- package/src/components/layout/side-rail.tsx +17 -0
- package/src/hooks/use-now.ts +32 -0
- package/src/hooks/use-websocket.ts +44 -0
- package/src/index.ts +1 -0
- package/src/lib/api-client.ts +75 -0
- package/src/stores/activity-store.ts +402 -0
- package/src/stores/ui-store.ts +13 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Activity as ActivityIcon,
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
ArrowLeft,
|
|
6
|
+
Bot,
|
|
7
|
+
Clock,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
Timer,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useShallow } from "zustand/react/shallow";
|
|
12
|
+
import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
13
|
+
import type { ActivitySpan } from "@schlessera/brain-ui-sdk/protocol";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
api,
|
|
17
|
+
type ActivityIntent,
|
|
18
|
+
type ActivityRollups,
|
|
19
|
+
type ActivityRunSummary,
|
|
20
|
+
} from "../../lib/api-client.js";
|
|
21
|
+
import { sendClientMessage } from "../../hooks/use-websocket.js";
|
|
22
|
+
import { useNow } from "../../hooks/use-now.js";
|
|
23
|
+
import { useActivityStore, runSpans } from "../../stores/activity-store.js";
|
|
24
|
+
import { useChatStore } from "../../stores/chat-store.js";
|
|
25
|
+
import { useUIStore } from "../../stores/ui-store.js";
|
|
26
|
+
import { cn } from "../../lib/utils.js";
|
|
27
|
+
import {
|
|
28
|
+
formatDuration,
|
|
29
|
+
formatRelativeTime,
|
|
30
|
+
formatTokenCount,
|
|
31
|
+
} from "../chat/tool-views.js";
|
|
32
|
+
import { SpanStatusDot, spanToolLabel } from "./span-bits.js";
|
|
33
|
+
import { PushToggle } from "./push-toggle.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The Activity surface: an INDEX of all agent activity — live runs first,
|
|
37
|
+
* then history — with cost/token rollups. Deliberately not a residence:
|
|
38
|
+
* a session run deep-links back into its chat, a subagent into the drill-in
|
|
39
|
+
* stack; only a cron run (which has no chat around it) details here.
|
|
40
|
+
*
|
|
41
|
+
* Live rows ride the index-view activity subscription while this surface is
|
|
42
|
+
* open; history and rollups come from the REST activity API.
|
|
43
|
+
*/
|
|
44
|
+
export function ActivityPage() {
|
|
45
|
+
const supported = useActivityStore((s) => s.supported);
|
|
46
|
+
const connectionEpoch = useActivityStore((s) => s.connectionEpoch);
|
|
47
|
+
const liveSpans = useActivityStore((s) => s.spans);
|
|
48
|
+
const setActiveView = useUIStore((s) => s.setActiveView);
|
|
49
|
+
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
|
50
|
+
const [runs, setRuns] = useState<{ live: ActivityRunSummary[]; history: ActivityRunSummary[] } | null>(null);
|
|
51
|
+
const [rollups, setRollups] = useState<ActivityRollups | null>(null);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [detailRunId, setDetailRunId] = useState<string | null>(null);
|
|
54
|
+
|
|
55
|
+
const inbox = useActivityStore((s) => s.inbox);
|
|
56
|
+
const loadInbox = useActivityStore((s) => s.loadInbox);
|
|
57
|
+
const acknowledgeIntent = useActivityStore((s) => s.acknowledgeIntent);
|
|
58
|
+
const acknowledgeAllIntents = useActivityStore((s) => s.acknowledgeAllIntents);
|
|
59
|
+
|
|
60
|
+
const refresh = () => {
|
|
61
|
+
api
|
|
62
|
+
.activityRuns({ limit: 100 })
|
|
63
|
+
.then(setRuns)
|
|
64
|
+
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
65
|
+
api.activityRollups(7).then(setRollups).catch(() => {});
|
|
66
|
+
void loadInbox();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Re-runs on every reconnect (connectionEpoch): the new socket has no
|
|
70
|
+
// server-side subscriptions, and the REST refresh heals whatever finished
|
|
71
|
+
// while disconnected.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
refresh();
|
|
74
|
+
if (supported) {
|
|
75
|
+
sendClientMessage({ type: "activity_subscribe", view: "index" });
|
|
76
|
+
return () => {
|
|
77
|
+
sendClientMessage({ type: "activity_unsubscribe", view: "index" });
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}, [supported, connectionEpoch]);
|
|
82
|
+
|
|
83
|
+
// Deep-link consumer: `#/activity/<runId>` (the push notification landing
|
|
84
|
+
// spot — the shell routes it here but leaves the hash intact) opens that
|
|
85
|
+
// run's detail. Opening/closing detail writes the hash back via
|
|
86
|
+
// history.replaceState so the link stays shareable without history spam.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const applyHash = () => {
|
|
89
|
+
const match = /^#\/activity\/(.+)$/.exec(window.location.hash);
|
|
90
|
+
if (match) setDetailRunId(decodeURIComponent(match[1]!));
|
|
91
|
+
};
|
|
92
|
+
applyHash();
|
|
93
|
+
window.addEventListener("hashchange", applyHash);
|
|
94
|
+
return () => window.removeEventListener("hashchange", applyHash);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
function showDetail(runId: string | null) {
|
|
98
|
+
setDetailRunId(runId);
|
|
99
|
+
const target = runId ? `#/activity/${encodeURIComponent(runId)}` : "#/activity";
|
|
100
|
+
if (window.location.hash !== target) {
|
|
101
|
+
// window-qualified: `history` is the run list in this scope.
|
|
102
|
+
window.history.replaceState(null, "", target);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Live open roots from the stream override/extend the REST snapshot.
|
|
107
|
+
const liveRoots = useMemo(() => {
|
|
108
|
+
const roots: ActivitySpan[] = [];
|
|
109
|
+
for (const byId of Object.values(liveSpans)) {
|
|
110
|
+
for (const span of Object.values(byId)) {
|
|
111
|
+
if (!span.parentSpanId && span.outcome === undefined) roots.push(span);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return roots.sort((a, b) => b.startedAt - a.startedAt);
|
|
115
|
+
}, [liveSpans]);
|
|
116
|
+
|
|
117
|
+
const liveRunIds = new Set(liveRoots.map((r) => r.runId));
|
|
118
|
+
const restLive = (runs?.live ?? []).filter((r) => !liveRunIds.has(r.runId));
|
|
119
|
+
const history = (runs?.history ?? []).filter((r) => !liveRunIds.has(r.runId));
|
|
120
|
+
|
|
121
|
+
function openRun(row: {
|
|
122
|
+
runId: string;
|
|
123
|
+
origin: string;
|
|
124
|
+
sessionId?: string | null;
|
|
125
|
+
running?: boolean;
|
|
126
|
+
}) {
|
|
127
|
+
if (row.origin === "session" && row.sessionId) {
|
|
128
|
+
// Chat is the residence — a session run opens its session.
|
|
129
|
+
setActiveSession(row.sessionId);
|
|
130
|
+
setActiveView("chat");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
showDetail(row.runId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** An inbox tap routes like any run row: chat for session runs, detail otherwise. */
|
|
137
|
+
function openIntent(intent: ActivityIntent) {
|
|
138
|
+
const liveRoot = liveRoots.find((r) => r.runId === intent.runId);
|
|
139
|
+
if (liveRoot) {
|
|
140
|
+
openRun({ runId: liveRoot.runId, origin: liveRoot.origin, sessionId: liveRoot.sessionId });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const known = [...(runs?.live ?? []), ...(runs?.history ?? [])].find(
|
|
144
|
+
(r) => r.runId === intent.runId
|
|
145
|
+
);
|
|
146
|
+
if (known) {
|
|
147
|
+
openRun(known);
|
|
148
|
+
} else {
|
|
149
|
+
showDetail(intent.runId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (detailRunId) {
|
|
154
|
+
return <RunDetail runId={detailRunId} onBack={() => showDetail(null)} />;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex h-full flex-col overflow-y-auto">
|
|
159
|
+
<div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
|
|
160
|
+
<ActivityIcon className="h-4 w-4 text-muted-foreground" />
|
|
161
|
+
<h1 className="text-sm font-medium">Activity</h1>
|
|
162
|
+
<div className="ml-auto flex items-center gap-2">
|
|
163
|
+
<PushToggle />
|
|
164
|
+
</div>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={refresh}
|
|
168
|
+
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
|
|
169
|
+
aria-label="Refresh"
|
|
170
|
+
>
|
|
171
|
+
<RefreshCw className="h-3.5 w-3.5" />
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
|
|
176
|
+
{error && (
|
|
177
|
+
<p className="text-xs text-destructive">Could not load activity: {error}</p>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{inbox.length > 0 && (
|
|
181
|
+
<section>
|
|
182
|
+
<div className="mb-2 flex items-center">
|
|
183
|
+
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
184
|
+
Needs attention
|
|
185
|
+
</h2>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => void acknowledgeAllIntents()}
|
|
189
|
+
className="ml-auto text-[11px] text-muted-foreground transition-colors hover:text-foreground"
|
|
190
|
+
>
|
|
191
|
+
Dismiss all
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div className="space-y-1">
|
|
195
|
+
{inbox.map((intent) => (
|
|
196
|
+
<div
|
|
197
|
+
key={intent.id}
|
|
198
|
+
className={cn(
|
|
199
|
+
"flex items-center gap-2 rounded-lg border px-3 py-2 text-xs",
|
|
200
|
+
intent.kind === "failure"
|
|
201
|
+
? "border-destructive/30 bg-destructive/5"
|
|
202
|
+
: "border-border-subtle bg-surface"
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{intent.kind === "failure" ? (
|
|
206
|
+
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
|
207
|
+
) : intent.kind === "stuck" ? (
|
|
208
|
+
<Timer className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
209
|
+
) : (
|
|
210
|
+
<ActivityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
211
|
+
)}
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
className="min-w-0 flex-1 truncate text-left hover:underline"
|
|
215
|
+
onClick={() => {
|
|
216
|
+
void acknowledgeIntent(intent.id);
|
|
217
|
+
openIntent(intent);
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{intent.title}
|
|
221
|
+
</button>
|
|
222
|
+
<span className="shrink-0 text-[10px] text-muted-foreground/60">
|
|
223
|
+
{formatRelativeTime(intent.createdAt)}
|
|
224
|
+
</span>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => void acknowledgeIntent(intent.id)}
|
|
228
|
+
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
|
|
229
|
+
aria-label="Dismiss"
|
|
230
|
+
>
|
|
231
|
+
×
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
</section>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{rollups && <RollupCards rollups={rollups} />}
|
|
240
|
+
|
|
241
|
+
<section>
|
|
242
|
+
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
243
|
+
Running now
|
|
244
|
+
</h2>
|
|
245
|
+
{liveRoots.length === 0 && restLive.length === 0 ? (
|
|
246
|
+
<p className="text-xs text-muted-foreground/70">Nothing is running.</p>
|
|
247
|
+
) : (
|
|
248
|
+
<div className="space-y-1">
|
|
249
|
+
{liveRoots.map((span) => (
|
|
250
|
+
<LiveRow key={span.runId} span={span} onOpen={openRun} />
|
|
251
|
+
))}
|
|
252
|
+
{restLive.map((run) => (
|
|
253
|
+
<RunRow key={run.runId} run={run} onOpen={openRun} />
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</section>
|
|
258
|
+
|
|
259
|
+
<section>
|
|
260
|
+
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
261
|
+
History
|
|
262
|
+
</h2>
|
|
263
|
+
{history.length === 0 ? (
|
|
264
|
+
<p className="text-xs text-muted-foreground/70">No recorded runs yet.</p>
|
|
265
|
+
) : (
|
|
266
|
+
<div className="space-y-1">
|
|
267
|
+
{history.map((run) => (
|
|
268
|
+
<RunRow key={run.runId} run={run} onOpen={openRun} />
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</section>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Today's key in the server's configured zone (mirrors its day formatter). */
|
|
279
|
+
function todayKey(timeZone: string): string {
|
|
280
|
+
let fmt: Intl.DateTimeFormat;
|
|
281
|
+
try {
|
|
282
|
+
fmt = new Intl.DateTimeFormat("en-CA", { timeZone, dateStyle: "short" });
|
|
283
|
+
} catch {
|
|
284
|
+
fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", dateStyle: "short" });
|
|
285
|
+
}
|
|
286
|
+
return fmt.format(new Date());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function RollupCards({ rollups }: { rollups: ActivityRollups }) {
|
|
290
|
+
// days[0] is merely the newest day WITH runs — on a quiet day that is
|
|
291
|
+
// yesterday, so look today up by its actual key (0 when absent).
|
|
292
|
+
const today = rollups.days.find((d) => d.day === todayKey(rollups.timeZone));
|
|
293
|
+
const week = rollups.days.reduce(
|
|
294
|
+
(acc, d) => ({
|
|
295
|
+
runs: acc.runs + d.runs,
|
|
296
|
+
failures: acc.failures + d.failures,
|
|
297
|
+
costUsd: acc.costUsd + d.costUsd,
|
|
298
|
+
tokens: acc.tokens + d.inputTokens + d.outputTokens,
|
|
299
|
+
}),
|
|
300
|
+
{ runs: 0, failures: 0, costUsd: 0, tokens: 0 }
|
|
301
|
+
);
|
|
302
|
+
return (
|
|
303
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
304
|
+
<StatCard label="Runs today" value={String(today?.runs ?? 0)} />
|
|
305
|
+
<StatCard
|
|
306
|
+
label="Failures (7d)"
|
|
307
|
+
value={String(week.failures)}
|
|
308
|
+
alert={week.failures > 0}
|
|
309
|
+
/>
|
|
310
|
+
<StatCard label="Spend (7d)" value={`$${week.costUsd.toFixed(2)}`} />
|
|
311
|
+
<StatCard label="Tokens (7d)" value={formatTokenCount(week.tokens)} />
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function StatCard({ label, value, alert }: { label: string; value: string; alert?: boolean }) {
|
|
317
|
+
return (
|
|
318
|
+
<div className="rounded-lg border border-border-subtle bg-surface p-3">
|
|
319
|
+
<div className={cn("text-lg font-semibold", alert && "text-destructive")}>{value}</div>
|
|
320
|
+
<div className="text-[11px] text-muted-foreground">{label}</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function LiveRow({
|
|
326
|
+
span,
|
|
327
|
+
onOpen,
|
|
328
|
+
}: {
|
|
329
|
+
span: ActivitySpan;
|
|
330
|
+
onOpen: (row: { runId: string; origin: string; sessionId?: string | null }) => void;
|
|
331
|
+
}) {
|
|
332
|
+
const children = useActivityStore(
|
|
333
|
+
useShallow((s) => Object.values(s.spans[span.runId] ?? {}).filter((x) => x.parentSpanId))
|
|
334
|
+
);
|
|
335
|
+
const now = useNow();
|
|
336
|
+
const current = children.filter((c) => c.outcome === undefined).at(-1);
|
|
337
|
+
const currentLabel = current ? spanToolLabel(current) : null;
|
|
338
|
+
return (
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => onOpen({ runId: span.runId, origin: span.origin, sessionId: span.sessionId })}
|
|
342
|
+
className="flex w-full items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised"
|
|
343
|
+
>
|
|
344
|
+
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-primary" />
|
|
345
|
+
{span.origin === "cron" ? (
|
|
346
|
+
<Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
347
|
+
) : (
|
|
348
|
+
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
349
|
+
)}
|
|
350
|
+
<span className="truncate font-medium">
|
|
351
|
+
{span.jobName ?? span.name}
|
|
352
|
+
</span>
|
|
353
|
+
{/* A span-only run (plain cron job) has no current step — name+elapsed
|
|
354
|
+
is its whole live story. */}
|
|
355
|
+
{currentLabel && (
|
|
356
|
+
<span className="truncate text-muted-foreground">· {currentLabel}</span>
|
|
357
|
+
)}
|
|
358
|
+
<span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
|
|
359
|
+
{formatDuration(now - span.startedAt)}
|
|
360
|
+
</span>
|
|
361
|
+
</button>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function RunRow({
|
|
366
|
+
run,
|
|
367
|
+
onOpen,
|
|
368
|
+
}: {
|
|
369
|
+
run: ActivityRunSummary;
|
|
370
|
+
onOpen: (row: ActivityRunSummary) => void;
|
|
371
|
+
}) {
|
|
372
|
+
const failed = isFailureOutcome(run.outcome);
|
|
373
|
+
return (
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
onClick={() => onOpen(run)}
|
|
377
|
+
className={cn(
|
|
378
|
+
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-surface-raised",
|
|
379
|
+
failed && "border border-destructive/30 bg-destructive/5"
|
|
380
|
+
)}
|
|
381
|
+
>
|
|
382
|
+
{run.origin === "cron" ? (
|
|
383
|
+
<Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
384
|
+
) : (
|
|
385
|
+
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
386
|
+
)}
|
|
387
|
+
<span className="truncate">{run.jobName ?? run.name}</span>
|
|
388
|
+
{failed && <AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />}
|
|
389
|
+
{run.outcome && run.outcome !== "success" && (
|
|
390
|
+
<span className={cn("text-[10px] uppercase", failed ? "text-destructive" : "text-muted-foreground")}>
|
|
391
|
+
{run.outcome}
|
|
392
|
+
</span>
|
|
393
|
+
)}
|
|
394
|
+
<span className="ml-auto flex shrink-0 items-center gap-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/60">
|
|
395
|
+
{run.costUsd !== null && run.costUsd > 0 && <span>${run.costUsd.toFixed(2)}</span>}
|
|
396
|
+
{run.durationMs !== null && (
|
|
397
|
+
<span className="flex items-center gap-0.5">
|
|
398
|
+
<Timer className="h-3 w-3" />
|
|
399
|
+
{formatDuration(run.durationMs)}
|
|
400
|
+
</span>
|
|
401
|
+
)}
|
|
402
|
+
<span>{formatRelativeTime(run.startedAt)}</span>
|
|
403
|
+
</span>
|
|
404
|
+
</button>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Chat-less run detail (cron runs; pruned runs show their rollup). */
|
|
409
|
+
function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) {
|
|
410
|
+
const streamed = useActivityStore(useShallow((s) => runSpans(s, runId)));
|
|
411
|
+
const applySnapshot = useActivityStore((s) => s.applySnapshot);
|
|
412
|
+
const [pruned, setPruned] = useState<object | null>(null);
|
|
413
|
+
const [missing, setMissing] = useState(false);
|
|
414
|
+
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
api
|
|
417
|
+
.activityRun(runId)
|
|
418
|
+
.then((detail) => {
|
|
419
|
+
if (detail.detailPruned) {
|
|
420
|
+
setPruned(detail.rollup ?? {});
|
|
421
|
+
} else if (detail.spans) {
|
|
422
|
+
applySnapshot({
|
|
423
|
+
type: "activity_snapshot",
|
|
424
|
+
view: "run",
|
|
425
|
+
runId,
|
|
426
|
+
spans: detail.spans,
|
|
427
|
+
events: detail.events ?? [],
|
|
428
|
+
highWaterSeq: { [runId]: detail.highWaterSeq ?? 0 },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
.catch(() => setMissing(true));
|
|
433
|
+
}, [runId, applySnapshot]);
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<div className="flex h-full flex-col overflow-y-auto">
|
|
437
|
+
<div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
onClick={onBack}
|
|
441
|
+
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
|
|
442
|
+
aria-label="Back"
|
|
443
|
+
>
|
|
444
|
+
<ArrowLeft className="h-4 w-4" />
|
|
445
|
+
</button>
|
|
446
|
+
<h1 className="truncate text-sm font-medium">{runId}</h1>
|
|
447
|
+
</div>
|
|
448
|
+
<div className="mx-auto w-full max-w-3xl space-y-1 p-4">
|
|
449
|
+
{missing && <p className="text-xs text-muted-foreground">Unknown run.</p>}
|
|
450
|
+
{pruned && (
|
|
451
|
+
<div className="rounded-lg border border-border-subtle bg-surface p-3 text-xs text-muted-foreground">
|
|
452
|
+
Detail pruned — only the rollup remains.
|
|
453
|
+
<pre className="mt-2 overflow-x-auto text-[11px]">{JSON.stringify(pruned, null, 2)}</pre>
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
{streamed.map((span) => (
|
|
457
|
+
<div
|
|
458
|
+
key={span.spanId}
|
|
459
|
+
className="flex items-center gap-2 text-xs text-muted-foreground"
|
|
460
|
+
style={{ paddingLeft: `${depthOf(span, streamed) * 16}px` }}
|
|
461
|
+
>
|
|
462
|
+
<SpanStatusDot span={span} />
|
|
463
|
+
<span className="truncate font-[family-name:var(--font-mono)]">
|
|
464
|
+
{spanToolLabel(span)}
|
|
465
|
+
</span>
|
|
466
|
+
{span.outcome && span.outcome !== "success" && (
|
|
467
|
+
<span className="text-[10px] uppercase">{span.outcome}</span>
|
|
468
|
+
)}
|
|
469
|
+
{span.outcomeReason && (
|
|
470
|
+
<span className="truncate text-[10px] text-muted-foreground/60">
|
|
471
|
+
{span.outcomeReason}
|
|
472
|
+
</span>
|
|
473
|
+
)}
|
|
474
|
+
<span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
|
|
475
|
+
{span.endedAt !== undefined
|
|
476
|
+
? formatDuration(span.endedAt - span.startedAt)
|
|
477
|
+
: "…"}
|
|
478
|
+
</span>
|
|
479
|
+
</div>
|
|
480
|
+
))}
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function depthOf(span: ActivitySpan, all: ActivitySpan[]): number {
|
|
487
|
+
let depth = 0;
|
|
488
|
+
let current: ActivitySpan | undefined = span;
|
|
489
|
+
while (current?.parentSpanId) {
|
|
490
|
+
depth += 1;
|
|
491
|
+
current = all.find((s) => s.spanId === current!.parentSpanId);
|
|
492
|
+
if (depth > 6) break;
|
|
493
|
+
}
|
|
494
|
+
return depth;
|
|
495
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { AlertTriangle, Newspaper, X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { api, type ActivityDigest } from "../../lib/api-client.js";
|
|
5
|
+
import { useUIStore } from "../../stores/ui-store.js";
|
|
6
|
+
import { cn } from "../../lib/utils.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The while-you-were-away card: presented PROACTIVELY on app open when a
|
|
10
|
+
* fresh digest exists (planning decision — built passively, the flow
|
|
11
|
+
* collapses back into asking the agent what happened). Once per digest:
|
|
12
|
+
* dismissing persists server-side, so a phone and a laptop don't each nag.
|
|
13
|
+
* An empty window stays quiet — no card for "nothing happened". The window
|
|
14
|
+
* is labeled: the card says what period it covers, never implies "now".
|
|
15
|
+
*/
|
|
16
|
+
export function DigestCard() {
|
|
17
|
+
const [digest, setDigest] = useState<ActivityDigest | null>(null);
|
|
18
|
+
const [visible, setVisible] = useState(false);
|
|
19
|
+
const setActiveView = useUIStore((s) => s.setActiveView);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
void api
|
|
23
|
+
.activityDigest()
|
|
24
|
+
.then(({ digest, dismissedAt }) => {
|
|
25
|
+
if (!digest || digest.runs === 0) return;
|
|
26
|
+
if (digest.generatedAt <= dismissedAt) return;
|
|
27
|
+
setDigest(digest);
|
|
28
|
+
setVisible(true);
|
|
29
|
+
})
|
|
30
|
+
.catch(() => {
|
|
31
|
+
// No digest is a quiet state, never an error surface.
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
if (!visible || !digest) return null;
|
|
36
|
+
|
|
37
|
+
function dismiss() {
|
|
38
|
+
setVisible(false);
|
|
39
|
+
void api.activityDigestDismiss().catch(() => {});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const windowLabel = `${formatDay(digest.windowStart)} – ${formatDay(digest.windowEnd)}`;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className={cn(
|
|
47
|
+
"mx-auto mb-3 w-full max-w-2xl rounded-xl border p-3 text-xs",
|
|
48
|
+
digest.failures > 0
|
|
49
|
+
? "border-destructive/30 bg-destructive/5"
|
|
50
|
+
: "border-border-subtle bg-surface"
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<div className="flex items-center gap-2">
|
|
54
|
+
<Newspaper className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
55
|
+
<span className="font-medium">While you were away</span>
|
|
56
|
+
<span className="text-[10px] text-muted-foreground/60">{windowLabel}</span>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={dismiss}
|
|
60
|
+
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
|
|
61
|
+
aria-label="Dismiss"
|
|
62
|
+
>
|
|
63
|
+
<X className="h-3.5 w-3.5" />
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="mt-1.5 text-muted-foreground">
|
|
67
|
+
{digest.runs} run{digest.runs === 1 ? "" : "s"}
|
|
68
|
+
{digest.failures > 0 && (
|
|
69
|
+
<span className="text-destructive"> · {digest.failures} failed</span>
|
|
70
|
+
)}
|
|
71
|
+
{digest.costUsd > 0 && ` · $${digest.costUsd.toFixed(2)} spent`}
|
|
72
|
+
</div>
|
|
73
|
+
{digest.notable.length > 0 && (
|
|
74
|
+
<div className="mt-1.5 space-y-0.5">
|
|
75
|
+
{digest.notable.slice(0, 3).map((n) => (
|
|
76
|
+
<div key={n.runId} className="flex items-center gap-1.5 text-[11px]">
|
|
77
|
+
<AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />
|
|
78
|
+
<span className="truncate">
|
|
79
|
+
{n.jobName ?? n.name} — {n.outcome}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => {
|
|
88
|
+
dismiss();
|
|
89
|
+
setActiveView("activity");
|
|
90
|
+
}}
|
|
91
|
+
className="mt-2 text-[11px] font-medium text-primary hover:underline"
|
|
92
|
+
>
|
|
93
|
+
Open Activity
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatDay(ms: number): string {
|
|
100
|
+
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
101
|
+
}
|