@sentry/junior-dashboard 0.277.0 → 0.278.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/app.js +30 -41
- package/dist/client.js +90 -90
- package/dist/index.js +30 -41
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/app.ts +1 -1
- package/src/client/App.tsx +9 -4
- package/src/client/components/DetailsPanel.tsx +1 -1
- package/src/client/components/ui/resizable.tsx +50 -0
- package/src/client/conversations/AutoArchiveToggle.tsx +50 -14
- package/src/client/conversations/ConversationHeader.tsx +6 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +4 -0
- package/src/client/conversations/ConversationMeta.tsx +2 -7
- package/src/client/conversations/ConversationPage.tsx +155 -72
- package/src/client/conversations/ConversationSidebar.tsx +2 -1
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/ConversationWorkspace.tsx +129 -41
- package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
- package/src/client/conversations/EventDetails.tsx +2 -62
- package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
- package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
- package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
- package/src/client/conversations/TranscriptView.tsx +89 -20
- package/src/client/conversations/conversationSidebarLayout.ts +61 -0
- package/src/client/conversations/eventLog.ts +2 -31
- package/src/client/conversations/eventTranscript.ts +9 -42
- package/src/client/conversations/queries.ts +76 -15
- package/src/client/conversations/transcript.ts +46 -16
- package/src/client/conversations/transcriptBottomPinning.ts +40 -24
- package/src/client/conversations/transcriptReadPosition.ts +31 -7
- package/src/client/conversations/transcriptRenderModel.ts +1 -14
- package/src/client/conversations/transcriptSearch.tsx +3 -11
- package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
- package/src/client/http.ts +5 -5
- package/src/client/markdownExport.ts +32 -42
- package/src/client/pages/memory/MemoryDetails.tsx +6 -33
- package/src/client/pages/memory/MemoryPage.tsx +7 -92
- package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
- package/src/client/pages/memory/memoryDashboard.ts +2 -38
- package/src/client/pages/memory/memoryRecord.ts +1 -6
- package/src/client/types.ts +18 -33
- package/src/conversation-cache.ts +6 -8
- package/src/mock-reporting/fixtures.ts +23 -33
- package/src/mock-reporting/routes.ts +3 -1
- package/src/tailwind.css +30 -0
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
1
3
|
import { Skeleton } from "../components/ui/skeleton";
|
|
2
4
|
import { dashboardContainerClass, cn } from "../styles";
|
|
3
5
|
import { ChatLayout } from "./ChatLayout";
|
|
4
6
|
import { ConversationHomeListLoading } from "./ConversationHomeList";
|
|
7
|
+
import {
|
|
8
|
+
readSidebarWidth,
|
|
9
|
+
useConversationSidebarOpen,
|
|
10
|
+
} from "./conversationSidebarLayout";
|
|
5
11
|
|
|
6
12
|
/** Keep the conversation workspace geometry stable while its first data loads. */
|
|
7
13
|
export function ConversationWorkspaceLoading(props: { detail: boolean }) {
|
|
14
|
+
const [sidebarOpen] = useConversationSidebarOpen();
|
|
8
15
|
if (!props.detail) return <ConversationHomeLoading />;
|
|
9
16
|
return (
|
|
10
17
|
<div
|
|
@@ -12,12 +19,19 @@ export function ConversationWorkspaceLoading(props: { detail: boolean }) {
|
|
|
12
19
|
aria-live="polite"
|
|
13
20
|
className={cn(
|
|
14
21
|
dashboardContainerClass,
|
|
15
|
-
"grid h-full min-h-0
|
|
22
|
+
"grid h-full min-h-0 grid-cols-[minmax(0,1fr)] overflow-hidden",
|
|
23
|
+
sidebarOpen &&
|
|
24
|
+
"md:grid-cols-[var(--conversation-sidebar-width)_minmax(0,1fr)]",
|
|
16
25
|
)}
|
|
17
26
|
role="status"
|
|
27
|
+
style={
|
|
28
|
+
{
|
|
29
|
+
"--conversation-sidebar-width": `${readSidebarWidth()}px`,
|
|
30
|
+
} as CSSProperties
|
|
31
|
+
}
|
|
18
32
|
>
|
|
19
33
|
<span className="sr-only">Loading conversation</span>
|
|
20
|
-
<ConversationSidebarLoading />
|
|
34
|
+
{sidebarOpen ? <ConversationSidebarLoading /> : null}
|
|
21
35
|
<ConversationDetailLoading />
|
|
22
36
|
</div>
|
|
23
37
|
);
|
|
@@ -90,7 +104,7 @@ function ConversationDetailLoading() {
|
|
|
90
104
|
>
|
|
91
105
|
<ChatLayout
|
|
92
106
|
scrollAriaLabel="Conversation transcript"
|
|
93
|
-
scrollClassName="px-3 pb-1.5 md:px-7 md:pb-2"
|
|
107
|
+
scrollClassName="thin-scrollbar px-3 pb-1.5 md:px-7 md:pb-2"
|
|
94
108
|
scroll={
|
|
95
109
|
<section className="min-w-0">
|
|
96
110
|
<div className="mb-4 grid gap-3 border-b border-dashboard-border-subtle py-4">
|
|
@@ -14,67 +14,17 @@ import { dashboardDisclosureChevronClass } from "../styles";
|
|
|
14
14
|
import { RedactedMarker } from "./TranscriptRedacted";
|
|
15
15
|
import { DetailsCard } from "./DetailsList";
|
|
16
16
|
import { DisplayText } from "../display-text/DisplayText";
|
|
17
|
-
import { eventLogModel } from "./eventLog";
|
|
18
|
-
import { formatCostBreakdown } from "../format";
|
|
19
17
|
import { EventUsage } from "./EventUsage";
|
|
20
18
|
|
|
21
19
|
/** Present event content as readable sections, with the exact report available on demand. */
|
|
22
20
|
export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
23
21
|
const data = event.data;
|
|
24
22
|
const mentions = event.mentions ?? [];
|
|
25
|
-
const model =
|
|
23
|
+
const model = event.model;
|
|
26
24
|
const { usage, ...callAttributes } = event.modelCall ?? {};
|
|
27
25
|
let content: ReactNode;
|
|
28
26
|
|
|
29
27
|
switch (data.type) {
|
|
30
|
-
case "turn_routed": {
|
|
31
|
-
const {
|
|
32
|
-
type: _,
|
|
33
|
-
modelId: _modelId,
|
|
34
|
-
modelProfile: _profile,
|
|
35
|
-
reasoningLevel: _reasoning,
|
|
36
|
-
costUsd,
|
|
37
|
-
...metadata
|
|
38
|
-
} = data;
|
|
39
|
-
content = (
|
|
40
|
-
<DetailSection title="Route selection">
|
|
41
|
-
<DetailValue
|
|
42
|
-
value={{
|
|
43
|
-
...metadata,
|
|
44
|
-
...(costUsd !== undefined
|
|
45
|
-
? { routerCostUsd: formatCostBreakdown({ total: costUsd }) }
|
|
46
|
-
: undefined),
|
|
47
|
-
}}
|
|
48
|
-
/>
|
|
49
|
-
</DetailSection>
|
|
50
|
-
);
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
case "handoff": {
|
|
54
|
-
const {
|
|
55
|
-
type: _,
|
|
56
|
-
modelId: _modelId,
|
|
57
|
-
modelProfile: _profile,
|
|
58
|
-
reasoningLevel: _reasoning,
|
|
59
|
-
summary,
|
|
60
|
-
...metadata
|
|
61
|
-
} = data;
|
|
62
|
-
content = (
|
|
63
|
-
<>
|
|
64
|
-
{Object.keys(metadata).length > 0 ? (
|
|
65
|
-
<DetailSection>
|
|
66
|
-
<DetailValue value={metadata} />
|
|
67
|
-
</DetailSection>
|
|
68
|
-
) : null}
|
|
69
|
-
{summary ? (
|
|
70
|
-
<DetailSection title="Continuation summary">
|
|
71
|
-
<DisplayText mentions={mentions} text={summary} />
|
|
72
|
-
</DetailSection>
|
|
73
|
-
) : null}
|
|
74
|
-
</>
|
|
75
|
-
);
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
28
|
case "message": {
|
|
79
29
|
const { type: _, text, redacted, ...metadata } = data;
|
|
80
30
|
content = (
|
|
@@ -172,20 +122,10 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
|
|
|
172
122
|
|
|
173
123
|
return (
|
|
174
124
|
<div className="@container grid min-w-0 gap-3">
|
|
175
|
-
<DetailSection
|
|
176
|
-
title={
|
|
177
|
-
data.type === "handoff" ? "Target configuration" : "Configuration"
|
|
178
|
-
}
|
|
179
|
-
>
|
|
125
|
+
<DetailSection title="Configuration">
|
|
180
126
|
<DetailValue
|
|
181
127
|
value={{
|
|
182
128
|
model: model?.modelId ?? "Not recorded",
|
|
183
|
-
...(model
|
|
184
|
-
? {
|
|
185
|
-
modelProfile: model.modelProfile ?? "Not recorded",
|
|
186
|
-
reasoningLevel: model.reasoningLevel ?? "Not recorded",
|
|
187
|
-
}
|
|
188
|
-
: undefined),
|
|
189
129
|
...callAttributes,
|
|
190
130
|
}}
|
|
191
131
|
/>
|
|
@@ -42,6 +42,33 @@ export function isCollapsibleActivityEntry(
|
|
|
42
42
|
return true;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Keys of message rows that fold into activity: each quiet reply and the
|
|
47
|
+
* Event messages that started its Turn. Event messages wait for the next
|
|
48
|
+
* reply; a message from a person clears them.
|
|
49
|
+
*/
|
|
50
|
+
export function quietTurnEntryKeys(
|
|
51
|
+
entries: readonly RenderedTranscriptEntry[],
|
|
52
|
+
): Set<string> {
|
|
53
|
+
const keys = new Set<string>();
|
|
54
|
+
let eventKeys: string[] = [];
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.kind !== "message") continue;
|
|
57
|
+
const { message } = entry;
|
|
58
|
+
if (message.role === "user") {
|
|
59
|
+
eventKeys = message.eventType ? [...eventKeys, entry.key] : [];
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (message.role !== "assistant") continue;
|
|
63
|
+
if (message.quiet) {
|
|
64
|
+
keys.add(entry.key);
|
|
65
|
+
for (const key of eventKeys) keys.add(key);
|
|
66
|
+
}
|
|
67
|
+
eventKeys = [];
|
|
68
|
+
}
|
|
69
|
+
return keys;
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
function entryStartMs(entry: RenderedTranscriptEntry): number | undefined {
|
|
46
73
|
if (entry.kind === "tool") {
|
|
47
74
|
return entry.part.startedTimestamp ?? entry.timestamp;
|
|
@@ -104,25 +131,18 @@ export function activityGroupSummary(
|
|
|
104
131
|
(entry) =>
|
|
105
132
|
entry.kind === "context" && entry.part.event.type === "compaction",
|
|
106
133
|
).length;
|
|
107
|
-
const handoffs = entries.flatMap((entry) =>
|
|
108
|
-
entry.kind === "context" && entry.part.event.type === "handoff"
|
|
109
|
-
? [entry.part.event]
|
|
110
|
-
: [],
|
|
111
|
-
);
|
|
112
134
|
const structuredCount = entries.filter(
|
|
113
135
|
(entry) => entry.kind === "structured_event",
|
|
114
136
|
).length;
|
|
115
137
|
const attachmentsCount = entries.filter(
|
|
116
138
|
(entry) => entry.kind === "attachments_delivered",
|
|
117
139
|
).length;
|
|
118
|
-
const eventCount = entries.filter(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
? countLabel(handoffs.length, "model handoff", "model handoffs")
|
|
125
|
-
: undefined;
|
|
140
|
+
const eventCount = entries.filter(
|
|
141
|
+
(entry) => entry.kind === "message" && entry.message.role !== "assistant",
|
|
142
|
+
).length;
|
|
143
|
+
const quietReplyCount = entries.filter(
|
|
144
|
+
(entry) => entry.kind === "message" && entry.message.role === "assistant",
|
|
145
|
+
).length;
|
|
126
146
|
|
|
127
147
|
const parts = [
|
|
128
148
|
toolCount > 0
|
|
@@ -134,7 +154,6 @@ export function activityGroupSummary(
|
|
|
134
154
|
compactionCount > 0
|
|
135
155
|
? countLabel(compactionCount, "context compacted", "context compacted")
|
|
136
156
|
: undefined,
|
|
137
|
-
handoffSummary,
|
|
138
157
|
structuredCount > 0
|
|
139
158
|
? countLabel(structuredCount, "1 structured event", "structured events")
|
|
140
159
|
: undefined,
|
|
@@ -142,6 +161,9 @@ export function activityGroupSummary(
|
|
|
142
161
|
? countLabel(attachmentsCount, "1 file delivery", "file deliveries")
|
|
143
162
|
: undefined,
|
|
144
163
|
eventCount > 0 ? countLabel(eventCount, "1 event", "events") : undefined,
|
|
164
|
+
quietReplyCount > 0
|
|
165
|
+
? countLabel(quietReplyCount, "1 quiet reply", "quiet replies")
|
|
166
|
+
: undefined,
|
|
145
167
|
].filter((value): value is string => value !== undefined);
|
|
146
168
|
|
|
147
169
|
return parts.length > 0 ? parts.join(" · ") : activityGroupLabel(entries);
|
|
@@ -16,24 +16,15 @@ export function TranscriptContextEventView(props: {
|
|
|
16
16
|
timestamp?: number;
|
|
17
17
|
}) {
|
|
18
18
|
const event = props.part.event;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
: "";
|
|
24
|
-
const compactionModel =
|
|
25
|
-
event.type === "compaction" && event.modelProfile && event.modelId
|
|
26
|
-
? ` using the ${event.modelProfile} profile (${event.modelId})`
|
|
27
|
-
: "";
|
|
19
|
+
const compactionDetail = event.details
|
|
20
|
+
? ` at approximately ${formatCompactNumber(event.details.estimatedInputTokens)} estimated tokens`
|
|
21
|
+
: "";
|
|
22
|
+
const compactionModel = event.modelId ? ` using ${event.modelId}` : "";
|
|
28
23
|
return (
|
|
29
24
|
<article className="min-w-0 py-0.5">
|
|
30
25
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
|
31
|
-
<strong
|
|
32
|
-
|
|
33
|
-
handoff ? "text-sky-100" : "text-amber-100"
|
|
34
|
-
}`}
|
|
35
|
-
>
|
|
36
|
-
{handoff ? "Model handoff" : "Context compacted"}
|
|
26
|
+
<strong className="font-sans text-xs font-semibold text-amber-100">
|
|
27
|
+
Context compacted
|
|
37
28
|
</strong>
|
|
38
29
|
{typeof props.timestamp === "number" ? (
|
|
39
30
|
<span className="text-xs text-dashboard-text-muted">
|
|
@@ -42,9 +33,7 @@ export function TranscriptContextEventView(props: {
|
|
|
42
33
|
) : null}
|
|
43
34
|
</div>
|
|
44
35
|
<div className="mt-1 text-xs leading-relaxed text-dashboard-text-muted">
|
|
45
|
-
{
|
|
46
|
-
? `Handed off to ${event.modelId} via the ${event.modelProfile} profile${event.reasoningLevel ? ` (${event.reasoningLevel})` : ""}.`
|
|
47
|
-
: `Earlier context was summarized${compactionDetail}${compactionModel} before execution continued.`}
|
|
36
|
+
{`Earlier context was summarized${compactionDetail}${compactionModel} before execution continued.`}
|
|
48
37
|
</div>
|
|
49
38
|
{event.summary ? (
|
|
50
39
|
<Collapsible className="mt-2">
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
MessageSquareText,
|
|
15
15
|
Minimize2,
|
|
16
16
|
Paperclip,
|
|
17
|
-
Send,
|
|
18
17
|
Sparkles,
|
|
19
18
|
TriangleAlert,
|
|
20
19
|
Webhook,
|
|
@@ -27,7 +26,6 @@ import type { TranscriptViewStructuredEventPart } from "../types";
|
|
|
27
26
|
type TranscriptRailEventKind =
|
|
28
27
|
| "attachments_delivered"
|
|
29
28
|
| "compaction"
|
|
30
|
-
| "handoff"
|
|
31
29
|
| "message_context"
|
|
32
30
|
| "event"
|
|
33
31
|
| "structured_event"
|
|
@@ -119,12 +117,6 @@ function transcriptRailMarker(kind: TranscriptRailEventKind): {
|
|
|
119
117
|
icon: Bot,
|
|
120
118
|
};
|
|
121
119
|
}
|
|
122
|
-
if (kind === "handoff") {
|
|
123
|
-
return {
|
|
124
|
-
className: "text-sky-200",
|
|
125
|
-
icon: Send,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
120
|
return {
|
|
129
121
|
className: "text-amber-200",
|
|
130
122
|
icon: Minimize2,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, type ReactNode, type Ref } from "react";
|
|
2
2
|
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
3
3
|
import { ArrowDown } from "lucide-react";
|
|
4
4
|
|
|
@@ -8,12 +8,13 @@ import type {
|
|
|
8
8
|
} from "../types";
|
|
9
9
|
import { EmptyTelemetry } from "../components/EmptyTelemetry";
|
|
10
10
|
import { Button } from "../components/ui/button";
|
|
11
|
+
import { cn } from "../styles";
|
|
11
12
|
import { ConversationTranscriptView } from "./ConversationTranscript";
|
|
12
13
|
import { ConversationEventLog } from "./ConversationEventLog";
|
|
13
14
|
import {
|
|
14
15
|
transcriptBottomVersion,
|
|
15
16
|
transcriptJuniorMessageVersion,
|
|
16
|
-
|
|
17
|
+
useAutoLoadHistoryPage,
|
|
17
18
|
usePinnedTranscriptBottom,
|
|
18
19
|
} from "./transcriptBottomPinning";
|
|
19
20
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type TranscriptUnreadRange,
|
|
23
24
|
} from "./transcriptReadPosition";
|
|
24
25
|
import { TranscriptSearchProvider } from "./transcriptSearch";
|
|
26
|
+
import type { TranscriptHistory } from "./transcriptUnreadHistory";
|
|
25
27
|
import {
|
|
26
28
|
TurnContextPanelProvider,
|
|
27
29
|
type TurnContextPanel,
|
|
@@ -36,6 +38,11 @@ export function Transcript(props: {
|
|
|
36
38
|
/** The read position when the Conversation opened. */
|
|
37
39
|
lastReadSeq?: number;
|
|
38
40
|
loadingPreviousPage?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Newer pages between the read position and the latest. They load on
|
|
43
|
+
* scroll. Jump to latest skips them.
|
|
44
|
+
*/
|
|
45
|
+
newerHistory?: TranscriptHistory["newer"];
|
|
39
46
|
pinRequestVersion?: number;
|
|
40
47
|
onLoadPreviousPage?: () => void;
|
|
41
48
|
/** Receive the last event sequence the reader saw in the transcript. */
|
|
@@ -68,6 +75,7 @@ export function Transcript(props: {
|
|
|
68
75
|
const bottomPinning = usePinnedTranscriptBottom({
|
|
69
76
|
conversationId: props.transcript?.conversationId,
|
|
70
77
|
enabled: props.live ?? false,
|
|
78
|
+
endsAtLatest: !props.newerHistory,
|
|
71
79
|
historyVersion: props.historyVersion ?? "empty",
|
|
72
80
|
juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript),
|
|
73
81
|
pinRequestVersion: props.pinRequestVersion,
|
|
@@ -88,7 +96,8 @@ export function Transcript(props: {
|
|
|
88
96
|
paused: view !== "rich" || search.trim().length > 0,
|
|
89
97
|
});
|
|
90
98
|
const loadPreviousPage = () => props.onLoadPreviousPage?.();
|
|
91
|
-
const previousPageRef =
|
|
99
|
+
const previousPageRef = useAutoLoadHistoryPage({
|
|
100
|
+
edge: "top",
|
|
92
101
|
enabled: Boolean(
|
|
93
102
|
props.onLoadPreviousPage &&
|
|
94
103
|
props.hasPreviousPage &&
|
|
@@ -98,6 +107,13 @@ export function Transcript(props: {
|
|
|
98
107
|
historyVersion: props.historyVersion ?? "empty",
|
|
99
108
|
onLoad: loadPreviousPage,
|
|
100
109
|
});
|
|
110
|
+
const newer = props.newerHistory;
|
|
111
|
+
const newerPageRef = useAutoLoadHistoryPage({
|
|
112
|
+
edge: "bottom",
|
|
113
|
+
enabled: Boolean(newer && !newer.loading && !newer.error),
|
|
114
|
+
historyVersion: props.historyVersion ?? "empty",
|
|
115
|
+
onLoad: () => newer?.load(),
|
|
116
|
+
});
|
|
101
117
|
|
|
102
118
|
if (!props.transcript) {
|
|
103
119
|
return (
|
|
@@ -120,22 +136,17 @@ export function Transcript(props: {
|
|
|
120
136
|
</div>
|
|
121
137
|
) : null}
|
|
122
138
|
{props.hasPreviousPage || props.loadingPreviousPage ? (
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? `Loading earlier ${historyLabel}…`
|
|
135
|
-
: `Show earlier ${historyLabel}`}
|
|
136
|
-
</button>
|
|
137
|
-
<span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
|
|
138
|
-
</div>
|
|
139
|
+
<HistoryPageControl
|
|
140
|
+
className="mb-3"
|
|
141
|
+
label="Load earlier events"
|
|
142
|
+
loading={props.loadingPreviousPage}
|
|
143
|
+
onClick={loadPreviousPage}
|
|
144
|
+
ref={previousPageRef}
|
|
145
|
+
>
|
|
146
|
+
{props.loadingPreviousPage
|
|
147
|
+
? `Loading earlier ${historyLabel}…`
|
|
148
|
+
: `Show earlier ${historyLabel}`}
|
|
149
|
+
</HistoryPageControl>
|
|
139
150
|
) : null}
|
|
140
151
|
{props.historyError ? (
|
|
141
152
|
<div
|
|
@@ -164,6 +175,30 @@ export function Transcript(props: {
|
|
|
164
175
|
/>
|
|
165
176
|
</TurnContextPanelProvider>
|
|
166
177
|
)}
|
|
178
|
+
{newer ? (
|
|
179
|
+
<>
|
|
180
|
+
{newer.error ? (
|
|
181
|
+
<div
|
|
182
|
+
aria-live="polite"
|
|
183
|
+
className="mt-2 text-center font-sans text-xs text-amber-100/65"
|
|
184
|
+
>
|
|
185
|
+
Newer events could not be loaded.
|
|
186
|
+
</div>
|
|
187
|
+
) : null}
|
|
188
|
+
{/* The bottom margin keeps the jump control off this row. */}
|
|
189
|
+
<HistoryPageControl
|
|
190
|
+
className="mb-12 mt-3"
|
|
191
|
+
label="Load newer events"
|
|
192
|
+
loading={newer.loading}
|
|
193
|
+
onClick={newer.load}
|
|
194
|
+
ref={newerPageRef}
|
|
195
|
+
>
|
|
196
|
+
{newer.loading
|
|
197
|
+
? `Loading newer ${historyLabel}…`
|
|
198
|
+
: `Show newer ${historyLabel}`}
|
|
199
|
+
</HistoryPageControl>
|
|
200
|
+
</>
|
|
201
|
+
) : null}
|
|
167
202
|
<div
|
|
168
203
|
aria-hidden="true"
|
|
169
204
|
className="h-px"
|
|
@@ -171,7 +206,11 @@ export function Transcript(props: {
|
|
|
171
206
|
/>
|
|
172
207
|
<JumpToLatestButton
|
|
173
208
|
hasPendingUpdate={bottomPinning.hasPendingUpdate}
|
|
174
|
-
onClick={
|
|
209
|
+
onClick={() => {
|
|
210
|
+
// The latest page is in the detail, so the jump needs no request.
|
|
211
|
+
newer?.showLatest();
|
|
212
|
+
bottomPinning.jumpToBottom();
|
|
213
|
+
}}
|
|
175
214
|
visible={bottomPinning.showJumpToLatest}
|
|
176
215
|
/>
|
|
177
216
|
</div>
|
|
@@ -179,6 +218,36 @@ export function Transcript(props: {
|
|
|
179
218
|
);
|
|
180
219
|
}
|
|
181
220
|
|
|
221
|
+
/** A divider with one control that loads another page of history. */
|
|
222
|
+
function HistoryPageControl(props: {
|
|
223
|
+
children: ReactNode;
|
|
224
|
+
className: string;
|
|
225
|
+
label: string;
|
|
226
|
+
loading?: boolean;
|
|
227
|
+
onClick: () => void;
|
|
228
|
+
ref?: Ref<HTMLDivElement>;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
className={cn("flex items-center gap-3", props.className)}
|
|
233
|
+
ref={props.ref}
|
|
234
|
+
>
|
|
235
|
+
<span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
|
|
236
|
+
<button
|
|
237
|
+
aria-label={props.label}
|
|
238
|
+
className="shrink-0 cursor-pointer border-0 bg-transparent px-1 py-0.5 font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:text-dashboard-text focus-visible:outline focus-visible:outline-1 focus-visible:outline-dashboard-focus/55 disabled:cursor-not-allowed disabled:opacity-50"
|
|
239
|
+
disabled={props.loading}
|
|
240
|
+
onClick={props.onClick}
|
|
241
|
+
title={props.label}
|
|
242
|
+
type="button"
|
|
243
|
+
>
|
|
244
|
+
{props.children}
|
|
245
|
+
</button>
|
|
246
|
+
<span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
182
251
|
function JumpToLatestButton(props: {
|
|
183
252
|
hasPendingUpdate: boolean;
|
|
184
253
|
onClick: () => void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useState, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
const OPEN_KEY = "junior:conversation:sidebar-open";
|
|
4
|
+
const WIDTH_KEY = "junior:conversation:sidebar-width";
|
|
5
|
+
const WIDE_QUERY = "(min-width: 768px)";
|
|
6
|
+
|
|
7
|
+
/** Pixel limits for the conversation list beside a selected conversation. */
|
|
8
|
+
export const SIDEBAR_WIDTH = { default: 320, max: 480, min: 240 } as const;
|
|
9
|
+
|
|
10
|
+
/** Remember whether the conversation list is open in this browser. */
|
|
11
|
+
export function useConversationSidebarOpen() {
|
|
12
|
+
const [open, setOpen] = useState(() => readStorage(OPEN_KEY) !== "false");
|
|
13
|
+
|
|
14
|
+
function updateOpen(next: boolean) {
|
|
15
|
+
setOpen(next);
|
|
16
|
+
writeStorage(OPEN_KEY, String(next));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return [open, updateOpen] as const;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read the conversation list width saved in this browser. */
|
|
23
|
+
export function readSidebarWidth(): number {
|
|
24
|
+
const saved = Number(readStorage(WIDTH_KEY));
|
|
25
|
+
if (!Number.isFinite(saved) || saved <= 0) return SIDEBAR_WIDTH.default;
|
|
26
|
+
return Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min, saved));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Save the conversation list width without a React render per drag step. */
|
|
30
|
+
export function saveSidebarWidth(width: number) {
|
|
31
|
+
writeStorage(WIDTH_KEY, String(Math.round(width)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Report whether the viewport has room for the list beside the transcript. */
|
|
35
|
+
export function useSidebarFits(): boolean {
|
|
36
|
+
return useSyncExternalStore(
|
|
37
|
+
(onChange) => {
|
|
38
|
+
const media = window.matchMedia(WIDE_QUERY);
|
|
39
|
+
media.addEventListener("change", onChange);
|
|
40
|
+
return () => media.removeEventListener("change", onChange);
|
|
41
|
+
},
|
|
42
|
+
() => window.matchMedia(WIDE_QUERY).matches,
|
|
43
|
+
() => false,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readStorage(key: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
return window.localStorage.getItem(key);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeStorage(key: string, value: string) {
|
|
56
|
+
try {
|
|
57
|
+
window.localStorage.setItem(key, value);
|
|
58
|
+
} catch {
|
|
59
|
+
// The layout still works for this page when browser storage is unavailable.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -10,31 +10,12 @@ import {
|
|
|
10
10
|
summarizeCost,
|
|
11
11
|
} from "../format";
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
export function eventLogModel(
|
|
15
|
-
event: ConversationReportEvent,
|
|
16
|
-
): ConversationReportEvent["model"] {
|
|
17
|
-
if (event.model) return event.model;
|
|
18
|
-
const data = event.data;
|
|
19
|
-
if (data.type === "turn_routed" || data.type === "handoff") {
|
|
20
|
-
return {
|
|
21
|
-
modelId: data.modelId,
|
|
22
|
-
modelProfile: data.modelProfile,
|
|
23
|
-
reasoningLevel: data.reasoningLevel,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Keep call costs distinct from router cost and never repeat usage on tool results. */
|
|
13
|
+
/** Show call usage once and never repeat it on tool results. */
|
|
30
14
|
export function eventLogUsage(event: ConversationReportEvent): string {
|
|
31
15
|
const usage = event.modelCall?.usage;
|
|
32
16
|
const cost = summarizeCost(usage);
|
|
33
17
|
const fields = [
|
|
34
18
|
cost ? `${formatCostBreakdown(cost)} call` : undefined,
|
|
35
|
-
event.data.type === "turn_routed" && event.data.costUsd !== undefined
|
|
36
|
-
? `${formatCostBreakdown({ total: event.data.costUsd })} router`
|
|
37
|
-
: undefined,
|
|
38
19
|
...(
|
|
39
20
|
[
|
|
40
21
|
["inputTokens", "in"],
|
|
@@ -77,8 +58,6 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
77
58
|
return data.state === "failed"
|
|
78
59
|
? `${data.state} · ${data.failureCode}${data.failureReason ? ` · ${data.failureReason}` : ""}`
|
|
79
60
|
: `${data.state} · ${data.turnId}`;
|
|
80
|
-
case "turn_routed":
|
|
81
|
-
return `Route selected · ${data.source}${data.confidence !== undefined ? ` · ${Math.round(data.confidence * 100)}% confidence` : ""}`;
|
|
82
61
|
case "turn_context":
|
|
83
62
|
return `${data.pluginName} · ${data.kind} · v${data.version}`;
|
|
84
63
|
case "guardian_action_reviewed":
|
|
@@ -91,8 +70,6 @@ export function eventLogSummary(data: ConversationReportEventData): string {
|
|
|
91
70
|
.join(", ");
|
|
92
71
|
case "compaction":
|
|
93
72
|
return data.summary ?? "Agent history compacted";
|
|
94
|
-
case "handoff":
|
|
95
|
-
return "Handoff · New configuration applied";
|
|
96
73
|
case "subagent":
|
|
97
74
|
return `${data.agentName ?? data.subagentKind} · ${data.status} · ${data.childConversationId}`;
|
|
98
75
|
}
|
|
@@ -132,16 +109,10 @@ export function eventLogRows(
|
|
|
132
109
|
events: readonly ConversationReportEvent[],
|
|
133
110
|
): EventLogRow[] {
|
|
134
111
|
return events.map((event) => {
|
|
135
|
-
const model = eventLogModel(event);
|
|
136
112
|
return {
|
|
137
113
|
event,
|
|
138
114
|
summary: eventLogSummary(event.data).replace(/\s+/g, " ").slice(0, 300),
|
|
139
|
-
metadata: [
|
|
140
|
-
model?.modelId,
|
|
141
|
-
model?.modelProfile,
|
|
142
|
-
model?.reasoningLevel && `${model.reasoningLevel} reasoning`,
|
|
143
|
-
eventLogUsage(event),
|
|
144
|
-
]
|
|
115
|
+
metadata: [event.model?.modelId, eventLogUsage(event)]
|
|
145
116
|
.filter(Boolean)
|
|
146
117
|
.join(" · "),
|
|
147
118
|
};
|