@schlessera/brain-ui-react 0.18.0 → 0.20.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 +243 -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 +55 -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 +121 -0
- package/dist/components/activity/push-toggle.js.map +1 -0
- package/dist/components/activity/span-bits.d.ts +76 -0
- package/dist/components/activity/span-bits.d.ts.map +1 -0
- package/dist/components/activity/span-bits.js +127 -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 +83 -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/components/settings/models-tab.d.ts +15 -0
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +100 -21
- package/dist/components/settings/models-tab.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 +67 -1
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +41 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/stores/activity-store.d.ts +108 -0
- package/dist/stores/activity-store.d.ts.map +1 -0
- package/dist/stores/activity-store.js +359 -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 +623 -0
- package/src/components/activity/digest-card.tsx +105 -0
- package/src/components/activity/push-toggle.tsx +157 -0
- package/src/components/activity/span-bits.tsx +194 -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 +239 -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/components/settings/models-tab.tsx +135 -21
- 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 +111 -0
- package/src/stores/activity-store.ts +451 -0
- package/src/stores/ui-store.ts +13 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { ArrowLeft, Bot, Check, ChevronRight, 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 { SpanPayload, 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
|
+
// Tool rows expand to their recorded payload (AE7); a pre-feature span
|
|
175
|
+
// expands to the no-payload notice, so the affordance stays uniform.
|
|
176
|
+
const [expanded, setExpanded] = useState(false);
|
|
177
|
+
const running = span.outcome === undefined;
|
|
178
|
+
const failed = isFailureOutcome(span.outcome);
|
|
179
|
+
const expandable = !onOpen && span.kind === "tool";
|
|
180
|
+
const duration =
|
|
181
|
+
span.endedAt !== undefined
|
|
182
|
+
? formatDuration(span.endedAt - (span.waitUntil ?? span.startedAt))
|
|
183
|
+
: null;
|
|
184
|
+
return (
|
|
185
|
+
<motion.div initial={{ opacity: 0, y: 2 }} animate={{ opacity: 1, y: 0 }}>
|
|
186
|
+
<div
|
|
187
|
+
className={cn(
|
|
188
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
|
|
189
|
+
failed ? "text-destructive/80" : "text-muted-foreground",
|
|
190
|
+
(onOpen || expandable) && "cursor-pointer hover:text-foreground"
|
|
191
|
+
)}
|
|
192
|
+
onClick={onOpen ?? (expandable ? () => setExpanded((v) => !v) : undefined)}
|
|
193
|
+
>
|
|
194
|
+
<SpanStatusDot span={span} className="h-2 w-2" />
|
|
195
|
+
<span className="font-[family-name:var(--font-mono)] font-medium">
|
|
196
|
+
{spanToolLabel(span)}
|
|
197
|
+
</span>
|
|
198
|
+
{span.outcome && span.outcome !== "success" && (
|
|
199
|
+
<span className="text-[10px] uppercase">{span.outcome}</span>
|
|
200
|
+
)}
|
|
201
|
+
<span className="ml-auto font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/50">
|
|
202
|
+
{duration ?? (running ? "…" : "")}
|
|
203
|
+
</span>
|
|
204
|
+
{expandable && (
|
|
205
|
+
<ChevronRight
|
|
206
|
+
className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
{expanded && <SpanPayload spanId={span.spanId} />}
|
|
211
|
+
</motion.div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function TranscriptRow({ event }: { event: ActivitySpanEvent }) {
|
|
216
|
+
const text = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload);
|
|
217
|
+
if (!text) return null;
|
|
218
|
+
return (
|
|
219
|
+
<div className="whitespace-pre-wrap rounded-md bg-surface px-3 py-2 text-xs text-foreground/80">
|
|
220
|
+
{text}
|
|
221
|
+
{event.truncated && <span className="text-muted-foreground/60"> …</span>}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type TimelineItem =
|
|
227
|
+
| { kind: "span"; span: ActivitySpan; ts: number }
|
|
228
|
+
| { kind: "event"; event: ActivitySpanEvent; ts: number };
|
|
229
|
+
|
|
230
|
+
/** Child steps and transcript excerpts in one chronological stream. */
|
|
231
|
+
function interleave(spans: ActivitySpan[], events: ActivitySpanEvent[]): TimelineItem[] {
|
|
232
|
+
const items: TimelineItem[] = [
|
|
233
|
+
...spans.map((span) => ({ kind: "span" as const, span, ts: span.startedAt })),
|
|
234
|
+
...events
|
|
235
|
+
.filter((e) => e.eventType.startsWith("transcript_"))
|
|
236
|
+
.map((event) => ({ kind: "event" as const, event, ts: event.ts })),
|
|
237
|
+
];
|
|
238
|
+
return items.sort((a, b) => a.ts - b.ts);
|
|
239
|
+
}
|
|
@@ -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++;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
+
Activity,
|
|
3
4
|
Brain,
|
|
4
5
|
RefreshCw,
|
|
5
6
|
History,
|
|
@@ -11,7 +12,9 @@ import {
|
|
|
11
12
|
} from "lucide-react";
|
|
12
13
|
import { useUIStore } from "../../stores/ui-store.js";
|
|
13
14
|
import { useChatStore } from "../../stores/chat-store.js";
|
|
15
|
+
import { useActivityStore } from "../../stores/activity-store.js";
|
|
14
16
|
import { cn } from "../../lib/utils.js";
|
|
17
|
+
import { CountBadge } from "../activity/span-bits.js";
|
|
15
18
|
|
|
16
19
|
export function MobileTabBar() {
|
|
17
20
|
const activeView = useUIStore((s) => s.activeView);
|
|
@@ -21,6 +24,7 @@ export function MobileTabBar() {
|
|
|
21
24
|
const toggleFilePanel = useUIStore((s) => s.toggleFilePanel);
|
|
22
25
|
const toggleSettingsPanel = useUIStore((s) => s.toggleSettingsPanel);
|
|
23
26
|
const clearMessages = useChatStore((s) => s.clearMessages);
|
|
27
|
+
const inboxCount = useActivityStore((s) => s.inbox.length);
|
|
24
28
|
|
|
25
29
|
/** Chat-scoped panels live in the chat page — surface it before opening them. */
|
|
26
30
|
function inChat(toggle: () => void) {
|
|
@@ -66,11 +70,15 @@ export function MobileTabBar() {
|
|
|
66
70
|
clearMessages();
|
|
67
71
|
}}
|
|
68
72
|
/>
|
|
73
|
+
{/* Activity holds the tab-bar slot; Graph moved into the More menu —
|
|
74
|
+
the phone glance-check ("is real work happening?") is the headline
|
|
75
|
+
flow and must stay one tap away (planning decision). */}
|
|
69
76
|
<TabIcon
|
|
70
|
-
icon={
|
|
71
|
-
label="
|
|
72
|
-
active={activeView === "
|
|
73
|
-
|
|
77
|
+
icon={Activity}
|
|
78
|
+
label="Activity"
|
|
79
|
+
active={activeView === "activity"}
|
|
80
|
+
badge={inboxCount}
|
|
81
|
+
onClick={() => setActiveView("activity")}
|
|
74
82
|
/>
|
|
75
83
|
<TabIcon icon={FolderTree} label="Files" onClick={toggleFilePanel} />
|
|
76
84
|
<div ref={moreRef} className="relative">
|
|
@@ -85,6 +93,14 @@ export function MobileTabBar() {
|
|
|
85
93
|
role="menu"
|
|
86
94
|
className="absolute bottom-full right-0 z-50 mb-2 min-w-[10rem] overflow-hidden rounded-xl border border-border bg-surface-overlay py-1 shadow-2xl"
|
|
87
95
|
>
|
|
96
|
+
<MoreItem
|
|
97
|
+
icon={Waypoints}
|
|
98
|
+
label="Graph"
|
|
99
|
+
onClick={() => {
|
|
100
|
+
setMoreOpen(false);
|
|
101
|
+
setActiveView("graph");
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
88
104
|
<MoreItem
|
|
89
105
|
icon={RefreshCw}
|
|
90
106
|
label="Sync"
|
|
@@ -121,21 +137,25 @@ function TabIcon({
|
|
|
121
137
|
label,
|
|
122
138
|
active,
|
|
123
139
|
onClick,
|
|
140
|
+
badge,
|
|
124
141
|
}: {
|
|
125
142
|
icon: typeof Brain;
|
|
126
143
|
label: string;
|
|
127
144
|
active?: boolean;
|
|
128
145
|
onClick?: () => void;
|
|
146
|
+
/** Unread-count dot (the inbox badge). Hidden at 0. */
|
|
147
|
+
badge?: number;
|
|
129
148
|
}) {
|
|
130
149
|
return (
|
|
131
150
|
<button
|
|
132
151
|
onClick={onClick}
|
|
133
152
|
className={cn(
|
|
134
|
-
"flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]",
|
|
153
|
+
"relative flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]",
|
|
135
154
|
active ? "text-primary" : "text-muted-foreground"
|
|
136
155
|
)}
|
|
137
156
|
>
|
|
138
157
|
<Icon className="h-5 w-5" />
|
|
158
|
+
{badge !== undefined && <CountBadge count={badge} className="right-0 top-0" />}
|
|
139
159
|
{label}
|
|
140
160
|
</button>
|
|
141
161
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Activity,
|
|
2
3
|
Brain,
|
|
3
4
|
RefreshCw,
|
|
4
5
|
Newspaper,
|
|
@@ -9,9 +10,11 @@ import {
|
|
|
9
10
|
Waypoints,
|
|
10
11
|
} from "lucide-react";
|
|
11
12
|
import { useConnectionStore } from "../../stores/connection-store.js";
|
|
13
|
+
import { useActivityStore } from "../../stores/activity-store.js";
|
|
12
14
|
import { useUIStore } from "../../stores/ui-store.js";
|
|
13
15
|
import { useChatStore, activeChat } from "../../stores/chat-store.js";
|
|
14
16
|
import { cn } from "../../lib/utils.js";
|
|
17
|
+
import { CountBadge } from "../activity/span-bits.js";
|
|
15
18
|
|
|
16
19
|
export function SideRail() {
|
|
17
20
|
const wsStatus = useConnectionStore((s) => s.wsStatus);
|
|
@@ -25,6 +28,7 @@ export function SideRail() {
|
|
|
25
28
|
const clearMessages = useChatStore((s) => s.clearMessages);
|
|
26
29
|
const hasMessages = useChatStore((s) => activeChat(s).messages.length > 0);
|
|
27
30
|
const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
|
|
31
|
+
const inboxCount = useActivityStore((s) => s.inbox.length);
|
|
28
32
|
|
|
29
33
|
/** Chat-scoped panels live in the chat page — surface it before opening them. */
|
|
30
34
|
function inChat(toggle: () => void) {
|
|
@@ -79,6 +83,15 @@ export function SideRail() {
|
|
|
79
83
|
setActiveView(activeView === "graph" ? "chat" : "graph")
|
|
80
84
|
}
|
|
81
85
|
/>
|
|
86
|
+
<RailButton
|
|
87
|
+
icon={Activity}
|
|
88
|
+
label="Activity"
|
|
89
|
+
active={activeView === "activity"}
|
|
90
|
+
badge={inboxCount}
|
|
91
|
+
onClick={() =>
|
|
92
|
+
setActiveView(activeView === "activity" ? "chat" : "activity")
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
82
95
|
|
|
83
96
|
{/* Spacer */}
|
|
84
97
|
<div className="flex-1" />
|
|
@@ -125,12 +138,15 @@ function RailButton({
|
|
|
125
138
|
onClick,
|
|
126
139
|
disabled,
|
|
127
140
|
active,
|
|
141
|
+
badge,
|
|
128
142
|
}: {
|
|
129
143
|
icon: typeof Brain;
|
|
130
144
|
label: string;
|
|
131
145
|
onClick: () => void;
|
|
132
146
|
disabled?: boolean;
|
|
133
147
|
active?: boolean;
|
|
148
|
+
/** Unread-count dot (the inbox badge). Hidden at 0. */
|
|
149
|
+
badge?: number;
|
|
134
150
|
}) {
|
|
135
151
|
return (
|
|
136
152
|
<button
|
|
@@ -143,6 +159,7 @@ function RailButton({
|
|
|
143
159
|
)}
|
|
144
160
|
>
|
|
145
161
|
<Icon className="h-4.5 w-4.5" />
|
|
162
|
+
{badge !== undefined && <CountBadge count={badge} className="-right-0.5 -top-0.5" />}
|
|
146
163
|
{/* Tooltip */}
|
|
147
164
|
<span className="pointer-events-none absolute left-full ml-2 whitespace-nowrap rounded-md bg-surface-overlay px-2.5 py-1 text-xs font-medium text-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 border border-border">
|
|
148
165
|
{label}
|