@sentry/junior-dashboard 0.260.0 → 0.262.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 (37) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +96 -74
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +7 -6
  6. package/src/client/components/BottomSheet.tsx +32 -0
  7. package/src/client/components/Button.tsx +2 -1
  8. package/src/client/components/Drawer.tsx +23 -109
  9. package/src/client/components/README.md +14 -4
  10. package/src/client/components/SegmentedTabs.tsx +23 -4
  11. package/src/client/components/Select.tsx +41 -7
  12. package/src/client/components/Sheet.LICENSE +21 -0
  13. package/src/client/components/Sheet.tsx +141 -0
  14. package/src/client/components/controls/DirectorySortSelect.tsx +6 -4
  15. package/src/client/conversations/{ConversationDetailsDrawer.tsx → ConversationDetails.tsx} +19 -38
  16. package/src/client/conversations/ConversationDetailsFrame.tsx +91 -0
  17. package/src/client/conversations/ConversationEventLog.tsx +0 -1
  18. package/src/client/conversations/ConversationHeader.tsx +20 -28
  19. package/src/client/conversations/ConversationHeaderActions.tsx +5 -1
  20. package/src/client/conversations/ConversationPage.tsx +49 -36
  21. package/src/client/conversations/ConversationWorkspace.tsx +9 -12
  22. package/src/client/conversations/ConversationWorkspaceLoading.tsx +1 -4
  23. package/src/client/conversations/ForkConversationButton.tsx +0 -1
  24. package/src/client/conversations/README.md +18 -2
  25. package/src/client/conversations/SubagentTranscriptDrawer.tsx +0 -1
  26. package/src/client/conversations/TelemetryMetrics.tsx +2 -2
  27. package/src/client/conversations/TranscriptSubagentView.tsx +1 -1
  28. package/src/client/conversations/TranscriptTurnContextView.tsx +0 -1
  29. package/src/client/debouncedValue.ts +14 -0
  30. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +0 -1
  31. package/src/client/pages/automations/AutomationFilters.tsx +4 -3
  32. package/src/client/pages/code/RepositoryTabs.tsx +3 -2
  33. package/src/client/pages/dev/ComponentsPage.tsx +20 -2
  34. package/src/client/pages/dev/SegmentedFixtures.tsx +8 -7
  35. package/src/client/pages/dev/SelectFixtures.tsx +62 -0
  36. package/src/client/pages/memory/MemoryDetailsDrawer.tsx +0 -1
  37. package/LICENSE +0 -201
@@ -3,7 +3,7 @@ import { useState, type ReactNode } from "react";
3
3
 
4
4
  import { formatRelativeTime, formatTime } from "../format";
5
5
  import { ButtonLink } from "../components/Button";
6
- import { Drawer } from "../components/Drawer";
6
+ import { ConversationDetailsFrame } from "./ConversationDetailsFrame";
7
7
  import { SegmentedTabs } from "../components/SegmentedTabs";
