@sentry/junior-dashboard 0.276.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 (73) hide show
  1. package/dist/app.js +196 -72
  2. package/dist/client.js +94 -89
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +191 -70
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/App.tsx +9 -4
  11. package/src/client/components/DetailsPanel.tsx +1 -1
  12. package/src/client/components/FormNotice.tsx +11 -14
  13. package/src/client/components/ProfileMenu.tsx +5 -6
  14. package/src/client/components/ui/resizable.tsx +50 -0
  15. package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
  16. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  17. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  18. package/src/client/conversations/ConversationDetails.tsx +6 -3
  19. package/src/client/conversations/ConversationHeader.tsx +15 -2
  20. package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
  21. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  22. package/src/client/conversations/ConversationMemories.tsx +64 -67
  23. package/src/client/conversations/ConversationMeta.tsx +2 -7
  24. package/src/client/conversations/ConversationPage.tsx +175 -79
  25. package/src/client/conversations/ConversationSidebar.tsx +82 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  27. package/src/client/conversations/ConversationWatches.tsx +15 -21
  28. package/src/client/conversations/ConversationWorkspace.tsx +132 -44
  29. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  30. package/src/client/conversations/DetailsList.tsx +8 -14
  31. package/src/client/conversations/EventDetails.tsx +2 -62
  32. package/src/client/conversations/MemoryIcons.tsx +23 -0
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  34. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  36. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  37. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  38. package/src/client/conversations/TranscriptView.tsx +89 -20
  39. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  40. package/src/client/conversations/conversationArchive.ts +255 -0
  41. package/src/client/conversations/conversationMemory.ts +2 -1
  42. package/src/client/conversations/conversationSections.ts +24 -18
  43. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  44. package/src/client/conversations/eventLog.ts +2 -31
  45. package/src/client/conversations/eventTranscript.ts +9 -42
  46. package/src/client/conversations/queries.ts +121 -250
  47. package/src/client/conversations/transcript.ts +46 -16
  48. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  49. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  50. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  51. package/src/client/conversations/transcriptSearch.tsx +3 -11
  52. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  53. package/src/client/conversations/usePinConversation.ts +88 -0
  54. package/src/client/display-text/DisplayText.tsx +7 -2
  55. package/src/client/display-text/codeBlocks.ts +92 -0
  56. package/src/client/format.ts +3 -67
  57. package/src/client/http.ts +5 -5
  58. package/src/client/markdownExport.ts +32 -42
  59. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  60. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  61. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  62. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  63. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  64. package/src/client/pages/memory/memoryRecord.ts +1 -6
  65. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  66. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  67. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  68. package/src/client/review-desk/queries.ts +2 -1
  69. package/src/client/types.ts +19 -33
  70. package/src/conversation-cache.ts +7 -9
  71. package/src/mock-reporting/fixtures.ts +126 -60
  72. package/src/mock-reporting/routes.ts +50 -1
  73. package/src/tailwind.css +30 -0
@@ -22,6 +22,7 @@ import { TranscriptSubagentView } from "./TranscriptSubagentView";
22
22
  import { TranscriptContextEventView } from "./TranscriptContextEventView";
23
23
  import {
24
24
  isCollapsibleActivityEntry,
25
+ quietTurnEntryKeys,
25
26
  TranscriptActivityGroup,
26
27
  } from "./TranscriptActivityGroup";
27
28
  import { TranscriptToolView } from "./TranscriptToolView";
@@ -232,9 +233,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
232
233
  keyPrefix={props.conversation.conversationId}
233
234
  unread={props.unread}
