@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,105 @@
|
|
|
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
|
+
import { digestCostClause } from "./span-bits.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The while-you-were-away card: presented PROACTIVELY on app open when a
|
|
11
|
+
* fresh digest exists (planning decision — built passively, the flow
|
|
12
|
+
* collapses back into asking the agent what happened). Once per digest:
|
|
13
|
+
* dismissing persists server-side, so a phone and a laptop don't each nag.
|
|
14
|
+
* An empty window stays quiet — no card for "nothing happened". The window
|
|
15
|
+
* is labeled: the card says what period it covers, never implies "now".
|
|
16
|
+
*/
|
|
17
|
+
export function DigestCard() {
|
|
18
|
+
const [digest, setDigest] = useState<ActivityDigest | null>(null);
|
|
19
|
+
const [visible, setVisible] = useState(false);
|
|
20
|
+
const setActiveView = useUIStore((s) => s.setActiveView);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
void api
|
|
24
|
+
.activityDigest()
|
|
25
|
+
.then(({ digest, dismissedAt }) => {
|
|
26
|
+
if (!digest || digest.runs === 0) return;
|
|
27
|
+
if (digest.generatedAt <= dismissedAt) return;
|
|
28
|
+
setDigest(digest);
|
|
29
|
+
setVisible(true);
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {
|
|
32
|
+
// No digest is a quiet state, never an error surface.
|
|
33
|
+
});
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
if (!visible || !digest) return null;
|
|
37
|
+
|
|
38
|
+
function dismiss() {
|
|
39
|
+
setVisible(false);
|
|
40
|
+
void api.activityDigestDismiss().catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const windowLabel = `${formatDay(digest.windowStart)} – ${formatDay(digest.windowEnd)}`;
|
|
44
|
+
// Effective-only spend clause; a digest persisted before pricing shipped
|
|
45
|
+
// falls back to its old list-cost clause inside the helper.
|
|
46
|
+
const costClause = digestCostClause(digest);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cn(
|
|
51
|
+
"mx-auto mb-3 w-full max-w-2xl rounded-xl border p-3 text-xs",
|
|
52
|
+
digest.failures > 0
|
|
53
|
+
? "border-destructive/30 bg-destructive/5"
|
|
54
|
+
: "border-border-subtle bg-surface"
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
<Newspaper className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
59
|
+
<span className="font-medium">While you were away</span>
|
|
60
|
+
<span className="text-[10px] text-muted-foreground/60">{windowLabel}</span>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={dismiss}
|
|
64
|
+
className="ml-auto rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
|
|
65
|
+
aria-label="Dismiss"
|
|
66
|
+
>
|
|
67
|
+
<X className="h-3.5 w-3.5" />
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="mt-1.5 text-muted-foreground">
|
|
71
|
+
{digest.runs} run{digest.runs === 1 ? "" : "s"}
|
|
72
|
+
{digest.failures > 0 && (
|
|
73
|
+
<span className="text-destructive"> · {digest.failures} failed</span>
|
|
74
|
+
)}
|
|
75
|
+
{costClause && ` · ${costClause}`}
|
|
76
|
+
</div>
|
|
77
|
+
{digest.notable.length > 0 && (
|
|
78
|
+
<div className="mt-1.5 space-y-0.5">
|
|
79
|
+
{digest.notable.slice(0, 3).map((n) => (
|
|
80
|
+
<div key={n.runId} className="flex items-center gap-1.5 text-[11px]">
|
|
81
|
+
<AlertTriangle className="h-3 w-3 shrink-0 text-destructive" />
|
|
82
|
+
<span className="truncate">
|
|
83
|
+
{n.jobName ?? n.name} — {n.outcome}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={() => {
|
|
92
|
+
dismiss();
|
|
93
|
+
setActiveView("activity");
|
|
94
|
+
}}
|
|
95
|
+
className="mt-2 text-[11px] font-medium text-primary hover:underline"
|
|
96
|
+
>
|
|
97
|
+
Open Activity
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatDay(ms: number): string {
|
|
104
|
+
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
105
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
/** The subscription's bound applicationServerKey, in the base64url form the
|
|
17
|
+
* server hands out — comparable against `pushPublicKey()` directly. */
|
|
18
|
+
function keyToBase64Url(key: ArrayBuffer): string {
|
|
19
|
+
let bin = "";
|
|
20
|
+
for (const b of new Uint8Array(key)) bin += String.fromCharCode(b);
|
|
21
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type PushState =
|
|
25
|
+
| "unsupported"
|
|
26
|
+
| "not-asked"
|
|
27
|
+
| "blocked"
|
|
28
|
+
| "subscribed"
|
|
29
|
+
| "unsubscribed";
|
|
30
|
+
|
|
31
|
+
export function PushToggle() {
|
|
32
|
+
const [state, setState] = useState<PushState>("unsupported");
|
|
33
|
+
const [busy, setBusy] = useState(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
void (async () => {
|
|
37
|
+
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
38
|
+
setState("unsupported");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (Notification.permission === "denied") {
|
|
42
|
+
setState("blocked");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (Notification.permission === "default") {
|
|
46
|
+
setState("not-asked");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const registration = await navigator.serviceWorker.ready;
|
|
50
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
51
|
+
if (subscription) {
|
|
52
|
+
// The browser having a subscription doesn't mean the SERVER can
|
|
53
|
+
// still use it. Two disagreement cases: the server pruned/lost the
|
|
54
|
+
// row (a dead-endpoint send, a DB restore) — healed by re-asserting,
|
|
55
|
+
// an idempotent upsert; or the server's VAPID keypair changed, which
|
|
56
|
+
// makes this subscription permanently unsendable — its
|
|
57
|
+
// applicationServerKey no longer matches, so drop it and surface the
|
|
58
|
+
// re-enable button. Offline, trust the browser's own state.
|
|
59
|
+
try {
|
|
60
|
+
const { publicKey } = await api.pushPublicKey();
|
|
61
|
+
const boundKey = subscription.options.applicationServerKey;
|
|
62
|
+
if (boundKey && keyToBase64Url(boundKey) !== publicKey) {
|
|
63
|
+
await subscription.unsubscribe().catch(() => {});
|
|
64
|
+
setState("unsubscribed");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
|
|
68
|
+
} catch {
|
|
69
|
+
// Server unreachable — keep the browser's answer.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
setState(subscription ? "subscribed" : "unsubscribed");
|
|
73
|
+
})();
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
async function enable() {
|
|
77
|
+
setBusy(true);
|
|
78
|
+
try {
|
|
79
|
+
const permission = await Notification.requestPermission();
|
|
80
|
+
if (permission === "denied") {
|
|
81
|
+
setState("blocked");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (permission !== "granted") {
|
|
85
|
+
setState("not-asked");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const { publicKey } = await api.pushPublicKey();
|
|
89
|
+
const registration = await navigator.serviceWorker.ready;
|
|
90
|
+
const subscription = await registration.pushManager.subscribe({
|
|
91
|
+
userVisibleOnly: true,
|
|
92
|
+
applicationServerKey: publicKey,
|
|
93
|
+
});
|
|
94
|
+
await api.pushSubscribe(subscription.toJSON(), navigator.userAgent.slice(0, 100));
|
|
95
|
+
setState("subscribed");
|
|
96
|
+
} catch {
|
|
97
|
+
setState("unsubscribed");
|
|
98
|
+
} finally {
|
|
99
|
+
setBusy(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function disable() {
|
|
104
|
+
setBusy(true);
|
|
105
|
+
try {
|
|
106
|
+
const registration = await navigator.serviceWorker.ready;
|
|
107
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
108
|
+
if (subscription) {
|
|
109
|
+
await api.pushUnsubscribe(subscription.endpoint).catch(() => {});
|
|
110
|
+
await subscription.unsubscribe();
|
|
111
|
+
}
|
|
112
|
+
setState("unsubscribed");
|
|
113
|
+
} finally {
|
|
114
|
+
setBusy(false);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (state === "unsupported") {
|
|
119
|
+
return (
|
|
120
|
+
<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+).">
|
|
121
|
+
<BellOff className="h-3.5 w-3.5" />
|
|
122
|
+
No push here
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (state === "blocked") {
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className="flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
|
131
|
+
title="Notifications are blocked at the browser level. Allow them in this site's browser settings, then reload."
|
|
132
|
+
>
|
|
133
|
+
<BellOff className="h-3.5 w-3.5 text-destructive" />
|
|
134
|
+
Blocked in browser settings
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const on = state === "subscribed";
|
|
140
|
+
return (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
disabled={busy}
|
|
144
|
+
onClick={() => void (on ? disable() : enable())}
|
|
145
|
+
className={cn(
|
|
146
|
+
"flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-50",
|
|
147
|
+
on
|
|
148
|
+
? "text-primary hover:bg-surface-raised"
|
|
149
|
+
: "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
|
|
150
|
+
)}
|
|
151
|
+
title={on ? "Push notifications are on for this device" : "Get a push notification when background work fails"}
|
|
152
|
+
>
|
|
153
|
+
{on ? <Bell className="h-3.5 w-3.5" /> : <BellOff className="h-3.5 w-3.5" />}
|
|
154
|
+
{on ? "Push on" : "Enable push"}
|
|
155
|
+
</button>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useShallow } from "zustand/react/shallow";
|
|
3
|
+
import { isFailureOutcome, SPAN_TOOL_NAME_PREFIX } from "@schlessera/brain-ui-sdk/protocol";
|
|
4
|
+
import type { ActivitySpan, ActivitySpanOutcome } from "@schlessera/brain-ui-sdk/protocol";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
useActivityStore,
|
|
8
|
+
payloadEventsFor,
|
|
9
|
+
loadSpanPayloads,
|
|
10
|
+
} from "../../stores/activity-store.js";
|
|
11
|
+
import { cn } from "../../lib/utils.js";
|
|
12
|
+
import { getToolLabel } from "../chat/tool-views.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* THE status dot for activity spans — one outcome→color mapping so every
|
|
16
|
+
* surface (timeline rows, drill-in, activity index) agrees on what a color
|
|
17
|
+
* means: running pulses primary, success is accent, failures (per
|
|
18
|
+
* `isFailureOutcome`) and denied are destructive, cancelled is muted.
|
|
19
|
+
*/
|
|
20
|
+
export function SpanStatusDot({
|
|
21
|
+
span,
|
|
22
|
+
outcome,
|
|
23
|
+
running,
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
span?: ActivitySpan;
|
|
27
|
+
/** Alternative to `span` when only the outcome is at hand. */
|
|
28
|
+
outcome?: ActivitySpanOutcome | string | null;
|
|
29
|
+
running?: boolean;
|
|
30
|
+
/** Size/position overrides; defaults to h-1.5 w-1.5. */
|
|
31
|
+
className?: string;
|
|
32
|
+
}) {
|
|
33
|
+
const resolved = span ? span.outcome : outcome;
|
|
34
|
+
const isRunning = running ?? (span !== undefined && span.outcome === undefined);
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
className={cn(
|
|
38
|
+
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
39
|
+
isRunning
|
|
40
|
+
? "animate-pulse bg-primary"
|
|
41
|
+
: resolved === "success"
|
|
42
|
+
? "bg-accent"
|
|
43
|
+
: isFailureOutcome(resolved) || resolved === "denied"
|
|
44
|
+
? "bg-destructive"
|
|
45
|
+
: "bg-muted-foreground",
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Display label for a span. Prefers the server-lifted `toolName`; root spans
|
|
54
|
+
* label by kind (a turn or cron root is not a tool call); the last resort
|
|
55
|
+
* un-parses the `execute_tool` naming convention for spans recorded before
|
|
56
|
+
* the field existed.
|
|
57
|
+
*/
|
|
58
|
+
export function spanToolLabel(span: ActivitySpan): string {
|
|
59
|
+
if (span.toolName) return getToolLabel(span.toolName);
|
|
60
|
+
if (span.kind === "turn") return "Turn";
|
|
61
|
+
if (span.kind === "cron") return span.jobName ?? span.name;
|
|
62
|
+
return getToolLabel(
|
|
63
|
+
span.name.startsWith(SPAN_TOOL_NAME_PREFIX)
|
|
64
|
+
? span.name.slice(SPAN_TOOL_NAME_PREFIX.length)
|
|
65
|
+
: span.name
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The recorded input/output of one tool span, expanded under its row — the
|
|
71
|
+
* ONE payload renderer shared by the subagent drill-in and the run detail.
|
|
72
|
+
* Mounted only while expanded (collapsed rows never subscribe). A span with
|
|
73
|
+
* no payload events (recorded before capture shipped) states so instead of
|
|
74
|
+
* offering an empty block (AE7); a payload clipped at persist time carries
|
|
75
|
+
* the wire `truncated` flag, rendered as a hint line under the block.
|
|
76
|
+
*/
|
|
77
|
+
export function SpanPayload({ spanId }: { spanId: string }) {
|
|
78
|
+
const events = useActivityStore(useShallow((s) => payloadEventsFor(s, spanId)));
|
|
79
|
+
// History fetches skip payload bodies (they exist for duration badges);
|
|
80
|
+
// the first expand of a finished run backfills them over REST. A no-op for
|
|
81
|
+
// live runs (payloads ride the WS deltas) and once per run thereafter.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (events.length === 0) void loadSpanPayloads(spanId);
|
|
84
|
+
}, [spanId, events.length]);
|
|
85
|
+
if (events.length === 0) {
|
|
86
|
+
return (
|
|
87
|
+
<p className="px-2 py-1 text-[11px] text-muted-foreground/60">
|
|
88
|
+
No payload recorded for this run.
|
|
89
|
+
</p>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-1.5 px-2 py-1">
|
|
94
|
+
{events.map((event) => (
|
|
95
|
+
<div key={`${event.spanId}:${event.eventIndex}`}>
|
|
96
|
+
<div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">
|
|
97
|
+
{event.eventType === "tool_input" ? "Input" : "Output"}
|
|
98
|
+
</div>
|
|
99
|
+
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed text-muted-foreground">
|
|
100
|
+
{typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload)}
|
|
101
|
+
</pre>
|
|
102
|
+
{event.truncated && (
|
|
103
|
+
<p className="mt-0.5 text-[10px] italic text-muted-foreground/60">… truncated</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* THE effective-cost glyph — one three-state rule so no surface ever renders
|
|
113
|
+
* an unknown cost as $0.00 (AE3): absent/NULL is "—" (we don't know), 0 is
|
|
114
|
+
* "free" (we know — subscription-billed or genuinely zero), positive is
|
|
115
|
+
* dollars, "~"-prefixed when computed from estimated rates. Sub-cent costs
|
|
116
|
+
* floor at "<$0.01" rather than rounding down to a zero look-alike.
|
|
117
|
+
*/
|
|
118
|
+
export function formatEffectiveCost(
|
|
119
|
+
costUsd: number | null | undefined,
|
|
120
|
+
estimate?: boolean
|
|
121
|
+
): string {
|
|
122
|
+
if (costUsd === null || costUsd === undefined) return "—";
|
|
123
|
+
if (costUsd === 0) return "free";
|
|
124
|
+
const amount = costUsd < 0.005 ? "<$0.01" : `$${costUsd.toFixed(2)}`;
|
|
125
|
+
return estimate ? `~${amount}` : amount;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An aggregate's effective-cost sum. Sums exclude unknown-cost runs, so a
|
|
130
|
+
* nonzero `unpricedRuns` makes the number a floor ("≥ $X"), never a total.
|
|
131
|
+
* A known sub-cent sum floors at "<$0.01" like the per-run glyph — only an
|
|
132
|
+
* exact 0 (genuinely nothing to add up) renders "$0.00" (AE3).
|
|
133
|
+
*/
|
|
134
|
+
export function formatAggregateCost(effectiveUsd: number, unpricedRuns: number): string {
|
|
135
|
+
const amount =
|
|
136
|
+
effectiveUsd > 0 && effectiveUsd < 0.005 ? "<$0.01" : `$${effectiveUsd.toFixed(2)}`;
|
|
137
|
+
return unpricedRuns > 0 ? `≥ ${amount}` : amount;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A run row's cost text, three-way on `effectiveCostUsd`: ABSENT means a
|
|
142
|
+
* pre-pricing server never sent the field — fall back to the original
|
|
143
|
+
* list-cost rendering (a positive `costUsd`, else nothing) instead of
|
|
144
|
+
* claiming unknown; explicit null means THIS server computed "unknown" and
|
|
145
|
+
* renders the em dash; a number goes through `formatEffectiveCost` (AE3).
|
|
146
|
+
*/
|
|
147
|
+
export function runCostText(run: {
|
|
148
|
+
costUsd: number | null;
|
|
149
|
+
effectiveCostUsd?: number | null;
|
|
150
|
+
pricingEstimate?: boolean;
|
|
151
|
+
}): string | null {
|
|
152
|
+
if (run.effectiveCostUsd === undefined) {
|
|
153
|
+
return run.costUsd !== null && run.costUsd > 0 ? `$${run.costUsd.toFixed(2)}` : null;
|
|
154
|
+
}
|
|
155
|
+
return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The digest card's cost clause (no leading separator), effective-only, or
|
|
160
|
+
* null for "say nothing". A digest persisted before pricing shipped carries
|
|
161
|
+
* neither new field and keeps its old list-cost clause.
|
|
162
|
+
*/
|
|
163
|
+
export function digestCostClause(digest: {
|
|
164
|
+
costUsd: number;
|
|
165
|
+
effectiveCostUsd?: number;
|
|
166
|
+
unpricedRuns?: number;
|
|
167
|
+
}): string | null {
|
|
168
|
+
if (digest.effectiveCostUsd === undefined) {
|
|
169
|
+
return digest.costUsd > 0 ? `$${digest.costUsd.toFixed(2)} spent` : null;
|
|
170
|
+
}
|
|
171
|
+
const unpriced = digest.unpricedRuns ?? 0;
|
|
172
|
+
const qualifier = unpriced > 0 ? ` (${unpriced} unpriced)` : "";
|
|
173
|
+
if (digest.effectiveCostUsd > 0) {
|
|
174
|
+
return `${formatAggregateCost(digest.effectiveCostUsd, unpriced)} spent${qualifier}`;
|
|
175
|
+
}
|
|
176
|
+
// Known-zero spend: nothing to add up, but the unknowns still get named.
|
|
177
|
+
if (unpriced > 0) return `${unpriced} unpriced`;
|
|
178
|
+
return "free";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The "9+" unread-count bubble shared by the rail and the tab bar. Hidden at 0. */
|
|
182
|
+
export function CountBadge({ count, className }: { count: number; className?: string }) {
|
|
183
|
+
if (count <= 0) return null;
|
|
184
|
+
return (
|
|
185
|
+
<span
|
|
186
|
+
className={cn(
|
|
187
|
+
"absolute flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-white",
|
|
188
|
+
className
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
{count > 9 ? "9+" : count}
|
|
192
|
+
</span>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -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
|
-
}
|