@sentry/junior-dashboard 0.265.0 → 0.266.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 (47) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +48 -48
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/components/ActorAvatar.tsx +10 -22
  7. package/src/client/components/ConnectionBanner.tsx +4 -3
  8. package/src/client/components/CopyButton.tsx +3 -4
  9. package/src/client/components/EmptyTelemetry.tsx +5 -3
  10. package/src/client/components/ImageAttachment.tsx +49 -63
  11. package/src/client/components/InlineError.tsx +8 -4
  12. package/src/client/components/Notice.tsx +19 -26
  13. package/src/client/components/Pagination.tsx +26 -26
  14. package/src/client/components/ParticipantAvatarStack.tsx +10 -10
  15. package/src/client/components/README.md +17 -1
  16. package/src/client/components/SearchInput.tsx +51 -41
  17. package/src/client/components/VersionDriftBanner.tsx +9 -7
  18. package/src/client/components/charts/CacheWrites.tsx +52 -43
  19. package/src/client/components/ui/alert.tsx +64 -0
  20. package/src/client/components/ui/avatar.tsx +53 -0
  21. package/src/client/components/ui/button.tsx +2 -1
  22. package/src/client/components/ui/dialog.tsx +66 -0
  23. package/src/client/components/ui/empty.tsx +32 -0
  24. package/src/client/components/ui/input-group.tsx +96 -0
  25. package/src/client/components/ui/pagination.tsx +60 -0
  26. package/src/client/components/ui/spinner.tsx +21 -0
  27. package/src/client/components/ui/table.tsx +22 -1
  28. package/src/client/components/ui/tooltip.tsx +1 -1
  29. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  30. package/src/client/conversations/ConversationHeader.tsx +5 -4
  31. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  32. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  33. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  34. package/src/client/conversations/ConversationWatches.tsx +67 -59
  35. package/src/client/conversations/DetailsList.tsx +44 -29
  36. package/src/client/conversations/EventUsage.tsx +99 -74
  37. package/src/client/conversations/PendingMailboxStack.tsx +5 -10
  38. package/src/client/conversations/README.md +3 -0
  39. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  40. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  41. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  42. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  43. package/src/client/conversations/TranscriptSummary.tsx +34 -2
  44. package/src/client/conversations/TranscriptTurnContextView.tsx +45 -38
  45. package/src/client/conversations/eventLog.ts +1 -0
  46. package/src/client/conversations/transcriptMentions.tsx +18 -5
  47. package/src/mock-reporting/watches.ts +1 -1
@@ -7,8 +7,8 @@ import { cn, dashboardDisclosureChevronClass } from "../styles";
7
7
  const COLLAPSED_ROW_COUNT = 3;
8
8
 
9
9
  /**
10
- * Show rows in a details card. Rows are split by thin rules. A list with more
11
- * than three rows shows the first three and a row that shows the rest.
10
+ * Show rows in a details card. A list with more than three rows shows the
11
+ * first three and a row that shows the rest.
12
12
  */