234
235
  renderContext={(entry) => (
235
- <TranscriptRailEvent
236
- kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
237
- >
236
+ <TranscriptRailEvent kind="compaction">
238
237
  <TranscriptContextEventView
239
238
  part={entry.part}
240
239
  timestamp={entry.timestamp}
@@ -359,6 +358,12 @@ function TranscriptEntryList(props: {
359
358
  }) {
360
359
  const search = useTranscriptSearch();
361
360
  const activityKeys = useRef(new Map<string, string>());
361
+ const quietKeys = useMemo(
362
+ () => quietTurnEntryKeys(props.entries),
363
+ [props.entries],
364
+ );
365
+ const collapsible = (entry: TranscriptEntry) =>
366
+ isCollapsibleActivityEntry(entry) || quietKeys.has(entry.key);
362
367
  const claimedActivityKeys = new Set<string>();
363
368
  const rows: ReactNode[] = [];
364
369
  // Search results are not a reading order, so they get no marker.
@@ -394,11 +399,11 @@ function TranscriptEntryList(props: {
394
399
  for (let index = 0; index < props.entries.length; ) {
395
400
  const entry = props.entries[index]!;
396
401
 
397
- if (isCollapsibleActivityEntry(entry)) {
402
+ if (collapsible(entry)) {
398
403
  const activityEntries: RenderedTranscriptEntry[] = [];
399
404
  while (
400
405
  index < props.entries.length &&
401
- isCollapsibleActivityEntry(props.entries[index]!)
406
+ collapsible(props.entries[index]!)
402
407
  ) {
403
408
  activityEntries.push(props.entries[index]!);
404
409
  index += 1;
@@ -523,9 +528,7 @@ function RedactedTranscriptView(props: {
523
528
  entries={groupTranscriptMessages(props.messages)}
524
529
  keyPrefix={`${props.conversation.conversationId}:redacted`}
525
530
  renderContext={(entry) => (
526
- <TranscriptRailEvent
527
- kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
528
- >
531
+ <TranscriptRailEvent kind="compaction">
529
532
  <TranscriptContextEventView
530
533
  part={entry.part}
531
534
  timestamp={entry.timestamp}
@@ -15,7 +15,7 @@ import {
15
15
  Timer,
16
16
  Users,
17
17
  } from "lucide-react";
18
- import { useState, type ReactNode } from "react";
18
+ import type { ReactNode } from "react";
19
19
  import {
20
20
  conversationWatchesReportSchema,
21
21
  type ConversationWatch,
@@ -24,6 +24,12 @@ import {
24
24
  import { DisplayText } from "../display-text/DisplayText";
25
25
  import { formatTime } from "../format";
26
26
  import { fetchDashboardJson } from "../http";
27
+ import { conversationSideReadQueryKey } from "./queries";
28
+ import {
29
+ Collapsible,
30
+ CollapsibleContent,
31
+ CollapsibleTrigger,
32
+ } from "../components/ui/collapsible";
27
33
  import { dashboardDisclosureChevronClass } from "../styles";
28
34
  import { DetailsList, DetailsRow } from "./DetailsList";
29
35
 
@@ -36,7 +42,7 @@ export function useConversationWatches(conversationId: string) {
36
42
  `/api/conversations/${encodeURIComponent(conversationId)}/watches`,
37
43
  signal,
38
44
  ),
39
- queryKey: ["conversation", conversationId, "watches"],
45
+ queryKey: conversationSideReadQueryKey(conversationId, "watches"),
40
46
  retry: false,
41
47
  });
42
48
  }
@@ -76,7 +82,6 @@ const resourceIcons: Partial<Record<string, LucideIcon>> = {
76
82
  */
77
83
  function WatchRow(props: { watch: ConversationWatch }) {
78
84
  const { watch } = props;
79
- const [expanded, setExpanded] = useState(false);
80
85
  const Icon = watch.redacted
81
86
  ? LockKeyhole
82
87
  : (resourceIcons[watch.resourceType] ?? Eye);
@@ -94,7 +99,6 @@ function WatchRow(props: { watch: ConversationWatch }) {
94
99
  />
95
100
  </span>
96
101
  );
97
- const toggle = () => setExpanded((value) => !value);
98
102
  const row = watch.url ? (
99
103
  // The row opens the watched resource, like a linked work row. The
100
104
  // chevron beside it shows the details.
@@ -102,32 +106,22 @@ function WatchRow(props: { watch: ConversationWatch }) {
102
106
  <div className="min-w-0 flex-1">
103
107
  <DetailsRow href={watch.url} icon={icon} title={title} />
104
108
  </div>
105
- <button
106
- aria-expanded={expanded}
109
+ <CollapsibleTrigger
107
110
  aria-label={`Details for ${title}`}
108
111
  className="group grid size-8 shrink-0 place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-surface-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
109
- onClick={toggle}
110
- type="button"
111
112
  >
112
113
  {chevron}
113
- </button>
114
+ </CollapsibleTrigger>
114
115
  </div>
115
116
  ) : (
116
- <DetailsRow
117
- expanded={expanded}
118
- icon={icon}
119
- meta={chevron}
120
- onToggle={toggle}
121
- title={title}
122
- />
117
+ <DetailsRow disclosure icon={icon} meta={chevron} title={title} />
123
118
  );
124
- if (!expanded) return row;
125
119
 
126
120
  return (
127
- <>
121
+ <Collapsible className="min-w-0">
128
122
  {row}
129
123
  {/* Indent under the title: icon column, column gap, and row padding. */}
130
- <div className="grid min-w-0 gap-1.5 pb-2 pl-9 pr-1.5 pt-0.5 text-xs leading-4 text-dashboard-text-muted">
124
+ <CollapsibleContent className="grid min-w-0 gap-1.5 pb-2 pl-9 pr-1.5 pt-0.5 text-xs leading-4 text-dashboard-text-muted">
131
125
  {watch.intent ? (
132
126
  <p className="m-0 break-words">
133
127
  <DisplayText inline text={watch.intent} />
@@ -166,8 +160,8 @@ function WatchRow(props: { watch: ConversationWatch }) {
166
160
  </span>
167
161
  </p>
168
162
  ) : null}
169
- </div>
170
- </>
163
+ </CollapsibleContent>
164
+ </Collapsible>
171
165
  );
172
166
  }
173
167
 
@@ -1,27 +1,35 @@
1
1
  import type { InputImage } from "@sentry/junior/api/schema";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { Globe2, LockKeyhole } from "lucide-react";
2
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
3
+ import { Globe2, LockKeyhole, PanelLeft } from "lucide-react";
4
4
  import { useNavigate, useParams } from "react-router";
5
+ import type { PanelImperativeHandle } from "react-resizable-panels";
5
6
 
6
7
  import { useConversationsData } from "../api";
7
8
  import { ConversationSidebar } from "./ConversationSidebar";
8
9
  import { SearchInput } from "../components/SearchInput";
10
+ import {
11
+ ResizableHandle,
12
+ ResizablePanel,
13
+ ResizablePanelGroup,
14
+ } from "../components/ui/resizable";
9
15
  import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
10
16
  import { pageCount, pageItems, PagePagination } from "../components/Pagination";
11
17
  import { ConversationHomeList } from "./ConversationHomeList";
12
18
  import { ConversationComposer } from "./ConversationComposer";
19
+ import { HeaderIconButton } from "./ConversationHeaderActions";
13
20
  import {
14
- useCreateConversation,
15
21
  usePendingArchiveConversationUpdates,
16
22
  type PendingArchiveConversationUpdate,
17
- } from "./queries";
23
+ } from "./conversationArchive";
24
+ import { useCreateConversation, type ConversationData } from "./queries";
18
25
  import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes";
19
26
  import {
20
27
  attentionConversationIds,
21
- attentionFirst,
28
+ pinnedAndAttentionFirst,
22
29
  } from "./conversationSections";
23
30
  import { buildConversations, getDashboardTimeZone } from "../format";
24
31
  import { useDebouncedValue } from "../debouncedValue";
32
+ import { cn } from "../styles";
25
33
  import type { Conversation } from "../types";
26
34
  import { ConversationPage } from "./ConversationPage";
27
35
  import {
@@ -30,13 +38,29 @@ import {
30
38
  } from "../review-desk/queries";
31
39
  import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
32
40
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
41
+ import {
42
+ readSidebarWidth,
43
+ saveSidebarWidth,
44
+ SIDEBAR_WIDTH,
45
+ useConversationSidebarOpen,
46
+ useSidebarFits,
47
+ } from "./conversationSidebarLayout";
33
48
 
34
49
  const CONVERSATION_PAGE_SIZE = 20;
50
+ const SIDEBAR_PANEL_ID = "conversation-list";
35
51
 
36
52
  /** Render the conversation home page or one selected conversation. */
37
- export function ConversationWorkspace() {
53
+ export function ConversationWorkspace(props: {
54
+ /** The selected Conversation's data, from the shell. */
55
+ detail: ConversationData;
56
+ }) {
38
57
  const [page, setPage] = useState(1);
39
58
  const [query, setQuery] = useState("");
59
+ const [sidebarOpen, setSidebarOpen] = useConversationSidebarOpen();
60
+ const sidebarFits = useSidebarFits();
61
+ const sidebarPanel = useRef<PanelImperativeHandle>(null);
62
+ const sidebarGroup = useRef<HTMLDivElement>(null);
63
+ const sidebarId = useId();
40
64
  const search = useDebouncedValue(query.trim());
41
65
  const params = useParams();
42
66
  const navigate = useNavigate();
@@ -73,7 +97,7 @@ export function ConversationWorkspace() {
73
97
  const pagedConversations = useMemo(
74
98
  () =>
75
99
  pageItems(
76
- attentionFirst(
100
+ pinnedAndAttentionFirst(
77
101
  conversations,
78
102
  attentionConversationIds(
79
103
  waitingConversationIds,
@@ -146,45 +170,109 @@ export function ConversationWorkspace() {
146
170
  );
147
171
  }
148
172
 
173
+ const toggleSidebar = () => {
174
+ const panel = sidebarPanel.current;
175
+ if (!panel) return;
176
+ if (panel.isCollapsed()) panel.resize(readSidebarWidth());
177
+ else panel.collapse();
178
+ };
179
+
149
180
  return (
150
- <div className="grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]">
151
- <div className="hidden h-full min-h-0 overflow-hidden md:block">
152
- <ConversationSidebar
153
- conversations={conversations}
154
- error={feed.error?.message}
155
- finishedConversationIds={finishedConversationIds}
156
- heldConversationId={heldConversationId}
157
- loading={feed.isPending}
158
- onNewConversation={openCreate}
159
- onQueryChange={setQuery}
160
- query={query}
161
- selectedId={selectedId}
162
- timeZone={getDashboardTimeZone()}
163
- waitingConversations={waitingConversations}
164
- />
165
- </div>
166
- <section
167
- aria-label="Selected conversation"
168
- className="grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-dashboard-bg"
169
- >
170
- <UnsentAnswersProvider conversationId={selectedId} key={selectedId}>
171
- <ConversationPage
172
- conversationId={selectedId}
173
- data={
174
- feed.data
175
- ? {
176
- conversations: feed.data,
177
- }
178
- : undefined
179
- }
180
- onRead={markRead}
181
- pendingArchiveUpdate={pendingArchiveUpdates.find(
182
- (update) => update.conversationId === selectedId,
183
- )}
181
+ <ResizablePanelGroup
182
+ className="min-h-0 overflow-hidden bg-dashboard-bg"
183
+ elementRef={sidebarGroup}
184
+ onLayoutChanged={(layout) => {
185
+ // Save after the drag ends. A drag that collapses the list passes
186
+ // the minimum width first, and that width is not the reader's pick.
187
+ const percent = layout[SIDEBAR_PANEL_ID] ?? 0;
188
+ const groupWidth = sidebarGroup.current?.offsetWidth ?? 0;
189
+ if (percent > 0) saveSidebarWidth((percent / 100) * groupWidth);
190
+ }}
191
+ >
192
+ {sidebarFits ? (
193
+ <>
194
+ <ResizablePanel
195
+ collapsible
196
+ defaultSize={sidebarOpen ? readSidebarWidth() : 0}
197
+ groupResizeBehavior="preserve-pixel-size"
198
+ id={SIDEBAR_PANEL_ID}
199
+ maxSize={SIDEBAR_WIDTH.max}
200
+ minSize={SIDEBAR_WIDTH.min}
201
+ onResize={(size) => {
202
+ // Dragging past the minimum width collapses the list too, so the
203
+ // panel size is the one source for the open state.
204
+ const open = size.inPixels > 0;
205
+ if (open !== sidebarOpen) setSidebarOpen(open);
206
+ }}
207
+ panelRef={sidebarPanel}
208
+ >
209
+ <div
210
+ className={cn(
211
+ "h-full min-h-0 overflow-hidden",
212
+ !sidebarOpen && "hidden",
213
+ )}
214
+ id={sidebarId}
215
+ >
216
+ <ConversationSidebar
217
+ conversations={conversations}
218
+ error={feed.error?.message}
219
+ finishedConversationIds={finishedConversationIds}
220
+ heldConversationId={heldConversationId}
221
+ loading={feed.isPending}
222
+ onNewConversation={openCreate}
223
+ onQueryChange={setQuery}
224
+ query={query}
225
+ selectedId={selectedId}
226
+ timeZone={getDashboardTimeZone()}
227
+ waitingConversations={waitingConversations}
228
+ />
229
+ </div>
230
+ </ResizablePanel>
231
+ <ResizableHandle
232
+ aria-label="Resize conversation list"
233
+ className="w-px bg-transparent transition-colors after:w-2 focus-visible:ring-dashboard-border-interactive data-[separator=active]:bg-dashboard-border-interactive data-[separator=hover]:bg-dashboard-border-interactive"
184
234
  />
185
- </UnsentAnswersProvider>
186
- </section>
187
- </div>
235
+ </>
236
+ ) : null}
237
+ <ResizablePanel id="selected-conversation">
238
+ <section
239
+ aria-label="Selected conversation"
240
+ className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-dashboard-bg"
241
+ >
242
+ <UnsentAnswersProvider conversationId={selectedId} key={selectedId}>
243
+ <ConversationPage
244
+ conversationId={selectedId}
245
+ detail={props.detail}
246
+ data={
247
+ feed.data
248
+ ? {
249
+ conversations: feed.data,
250
+ }
251
+ : undefined
252
+ }
253
+ onRead={markRead}
254
+ pendingArchiveUpdate={pendingArchiveUpdates.find(
255
+ (update) => update.conversationId === selectedId,
256
+ )}
257
+ sidebarToggle={
258
+ <HeaderIconButton
259
+ controls={sidebarId}
260
+ expanded={sidebarOpen}
261
+ label={
262
+ sidebarOpen
263
+ ? "Hide conversation list"
264
+ : "Show conversation list"
265
+ }
266
+ onClick={toggleSidebar}
267
+ >
268
+ <PanelLeft aria-hidden="true" size={15} strokeWidth={2} />
269
+ </HeaderIconButton>
270
+ }
271
+ />
272
+ </UnsentAnswersProvider>
273
+ </section>
274
+ </ResizablePanel>
275
+ </ResizablePanelGroup>
188
276
  );
189
277
  }
190
278
 
@@ -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">
@@ -1,6 +1,7 @@
1
1
  import { ChevronRight } from "lucide-react";
2
2
  import { useState, type ReactNode } from "react";
3
3
 
4
+ import { CollapsibleTrigger } from "../components/ui/collapsible";
4
5
  import { cn, dashboardDisclosureChevronClass } from "../styles";
5
6
 
6
7
  /**
@@ -87,21 +88,20 @@ export function DetailsList<T>(props: {
87
88
  /**
88
89
  * Lay out one details list row: an icon, a title, and short trailing text on
89
90
  * the first line, then optional lines under the title. A row with `href`
90
- * opens that link in a new tab. A row with `onToggle` is a disclosure button
91
- * that shows or hides content the caller renders under it.
91
+ * opens that link in a new tab. A `disclosure` row is the trigger of the
92
+ * enclosing `Collapsible`, so it shows or hides the content under it.
92
93
  */
93
94
  export function DetailsRow(props: {
94
95
  children?: ReactNode;
95
- expanded?: boolean;
96
+ disclosure?: boolean;
96
97
  href?: string;
97
98
  icon: ReactNode;
98
99
  meta?: ReactNode;
99
- onToggle?(): void;
100
100
  title: ReactNode;
101
101
  /** Native hover text with detail that the row does not show. */
102
102
  tooltip?: string;
103
103
  }) {
104
- const interactive = props.href !== undefined || props.onToggle !== undefined;
104
+ const interactive = props.href !== undefined || props.disclosure === true;
105
105
  const className = cn(
106
106
  "grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-1 rounded-md px-1.5 py-1.5 text-left text-inherit no-underline",
107
107
  interactive &&
@@ -134,17 +134,11 @@ export function DetailsRow(props: {
134
134
  </a>
135
135
  );
136
136
  }
137
- if (props.onToggle) {
137
+ if (props.disclosure) {
138
138
  return (
139
- <button
140
- aria-expanded={props.expanded ?? false}
141
- className={className}
142
- onClick={props.onToggle}
143
- title={props.tooltip}
144
- type="button"
145
- >
139
+ <CollapsibleTrigger className={className} title={props.tooltip}>
146
140
  {content}
147
- </button>
141
+ </CollapsibleTrigger>
148
142
  );
149
143
  }
150
144
  return (
@@ -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
  />
@@ -0,0 +1,23 @@
1
+ import { ArrowUpRight, Lock } from "lucide-react";
2
+
3
+ /** Mark a memory that only its owner can see. */
4
+ export function PrivateMemoryIcon() {
5
+ return <Lock aria-label="Private" role="img" size={13} strokeWidth={1.8} />;
6
+ }
7
+
8
+ /**
9
+ * End a memory row that links to the memory's page. Its parent link needs
10
+ * the `group` class, so the arrow follows the row hover.
11
+ */
12
+ export function MemoryLinkIcons(props: { private: boolean }) {
13
+ return (
14
+ <span className="flex items-center gap-1.5 pt-1 text-dashboard-text-faint">
15
+ {props.private ? <PrivateMemoryIcon /> : null}
16
+ <ArrowUpRight
17
+ aria-hidden="true"
18
+ className="transition-colors group-hover:text-dashboard-text"
19
+ size={14}
20
+ />
21
+ </span>
22
+ );
23
+ }
@@ -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);