@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.
Files changed (45) hide show
  1. package/dist/app.js +30 -41
  2. package/dist/client.js +90 -90
  3. package/dist/index.js +30 -41
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +1 -1
  7. package/src/client/App.tsx +9 -4
  8. package/src/client/components/DetailsPanel.tsx +1 -1
  9. package/src/client/components/ui/resizable.tsx +50 -0
  10. package/src/client/conversations/AutoArchiveToggle.tsx +50 -14
  11. package/src/client/conversations/ConversationHeader.tsx +6 -2
  12. package/src/client/conversations/ConversationHeaderActions.tsx +4 -0
  13. package/src/client/conversations/ConversationMeta.tsx +2 -7
  14. package/src/client/conversations/ConversationPage.tsx +155 -72
  15. package/src/client/conversations/ConversationSidebar.tsx +2 -1
  16. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  17. package/src/client/conversations/ConversationWorkspace.tsx +129 -41
  18. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  19. package/src/client/conversations/EventDetails.tsx +2 -62
  20. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  21. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  22. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  23. package/src/client/conversations/TranscriptView.tsx +89 -20
  24. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  25. package/src/client/conversations/eventLog.ts +2 -31
  26. package/src/client/conversations/eventTranscript.ts +9 -42
  27. package/src/client/conversations/queries.ts +76 -15
  28. package/src/client/conversations/transcript.ts +46 -16
  29. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  30. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  31. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  32. package/src/client/conversations/transcriptSearch.tsx +3 -11
  33. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  34. package/src/client/http.ts +5 -5
  35. package/src/client/markdownExport.ts +32 -42
  36. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  37. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  38. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  39. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  40. package/src/client/pages/memory/memoryRecord.ts +1 -6
  41. package/src/client/types.ts +18 -33
  42. package/src/conversation-cache.ts +6 -8
  43. package/src/mock-reporting/fixtures.ts +23 -33
  44. package/src/mock-reporting/routes.ts +3 -1
  45. 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 overflow-hidden md:grid-cols-[20rem_minmax(0,1fr)]",
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 = eventLogModel(event);
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((entry) => entry.kind === "message").length;
119
- const [onlyHandoff] = handoffs;
120
- const handoffSummary =
121
- handoffs.length === 1 && onlyHandoff
122
- ? `model handoff to ${onlyHandoff.modelId} (${onlyHandoff.modelProfile})`
123
- : handoffs.length > 1
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 handoff = event.type === "handoff";
20
- const compactionDetail =
21
- event.type === "compaction" && event.details
22
- ? ` at approximately ${formatCompactNumber(event.details.estimatedInputTokens)} estimated tokens`
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
- className={`font-sans text-xs font-semibold ${
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
- {handoff
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
- useAutoLoadPreviousPage,
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 = useAutoLoadPreviousPage({
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
- <div className="mb-3 flex items-center gap-3" ref={previousPageRef}>
124
- <span className="h-px min-w-4 flex-1 bg-dashboard-fill-strong" />
125
- <button
126
- aria-label="Load earlier events"
127
- 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"
128
- disabled={props.loadingPreviousPage}
129
- onClick={loadPreviousPage}
130
- title="Load earlier events"
131
- type="button"
132
- >
133
- {props.loadingPreviousPage
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={bottomPinning.jumpToBottom}
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
- /** Read recorded event configuration, including reports from before model context was added. */
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
  };