13
13
  export function DetailsList<T>(props: {
14
14
  items: readonly T[];
@@ -23,26 +23,21 @@ export function DetailsList<T>(props: {
23
23
  : props.items.slice(0, COLLAPSED_ROW_COUNT);
24
24
 
25
25
  return (
26
- // Rows own their padding, so hover fills reach the rules. The negative
27
- // margin keeps the first and last text on the card padding.
28
- <ul className="-my-2.5 grid list-none p-0">
26
+ // Rows own their padding, so a hover fill sits inside the card with an
27
+ // even inset. The negative margins keep row text on the card padding.
28
+ <ul className="-mx-1.5 -mb-1.5 -mt-1 grid list-none gap-0.5 p-0">
29
29
  {visible.map((item) => (
30
- <li
31
- className="border-t border-dashboard-border-subtle first:border-t-0"
32
- key={props.itemKey(item)}
33
- >
34
- {props.renderItem(item)}
35
- </li>
30
+ <li key={props.itemKey(item)}>{props.renderItem(item)}</li>
36
31
  ))}
37
32
  {hidden > 0 ? (
38
- <li className="border-t border-dashboard-border-subtle">
33
+ <li>
39
34
  <button
40
35
  aria-expanded={expanded}
41
- className="group -mx-2 flex min-h-8 items-center gap-2.5 rounded-md px-2 py-2 text-left text-xs font-medium leading-4 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"
36
+ className="group flex min-h-8 w-full items-center gap-2.5 rounded-md px-1.5 py-1.5 text-left text-xs font-medium leading-4 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"
42
37
  onClick={() => setExpanded((value) => !value)}
43
38
  type="button"
44
39
  >
45
- <span className="grid size-4 shrink-0 place-items-center">
40
+ <span className="grid size-5 shrink-0 place-items-center">
46
41
  <ChevronRight
47
42
  aria-hidden="true"
48
43
  className={dashboardDisclosureChevronClass}
@@ -59,20 +54,24 @@ export function DetailsList<T>(props: {
59
54
  /**
60
55
  * Lay out one details list row: an icon, a title, and short trailing text on
61
56
  * the first line, then optional lines under the title. A row with `href`
62
- * opens that link in a new tab.
57
+ * opens that link in a new tab. A row with `onToggle` is a disclosure button
58
+ * that shows or hides content the caller renders under it.
63
59
  */
64
60
  export function DetailsRow(props: {
65
61
  children?: ReactNode;
62
+ expanded?: boolean;
66
63
  href?: string;
67
64
  icon: ReactNode;
68
65
  meta?: ReactNode;
66
+ onToggle?(): void;
69
67
  title: ReactNode;
70
68
  /** Native hover text with detail that the row does not show. */
71
69
  tooltip?: string;
72
70
  }) {
71
+ const interactive = props.href !== undefined || props.onToggle !== undefined;
73
72
  const className = cn(
74
- "-mx-2 grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-1 rounded-md px-2 py-2.5 text-inherit no-underline",
75
- props.href &&
73
+ "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",
74
+ interactive &&
76
75
  "group transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus",
77
76
  );
78
77
  const content = (
@@ -80,7 +79,7 @@ export function DetailsRow(props: {
80
79
  <span className="grid size-5 shrink-0 place-items-center text-dashboard-text-muted">
81
80
  {props.icon}
82
81
  </span>
83
- <span className="min-w-0 truncate text-sm font-medium leading-5 text-dashboard-text group-hover:underline">
82
+ <span className="min-w-0 truncate text-sm font-medium leading-5 text-dashboard-text">
84
83
  {props.title}
85
84
  </span>
86
85
  <span className="shrink-0 text-xs leading-5 tabular-nums text-dashboard-text-muted">
@@ -89,17 +88,33 @@ export function DetailsRow(props: {
89
88
  {props.children}
90
89
  </>
91
90
  );
92
- return props.href ? (
93
- <a
94
- className={className}
95
- href={props.href}
96
- rel="noreferrer"
97
- target="_blank"
98
- title={props.tooltip}
99
- >
100
- {content}
101
- </a>
102
- ) : (
91
+ if (props.href) {
92
+ return (
93
+ <a
94
+ className={className}
95
+ href={props.href}
96
+ rel="noreferrer"
97
+ target="_blank"
98
+ title={props.tooltip}
99
+ >
100
+ {content}
101
+ </a>
102
+ );
103
+ }
104
+ if (props.onToggle) {
105
+ return (
106
+ <button
107
+ aria-expanded={props.expanded ?? false}
108
+ className={className}
109
+ onClick={props.onToggle}
110
+ title={props.tooltip}
111
+ type="button"
112
+ >
113
+ {content}
114
+ </button>
115
+ );
116
+ }
117
+ return (
103
118
  <div className={className} title={props.tooltip}>
104
119
  {content}
105
120
  </div>
@@ -1,6 +1,15 @@
1
1
  import type { ConversationUsage } from "@sentry/junior/api/schema";
2
2
 
3
3
  import { formatCostBreakdown, summarizeCost } from "../format";
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableFooter,
10
+ TableHeader,
11
+ TableRow,
12
+ } from "../components/ui/table";
4
13
  import { cn } from "../styles";
5
14
 
6
15
  /** Align recorded token counts with their costs without adding overlapping counters. */
@@ -31,83 +40,53 @@ export function EventUsage({ usage }: { usage?: ConversationUsage }) {
31
40
  );
32
41
  }
33
42
 
43
+ const total = rows.find((row) => row.label === "Total");
34
44
  return (
35
45
  <>
36
- <div className="min-w-0 overflow-x-auto">
37
- <table
38
- aria-label="Model call token and cost breakdown"
39
- className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
40
- >
41
- <colgroup>
42
- <col />
43
- <col className="w-24" />
44
- <col className="w-24" />
45
- </colgroup>
46
- <thead>
47
- <tr className="border-b border-dashboard-border text-dashboard-text-muted">
48
- <th scope="col" className="py-2 text-left font-normal">
49
- Usage
50
- </th>
51
- <th scope="col" className="py-2 text-right font-normal">
52
- Tokens
53
- </th>
54
- <th scope="col" className="py-2 text-right font-normal">
55
- Cost (USD)
56
- </th>
57
- </tr>
58
- </thead>
59
- <tbody>
60
- {rows.map((row) => (
61
- <tr
62
- key={row.label}
63
- className={cn(
64
- "border-b border-dashboard-border-subtle last:border-b-0",
65
- row.label === "Total" &&
66
- "border-t border-dashboard-border font-semibold",
67
- )}
68
- >
69
- <th
70
- scope="row"
71
- className={cn(
72
- "py-2 text-left font-normal",
73
- row.label === "Total"
74
- ? "font-semibold text-dashboard-text"
75
- : "text-dashboard-text-muted",
76
- )}
77
- >
78
- {row.label}
79
- </th>
80
- <td className="py-2 text-right font-sans tabular-nums text-dashboard-text">
81
- {row.tokens === undefined ? (
82
- <span
83
- aria-label="Not recorded"
84
- className="text-dashboard-text-faint"
85
- >
86
- —
87
- </span>
88
- ) : (
89
- row.tokens.toLocaleString("en-US")
90
- )}
91
- </td>
92
- <td className="py-2 text-right font-sans tabular-nums text-dashboard-text">
93
- {row.cost === undefined ? (
94
- <span
95
- aria-label="Not recorded separately"
96
- className="text-dashboard-text-faint"
97
- >
98
- —
99
- </span>
100
- ) : (
101
- <span title={`${row.cost} USD`}>
102
- {formatCostBreakdown({ total: row.cost })}
103
- </span>
104
- )}
105
- </td>
106
- </tr>
46
+ <Table
47
+ aria-label="Model call token and cost breakdown"
48
+ className="min-w-72 table-fixed text-xs leading-relaxed"
49
+ >
50
+ <colgroup>
51
+ <col />
52
+ <col className="w-24" />
53
+ <col className="w-24" />
54
+ </colgroup>
55
+ <TableHeader>
56
+ <TableRow className="border-dashboard-border">
57
+ <TableHead
58
+ scope="col"
59
+ className="px-0 py-2 leading-relaxed font-normal"
60
+ >
61
+ Usage
62
+ </TableHead>
63
+ <TableHead
64
+ scope="col"
65
+ className="px-0 py-2 text-right leading-relaxed font-normal"
66
+ >
67
+ Tokens
68
+ </TableHead>
69
+ <TableHead
70
+ scope="col"
71
+ className="px-0 py-2 text-right leading-relaxed font-normal"
72
+ >
73
+ Cost (USD)
74
+ </TableHead>
75
+ </TableRow>
76
+ </TableHeader>
77
+ <TableBody>
78
+ {rows
79
+ .filter((row) => row !== total)
80
+ .map((row) => (
81
+ <UsageRow key={row.label} {...row} />
107
82
  ))}
108
- </tbody>
109
- </table>
110
- </div>
83
+ </TableBody>
84
+ {total ? (
85
+ <TableFooter className="font-semibold">
86
+ <UsageRow {...total} total />
87
+ </TableFooter>
88
+ ) : null}
89
+ </Table>
111
90
  <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
112
91
  This call only · Estimated costs
113
92
  <br />
@@ -116,3 +95,49 @@ export function EventUsage({ usage }: { usage?: ConversationUsage }) {
116
95
  </>
117
96
  );
118
97
  }
98
+
99
+ function UsageRow(props: {
100
+ cost?: number;
101
+ label: string;
102
+ tokens?: number;
103
+ total?: boolean;
104
+ }) {
105
+ return (
106
+ <TableRow>
107
+ <TableHead
108
+ scope="row"
109
+ className={cn(
110
+ "px-0 py-2 leading-relaxed",
111
+ props.total
112
+ ? "font-semibold text-dashboard-text"
113
+ : "font-normal text-dashboard-text-muted",
114
+ )}
115
+ >
116
+ {props.label}
117
+ </TableHead>
118
+ <TableCell className="px-0 py-2 text-right font-sans tabular-nums">
119
+ {props.tokens === undefined ? (
120
+ <span aria-label="Not recorded" className="text-dashboard-text-faint">
121
+ —
122
+ </span>
123
+ ) : (
124
+ props.tokens.toLocaleString("en-US")
125
+ )}
126
+ </TableCell>
127
+ <TableCell className="px-0 py-2 text-right font-sans tabular-nums">
128
+ {props.cost === undefined ? (
129
+ <span
130
+ aria-label="Not recorded separately"
131
+ className="text-dashboard-text-faint"
132
+ >
133
+ —
134
+ </span>
135
+ ) : (
136
+ <span title={`${props.cost} USD`}>
137
+ {formatCostBreakdown({ total: props.cost })}
138
+ </span>
139
+ )}
140
+ </TableCell>
141
+ </TableRow>
142
+ );
143
+ }
@@ -9,7 +9,6 @@ import {
9
9
  import {
10
10
  AlertCircle,
11
11
  Clock3,
12
- LoaderCircle,
13
12
  SkipForward,
14
13
  X,
15
14
  type LucideIcon,
@@ -20,6 +19,7 @@ import {
20
19
  TooltipContent,
21
20
  TooltipTrigger,
22
21
  } from "../components/ui/tooltip";
22
+ import { Spinner } from "../components/ui/spinner";
23
23
  import { actorLabel } from "../format";
24
24
  import type { ConversationMailboxMessage } from "./conversationOutbox";
25
25
  import {
@@ -33,12 +33,12 @@ const COLLAPSED_PENDING_ROW_COUNT = 2;
33
33
 
34
34
  function pendingDeliveryMeta(
35
35
  message: Pick<ConversationMailboxMessage, "clientStatus" | "delivery">,
36
- ): { icon: LucideIcon; label: string; spin?: boolean } {
36
+ ): { icon: LucideIcon | typeof Spinner; label: string } {
37
37
  if (message.clientStatus === "failed") {
38
38
  return { icon: AlertCircle, label: "Failed to send" };
39
39
  }
40
40
  if (message.clientStatus === "sending") {
41
- return { icon: LoaderCircle, label: "Sending", spin: true };
41
+ return { icon: Spinner, label: "Sending" };
42
42
  }
43
43
  if (message.delivery === "interrupt") {
44
44
  return { icon: SkipForward, label: "Interrupt" };
@@ -92,8 +92,7 @@ function PendingMetaIcons(props: {
92
92
  <PendingMetaIcon label={delivery.label}>
93
93
  <DeliveryIcon
94
94
  aria-hidden="true"
95
- className={delivery.spin ? "animate-spin" : undefined}
96
- size={13}
95
+ className="size-3.25"
97
96
  strokeWidth={2.2}
98
97
  />
99
98
  </PendingMetaIcon>
@@ -113,11 +112,7 @@ function PendingMetaIcons(props: {
113
112
  type="button"
114
113
  >
115
114
  {props.cancelPending ? (
116
- <LoaderCircle
117
- aria-hidden="true"
118
- className="animate-spin"
119
- size={13}
120
- />
115
+ <Spinner aria-hidden="true" className="size-3.25" />
121
116
  ) : (
122
117
  <X aria-hidden="true" size={14} strokeWidth={2.2} />
123
118
  )}
@@ -16,6 +16,9 @@ shared controls and overlays.
16
16
  `flush` when the text already sits on the transcript edge. It extends the
17
17
  padded control around that text. Do not repeat negative margins at call sites.
18
18
  The caller owns the native `details` element and its content.
19
+ `TranscriptSummaryTrigger` gives a `ui/collapsible` trigger the same look.
20
+ Its caller owns the `Collapsible` and its content. Transcript search holds
21
+ event and context rows open.
19
22
  - Activity rows (tools, reasoning, and grouped rail events) share one
20
23
  layout: a 1rem icon column, an 8px gap, then the title and metadata.
21
24
  Tool and reasoning rows use `ui/trace`. Transcript search forces them open.
@@ -96,15 +96,14 @@ export function ShareConversationButton(props: {
96
96
  </TooltipContent>
97
97
  </Tooltip>
98
98
  ) : props.layout === "menu" ? (
99
- <button
99
+ <Button
100
100
  aria-label={label}
101
- 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"
102
101
  disabled={status === "sharing"}
103
102
  onClick={() => void shareLink()}
104
- type="button"
103
+ size="menu"
105
104
  >
106
105
  {content}
107
- </button>
106
+ </Button>
108
107
  ) : (
109
108
  <Button
110
109
  aria-label={label}
@@ -1,11 +1,14 @@
1
1
  import { ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
2
3
 
4
+ import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
5
+ import { cn, dashboardDisclosureChevronClass } from "../styles";
3
6
  import { MentionText } from "./transcriptMentions";
4
7
  import { messageRawText } from "./transcriptRenderModel";
5
- import { HighlightText } from "./transcriptSearch";
8
+ import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
6
9
  import { RedactedMarker } from "./TranscriptRedacted";
7
10
  import type { TranscriptViewMessage } from "../types";
8
- import { TranscriptSummary } from "./TranscriptSummary";
11
+ import { TranscriptSummaryTrigger } from "./TranscriptSummary";
9
12
 
10
13
  function EventTypeChip(props: { eventType: string }) {
11
14
  return (
@@ -25,6 +28,8 @@ function batchSummary(events: readonly { label?: string }[]): string {
25
28
 
26
29
  /** Render an Event Message whose heading expands its full text. */
27
30
  export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
31
+ const { active: searchActive } = useTranscriptSearch();
32
+ const [open, setOpen] = useState(false);
28
33
  const text = messageRawText(props.message);
29
34
  const redacted = props.message.parts.some(
30
35
  (part) => part.type === "text" && part.redacted,
@@ -35,33 +40,38 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
35
40
  const summary = isBatch
36
41
  ? batchSummary(events)
37
42
  : (props.message.trustedSummary ?? props.message.eventType ?? "");
43
+ // With text, the heading sits in the disclosure button, which cannot hold
44
+ // links.
45
+ const links = !text;
38
46
  const heading = (
39
47
  <span className="flex min-w-0 flex-col gap-1">
40
48
  <span className="flex min-h-6 min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
41
49
  <span className="min-w-0 break-words font-display text-sm font-semibold leading-6 text-dashboard-text">
42
- <MentionText mentions={mentions} text={summary} />
50
+ <MentionText links={links} mentions={mentions} text={summary} />
43
51
  </span>
44
52
  {!isBatch && props.message.eventType ? (
45
53
  <EventTypeChip eventType={props.message.eventType} />
46
54
  ) : null}
47
55
  </span>
48
56
  {isBatch ? (
49
- <ul className="flex min-w-0 flex-col gap-1">
57
+ <span className="flex min-w-0 flex-col gap-1" role="list">
50
58
  {events.map((event, index) => (
51
- <li
59
+ <span
52
60
  key={index}
61
+ role="listitem"
53
62
  className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-5 text-dashboard-text-muted"
54
63
  >
55
64
  <span className="min-w-0 break-words">
56
65
  <MentionText
66
+ links={links}
57
67
  mentions={mentions}
58
68
  text={event.trustedSummary ?? event.eventType}
59
69
  />
60
70
  </span>
61
71
  <EventTypeChip eventType={event.eventType} />
62
- </li>
72
+ </span>
63
73
  ))}
64
- </ul>
74
+ </span>
65
75
  ) : null}
66
76
  </span>
67
77
  );
@@ -69,22 +79,31 @@ export function TranscriptEventView(props: { message: TranscriptViewMessage }) {
69
79
  return (
70
80
  <div className="min-w-0 py-1">
71
81
  {text ? (
72
- <details className="group/event min-w-0">
73
- <TranscriptSummary
82
+ <Collapsible
83
+ className="min-w-0"
84
+ // Search holds the text open, so the heading button is off until
85
+ // search ends.
86
+ disabled={searchActive}
87
+ onOpenChange={setOpen}
88
+ open={searchActive || open}
89
+ >
90
+ <TranscriptSummaryTrigger
74
91
  flush
75
- className="flex items-start gap-2 text-dashboard-text-muted"
92
+ className="group flex items-start gap-2 text-dashboard-text-muted"
76
93
  >
77
94
  {heading}
78
95
  <ChevronRight
79
96
  aria-hidden="true"
80
- className="mt-1.5 size-3 shrink-0 transition-transform group-open/event:rotate-90"
97
+ className={cn(dashboardDisclosureChevronClass, "mt-1.5")}
81
98
  strokeWidth={2.2}
82
99
  />
83
- </TranscriptSummary>
84
- <pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-dashboard-border bg-dashboard-surface-panel px-3 py-2 font-sans text-xs leading-relaxed text-dashboard-text-muted">
85
- <HighlightText text={text} />
86
- </pre>
87
- </details>
100
+ </TranscriptSummaryTrigger>
101
+ <CollapsibleContent hiddenUntilFound>
102
+ <pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-dashboard-border bg-dashboard-surface-panel px-3 py-2 font-sans text-xs leading-relaxed text-dashboard-text-muted">
103
+ <HighlightText text={text} />
104
+ </pre>
105
+ </CollapsibleContent>
106
+ </Collapsible>
88
107
  ) : (
89
108
  <>
90
109
  {heading}
@@ -1,16 +1,19 @@
1
1
  import { ChevronRight } from "lucide-react";
2
+ import { useState } from "react";
2
3
 
4
+ import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
5
+ import { dashboardDisclosureChevronClass } from "../styles";
3
6
  import { formatMessageTimestamp, transcriptMessageActorLabel } from "../format";
4
7
  import type { ConversationTranscript, TranscriptViewMessage } from "../types";
5
8
  import {
6
9
  TranscriptHeadingMeta,
7
10
  TranscriptHeadingRow,
8
11
  } from "./TranscriptHeadingRow";
9
- import { HighlightText } from "./transcriptSearch";
12
+ import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
10
13
  import { messageRawText } from "./transcriptRenderModel";
11
14
  import { RedactedMarker } from "./TranscriptRedacted";
12
15
  import { TranscriptTurnContextView } from "./TranscriptTurnContextView";
13
- import { TranscriptSummary } from "./TranscriptSummary";
16
+ import { TranscriptSummaryTrigger } from "./TranscriptSummary";
14
17
 
15
18
  /** Render ambient conversation context that is not a primary chat bubble. */
16
19
  export function TranscriptMessageContextView(props: {
@@ -18,6 +21,8 @@ export function TranscriptMessageContextView(props: {
18
21
  message: TranscriptViewMessage;
19
22
  redacted?: boolean;
20
23
  }) {
24
+ const { active: searchActive } = useTranscriptSearch();
25
+ const [open, setOpen] = useState(false);
21
26
  const actor = transcriptMessageActorLabel(props.conversation, props.message);
22
27
  const timestamp = formatMessageTimestamp(props.message.timestamp);
23
28
  const text = messageRawText(props.message);
@@ -30,33 +35,39 @@ export function TranscriptMessageContextView(props: {
30
35
 
31
36
  return (
32
37
  <>
33
- <details
34
- className="group/message-context min-w-0 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 md:hidden"
38
+ <Collapsible
39
+ className="min-w-0 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 md:hidden"
35
40
  data-transcript-message-context
41
+ // Search holds the text open, so the heading button is off until
42
+ // search ends.
43
+ disabled={searchActive}
44
+ onOpenChange={setOpen}
45
+ open={searchActive || open}
36
46
  >
37
- <TranscriptSummary
47
+ <TranscriptSummaryTrigger
38
48
  flush
39
- className="flex items-center justify-between gap-2 font-sans text-xs font-medium text-dashboard-text-muted"
49
+ className="group flex items-center justify-between gap-2 font-sans text-xs font-medium text-dashboard-text-muted"
40
50
  >
41
51
  <span className="min-w-0 truncate">Context from {actor}</span>
42
52
  <ChevronRight
43
53
  aria-hidden="true"
44
- className="shrink-0 transition-transform group-open/message-context:rotate-90"
45
- size={14}
54
+ className={dashboardDisclosureChevronClass}
46
55
  />
47
- </TranscriptSummary>
48
- <div className="mt-2 whitespace-pre-wrap pt-2 text-sm leading-relaxed text-dashboard-text-subtle">
49
- {content}
50
- </div>
51
- {!props.redacted && props.message.contexts?.length ? (
52
- <div className="mt-2">
53
- <TranscriptTurnContextView
54
- contexts={props.message.contexts}
55
- messageId={props.message.messageId}
56
- />
56
+ </TranscriptSummaryTrigger>
57
+ <CollapsibleContent hiddenUntilFound>
58
+ <div className="mt-2 whitespace-pre-wrap pt-2 text-sm leading-relaxed text-dashboard-text-subtle">
59
+ {content}
57
60
  </div>
58
- ) : null}
59
- </details>
61
+ {!props.redacted && props.message.contexts?.length ? (
62
+ <div className="mt-2">
63
+ <TranscriptTurnContextView
64
+ contexts={props.message.contexts}
65
+ messageId={props.message.messageId}
66
+ />
67
+ </div>
68
+ ) : null}
69
+ </CollapsibleContent>
70
+ </Collapsible>
60
71
  <article
61
72
  className="hidden min-w-0 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 md:block"
62
73
  data-transcript-message-context