@sentry/junior-dashboard 0.262.0 → 0.264.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.
@@ -1,9 +1,10 @@
1
- import { ExternalLink } from "lucide-react";
2
1
  import { useState, type ReactNode } from "react";
3
2
 
4
- import { formatRelativeTime, formatTime } from "../format";
5
- import { ButtonLink } from "../components/Button";
6
- import { ConversationDetailsFrame } from "./ConversationDetailsFrame";
3
+ import { IconButtonTooltip } from "../components/Tooltip";
4
+ import {
5
+ ConversationDetailsFrame,
6
+ useConversationDetailsPanel,
7
+ } from "./ConversationDetailsFrame";
7
8
  import { SegmentedTabs } from "../components/SegmentedTabs";
8
9
  import {
9
10
  ConversationChildAgents,
@@ -18,20 +19,25 @@ import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
18
19
 
19
20
  type DetailsTab = "agents" | "summary" | "memories";
20
21
 
21
- /** Put conversation context before usage and diagnostic links. */
22
+ /**
23
+ * Show conversation details: the brief and facts first, then linked work,
24
+ * participants, and the usage breakdown. Share and Sentry links stay in the
25
+ * header so every tab can reach them.
26
+ */
22
27
  export function ConversationDetails(props: {
23
28
  annotations: ReactNode;
24
29
  brief: ReactNode;
25
30
  conversationId: string;
26
31
  identity: ReactNode;
27
- lastActivityAt?: string;
28
32
  onClose(): void;
29
- /** Open one child agent transcript. Details closes first. */
33
+ /** Open one child agent transcript. A details sheet closes first. */
30
34
  onOpenSubagent(target: SubagentTranscriptTarget): void;
35
+ overview: ReactNode;
31
36
  sentryConversationUrl?: string;
32
- stats: ReactNode;
37
+ usage: ReactNode;
33
38
  }) {
34
39
  const [tab, setTab] = useState<DetailsTab>("summary");
40
+ const panel = useConversationDetailsPanel();
35
41
  // Load both lists with details so each tab can show its count.
36
42
  const memories = useConversationMemories(props.conversationId);
37
43
  const agents = useConversationChildAgents(props.conversationId);
@@ -46,14 +52,38 @@ export function ConversationDetails(props: {
46
52
  ] as const;
47
53
 
48
54
  const sections = [
49
- { content: props.brief, title: undefined },
50
- { content: props.annotations, title: "Linked work" },
51
- { content: props.identity, title: "Participants" },
52
- { content: props.stats, title: "Usage" },
55
+ { content: props.overview, key: "overview", title: undefined },
56
+ { content: props.identity, key: "participants", title: "Participants" },
57
+ { content: props.annotations, key: "linked-work", title: "Linked work" },
58
+ { content: props.usage, key: "usage", title: "Usage" },
53
59
  ].filter((section) => section.content != null);
54
60
 
55
61
  return (
56
- <ConversationDetailsFrame onClose={props.onClose}>
62
+ <ConversationDetailsFrame
63
+ actions={
64
+ <>
65
+ <ShareConversationButton
66
+ key={props.conversationId}
67
+ conversationId={props.conversationId}
68
+ layout="icon"
69
+ />
70
+ {props.sentryConversationUrl ? (
71
+ <IconButtonTooltip label="Open in Sentry" placement="below">
72
+ <a
73
+ aria-label="Open in Sentry"
74
+ className="grid size-9 place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
75
+ href={props.sentryConversationUrl}
76
+ rel="noopener noreferrer"
77
+ target="_blank"
78
+ >
79
+ <SentryGlyph />
80
+ </a>
81
+ </IconButtonTooltip>
82
+ ) : null}
83
+ </>
84
+ }
85
+ onClose={props.onClose}
86
+ >
57
87
  <SegmentedTabs
58
88
  scroll
59
89
  items={tabs}
@@ -67,26 +97,22 @@ export function ConversationDetails(props: {
67
97
  <ConversationChildAgents
68
98
  conversationId={props.conversationId}
69
99
  onOpenAgent={(target) => {
70
- props.onClose();
100
+ // The panel stays beside the drawer. A modal sheet cannot.
101
+ if (!panel) props.onClose();
71
102
  props.onOpenSubagent(target);
72
103
  }}
73
104
  />
74
- ) : sections.length > 0 ? (
75
- <div className="grid min-w-0 gap-5">
105
+ ) : props.brief != null || sections.length > 0 ? (
106
+ <div className="grid min-w-0 gap-3">
107
+ {props.brief != null ? (
108
+ <div className="min-w-0 break-words px-1 pb-1 font-sans text-sm leading-relaxed text-dashboard-text-muted">
109
+ {props.brief}
110
+ </div>
111
+ ) : null}
76
112
  {sections.map((section) => (
77
- <section
78
- className="grid min-w-0 gap-3 border-b border-dashboard-border pb-5 last:border-0 last:pb-0"
79
- key={section.title ?? "summary"}
80
- >
81
- {section.title ? (
82
- <h3 className="m-0 text-sm font-semibold text-dashboard-text">
83
- {section.title}
84
- </h3>
85
- ) : null}
86
- <div className="min-w-0 break-words font-sans text-sm leading-relaxed text-dashboard-text-muted">
87
- {section.content}
88
- </div>
89
- </section>
113
+ <DetailsSection key={section.key} title={section.title}>
114
+ {section.content}
115
+ </DetailsSection>
90
116
  ))}
91
117
  </div>
92
118
  ) : (
@@ -94,45 +120,37 @@ export function ConversationDetails(props: {
94
120
  No additional conversation details.
95
121
  </p>
96
122
  )}
97
- {tab === "summary" ? (
98
- <footer className="mt-5 grid gap-6 border-t border-dashboard-border pt-4 text-xs text-dashboard-text-muted">
99
- {props.lastActivityAt ? (
100
- <div className="flex flex-wrap items-baseline justify-between gap-2">
101
- <span>Last activity</span>
102
- <time
103
- dateTime={props.lastActivityAt}
104
- title={formatTime(props.lastActivityAt, {
105
- dateStyle: "medium",
106
- timeStyle: "short",
107
- })}
108
- >
109
- {formatRelativeTime(props.lastActivityAt)}
110
- </time>
111
- </div>
112
- ) : null}
113
- <div className="flex flex-wrap items-center gap-2">
114
- <ShareConversationButton
115
- key={props.conversationId}
116
- conversationId={props.conversationId}
117
- />
118
- {props.sentryConversationUrl ? (
119
- <ButtonLink
120
- className="w-fit focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
121
- to={props.sentryConversationUrl}
122
- rel="noopener noreferrer"
123
- target="_blank"
124
- >
125
- Open in Sentry
126
- <ExternalLink
127
- aria-hidden="true"
128
- className="size-3.5 shrink-0"
129
- />
130
- </ButtonLink>
131
- ) : null}
132
- </div>
133
- </footer>
134
- ) : null}
135
123
  </SegmentedTabs>
136
124
  </ConversationDetailsFrame>
137
125
  );
138
126
  }
127
+
128
+ /** Group one kind of conversation detail in a compact card. */
129
+ function DetailsSection(props: { children: ReactNode; title?: string }) {
130
+ return (
131
+ <section className="grid min-w-0 gap-2.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3">
132
+ {props.title ? (
133
+ <h3 className="m-0 font-sans text-xs font-medium text-dashboard-text-muted">
134
+ {props.title}
135
+ </h3>
136
+ ) : null}
137
+ <div className="min-w-0 break-words font-sans text-xs leading-relaxed text-dashboard-text-muted">
138
+ {props.children}
139
+ </div>
140
+ </section>
141
+ );
142
+ }
143
+
144
+ /** Draw the Sentry glyph in the current text color, sized like a header icon. */
145
+ function SentryGlyph() {
146
+ return (
147
+ <svg
148
+ aria-hidden="true"
149
+ className="size-4"
150
+ fill="currentColor"
151
+ viewBox="0 0 24 24"
152
+ >
153
+ <path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z" />
154
+ </svg>
155
+ );
156
+ }
@@ -11,28 +11,42 @@ import { Button } from "../components/Button";
11
11
  import { BottomSheet } from "../components/BottomSheet";
12
12
 
13
13
  const panelMediaQuery = "(min-width: 1280px)";
14
+ // Open details by default only when the chat column stays wide beside them.
15
+ const defaultOpenMediaQuery = "(min-width: 1440px)";
14
16
 
15
- function subscribeToPanelLayout(onChange: () => void) {
16
- const media = window.matchMedia(panelMediaQuery);
17
- media.addEventListener("change", onChange);
18
- return () => media.removeEventListener("change", onChange);
17
+ function useMediaQuery(query: string): boolean {
18
+ return useSyncExternalStore(
19
+ (onChange) => {
20
+ const media = window.matchMedia(query);
21
+ media.addEventListener("change", onChange);
22
+ return () => media.removeEventListener("change", onChange);
23
+ },
24
+ () => window.matchMedia(query).matches,
25
+ () => false,
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Report whether the viewport has room to show details as a panel beside the
31
+ * chat. Narrower viewports show details in a bottom sheet.
32
+ */
33
+ export function useConversationDetailsPanel(): boolean {
34
+ return useMediaQuery(panelMediaQuery);
19
35
  }
20
36
 
21
- function hasPanelSpace() {
22
- return window.matchMedia(panelMediaQuery).matches;
37
+ /** Report whether details should open beside the chat without a request. */
38
+ export function useConversationDetailsDefaultOpen(): boolean {
39
+ return useMediaQuery(defaultOpenMediaQuery);
23
40
  }
24
41
 
25
42
  /** Keep details beside the chat when space permits, or in a modal bottom sheet. */
26
43
  export function ConversationDetailsFrame(props: {
44
+ actions?: ReactNode;
27
45
  children: ReactNode;
28
46
  onClose(): void;
29
47
  }) {
30
48
  const titleId = useId();
31
- const panel = useSyncExternalStore(
32
- subscribeToPanelLayout,
33
- hasPanelSpace,
34
- () => false,
35
- );
49
+ const panel = useConversationDetailsPanel();
36
50
  const closeRef = useRef<HTMLButtonElement>(null);
37
51
  const wasPanel = useRef(panel);
38
52
  useEffect(() => {
@@ -49,6 +63,12 @@ export function ConversationDetailsFrame(props: {
49
63
  return (
50
64
  <BottomSheet label="Conversation details" onClose={props.onClose}>
51
65
  {props.children}
66
+ {/* After the tabs, so the sheet still opens with the first tab focused. */}
67
+ {props.actions ? (
68
+ <div className="flex justify-end gap-1 border-t border-dashboard-border px-4 py-2">
69
+ {props.actions}
70
+ </div>
71
+ ) : null}
52
72
  </BottomSheet>
53
73
  );
54
74
  }
@@ -68,14 +88,22 @@ export function ConversationDetailsFrame(props: {
68
88
  }
69
89
  }}
70
90
  >
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>
91
+ <header className="flex items-center justify-between gap-3 border-b border-dashboard-border py-2 pl-5 pr-3">
92
+ {/* Keep actions beside the title. The panel opens under the pointer
93
+ that clicked the chat's Details button, so that corner holds only
94
+ Close. */}
95
+ <div className="flex min-w-0 items-center gap-3">
96
+ <h2
97
+ aria-label="Conversation details"
98
+ className="m-0 text-base font-semibold text-dashboard-text"
99
+ id={titleId}
100
+ >
101
+ Details
102
+ </h2>
103
+ {props.actions ? (
104
+ <div className="flex items-center gap-0.5">{props.actions}</div>
105
+ ) : null}
106
+ </div>
79
107
  <Button
80
108
  ref={closeRef}
81
109
  aria-label="Close conversation details"
@@ -16,6 +16,8 @@ import type { ConversationDetailReport } from "@sentry/junior/api/schema";
16
16
 
17
17
  import {
18
18
  formatConversationDuration,
19
+ formatRelativeTime,
20
+ formatTime,
19
21
  slackLocationLabel,
20
22
  summarizeCost,
21
23
  summarizeModelUsage,
@@ -547,7 +549,13 @@ export function hasConversationStats(props: {
547
549
  return conversationStatItems(props).length > 0;
548
550
  }
549
551
 
550
- /** Render runtime, source, token, and cost metadata under the conversation title. */
552
+ type ConversationStatItem = MetricListItem & { label: string };
553
+
554
+ /**
555
+ * Render runtime, source, token, and cost metadata. The compact variant is one
556
+ * line under the conversation title. The full variant is a labeled list for
557
+ * conversation details.
558
+ */
551
559
  export function ConversationStats(props: {
552
560
  conversation: Conversation | undefined;
553
561
  detail?: ConversationDetailReport;
@@ -556,6 +564,24 @@ export function ConversationStats(props: {
556
564
  const stats = conversationStatItems(props);
557
565
  if (stats.length === 0) return null;
558
566
 
567
+ if ((props.variant ?? "full") === "full") {
568
+ return (
569
+ <dl className="m-0 grid min-w-0 gap-2 text-xs leading-snug">
570
+ {stats.map((item) => (
571
+ <div
572
+ className="flex min-w-0 items-baseline justify-between gap-4"
573
+ key={item.key}
574
+ >
575
+ <dt className="shrink-0 text-dashboard-text-muted">{item.label}</dt>
576
+ <dd className="m-0 min-w-0 break-words text-right tabular-nums text-dashboard-text">
577
+ {item.content}
578
+ </dd>
579
+ </div>
580
+ ))}
581
+ </dl>
582
+ );
583
+ }
584
+
559
585
  return (
560
586
  <MetricList
561
587
  className="break-words text-xs leading-[1.45] text-dashboard-text-muted"
@@ -568,7 +594,7 @@ function conversationStatItems(props: {
568
594
  conversation: Conversation | undefined;
569
595
  detail?: ConversationDetailReport;
570
596
  variant?: "compact" | "full";
571
- }): MetricListItem[] {
597
+ }): ConversationStatItem[] {
572
598
  if (!props.conversation) return [];
573
599
  const variant = props.variant ?? "full";
574
600
  const completeDetail = props.detail?.previousCursor
@@ -590,17 +616,37 @@ function conversationStatItems(props: {
590
616
  const sourceTask = props.detail?.sourceTask;
591
617
  const childAgentTotals =
592
618
  props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
593
- const rawStats: Array<MetricListItem | undefined> = [
619
+ const lastActivityAt = props.conversation.lastSeenAt;
620
+ const rawStats: Array<ConversationStatItem | undefined> = [
594
621
  location
595
622
  ? {
596
623
  content: <LocationLink label={location} locationUrl={locationUrl} />,
597
624
  key: "location",
625
+ label: "Location",
598
626
  }
599
627
  : undefined,
600
628
  variant === "full" && sourceTask
601
629
  ? {
602
630
  content: <SourceTask sourceTask={sourceTask} />,
603
631
  key: "source-task",
632
+ label: "Source",
633
+ }
634
+ : undefined,
635
+ variant === "full" && lastActivityAt
636
+ ? {
637
+ content: (
638
+ <time
639
+ dateTime={lastActivityAt}
640
+ title={formatTime(lastActivityAt, {
641
+ dateStyle: "medium",
642
+ timeStyle: "short",
643
+ })}
644
+ >
645
+ {formatRelativeTime(lastActivityAt)}
646
+ </time>
647
+ ),
648
+ key: "last-activity",
649
+ label: "Last activity",
604
650
  }
605
651
  : undefined,
606
652
  durationLabel !== "none"
@@ -615,6 +661,7 @@ function conversationStatItems(props: {
615
661
  />
616
662
  ),
617
663
  key: "duration",
664
+ label: "Runtime",
618
665
  }
619
666
  : undefined,
620
667
  tokenSummary
@@ -637,6 +684,7 @@ function conversationStatItems(props: {
637
684
  />
638
685
  ),
639
686
  key: "tokens",
687
+ label: "Tokens",
640
688
  }
641
689
  : undefined,
642
690
  costSummary ||
@@ -659,14 +707,18 @@ function conversationStatItems(props: {
659
707
  />
660
708
  ),
661
709
  key: "cost",
710
+ label: "Cost",
662
711
  }
663
712
  : undefined,
664
713
  childAgentTotals
665
714
  ? {
666
715
  content: <SubagentsMetric childAgentTotals={childAgentTotals} />,
667
716
  key: "subagents",
717
+ label: "Subagents",
668
718
  }
669
719
  : undefined,
670
720
  ];
671
- return rawStats.filter((item): item is MetricListItem => item !== undefined);
721
+ return rawStats.filter(
722
+ (item): item is ConversationStatItem => item !== undefined,
723
+ );
672
724
  }
@@ -27,6 +27,7 @@ import {
27
27
  import { ConversationComposer } from "./ConversationComposer";
28
28
  import { ConversationHeader } from "./ConversationHeader";
29
29
  import { ConversationDetails } from "./ConversationDetails";
30
+ import { useConversationDetailsDefaultOpen } from "./ConversationDetailsFrame";
30
31
  import { cn } from "../styles";
31
32
  import { ConversationForkLinks } from "./ConversationForkLinks";
32
33
  import { ConversationAgentTree } from "./ConversationAgentTree";
@@ -39,7 +40,10 @@ import {
39
40
  hasConversationStats,
40
41
  PendingAuthorization,
41
42
  } from "./ConversationMeta";
42
- import { ConversationUsageBreakdown } from "./ConversationUsageBreakdown";
43
+ import {
44
+ ConversationUsageBreakdown,
45
+ hasConversationUsageBreakdown,
46
+ } from "./ConversationUsageBreakdown";
43
47
  import { PendingMailboxStack } from "./PendingMailboxStack";
44
48
  import {
45
49
  buildConversations,
@@ -78,7 +82,11 @@ export function ConversationPage(props: {
78
82
  onRead?(conversationId: string, lastReadAt: string): void;
79
83
  pendingArchiveUpdate?: PendingArchiveConversationUpdate;
80
84
  }) {
81
- const [detailsOpen, setDetailsOpen] = useState(false);
85
+ // Large viewports show details beside the chat until the reader closes
86
+ // them. Smaller viewports open details only on request.
87
+ const detailsDefaultOpen = useConversationDetailsDefaultOpen();
88
+ const [detailsChoice, setDetailsChoice] = useState<boolean>();
89
+ const detailsOpen = detailsChoice ?? detailsDefaultOpen;
82
90
  const [subagentTarget, setSubagentTarget] =
83
91
  useState<SubagentTranscriptTarget>();
84
92
  const [searchParams, setSearchParams] = useSearchParams();
@@ -217,7 +225,7 @@ export function ConversationPage(props: {
217
225
  }
218
226
  conversationId={conversationId}
219
227
  detailsOpen={detailsOpen}
220
- onDetailsClick={() => setDetailsOpen((open) => !open)}
228
+ onDetailsClick={() => setDetailsChoice(!detailsOpen)}
221
229
  forks={
222
230
  detail.data?.forkedFromConversationId ||
223
231
  detail.data?.forks?.length ? (
@@ -362,9 +370,8 @@ export function ConversationPage(props: {
362
370
  <ConversationDetails
363
371
  conversationId={conversationId}
364
372
  identity={identity}
365
- lastActivityAt={conversation?.lastSeenAt}
366
373
  sentryConversationUrl={detail.data?.sentryConversationUrl}
367
- onClose={() => setDetailsOpen(false)}
374
+ onClose={() => setDetailsChoice(false)}
368
375
  onOpenSubagent={setSubagentTarget}
369
376
  annotations={
370
377
  hasConversationAnnotations(detail.data?.annotations) ? (
@@ -376,21 +383,26 @@ export function ConversationPage(props: {
376
383
  <ConversationBrief brief={detail.data.brief} variant="summary" />
377
384
  ) : null
378
385
  }
379
- stats={
386
+ overview={
380
387
  hasConversationStats({
381
388
  conversation,
382
389
  detail: detail.data,
383
390
  }) ? (
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>
391
+ <ConversationStats
392
+ conversation={conversation}
393
+ detail={detail.data}
394
+ />
395
+ ) : null
396
+ }
397
+ usage={
398
+ hasConversationUsageBreakdown({
399
+ conversation,
400
+ detail: detail.data,
401
+ }) ? (
402
+ <ConversationUsageBreakdown
403
+ conversation={conversation}
404
+ detail={detail.data}
405
+ />
394
406
  ) : null
395
407
  }
396
408
  />
@@ -167,6 +167,19 @@ function BreakdownTable(props: {
167
167
  );
168
168
  }
169
169
 
170
+ /** True when the Conversation has model or auxiliary usage to break down. */
171
+ export function hasConversationUsageBreakdown(props: {
172
+ conversation: Conversation | undefined;
173
+ detail?: ConversationDetailReport;
174
+ }): boolean {
175
+ if (!props.conversation) return false;
176
+ const auxiliaryCosts =
177
+ props.detail?.auxiliaryCosts ?? props.conversation.auxiliaryCosts;
178
+ return Boolean(
179
+ props.detail?.modelUsage?.length || auxiliaryCosts?.operations?.length,
180
+ );
181
+ }
182
+
170
183
  /**
171
184
  * Break a Conversation's usage down by agent, model, and auxiliary
172
185
  * operation. The header tooltips show only the largest items; this lists
@@ -177,7 +190,9 @@ export function ConversationUsageBreakdown(props: {
177
190
  conversation: Conversation | undefined;
178
191
  detail?: ConversationDetailReport;
179
192
  }) {
180
- if (!props.conversation) return null;
193
+ if (!hasConversationUsageBreakdown(props) || !props.conversation) {
194
+ return null;
195
+ }
181
196
  const modelUsage = props.detail?.modelUsage ?? [];
182
197
  const auxiliaryCosts =
183
198
  props.detail?.auxiliaryCosts ?? props.conversation.auxiliaryCosts;
@@ -193,8 +208,6 @@ export function ConversationUsageBreakdown(props: {
193
208
  const operations = [...(auxiliaryCosts?.operations ?? [])].sort(
194
209
  (a, b) => b.costUsd - a.costUsd,
195
210
  );
196
- if (!modelUsage.length && !operations.length) return null;
197
-
198
211
  const formatCost = costFormatter(cost ?? 0);
199
212
  const formatTokens = (value: number | undefined) =>
200
213
  value === undefined ? NOT_RECORDED : formatCompactNumber(value);
@@ -57,9 +57,10 @@ dismissal.
57
57
 
58
58
  `ConversationDetails` owns one set of tabs and content. `ConversationDetailsFrame`
59
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.
60
+ Smaller windows use `BottomSheet` with a visible title and close control. The sheet and
61
+ side drawers use the shadcn Base UI `Sheet` and share the app's document
62
+ scroll lock. A resize changes the frame and keeps the selected tab. Tabs stay
63
+ fixed above the scrolling content in both layouts. The sheet title and close control sit above the tabs. Use the close
64
+ control, tap the backdrop, or press Escape to dismiss it. Base UI owns the
65
+ transition lifecycle.
65
66
  A pending exit cannot close a new panel or a different Conversation.
@@ -3,12 +3,13 @@ import { useHref } from "react-router";
3
3
  import { Check, Share2 } from "lucide-react";
4
4
 
5
5
  import { Button } from "../components/Button";
6
+ import { IconButtonTooltip } from "../components/Tooltip";
6
7
  import { conversationPath } from "./conversationRoutes";
7
8
 
8
9
  /** Share a conversation link without changing who can access it. */
9
10
  export function ShareConversationButton(props: {
10
11
  conversationId: string;
11
- layout?: "button" | "menu";
12
+ layout?: "button" | "icon" | "menu";
12
13
  }) {
13
14
  const href = useHref(conversationPath(props.conversationId));
14
15
  const [status, setStatus] = useState<
@@ -70,7 +71,19 @@ export function ShareConversationButton(props: {
70
71
  </>
71
72
  );
72
73
  const button =
73
- props.layout === "menu" ? (
74
+ props.layout === "icon" ? (
75
+ <IconButtonTooltip label={label} placement="below">
76
+ <Button
77
+ aria-label={label}
78
+ className="text-dashboard-text-muted"
79
+ disabled={status === "sharing"}
80
+ onClick={() => void shareLink()}
81
+ size="icon"
82
+ >
83
+ <Icon aria-hidden="true" size={15} />
84
+ </Button>
85
+ </IconButtonTooltip>
86
+ ) : props.layout === "menu" ? (
74
87
  <button
75
88
  aria-label={label}
76
89
  className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-hover focus-visible:bg-dashboard-fill-hover focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
@@ -8,9 +8,9 @@ import { subagentStatusTone } from "./ConversationChildAgents";
8
8
  import { HighlightText } from "./transcriptSearch";
9
9
 
10
10
  /**
11
- * Render a child agent as a compact card in the transcript stream. The card
12
- * matches the child agent rows in conversation details: name, status, and run
13
- * time first, then the task title. It opens the child transcript.
11
+ * Render a child agent as a compact card in the transcript stream: name,
12
+ * status, and run time first, then the task title. It opens the child
13
+ * transcript.
14
14
  */
15
15
  export function TranscriptSubagentView(props: {
16
16
  onOpenTranscript?: (part: TranscriptViewSubagentPart) => void;
@@ -214,6 +214,9 @@ export function transcriptMessagesFromEvents(
214
214
  ...(data.actorIdentity
215
215
  ? { actorIdentity: data.actorIdentity }
216
216
  : undefined),
217
+ ...(data.agentInvocationId
218
+ ? { agentInvocationId: data.agentInvocationId }
219
+ : undefined),
217
220
  ...(data.eventType ? { eventType: data.eventType } : undefined),
218
221
  eventObjectType: data.eventObjectType,
219
222
  ...(data.trustedSummary
@@ -368,6 +368,9 @@ export function transcriptMessageActorLabel(
368
368
  const kind = transcriptRoleKind(message.role);
369
369
  if (kind === "assistant") return getDashboardAgentName();
370
370
  if (kind === "user") {
371
+ // The conversation actor started the child agent but did not write its
372
+ // input, so do not fall back to that actor here.
373
+ if (message.agentInvocationId) return "Agent invocation";
371
374
  return (
372
375
  actorLabel(message.actorIdentity) ??
373
376
  actorLabel(conversation.actorIdentity) ??
@@ -0,0 +1 @@
1
+ export { cn } from "cn";
@@ -313,7 +313,7 @@ function FoundationsGalleryPage() {
313
313
  onClose={() => setSheetOpen(false)}
314
314
  >
315
315
  <p className="m-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-sm text-dashboard-text-muted">
316
- Swipe the handle down, tap outside, or press Escape to return to
316
+ Use the close button, tap outside, or press Escape to return to
317
317
  the page.
318
318
  </p>
319
319
  </BottomSheet>