8
8
  import {
9
9
  ConversationChildAgents,
@@ -16,29 +16,27 @@ import {
16
16
  import { ShareConversationButton } from "./ShareConversationButton";
17
17
  import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
18
18
 
19
- type DetailsTab = "agents" | "details" | "memories";
19
+ type DetailsTab = "agents" | "summary" | "memories";
20
20
 
21
21
  /** Put conversation context before usage and diagnostic links. */
22
- export function ConversationDetailsDrawer(props: {
22
+ export function ConversationDetails(props: {
23
23
  annotations: ReactNode;
24
24
  brief: ReactNode;
25
25
  conversationId: string;
26
26
  identity: ReactNode;
27
27
  lastActivityAt?: string;
28
28
  onClose(): void;
29
- /** Open one child agent transcript. The details drawer closes first. */
29
+ /** Open one child agent transcript. Details closes first. */
30
30
  onOpenSubagent(target: SubagentTranscriptTarget): void;
31
31
  sentryConversationUrl?: string;
32
- privacy: ReactNode;
33
32
  stats: ReactNode;
34
- title: string;
35
33
  }) {
36
- const [tab, setTab] = useState<DetailsTab>("details");
37
- // Load both lists with the drawer so each tab can show its count.
34
+ const [tab, setTab] = useState<DetailsTab>("summary");
35
+ // Load both lists with details so each tab can show its count.
38
36
  const memories = useConversationMemories(props.conversationId);
39
37
  const agents = useConversationChildAgents(props.conversationId);
40
38
  const tabs = [
41
- { label: "Details", value: "details" },
39
+ { label: "Summary", value: "summary" },
42
40
  {
43
41
  count: memories.data?.memories.length,
44
42
  label: "Memories",
@@ -46,37 +44,18 @@ export function ConversationDetailsDrawer(props: {
46
44
  },
47
45
  { count: agents.data?.agents.length, label: "Subagents", value: "agents" },
48
46
  ] as const;
49
- const titleId = "conversation-details-drawer-title";
47
+
50
48
  const sections = [
51
- { content: props.brief, title: "Summary" },
49
+ { content: props.brief, title: undefined },
52
50
  { content: props.annotations, title: "Linked work" },
53
51
  { content: props.identity, title: "Participants" },
54
52
  { content: props.stats, title: "Usage" },
55
53
  ].filter((section) => section.content != null);
56
54
 
57
55
  return (
58
- <Drawer
59
- closeLabel="Close conversation details"
60
- dismissLabel="Dismiss conversation details"
61
- header={
62
- <>
63
- <h2
64
- className="m-0 min-w-0 break-words text-lg font-bold leading-tight text-dashboard-text"
65
- id={titleId}
66
- >
67
- {props.title}
68
- </h2>
69
- <div className="mt-2 flex flex-wrap items-center gap-2">
70
- {props.privacy}
71
- </div>
72
- </>
73
- }
74
- onClose={props.onClose}
75
- openKey={props.conversationId}
76
- titleId={titleId}
77
- width="narrow"
78
- >
56
+ <ConversationDetailsFrame onClose={props.onClose}>
79
57
  <SegmentedTabs
58
+ scroll
80
59
  items={tabs}
81
60
  label="Conversation details"
82
61
  onChange={setTab}
@@ -97,11 +76,13 @@ export function ConversationDetailsDrawer(props: {
97
76
  {sections.map((section) => (
98
77
  <section
99
78
  className="grid min-w-0 gap-3 border-b border-dashboard-border pb-5 last:border-0 last:pb-0"
100
- key={section.title}
79
+ key={section.title ?? "summary"}
101
80
  >
102
- <h3 className="m-0 text-sm font-semibold text-dashboard-text">
103
- {section.title}
104
- </h3>
81
+ {section.title ? (
82
+ <h3 className="m-0 text-sm font-semibold text-dashboard-text">
83
+ {section.title}
84
+ </h3>
85
+ ) : null}
105
86
  <div className="min-w-0 break-words font-sans text-sm leading-relaxed text-dashboard-text-muted">
106
87
  {section.content}
107
88
  </div>
@@ -113,7 +94,7 @@ export function ConversationDetailsDrawer(props: {
113
94
  No additional conversation details.
114
95
  </p>
115
96
  )}
116
- {tab === "details" ? (
97
+ {tab === "summary" ? (
117
98
  <footer className="mt-5 grid gap-6 border-t border-dashboard-border pt-4 text-xs text-dashboard-text-muted">
118
99
  {props.lastActivityAt ? (
119
100
  <div className="flex flex-wrap items-baseline justify-between gap-2">
@@ -152,6 +133,6 @@ export function ConversationDetailsDrawer(props: {
152
133
  </footer>
153
134
  ) : null}
154
135
  </SegmentedTabs>
155
- </Drawer>
136
+ </ConversationDetailsFrame>
156
137
  );
157
138
  }
@@ -0,0 +1,91 @@
1
+ import { X } from "lucide-react";
2
+ import {
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ useSyncExternalStore,
7
+ type ReactNode,
8
+ } from "react";
9
+
10
+ import { Button } from "../components/Button";
11
+ import { BottomSheet } from "../components/BottomSheet";
12
+
13
+ const panelMediaQuery = "(min-width: 1280px)";
14
+
15
+ function subscribeToPanelLayout(onChange: () => void) {
16
+ const media = window.matchMedia(panelMediaQuery);
17
+ media.addEventListener("change", onChange);
18
+ return () => media.removeEventListener("change", onChange);
19
+ }
20
+
21
+ function hasPanelSpace() {
22
+ return window.matchMedia(panelMediaQuery).matches;
23
+ }
24
+
25
+ /** Keep details beside the chat when space permits, or in a modal bottom sheet. */
26
+ export function ConversationDetailsFrame(props: {
27
+ children: ReactNode;
28
+ onClose(): void;
29
+ }) {
30
+ const titleId = useId();
31
+ const panel = useSyncExternalStore(
32
+ subscribeToPanelLayout,
33
+ hasPanelSpace,
34
+ () => false,
35
+ );
36
+ const closeRef = useRef<HTMLButtonElement>(null);
37
+ const wasPanel = useRef(panel);
38
+ useEffect(() => {
39
+ if (
40
+ panel &&
41
+ !wasPanel.current &&
42
+ !document.querySelector("[data-dashboard-modal]")
43
+ ) {
44
+ closeRef.current?.focus({ preventScroll: true });
45
+ }
46
+ wasPanel.current = panel;
47
+ }, [panel]);
48
+ if (!panel) {
49
+ return (
50
+ <BottomSheet label="Conversation details" onClose={props.onClose}>
51
+ {props.children}
52
+ </BottomSheet>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <aside
58
+ aria-labelledby={titleId}
59
+ className="grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] border-l border-dashboard-border bg-dashboard-bg-elevated"
60
+ onKeyDown={(event) => {
61
+ if (
62
+ event.key === "Escape" &&
63
+ !event.defaultPrevented &&
64
+ !document.querySelector("[data-dashboard-modal]")
65
+ ) {
66
+ event.preventDefault();
67
+ props.onClose();
68
+ }
69
+ }}
70
+ >
71
+ <header className="flex items-center justify-between gap-3 border-b border-dashboard-border px-5 py-3">
72
+ <h2
73
+ aria-label="Conversation details"
74
+ className="m-0 text-base font-semibold text-dashboard-text"
75
+ id={titleId}
76
+ >
77
+ Details
78
+ </h2>
79
+ <Button
80
+ ref={closeRef}
81
+ aria-label="Close conversation details"
82
+ onClick={props.onClose}
83
+ size="icon"
84
+ >
85
+ <X aria-hidden="true" size={15} />
86
+ </Button>
87
+ </header>
88
+ <div className="min-h-0">{props.children}</div>
89
+ </aside>
90
+ );
91
+ }
@@ -200,7 +200,6 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
200
200
  ? createPortal(
201
201
  <Drawer
202
202
  closeLabel="Close event details"
203
- dismissLabel="Dismiss event details"
204
203
  header={
205
204
  <>
206
205
  <h2
@@ -22,37 +22,33 @@ import {
22
22
  TranscriptViewToggle,
23
23
  type ConversationArchiveAction,
24
24
  } from "./ConversationHeaderActions";
25
- import { ConversationDetailsDrawer } from "./ConversationDetailsDrawer";
26
25
  import type { TranscriptViewMode } from "./transcriptRenderModel";
27
- import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
28
26
 
29
27
  /** Show the conversation title, search, and details. */
30
28
  export function ConversationHeader(props: {
31
29
  /** Parent chain of a child Conversation. Root Conversations omit it. */
32
30
  agentTree?: ReactNode;
33
31
  copyAction?: ReactNode;
34
- annotations: ReactNode;
35
32
  archive: ConversationArchiveAction;
36
- brief?: ReactNode;
37
33
  conversationId: string;
34
+ detailsOpen: boolean;
35
+ onDetailsClick(): void;
38
36
  /** Links to the fork source and to forks of this conversation. */
39
37
  forks?: ReactNode;
40
- identity: ReactNode;
41
38
  linkedWork?: ReactNode;
42
- lastActivityAt?: string;
43
- sentryConversationUrl?: string;
44
39
  live: boolean;
45
40
  meta?: ReactNode;
46
- onOpenSubagent(target: SubagentTranscriptTarget): void;
47
41
  onSearchChange(value: string): void;
48
42
  onViewChange(value: TranscriptViewMode): void;
49
43
  privacy: ReactNode;
50
44
  search: string;
51
- stats: ReactNode;
52
45
  title: string;
53
46
  view: TranscriptViewMode;
54
47
  }) {
55
- const [detailsOpen, setDetailsOpen] = useState(false);
48
+ const detailsOpen = props.detailsOpen;
49
+ const detailsButtonRef = useRef<HTMLButtonElement>(null);
50
+ const menuButtonRef = useRef<HTMLButtonElement>(null);
51
+ const wasDetailsOpen = useRef(detailsOpen);
56
52
  const [searchOpen, setSearchOpen] = useState(false);
57
53
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
58
54
  const searchInputRef = useRef<HTMLInputElement>(null);
@@ -60,6 +56,16 @@ export function ConversationHeader(props: {
60
56
  const searchOpenVisible = searchOpen || props.search.length > 0;
61
57
  const openMobileNavigation = useOpenMobileNavigation();
62
58
 
59
+ useEffect(() => {
60
+ if (wasDetailsOpen.current && !detailsOpen) {
61
+ const trigger = window.matchMedia("(max-width: 767px)").matches
62
+ ? menuButtonRef.current
63
+ : detailsButtonRef.current;
64
+ trigger?.focus({ preventScroll: true });
65
+ }
66
+ wasDetailsOpen.current = detailsOpen;
67
+ }, [detailsOpen]);
68
+
63
69
  useEffect(() => {
64
70
  if (!searchOpenVisible) return;
65
71
  searchInputRef.current?.focus();
@@ -107,6 +113,7 @@ export function ConversationHeader(props: {
107
113
  const mobileOverflow = (
108
114
  <div className="relative" ref={mobileMenuRef}>
109
115
  <HeaderIconButton
116
+ ref={menuButtonRef}
110
117
  label={mobileMenuOpen ? "Close conversation menu" : "Conversation menu"}
111
118
  onClick={() => setMobileMenuOpen((open) => !open)}
112
119
  pressed={mobileMenuOpen}
@@ -123,7 +130,7 @@ export function ConversationHeader(props: {
123
130
  layout="menu"
124
131
  onDetailsClick={() => {
125
132
  setMobileMenuOpen(false);
126
- setDetailsOpen(true);
133
+ props.onDetailsClick();
127
134
  }}
128
135
  onSearchClick={() => {
129
136
  setMobileMenuOpen(false);
@@ -205,7 +212,8 @@ export function ConversationHeader(props: {
205
212
  conversationId={props.conversationId}
206
213
  copyAction={props.copyAction}
207
214
  detailsOpen={detailsOpen}
208
- onDetailsClick={() => setDetailsOpen(true)}
215
+ detailsButtonRef={detailsButtonRef}
216
+ onDetailsClick={props.onDetailsClick}
209
217
  onSearchClick={toggleSearch}
210
218
  onViewChange={props.onViewChange}
211
219
  searchOpen={searchOpenVisible}
@@ -252,22 +260,6 @@ export function ConversationHeader(props: {
252
260
  </div>
253
261
  ) : null}
254
262
  </header>
255
-
256
- {detailsOpen ? (
257
- <ConversationDetailsDrawer
258
- annotations={props.annotations}
259
- brief={props.brief}
260
- conversationId={props.conversationId}
261
- identity={props.identity}
262
- lastActivityAt={props.lastActivityAt}
263
- sentryConversationUrl={props.sentryConversationUrl}
264
- onClose={() => setDetailsOpen(false)}
265
- onOpenSubagent={props.onOpenSubagent}
266
- privacy={props.privacy}
267
- stats={props.stats}
268
- title={props.title}
269
- />
270
- ) : null}
271
263
  </>
272
264
  );
273
265
  }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import type { ReactNode, Ref } from "react";
2
2
  import {
3
3
  Archive,
4
4
  ArchiveRestore,
@@ -30,6 +30,7 @@ export function ConversationHeaderActions(props: {
30
30
  conversationId: string;
31
31
  copyAction?: ReactNode;
32
32
  detailsOpen: boolean;
33
+ detailsButtonRef?: Ref<HTMLButtonElement>;
33
34
  /** `bar` is the desktop icon row. `menu` stacks actions for mobile overflow. */
34
35
  layout?: "bar" | "menu";
35
36
  onDetailsClick(): void;
@@ -94,6 +95,7 @@ export function ConversationHeaderActions(props: {
94
95
  {props.copyAction}
95
96
  <ArchiveConversationButton {...props.archive} layout="bar" />
96
97
  <Button
98
+ ref={props.detailsButtonRef}
97
99
  aria-label="Conversation details"
98
100
  aria-pressed={props.detailsOpen}
99
101
  className="ml-2"
@@ -180,6 +182,7 @@ function ViewModeButton(props: {
180
182
  /** Shared icon control used by conversation and subagent transcript headers. */
181
183
  export function HeaderIconButton(props: {
182
184
  children: ReactNode;
185
+ ref?: Ref<HTMLButtonElement>;
183
186
  label: string;
184
187
  onClick(): void;
185
188
  pressed?: boolean;
@@ -187,6 +190,7 @@ export function HeaderIconButton(props: {
187
190
  return (
188
191
  <IconButtonTooltip label={props.label}>
189
192
  <Button
193
+ ref={props.ref}
190
194
  aria-label={props.label}
191
195
  aria-pressed={props.pressed}
192
196
  className={cn(
@@ -26,6 +26,8 @@ import {
26
26
  } from "./conversationRoutes";
27
27
  import { ConversationComposer } from "./ConversationComposer";
28
28
  import { ConversationHeader } from "./ConversationHeader";
29
+ import { ConversationDetails } from "./ConversationDetails";
30
+ import { cn } from "../styles";
29
31
  import { ConversationForkLinks } from "./ConversationForkLinks";
30
32
  import { ConversationAgentTree } from "./ConversationAgentTree";
31
33
  import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
@@ -76,6 +78,7 @@ export function ConversationPage(props: {
76
78
  onRead?(conversationId: string, lastReadAt: string): void;
77
79
  pendingArchiveUpdate?: PendingArchiveConversationUpdate;
78
80
  }) {
81
+ const [detailsOpen, setDetailsOpen] = useState(false);
79
82
  const [subagentTarget, setSubagentTarget] =
80
83
  useState<SubagentTranscriptTarget>();
81
84
  const [searchParams, setSearchParams] = useSearchParams();
@@ -196,7 +199,12 @@ export function ConversationPage(props: {
196
199
  );
197
200
 
198
201
  return (
199
- <>
202
+ <div
203
+ className={cn(
204
+ "grid h-full min-h-0 min-w-0",
205
+ detailsOpen && "xl:grid-cols-[minmax(0,1fr)_25rem]",
206
+ )}
207
+ >
200
208
  <ChatLayout
201
209
  scrollMinTall
202
210
  scrollAriaLabel="Conversation transcript"
@@ -208,6 +216,8 @@ export function ConversationPage(props: {
208
216
  agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
209
217
  }
210
218
  conversationId={conversationId}
219
+ detailsOpen={detailsOpen}
220
+ onDetailsClick={() => setDetailsOpen((open) => !open)}
211
221
  forks={
212
222
  detail.data?.forkedFromConversationId ||
213
223
  detail.data?.forks?.length ? (
@@ -220,8 +230,6 @@ export function ConversationPage(props: {
220
230
  />
221
231
  ) : null
222
232
  }
223
- lastActivityAt={conversation?.lastSeenAt}
224
- sentryConversationUrl={detail.data?.sentryConversationUrl}
225
233
  copyAction={
226
234
  <CopyMarkdownButton
227
235
  key={conversationDetail?.conversationId ?? "loading"}
@@ -236,11 +244,6 @@ export function ConversationPage(props: {
236
244
  }
237
245
  />
238
246
  }
239
- annotations={
240
- hasConversationAnnotations(detail.data?.annotations) ? (
241
- <ConversationAnnotations detail={detail.data} />
242
- ) : null
243
- }
244
247
  linkedWork={
245
248
  hasConversationAnnotations(detail.data?.annotations) ? (
246
249
  <ConversationAnnotations
@@ -260,15 +263,6 @@ export function ConversationPage(props: {
260
263
  }),
261
264
  pending: archive.isPending,
262
265
  }}
263
- brief={
264
- detail.data?.brief ? (
265
- <ConversationBrief
266
- brief={detail.data.brief}
267
- variant="summary"
268
- />
269
- ) : null
270
- }
271
- identity={identity}
272
266
  live={live}
273
267
  meta={
274
268
  <ConversationHeaderMeta
@@ -288,7 +282,6 @@ export function ConversationPage(props: {
288
282
  }
289
283
  />
290
284
  }
291
- onOpenSubagent={setSubagentTarget}
292
285
  onSearchChange={setSearch}
293
286
  onViewChange={onViewChange}
294
287
  privacy={
@@ -297,23 +290,6 @@ export function ConversationPage(props: {
297
290
  />
298
291
  }
299
292
  search={search}
300
- stats={
301
- hasConversationStats({
302
- conversation,
303
- detail: detail.data,
304
- }) ? (
305
- <div className="grid min-w-0 gap-5">
306
- <ConversationStats
307
- conversation={conversation}
308
- detail={detail.data}
309
- />
310
- <ConversationUsageBreakdown
311
- conversation={conversation}
312
- detail={detail.data}
313
- />
314
- </div>
315
- ) : null
316
- }
317
293
  title={title}
318
294
  view={view}
319
295
  />
@@ -382,11 +358,48 @@ export function ConversationPage(props: {
382
358
  ) : undefined
383
359
  }
384
360
  />
361
+ {detailsOpen ? (
362
+ <ConversationDetails
363
+ conversationId={conversationId}
364
+ identity={identity}
365
+ lastActivityAt={conversation?.lastSeenAt}
366
+ sentryConversationUrl={detail.data?.sentryConversationUrl}
367
+ onClose={() => setDetailsOpen(false)}
368
+ onOpenSubagent={setSubagentTarget}
369
+ annotations={
370
+ hasConversationAnnotations(detail.data?.annotations) ? (
371
+ <ConversationAnnotations detail={detail.data} />
372
+ ) : null
373
+ }
374
+ brief={
375
+ detail.data?.brief ? (
376
+ <ConversationBrief brief={detail.data.brief} variant="summary" />
377
+ ) : null
378
+ }
379
+ stats={
380
+ hasConversationStats({
381
+ conversation,
382
+ detail: detail.data,
383
+ }) ? (
384
+ <div className="grid min-w-0 gap-5">
385
+ <ConversationStats
386
+ conversation={conversation}
387
+ detail={detail.data}
388
+ />
389
+ <ConversationUsageBreakdown
390
+ conversation={conversation}
391
+ detail={detail.data}
392
+ />
393
+ </div>
394
+ ) : null
395
+ }
396
+ />
397
+ ) : null}
385
398
  <SubagentTranscriptDrawer
386
399
  onClose={() => setSubagentTarget(undefined)}
387
400
  target={subagentTarget}
388
401
  />
389
- </>
402
+ </div>
390
403
  );
391
404
  }
392
405
 
@@ -1,5 +1,5 @@
1
1
  import type { InputImage } from "@sentry/junior/api/schema";
2
- import { useDeferredValue, useEffect, useMemo, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import { Globe2, LockKeyhole } from "lucide-react";
4
4
  import { useNavigate, useParams } from "react-router";
5
5
 
@@ -17,8 +17,8 @@ import {
17
17
  } from "./queries";
18
18
  import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes";
19
19
  import { buildConversations, getDashboardTimeZone } from "../format";
20
+ import { useDebouncedValue } from "../debouncedValue";
20
21
  import type { Conversation } from "../types";
21
- import { cn, dashboardContainerClass } from "../styles";
22
22
  import { ConversationPage } from "./ConversationPage";
23
23
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
24
24
 
@@ -28,7 +28,7 @@ const CONVERSATION_PAGE_SIZE = 20;
28
28
  export function ConversationWorkspace() {
29
29
  const [page, setPage] = useState(1);
30
30
  const [query, setQuery] = useState("");
31
- const search = useDeferredValue(query.trim());
31
+ const search = useDebouncedValue(query.trim());
32
32
  const params = useParams();
33
33
  const navigate = useNavigate();
34
34
  const selectedId = params.conversationId;
@@ -90,12 +90,7 @@ export function ConversationWorkspace() {
90
90
 
91
91
  if (home) {
92
92
  return (
93
- <main
94
- className={cn(
95
- dashboardContainerClass,
96
- "h-full min-h-0 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-6",
97
- )}
98
- >
93
+ <main className="h-full min-h-0 w-full overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-6">
99
94
  <div className="mx-auto grid w-full max-w-6xl gap-8">
100
95
  {createView}
101
96
  <section aria-label="Conversations" className="grid gap-3">
@@ -192,25 +187,27 @@ function NewConversationView(props: {
192
187
  footerStart={
193
188
  <div
194
189
  aria-label="Conversation visibility"
195
- className="inline-flex items-center gap-1"
190
+ className="inline-flex shrink-0 items-center gap-0.5 rounded-lg border border-dashboard-border bg-dashboard-surface-panel p-0.5"
196
191
  role="group"
197
192
  >
198
193
  <ToggleButton
194
+ className="min-w-20 gap-1.5"
199
195
  onClick={() => setVisibility("public")}
200
196
  pressed={isPublic}
201
197
  type="button"
202
198
  variant="segment"
203
199
  >
204
- <Globe2 aria-hidden="true" className="mr-1 inline size-3" />
200
+ <Globe2 aria-hidden="true" className="size-3" />
205
201
  Public
206
202
  </ToggleButton>
207
203
  <ToggleButton
204
+ className="min-w-20 gap-1.5"
208
205
  onClick={() => setVisibility("private")}
209
206
  pressed={!isPublic}
210
207
  type="button"
211
208
  variant="segment"
212
209
  >
213
- <LockKeyhole aria-hidden="true" className="mr-1 inline size-3" />
210
+ <LockKeyhole aria-hidden="true" className="size-3" />
214
211
  Private
215
212
  </ToggleButton>
216
213
  </div>
@@ -58,10 +58,7 @@ function ConversationHomeLoading() {
58
58
  <main
59
59
  aria-busy="true"
60
60
  aria-live="polite"
61
- className={cn(
62
- dashboardContainerClass,
63
- "h-full min-h-0 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-6",
64
- )}
61
+ className="h-full min-h-0 w-full overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-6"
65
62
  role="status"
66
63
  >
67
64
  <span className="sr-only">Loading conversations</span>
@@ -60,7 +60,6 @@ function ForkConversationDialog(props: ForkTarget & { onClose(): void }) {
60
60
  return (
61
61
  <Drawer
62
62
  closeLabel="Close fork dialog"
63
- dismissLabel="Dismiss fork dialog"
64
63
  header={
65
64
  <h2 id={titleId} className="m-0 text-lg font-semibold">
66
65
  Fork Conversation
@@ -40,10 +40,26 @@ shared controls and overlays.
40
40
  Automation. Render this text with `TranscriptText` or `MentionText`. Both
41
41
  require `mentions`, so a caller cannot forget them. Do not resolve names in
42
42
  the dashboard, and do not rewrite tokens into links on the server.
43
- - `TranscriptTurnContextView` owns context content. The shared `Drawer` owns
44
- dismissal, focus trapping, focus return, scroll locking, and panel layout.
43
+ - `TranscriptTurnContextView` owns context content. The shared `Drawer` adds
44
+ its header and layout to the Base UI `Sheet` focus and dismissal boundary.
45
45
 
46
46
  Review these components together in `/dev/transcripts`. Use the conversation
47
47
  fixture for messages, events, activity, context, and typing. Use the attachment
48
48
  fixture for file wrapping. Keep visual checks out of automated assertions;
49
49
  browser tests cover opening, closing, and keyboard interaction.
50
+
51
+ ## Conversation details
52
+
53
+ `ConversationPage` owns whether details are open. The page is keyed by the
54
+ Conversation, so switching Conversations closes details. `ConversationHeader`
55
+ only toggles details and restores focus through explicit control refs after
56
+ dismissal.
57
+
58
+ `ConversationDetails` owns one set of tabs and content. `ConversationDetailsFrame`
59
+ uses a 400px aside at 1280px and above. The chat and aside scroll separately.
60
+ Smaller windows use `BottomSheet` with a plain accessible label. The sheet and
61
+ side drawers share the Base UI `Sheet` focus rules and the app's document
62
+ scroll lock. A resize changes the frame and keeps the selected tab. Tabs stay fixed above the scrolling content in both
63
+ layouts. Only the swipe handle sits above the sheet tabs. Swipe down, tap the
64
+ backdrop, or press Escape to dismiss it. Base UI owns the transition lifecycle.
65
+ A pending exit cannot close a new panel or a different Conversation.
@@ -103,7 +103,6 @@ function SubagentTranscriptDrawerContent(props: {
103
103
  </>
104
104
  }
105
105
  closeLabel="Close subagent transcript"
106
- dismissLabel="Dismiss subagent transcript"
107
106
  header={
108
107
  <>
109
108
  <div className="flex min-w-0 items-center gap-2">
@@ -354,7 +354,7 @@ export function CostMetric(props: {
354
354
  align?: "left" | "right";
355
355
  auxiliaryCosts?: ConversationAuxiliaryCosts;
356
356
  childAgentTotals?: ConversationChildAgentTotals;
357
- /** Point to the Details drawer, which has the full breakdown. */
357
+ /** Point to conversation details, which has the full breakdown. */
358
358
  detailsHint?: boolean;
359
359
  live?: boolean;
360
360
  liveModelId?: string;
@@ -398,7 +398,7 @@ export function TokenMetric(props: {
398
398
  align?: "left" | "right";
399
399
  childAgentTotals?: ConversationChildAgentTotals;
400
400
  compactionCount?: number;
401
- /** Point to the Details drawer, which has the full breakdown. */
401
+ /** Point to conversation details, which has the full breakdown. */
402
402
  detailsHint?: boolean;
403
403
  live?: boolean;
404
404
  liveModelId?: string;