@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,128 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Bell, BellOff } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { api } from "../../lib/api-client.js";
|
|
5
|
+
import { cn } from "../../lib/utils.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The push enable/disable control, living on the Activity surface (where the
|
|
9
|
+
* alerts it governs land). The permission model is THREE-state, not a bare
|
|
10
|
+
* toggle: once the browser prompt is denied, Chrome silently ignores every
|
|
11
|
+
* further in-page request — so the blocked state must say where the real
|
|
12
|
+
* switch lives (site settings) instead of offering a button that does
|
|
13
|
+
* nothing. Where the platform has no push at all (iOS Safari in-browser,
|
|
14
|
+
* pre-16.4), the control renders disabled-with-explanation, not hidden.
|
|
15
|
+
*/
|
|
16
|
+
type PushState =
|
|
17
|
+
| "unsupported"
|
|
18
|
+
| "not-asked"
|
|
19
|
+
| "blocked"
|
|
20
|
+
| "subscribed"
|
|
21
|
+
| "unsubscribed";
|
|
22
|
+
|
|
23
|
+
export function PushToggle() {
|
|
24
|
+
const [state, setState] = useState<PushState>("unsupported");
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
void (async () => {
|
|
29
|
+
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
30
|
+
setState("unsupported");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (Notification.permission === "denied") {
|
|
34
|
+
setState("blocked");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (Notification.permission === "default") {
|
|
38
|
+
setState("not-asked");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const registration = await navigator.serviceWorker.ready;
|
|
42
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
43
|
+
setState(subscription ? "subscribed" : "unsubscribed");
|
|
44
|
+
})();
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
async function enable() {
|
|
48
|
+
setBusy(true);
|
|
49
|
+
try {
|
|
50
|
+
const permission = await Notification.requestPermission();
|
|
51
|
+
if (permission === "denied") {
|
|
52
|
+
setState("blocked");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (permission !== "granted") {
|
|
56
|
+
setState("not-asked");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const { publicKey } = await api.pushPublicKey();
|
|
60
|
+
const registration = await navigator.serviceWorker.ready;
|
|
61
|
+
const subscription = await registration.pushManager.subscribe({
|
|
62
|
+
userVisibleOnly: true,
|
|
63
|
+
applicationServerKey: publicKey,
|
|
64
|
+
});
|
|
65
|
+
await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
|
|
66
|
+
setState("subscribed");
|
|
67
|
+
} catch {
|
|
68
|
+
setState("unsubscribed");
|
|
69
|
+
} finally {
|
|
70
|
+
setBusy(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function disable() {
|
|
75
|
+
setBusy(true);
|
|
76
|
+
try {
|
|
77
|
+
const registration = await navigator.serviceWorker.ready;
|
|
78
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
79
|
+
if (subscription) {
|
|
80
|
+
await api.pushUnsubscribe(subscription.endpoint).catch(() => {});
|
|
81
|
+
await subscription.unsubscribe();
|
|
82
|
+
}
|
|
83
|
+
setState("unsubscribed");
|
|
84
|
+
} finally {
|
|
85
|
+
setBusy(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (state === "unsupported") {
|
|
90
|
+
return (
|
|
91
|
+
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60" title="This browser has no web push. On iOS, install the app to the home screen (iOS 16.4+).">
|
|
92
|
+
<BellOff className="h-3.5 w-3.5" />
|
|
93
|
+
No push here
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (state === "blocked") {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className="flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
|
102
|
+
title="Notifications are blocked at the browser level. Allow them in this site's browser settings, then reload."
|
|
103
|
+
>
|
|
104
|
+
<BellOff className="h-3.5 w-3.5 text-destructive" />
|
|
105
|
+
Blocked in browser settings
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const on = state === "subscribed";
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
disabled={busy}
|
|
115
|
+
onClick={() => void (on ? disable() : enable())}
|
|
116
|
+
className={cn(
|
|
117
|
+
"flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-50",
|
|
118
|
+
on
|
|
119
|
+
? "text-primary hover:bg-surface-raised"
|
|
120
|
+
: "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
|
|
121
|
+
)}
|
|
122
|
+
title={on ? "Push notifications are on for this device" : "Get a push notification when background work fails"}
|
|
123
|
+
>
|
|
124
|
+
{on ? <Bell className="h-3.5 w-3.5" /> : <BellOff className="h-3.5 w-3.5" />}
|
|
125
|
+
{on ? "Push on" : "Enable push"}
|
|
126
|
+
</button>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils.js";
|
|
5
|
+
import { getToolLabel } from "../chat/tool-views.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* THE status dot for activity spans — one outcome→color mapping so every
|
|
9
|
+
* surface (timeline rows, drill-in, activity index) agrees on what a color
|
|
10
|
+
* means: running pulses primary, success is accent, failures (per
|
|
11
|
+
* `isFailureOutcome`) and denied are destructive, cancelled is muted.
|
|
12
|
+
*/
|
|
13
|
+
export function SpanStatusDot({
|
|
14
|
+
span,
|
|
15
|
+
outcome,
|
|
16
|
+
running,
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
span?: ActivitySpan;
|
|
20
|
+
/** Alternative to `span` when only the outcome is at hand. */
|
|
21
|
+
outcome?: ActivitySpanOutcome | string | null;
|
|
22
|
+
running?: boolean;
|
|
23
|
+
/** Size/position overrides; defaults to h-1.5 w-1.5. */
|
|
24
|
+
className?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const resolved = span ? span.outcome : outcome;
|
|
27
|
+
const isRunning = running ?? (span !== undefined && span.outcome === undefined);
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn(
|
|
31
|
+
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
32
|
+
isRunning
|
|
33
|
+
? "animate-pulse bg-primary"
|
|
34
|
+
: resolved === "success"
|
|
35
|
+
? "bg-accent"
|
|
36
|
+
: isFailureOutcome(resolved) || resolved === "denied"
|
|
37
|
+
? "bg-destructive"
|
|
38
|
+
: "bg-muted-foreground",
|
|
39
|
+
className
|
|
40
|
+
)}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Display label for a span. Prefers the server-lifted `toolName`; root spans
|
|
47
|
+
* label by kind (a turn or cron root is not a tool call); the last resort
|
|
48
|
+
* un-parses the `execute_tool` naming convention for spans recorded before
|
|
49
|
+
* the field existed.
|
|
50
|
+
*/
|
|
51
|
+
export function spanToolLabel(span: ActivitySpan): string {
|
|
52
|
+
if (span.toolName) return getToolLabel(span.toolName);
|
|
53
|
+
if (span.kind === "turn") return "Turn";
|
|
54
|
+
if (span.kind === "cron") return span.jobName ?? span.name;
|
|
55
|
+
return getToolLabel(span.name.replace(/^execute_tool /, ""));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The "9+" unread-count bubble shared by the rail and the tab bar. Hidden at 0. */
|
|
59
|
+
export function CountBadge({ count, className }: { count: number; className?: string }) {
|
|
60
|
+
if (count <= 0) return null;
|
|
61
|
+
return (
|
|
62
|
+
<span
|
|
63
|
+
className={cn(
|
|
64
|
+
"absolute flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-white",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{count > 9 ? "9+" : count}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -33,6 +33,8 @@ import { WelcomeState } from "./welcome-state.js";
|
|
|
33
33
|
import { ShareIntake } from "./share-card.js";
|
|
34
34
|
import { CommandPalette } from "./command-palette.js";
|
|
35
35
|
import { SessionDrawer } from "./session-drawer.js";
|
|
36
|
+
import { SubagentView } from "./subagent-view.js";
|
|
37
|
+
import { DigestCard } from "../activity/digest-card.js";
|
|
36
38
|
import { SettingsPanel } from "../settings/settings-panel.js";
|
|
37
39
|
import { StreamingPanel } from "../quick-actions/streaming-modal.js";
|
|
38
40
|
import { WhatsupPanel } from "../quick-actions/whatsup-modal.js";
|
|
@@ -125,6 +127,7 @@ export function ChatPage() {
|
|
|
125
127
|
|
|
126
128
|
const sessionPanelOpen = useUIStore((s) => s.sessionPanelOpen);
|
|
127
129
|
const setSessionPanelOpen = useUIStore((s) => s.setSessionPanelOpen);
|
|
130
|
+
const subagentStack = useUIStore((s) => s.subagentStack);
|
|
128
131
|
const syncPanelOpen = useUIStore((s) => s.syncPanelOpen);
|
|
129
132
|
const setSyncPanelOpen = useUIStore((s) => s.setSyncPanelOpen);
|
|
130
133
|
const whatsupPanelOpen = useUIStore((s) => s.whatsupPanelOpen);
|
|
@@ -521,6 +524,17 @@ export function ChatPage() {
|
|
|
521
524
|
}
|
|
522
525
|
/>
|
|
523
526
|
|
|
527
|
+
{/* Subagent drill-in: an overlay stack — chat is the residence, the
|
|
528
|
+
drill-in opens over it and backs out level by level. */}
|
|
529
|
+
{subagentStack.length > 0 && (
|
|
530
|
+
<div className="fixed inset-0 z-40 bg-background">
|
|
531
|
+
<SubagentView
|
|
532
|
+
spanId={subagentStack[subagentStack.length - 1]!}
|
|
533
|
+
onApproval={handleToolApproval}
|
|
534
|
+
/>
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
|
|
524
538
|
{/* Panels */}
|
|
525
539
|
<SessionDrawer
|
|
526
540
|
open={sessionPanelOpen}
|
|
@@ -558,6 +572,9 @@ export function ChatPage() {
|
|
|
558
572
|
<div className="relative flex-1 overflow-hidden">
|
|
559
573
|
{messages.length === 0 ? (
|
|
560
574
|
<div className="h-full overflow-y-auto">
|
|
575
|
+
<div className="px-4 pt-3 md:px-6">
|
|
576
|
+
<DigestCard />
|
|
577
|
+
</div>
|
|
561
578
|
<WelcomeState onAction={handleWelcomeAction} />
|
|
562
579
|
</div>
|
|
563
580
|
) : (
|
|
@@ -4,6 +4,7 @@ import { api } from "../../lib/api-client.js";
|
|
|
4
4
|
import { useChatStore } from "../../stores/chat-store.js";
|
|
5
5
|
import { SlidePanel } from "../layout/slide-panel.js";
|
|
6
6
|
import { cn } from "../../lib/utils.js";
|
|
7
|
+
import { formatRelativeTime } from "./tool-views.js";
|
|
7
8
|
|
|
8
9
|
interface SessionInfo {
|
|
9
10
|
id: string;
|
|
@@ -215,15 +216,3 @@ function groupSessionsByDate(sessions: SessionInfo[]): GroupedSessions[] {
|
|
|
215
216
|
.filter(([, sessions]) => sessions.length > 0)
|
|
216
217
|
.map(([label, sessions]) => ({ label, sessions }));
|
|
217
218
|
}
|
|
218
|
-
|
|
219
|
-
function formatRelativeTime(ms: number): string {
|
|
220
|
-
const diff = Date.now() - ms;
|
|
221
|
-
const minutes = Math.floor(diff / 60000);
|
|
222
|
-
if (minutes < 1) return "just now";
|
|
223
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
224
|
-
const hours = Math.floor(minutes / 60);
|
|
225
|
-
if (hours < 24) return `${hours}h ago`;
|
|
226
|
-
const days = Math.floor(hours / 24);
|
|
227
|
-
if (days < 7) return `${days}d ago`;
|
|
228
|
-
return new Date(ms).toLocaleDateString();
|
|
229
|
-
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { ArrowLeft, Bot, Check, X } from "lucide-react";
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import { useShallow } from "zustand/react/shallow";
|
|
5
|
+
import { isFailureOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
6
|
+
import type { ActivitySpan, ActivitySpanEvent } from "@schlessera/brain-ui-sdk/protocol";
|
|
7
|
+
|
|
8
|
+
import { useActivityStore, childSpans, eventsFor, spanForTool } from "../../stores/activity-store.js";
|
|
9
|
+
import { useUIStore } from "../../stores/ui-store.js";
|
|
10
|
+
import { useChatStore, activeChat } from "../../stores/chat-store.js";
|
|
11
|
+
import { cn } from "../../lib/utils.js";
|
|
12
|
+
import { getToolLabel, formatDuration } from "./tool-views.js";
|
|
13
|
+
import { SpanStatusDot, spanToolLabel } from "../activity/span-bits.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drill-in view of one subagent: the span tree under its Agent tool call,
|
|
17
|
+
* transcript excerpts interleaved by time, live while the subagent runs.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately observation-shaped — no composer, no way to address the
|
|
20
|
+
* subagent — with ONE exception, decided during planning: an approval
|
|
21
|
+
* request raised by a tool inside this subagent renders actionable here as
|
|
22
|
+
* well as in the parent chat, because a fan-out stalled on an unaddressable
|
|
23
|
+
* approval is worse than a relaxed rule.
|
|
24
|
+
*
|
|
25
|
+
* When transcript forwarding is off (or the backend cannot forward), the
|
|
26
|
+
* view degrades to activity-only and says so, rather than looking empty.
|
|
27
|
+
*/
|
|
28
|
+
export function SubagentView({
|
|
29
|
+
spanId,
|
|
30
|
+
onApproval,
|
|
31
|
+
}: {
|
|
32
|
+
spanId: string;
|
|
33
|
+
onApproval?: (toolUseId: string, approved: boolean) => void;
|
|
34
|
+
}) {
|
|
35
|
+
const popSubagentView = useUIStore((s) => s.popSubagentView);
|
|
36
|
+
const pushSubagentView = useUIStore((s) => s.pushSubagentView);
|
|
37
|
+
const span = useActivityStore((s) => spanForTool(s, spanId));
|
|
38
|
+
const children = useActivityStore(useShallow((s) => childSpans(s, spanId)));
|
|
39
|
+
const events = useActivityStore((s) => eventsFor(s, spanId));
|
|
40
|
+
const pendingApprovals = useChatStore(
|
|
41
|
+
useShallow((s) => {
|
|
42
|
+
const chat = activeChat(s);
|
|
43
|
+
const last = chat.messages.at(-1);
|
|
44
|
+
return (last?.toolCalls ?? []).filter((t) => t.status === "pending_approval");
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const timelineItems = useMemo(
|
|
49
|
+
() => interleave(children, events),
|
|
50
|
+
[children, events]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (!span) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-col gap-3 p-4">
|
|
56
|
+
<BackButton onClick={popSubagentView} />
|
|
57
|
+
<p className="text-sm text-muted-foreground">
|
|
58
|
+
No recorded activity for this subagent — it may have been pruned.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const description = span.subagent?.description ?? null;
|
|
65
|
+
const subagentType = span.subagent?.type ?? null;
|
|
66
|
+
const summary = span.subagent?.summary ?? null;
|
|
67
|
+
const running = span.outcome === undefined;
|
|
68
|
+
const duration =
|
|
69
|
+
span.endedAt !== undefined ? formatDuration(span.endedAt - span.startedAt) : null;
|
|
70
|
+
const hasTranscript = events.some((e) => e.eventType.startsWith("transcript_"));
|
|
71
|
+
const approvalsHere = pendingApprovals.filter((t) =>
|
|
72
|
+
children.some((c) => c.spanId === t.id)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex h-full flex-col">
|
|
77
|
+
<div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
|
|
78
|
+
<BackButton onClick={popSubagentView} />
|
|
79
|
+
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
80
|
+
<div className="min-w-0 flex-1">
|
|
81
|
+
<div className="truncate text-sm font-medium">
|
|
82
|
+
{description ?? "Subagent"}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="truncate text-[11px] text-muted-foreground">
|
|
85
|
+
{subagentType ?? "agent"}
|
|
86
|
+
{running ? " · running" : span.outcome ? ` · ${span.outcome}` : ""}
|
|
87
|
+
{duration ? ` · ${duration}` : ""}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
{running && (
|
|
91
|
+
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="flex-1 space-y-1.5 overflow-y-auto p-4">
|
|
96
|
+
{approvalsHere.map((tool) => (
|
|
97
|
+
<div
|
|
98
|
+
key={tool.id}
|
|
99
|
+
className="rounded-lg border-2 border-primary/40 bg-primary/5 p-3 text-xs"
|
|
100
|
+
>
|
|
101
|
+
<div className="mb-2 font-medium">
|
|
102
|
+
Approval needed: {getToolLabel(tool.name)}
|
|
103
|
+
</div>
|
|
104
|
+
{onApproval && (
|
|
105
|
+
<div className="flex gap-2">
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => onApproval(tool.id, true)}
|
|
108
|
+
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground hover:brightness-110"
|
|
109
|
+
>
|
|
110
|
+
<Check className="h-3 w-3" /> Allow
|
|
111
|
+
</button>
|
|
112
|
+
<button
|
|
113
|
+
onClick={() => onApproval(tool.id, false)}
|
|
114
|
+
className="flex items-center gap-1.5 rounded-lg border border-destructive/30 px-4 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/10"
|
|
115
|
+
>
|
|
116
|
+
<X className="h-3 w-3" /> Deny
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
|
|
123
|
+
{timelineItems.length === 0 && (
|
|
124
|
+
<p className="text-xs text-muted-foreground">
|
|
125
|
+
{running ? "Waiting for the first step…" : "No recorded steps."}
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{timelineItems.map((item) =>
|
|
130
|
+
item.kind === "span" ? (
|
|
131
|
+
<SpanRow
|
|
132
|
+
key={item.span.spanId}
|
|
133
|
+
span={item.span}
|
|
134
|
+
onOpen={
|
|
135
|
+
item.span.kind === "subagent"
|
|
136
|
+
? () => pushSubagentView(item.span.spanId)
|
|
137
|
+
: undefined
|
|
138
|
+
}
|
|
139
|
+
/>
|
|
140
|
+
) : (
|
|
141
|
+
<TranscriptRow key={`${item.event.spanId}:${item.event.eventIndex}`} event={item.event} />
|
|
142
|
+
)
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{!hasTranscript && children.length > 0 && (
|
|
146
|
+
<p className="pt-2 text-[11px] text-muted-foreground/60">
|
|
147
|
+
Activity only — this subagent's transcript was not captured.
|
|
148
|
+
</p>
|
|
149
|
+
)}
|
|
150
|
+
{summary && !running && (
|
|
151
|
+
<div className="mt-2 rounded-lg border border-border-subtle bg-surface p-3 text-xs">
|
|
152
|
+
{summary}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function BackButton({ onClick }: { onClick: () => void }) {
|
|
161
|
+
return (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={onClick}
|
|
165
|
+
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
|
|
166
|
+
aria-label="Back"
|
|
167
|
+
>
|
|
168
|
+
<ArrowLeft className="h-4 w-4" />
|
|
169
|
+
</button>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function SpanRow({ span, onOpen }: { span: ActivitySpan; onOpen?: () => void }) {
|
|
174
|
+
const running = span.outcome === undefined;
|
|
175
|
+
const failed = isFailureOutcome(span.outcome);
|
|
176
|
+
const duration =
|
|
177
|
+
span.endedAt !== undefined
|
|
178
|
+
? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
|
|
179
|
+
: null;
|
|
180
|
+
return (
|
|
181
|
+
<motion.div
|
|
182
|
+
initial={{ opacity: 0, y: 2 }}
|
|
183
|
+
animate={{ opacity: 1, y: 0 }}
|
|
184
|
+
className={cn(
|
|
185
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
|
|
186
|
+
failed ? "text-destructive/80" : "text-muted-foreground",
|
|
187
|
+
onOpen && "cursor-pointer hover:text-foreground"
|
|
188
|
+
)}
|
|
189
|
+
onClick={onOpen}
|
|
190
|
+
>
|
|
191
|
+
<SpanStatusDot span={span} className="h-2 w-2" />
|
|
192
|
+
<span className="font-[family-name:var(--font-mono)] font-medium">
|
|
193
|
+
{spanToolLabel(span)}
|
|
194
|
+
</span>
|
|
195
|
+
{span.outcome && span.outcome !== "success" && (
|
|
196
|
+
<span className="text-[10px] uppercase">{span.outcome}</span>
|
|
197
|
+
)}
|
|
198
|
+
<span className="ml-auto font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
|
|
199
|
+
{duration ?? (running ? "…" : "")}
|
|
200
|
+
</span>
|
|
201
|
+
</motion.div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function TranscriptRow({ event }: { event: ActivitySpanEvent }) {
|
|
206
|
+
const text = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload);
|
|
207
|
+
if (!text) return null;
|
|
208
|
+
return (
|
|
209
|
+
<div className="whitespace-pre-wrap rounded-md bg-surface px-3 py-2 text-xs text-foreground/80">
|
|
210
|
+
{text}
|
|
211
|
+
{event.truncated && <span className="text-muted-foreground/60"> …</span>}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
type TimelineItem =
|
|
217
|
+
| { kind: "span"; span: ActivitySpan; ts: number }
|
|
218
|
+
| { kind: "event"; event: ActivitySpanEvent; ts: number };
|
|
219
|
+
|
|
220
|
+
/** Child steps and transcript excerpts in one chronological stream. */
|
|
221
|
+
function interleave(spans: ActivitySpan[], events: ActivitySpanEvent[]): TimelineItem[] {
|
|
222
|
+
const items: TimelineItem[] = [
|
|
223
|
+
...spans.map((span) => ({ kind: "span" as const, span, ts: span.startedAt })),
|
|
224
|
+
...events
|
|
225
|
+
.filter((e) => e.eventType.startsWith("transcript_"))
|
|
226
|
+
.map((event) => ({ kind: "event" as const, event, ts: event.ts })),
|
|
227
|
+
];
|
|
228
|
+
return items.sort((a, b) => a.ts - b.ts);
|
|
229
|
+
}
|
|
@@ -13,9 +13,14 @@ import { resolveToolRenderer } from "@schlessera/brain-ui-sdk/client";
|
|
|
13
13
|
import type { ToolCall } from "../../stores/chat-store.js";
|
|
14
14
|
import { cn } from "../../lib/utils.js";
|
|
15
15
|
import { motion, AnimatePresence } from "framer-motion";
|
|
16
|
-
import { getToolLabel, getTouchedFile, formatDuration } from "./tool-views.js";
|
|
16
|
+
import { getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
|
|
17
17
|
import { registerBuiltinRenderers, GENERIC_RENDERER } from "./renderers/index.js";
|
|
18
18
|
import { riskHints } from "./risk-hints.js";
|
|
19
|
+
import { useShallow } from "zustand/react/shallow";
|
|
20
|
+
import { useActivityStore, spanForTool, childSpans } from "../../stores/activity-store.js";
|
|
21
|
+
import { useUIStore } from "../../stores/ui-store.js";
|
|
22
|
+
import { useNow } from "../../hooks/use-now.js";
|
|
23
|
+
import { SpanStatusDot } from "../activity/span-bits.js";
|
|
19
24
|
|
|
20
25
|
// Register the built-in renderer packs once. Registration is build-time; this
|
|
21
26
|
// runs on module load.
|
|
@@ -127,13 +132,26 @@ function ToolCallEntry({
|
|
|
127
132
|
toolCall.status === "pending_approval"
|
|
128
133
|
);
|
|
129
134
|
const prevStatus = useRef(toolCall.status);
|
|
135
|
+
// One clock for live and reloaded views: when the activity stream carries
|
|
136
|
+
// this call's span, its server-stamped timing wins over the client stamps
|
|
137
|
+
// (which don't exist at all for history-loaded messages). Selecting the
|
|
138
|
+
// span itself (a stable reference) rather than a derived timing object
|
|
139
|
+
// keeps every other entry from re-rendering on every activity delta.
|
|
140
|
+
const span = useActivityStore((s) => spanForTool(s, toolCall.id));
|
|
141
|
+
const timed: ToolCall = span
|
|
142
|
+
? {
|
|
143
|
+
...toolCall,
|
|
144
|
+
startedAt: span.waitUntil ?? span.startedAt,
|
|
145
|
+
...(span.endedAt !== undefined ? { endedAt: span.endedAt } : {}),
|
|
146
|
+
}
|
|
147
|
+
: toolCall;
|
|
130
148
|
// Resolve the renderer for this tool (backend-scoped exact -> global exact ->
|
|
131
149
|
// shape-sniffing predicate). Falls back to the generic renderer.
|
|
132
150
|
const renderer = resolveToolRenderer(toolCall, BACKEND_ID) ?? GENERIC_RENDERER;
|
|
133
151
|
const Icon = renderer.icon ?? FileText;
|
|
134
152
|
const isPending = toolCall.status === "pending_approval";
|
|
135
|
-
const summary = renderer.summary?.(
|
|
136
|
-
const meta = renderer.meta?.(
|
|
153
|
+
const summary = renderer.summary?.(timed) ?? null;
|
|
154
|
+
const meta = renderer.meta?.(timed) ?? null;
|
|
137
155
|
const Input = renderer.Input;
|
|
138
156
|
const Output = renderer.Output;
|
|
139
157
|
|
|
@@ -208,6 +226,9 @@ function ToolCallEntry({
|
|
|
208
226
|
</span>
|
|
209
227
|
</button>
|
|
210
228
|
|
|
229
|
+
{/* Live subagent state for Agent fan-outs, fed by the activity stream. */}
|
|
230
|
+
{toolCall.name === "Agent" && <SubagentEntryRows agentToolUseId={toolCall.id} />}
|
|
231
|
+
|
|
211
232
|
{/* Expandable detail */}
|
|
212
233
|
<AnimatePresence>
|
|
213
234
|
{expanded && (
|
|
@@ -275,6 +296,43 @@ function ToolCallEntry({
|
|
|
275
296
|
);
|
|
276
297
|
}
|
|
277
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Compact live status of one Agent fan-out: what the subagent is, its state,
|
|
301
|
+
* elapsed time and step count — with a tap opening the drill-in view. Data
|
|
302
|
+
* comes from the activity stream (session subscription); on a host without
|
|
303
|
+
* activity recording this renders nothing and the entry stays as before.
|
|
304
|
+
*/
|
|
305
|
+
function SubagentEntryRows({ agentToolUseId }: { agentToolUseId: string }) {
|
|
306
|
+
const span = useActivityStore((s) => spanForTool(s, agentToolUseId));
|
|
307
|
+
const stepCount = useActivityStore(
|
|
308
|
+
useShallow((s) => childSpans(s, agentToolUseId).length)
|
|
309
|
+
);
|
|
310
|
+
const pushSubagentView = useUIStore((s) => s.pushSubagentView);
|
|
311
|
+
const now = useNow();
|
|
312
|
+
if (!span || span.kind !== "subagent") return null;
|
|
313
|
+
|
|
314
|
+
const description = span.subagent?.description ?? "subagent";
|
|
315
|
+
const tokens = span.subagent?.totalTokens ?? null;
|
|
316
|
+
const elapsed = formatDuration((span.endedAt ?? now) - span.startedAt);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => pushSubagentView(agentToolUseId)}
|
|
322
|
+
className="ml-6 flex w-[calc(100%-1.5rem)] items-center gap-2 rounded-md px-2 py-1 text-[11px] text-muted-foreground/80 transition-colors hover:text-foreground"
|
|
323
|
+
>
|
|
324
|
+
<SpanStatusDot span={span} />
|
|
325
|
+
<span className="truncate">{description}</span>
|
|
326
|
+
<span className="ml-auto shrink-0 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
|
|
327
|
+
{stepCount > 0 && `${stepCount} step${stepCount === 1 ? "" : "s"} · `}
|
|
328
|
+
{tokens !== null && `${formatTokenCount(tokens)} tok · `}
|
|
329
|
+
{elapsed}
|
|
330
|
+
</span>
|
|
331
|
+
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
332
|
+
</button>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
278
336
|
function RiskHints({ toolCall }: { toolCall: ToolCall }) {
|
|
279
337
|
const hints = riskHints(toolCall);
|
|
280
338
|
if (hints.length === 0) return null;
|
|
@@ -68,6 +68,8 @@ function fileLabel(path: string): ReactNode {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function formatDuration(ms: number): string {
|
|
71
|
+
// Server and browser clocks can skew; a negative duration is just 0.
|
|
72
|
+
ms = Math.max(0, ms);
|
|
71
73
|
if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
72
74
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
73
75
|
const m = Math.floor(ms / 60_000);
|
|
@@ -75,6 +77,26 @@ export function formatDuration(ms: number): string {
|
|
|
75
77
|
return `${m}m ${s}s`;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/** Compact token count: 1234 → "1k", 2_345_678 → "2.3M". */
|
|
81
|
+
export function formatTokenCount(n: number): string {
|
|
82
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
83
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
|
|
84
|
+
return String(n);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Relative timestamp for list rows ("3m ago"); dates beyond a week. */
|
|
88
|
+
export function formatRelativeTime(ms: number): string {
|
|
89
|
+
const diff = Date.now() - ms;
|
|
90
|
+
const minutes = Math.floor(diff / 60000);
|
|
91
|
+
if (minutes < 1) return "just now";
|
|
92
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
93
|
+
const hours = Math.floor(minutes / 60);
|
|
94
|
+
if (hours < 24) return `${hours}h ago`;
|
|
95
|
+
const days = Math.floor(hours / 24);
|
|
96
|
+
if (days < 7) return `${days}d ago`;
|
|
97
|
+
return new Date(ms).toLocaleDateString();
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
export function countLines(text: string): number {
|
|
79
101
|
let n = 1;
|
|
80
102
|
for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
|