@sentry/junior-dashboard 0.280.0 → 0.281.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 (81) hide show
  1. package/dist/app.js +23 -30
  2. package/dist/client-manifest.json +1 -1
  3. package/dist/index.js +23 -30
  4. package/dist/public/chunks/{AdminPage-YEKZOKH6.js → AdminPage-M5RBVZFN.js} +1 -1
  5. package/dist/public/chunks/{AutomationPage-5GS3IUAK.js → AutomationPage-WALCREDT.js} +1 -1
  6. package/dist/public/chunks/{AutomationRunsPage-IPYEZU4L.js → AutomationRunsPage-CV47E4UX.js} +1 -1
  7. package/dist/public/chunks/{AutomationsPage-HSAYPC3C.js → AutomationsPage-57L3AQVJ.js} +1 -1
  8. package/dist/public/chunks/{CodePage-IPFKXWFB.js → CodePage-XJUYMFJH.js} +1 -1
  9. package/dist/public/chunks/{ConnectionsPage-5XUEK6FS.js → ConnectionsPage-3QQAGYWF.js} +1 -1
  10. package/dist/public/chunks/{LocationDetailPage-27ETUGR6.js → LocationDetailPage-4IL5HQML.js} +1 -1
  11. package/dist/public/chunks/{LocationsPage-WOKMQAGM.js → LocationsPage-D2DUQAHJ.js} +1 -1
  12. package/dist/public/chunks/{PeoplePage-6YQLDOUL.js → PeoplePage-6F5GPQ7C.js} +1 -1
  13. package/dist/public/chunks/{PersonProfilePage-GYMAZFIK.js → PersonProfilePage-SPAYQPTD.js} +1 -1
  14. package/dist/public/chunks/{PersonalTokensPage-4BCR744B.js → PersonalTokensPage-SHAUJ74M.js} +1 -1
  15. package/dist/public/chunks/{PluginUserPage-IP6WL7MD.js → PluginUserPage-64FIWD7M.js} +1 -1
  16. package/dist/public/chunks/{RepositoryPage-TRD44LQ3.js → RepositoryPage-L7IHOXSN.js} +1 -1
  17. package/dist/public/chunks/{SettingsPage-4E3SLQ36.js → SettingsPage-U7WG4FHE.js} +1 -1
  18. package/dist/public/chunks/{SystemPage-C3XBKXKW.js → SystemPage-ZBC43OJH.js} +1 -1
  19. package/dist/public/chunks/{WorkspaceFormPage-2LS2X67Q.js → WorkspaceFormPage-3NVIIZ4P.js} +1 -1
  20. package/dist/public/chunks/{WorkspacesPage-5WB3CUTX.js → WorkspacesPage-G73HPBDD.js} +1 -1
  21. package/dist/public/chunks/{chunk-VFEBNOPX.js → chunk-2J3LGF5Q.js} +1 -1
  22. package/dist/public/chunks/chunk-2NNKHSZE.js +1 -0
  23. package/dist/public/chunks/chunk-4TQSTCIN.js +13 -0
  24. package/dist/public/chunks/chunk-64GPEGGE.js +1 -0
  25. package/dist/public/chunks/{chunk-WZVO3HG4.js → chunk-6FHY65EQ.js} +1 -1
  26. package/dist/public/chunks/{chunk-YYNMZ4WC.js → chunk-DEEO4D4N.js} +1 -1
  27. package/dist/public/chunks/{chunk-SDKV3OID.js → chunk-FSGVY7HB.js} +1 -1
  28. package/dist/public/chunks/chunk-GZBMHYYU.js +1 -0
  29. package/dist/public/chunks/{chunk-NQMDED2R.js → chunk-II4NXLSQ.js} +1 -1
  30. package/dist/public/chunks/{chunk-B6HGRBBX.js → chunk-ILH47V7O.js} +1 -1
  31. package/dist/public/chunks/{chunk-GRMX2HX6.js → chunk-JPL2NRWK.js} +1 -1
  32. package/dist/public/chunks/{chunk-NQOJAZFQ.js → chunk-JVJ5UWIK.js} +1 -1
  33. package/dist/public/chunks/{chunk-V5UDSIRA.js → chunk-K4TYMAA3.js} +1 -1
  34. package/dist/public/chunks/{chunk-EFEREKX4.js → chunk-KDUTK357.js} +1 -1
  35. package/dist/public/chunks/{chunk-RHIXHBS5.js → chunk-LZYAHJVQ.js} +1 -1
  36. package/dist/public/chunks/{chunk-5GZDBGAR.js → chunk-MKKFXRYO.js} +1 -1
  37. package/dist/public/chunks/{chunk-PEQNT5MO.js → chunk-NGQRNS3C.js} +1 -1
  38. package/dist/public/chunks/{chunk-IA6BJDKN.js → chunk-ONDKE72P.js} +1 -1
  39. package/dist/public/chunks/{chunk-WJ3OXMVX.js → chunk-PG7M7L24.js} +1 -1
  40. package/dist/public/chunks/{chunk-6GQL3EVJ.js → chunk-QQUFROWT.js} +1 -1
  41. package/dist/public/chunks/{chunk-UAH6DWNS.js → chunk-RB5FBDEY.js} +1 -1
  42. package/dist/public/chunks/{chunk-A2QWWA7O.js → chunk-RGXXD4DM.js} +1 -1
  43. package/dist/public/chunks/{chunk-AYXSBMWA.js → chunk-TC7TU4GK.js} +1 -1
  44. package/dist/public/chunks/chunk-WLIOZO3W.js +1 -0
  45. package/dist/public/chunks/{chunk-VXT3FIVL.js → chunk-XDUSY26N.js} +11 -11
  46. package/dist/public/client-4OAAOHF4.js +68 -0
  47. package/dist/tailwind.css +1 -1
  48. package/package.json +4 -4
  49. package/src/client/components/BottomSheet.tsx +1 -1
  50. package/src/client/components/DetailsPanel.tsx +2 -1
  51. package/src/client/conversations/ConversationBrief.tsx +129 -65
  52. package/src/client/conversations/ConversationDetails.tsx +13 -14
  53. package/src/client/conversations/ConversationEventLog.tsx +20 -97
  54. package/src/client/conversations/ConversationHeader.tsx +41 -26
  55. package/src/client/conversations/ConversationHeaderActions.tsx +3 -1
  56. package/src/client/conversations/ConversationLinkedWork.tsx +30 -10
  57. package/src/client/conversations/ConversationMeta.tsx +15 -15
  58. package/src/client/conversations/ConversationPage.tsx +31 -7
  59. package/src/client/conversations/ConversationTranscript.tsx +234 -186
  60. package/src/client/conversations/ConversationUsageBreakdown.tsx +142 -64
  61. package/src/client/conversations/DetailsList.tsx +7 -1
  62. package/src/client/conversations/PendingMailboxStack.tsx +88 -44
  63. package/src/client/conversations/TranscriptActivityGroup.tsx +7 -14
  64. package/src/client/conversations/TranscriptEventView.tsx +7 -3
  65. package/src/client/conversations/TranscriptReasoningView.tsx +4 -2
  66. package/src/client/conversations/TranscriptRows.tsx +37 -5
  67. package/src/client/conversations/TranscriptView.tsx +4 -0
  68. package/src/client/conversations/queries.ts +74 -43
  69. package/src/client/conversations/transcriptBottomPinning.ts +13 -5
  70. package/src/client/conversations/transcriptListRows.ts +215 -0
  71. package/src/client/conversations/virtualRows.ts +168 -0
  72. package/src/client/http.ts +6 -5
  73. package/src/client/sentry.ts +53 -3
  74. package/src/conversation-cache.ts +8 -10
  75. package/src/mock-reporting/fixtures.ts +0 -8
  76. package/dist/public/chunks/chunk-GO45RQDL.js +0 -1
  77. package/dist/public/chunks/chunk-N4AONKD3.js +0 -1
  78. package/dist/public/chunks/chunk-QNA7F7QB.js +0 -1
  79. package/dist/public/chunks/chunk-R4ORRE77.js +0 -1
  80. package/dist/public/chunks/chunk-Z2JPRGFT.js +0 -1
  81. package/dist/public/client-TYC7JKRE.js +0 -91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.280.0",
3
+ "version": "0.281.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -42,10 +42,10 @@
42
42
  "remark-gfm": "^4.0.1",
43
43
  "shiki": "4.1.0",
44
44
  "zod": "^4.5.4",
45
- "@sentry/junior-plugin-api": "0.280.0"
45
+ "@sentry/junior-plugin-api": "0.281.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@sentry/junior": "0.280.0"
48
+ "@sentry/junior": "0.281.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@oxlint/plugins": "1.79.0",
@@ -66,7 +66,7 @@
66
66
  "tw-animate-css": "^1.4.0",
67
67
  "typescript": "^7.0.2",
68
68
  "vitest": "^4.1.12",
69
- "@sentry/junior": "0.280.0"
69
+ "@sentry/junior": "0.281.0"
70
70
  },
71
71
  "scripts": {
72
72
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
@@ -31,7 +31,7 @@ export function BottomSheet(props: {
31
31
  {props.title ?? props.label}
32
32
  </SheetTitle>
33
33
  </SheetHeader>
34
- <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
34
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
35
35
  {props.children}
36
36
  </div>
37
37
  </SheetContent>
@@ -117,6 +117,7 @@ export function DetailsPanel(props: {
117
117
  <Button
118
118
  ref={closeRef}
119
119
  aria-label={closeLabel}
120
+ data-slot="details-panel-close"
120
121
  onClick={onClose}
121
122
  size="icon"
122
123
  >
@@ -124,7 +125,7 @@ export function DetailsPanel(props: {
124
125
  </Button>
125
126
  </div>
126
127
  </header>
127
- <div className="thin-scrollbar min-h-0 overflow-y-auto overscroll-contain">
128
+ <div className="thin-scrollbar min-h-0 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
128
129
  {props.children}
129
130
  </div>
130
131
  </aside>
@@ -1,5 +1,11 @@
1
- import { ChevronRight } from "lucide-react";
2
- import type { ReactNode } from "react";
1
+ import { ChevronDown, ChevronRight } from "lucide-react";
2
+ import {
3
+ useId,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
3
9
  import type {
4
10
  ActorIdentity,
5
11
  ConversationDetailReport,
@@ -20,6 +26,9 @@ type ConversationBriefReport = NonNullable<ConversationDetailReport["brief"]>;
20
26
 
21
27
  type BriefContent = ConversationBriefReport["content"];
22
28
 
29
+ const SUMMARY_PREVIEW_LINES = 4;
30
+ const SUMMARY_FADE_LINES = 1.65;
31
+
23
32
  const outcomeTones: Record<BriefContent["outcome"]["status"], StatusChipTone> =
24
33
  {
25
34
  abandoned: "neutral",
@@ -39,20 +48,9 @@ export function ConversationBrief(props: {
39
48
  const mentions = props.brief.mentions;
40
49
  if (props.variant === "summary") {
41
50
  return (
42
- <div className="grid min-w-0 gap-4">
51
+ <BriefSummary>
43
52
  <DisplayText mentions={mentions} text={content.summary} />
44
- {content.openDecisions.length > 0 ? (
45
- <div className="grid gap-2">
46
- <h4 className="m-0 text-sm font-medium text-dashboard-text">
47
- Open decisions
48
- </h4>
49
- <BriefOpenDecisions
50
- decisions={content.openDecisions}
51
- mentions={mentions}
52
- />
53
- </div>
54
- ) : null}
55
- </div>
53
+ </BriefSummary>
56
54
  );
57
55
  }
58
56
  return (
@@ -80,26 +78,9 @@ export function ConversationBrief(props: {
80
78
  </Detail>
81
79
  </DetailList>
82
80
  </BriefSection>
83
- {content.decisions.length || content.openDecisions.length ? (
81
+ {content.decisions.length ? (
84
82
  <BriefSection title="Decisions">
85
- <DetailList>
86
- {content.decisions.length ? (
87
- <Detail label="Decisions">
88
- <BriefDecisions
89
- decisions={content.decisions}
90
- mentions={mentions}
91
- />
92
- </Detail>
93
- ) : null}
94
- {content.openDecisions.length ? (
95
- <Detail label="Open decisions">
96
- <BriefOpenDecisions
97
- decisions={content.openDecisions}
98
- mentions={mentions}
99
- />
100
- </Detail>
101
- ) : null}
102
- </DetailList>
83
+ <BriefDecisions decisions={content.decisions} mentions={mentions} />
103
84
  </BriefSection>
104
85
  ) : null}
105
86
  {content.facts.length ||
@@ -162,6 +143,120 @@ export function ConversationBrief(props: {
162
143
  );
163
144
  }
164
145
 
146
+ function BriefSummary(props: { children: ReactNode }) {
147
+ const id = useId();
148
+ const previewRef = useRef<HTMLDivElement>(null);
149
+ const contentRef = useRef<HTMLDivElement>(null);
150
+ const toggleRef = useRef<HTMLButtonElement>(null);
151
+ const [hasMore, setHasMore] = useState(false);
152
+ const [expanded, setExpanded] = useState(false);
153
+ const collapsed = hasMore && !expanded;
154
+
155
+ useLayoutEffect(() => {
156
+ const preview = previewRef.current;
157
+ const content = contentRef.current;
158
+ if (!preview || !content) return;
159
+
160
+ const measure = () => {
161
+ const lineHeight = Number.parseFloat(
162
+ getComputedStyle(preview).lineHeight,
163
+ );
164
+ const height = lineHeight * SUMMARY_PREVIEW_LINES;
165
+ const overflows = content.getBoundingClientRect().height > height + 0.5;
166
+ const focused = document.activeElement;
167
+ if (
168
+ overflows &&
169
+ focused &&
170
+ content.contains(focused) &&
171
+ focused.getBoundingClientRect().bottom >
172
+ preview.getBoundingClientRect().top +
173
+ height -
174
+ lineHeight * SUMMARY_FADE_LINES
175
+ ) {
176
+ setExpanded(true);
177
+ } else if (!overflows && focused && focused === toggleRef.current) {
178
+ preview.focus({ preventScroll: true });
179
+ }
180
+ setHasMore(overflows);
181
+ };
182
+ measure();
183
+ const observer = new ResizeObserver(measure);
184
+ observer.observe(content);
185
+ return () => observer.disconnect();
186
+ }, []);
187
+
188
+ useLayoutEffect(() => {
189
+ const toggle = toggleRef.current;
190
+ if (!expanded && toggle && document.activeElement === toggle) {
191
+ toggle.scrollIntoView({ block: "nearest" });
192
+ }
193
+ }, [expanded]);
194
+
195
+ return (
196
+ <div className="relative min-w-0">
197
+ <div
198
+ className={cn("leading-6 md:leading-7", collapsed && "overflow-clip")}
199
+ id={id}
200
+ onFocusCapture={(event) => {
201
+ if (!collapsed) return;
202
+ // Keep focused links clear of both the clipping and the fade.
203
+ const preview = event.currentTarget;
204
+ const lineHeight = Number.parseFloat(
205
+ getComputedStyle(preview).lineHeight,
206
+ );
207
+ if (
208
+ event.target.getBoundingClientRect().bottom >
209
+ preview.getBoundingClientRect().bottom -
210
+ lineHeight * SUMMARY_FADE_LINES
211
+ ) {
212
+ setExpanded(true);
213
+ }
214
+ }}
215
+ ref={previewRef}
216
+ style={
217
+ collapsed
218
+ ? {
219
+ maskImage: `linear-gradient(black calc(100% - ${SUMMARY_FADE_LINES}lh), transparent)`,
220
+ maxHeight: `${SUMMARY_PREVIEW_LINES}lh`,
221
+ }
222
+ : undefined
223
+ }
224
+ tabIndex={-1}
225
+ >
226
+ <div ref={contentRef}>{props.children}</div>
227
+ </div>
228
+ {hasMore ? (
229
+ <button
230
+ aria-controls={id}
231
+ aria-expanded={expanded}
232
+ aria-label={
233
+ expanded ? "Collapse Brief summary" : "Expand Brief summary"
234
+ }
235
+ className={cn(
236
+ "group flex h-6 w-8 cursor-pointer items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-dashboard-focus",
237
+ collapsed
238
+ ? "absolute bottom-0 left-1/2 -translate-x-1/2"
239
+ : "mx-auto mt-1",
240
+ )}
241
+ onClick={() => setExpanded((open) => !open)}
242
+ ref={toggleRef}
243
+ type="button"
244
+ >
245
+ <span
246
+ aria-hidden="true"
247
+ className="grid size-5 place-items-center rounded-full border border-dashboard-border bg-dashboard-ink text-dashboard-text-solid transition-[filter] group-hover:brightness-95 dark:bg-dashboard-text-solid dark:text-dashboard-text-inverse"
248
+ >
249
+ <ChevronDown
250
+ className="size-4 group-aria-expanded:rotate-180"
251
+ strokeWidth={2.25}
252
+ />
253
+ </span>
254
+ </button>
255
+ ) : null}
256
+ </div>
257
+ );
258
+ }
259
+
165
260
  function BriefSection(props: { children: ReactNode; title: string }) {
166
261
  return (
167
262
  <Collapsible className="min-w-0 border-t border-dashboard-border pt-3">
@@ -221,37 +316,6 @@ function BriefDecisions(props: {
221
316
  );
222
317
  }
223
318
 
224
- function BriefOpenDecisions(props: {
225
- decisions: BriefContent["openDecisions"];
226
- mentions?: readonly ActorIdentity[];
227
- }) {
228
- return (
229
- <ul className="m-0 grid list-none gap-2 p-0">
230
- {props.decisions.map((decision, index) => (
231
- <li key={`${decision.text}:${index}`}>
232
- <div>
233
- <DisplayText
234
- inline
235
- mentions={props.mentions}
236
- text={decision.text}
237
- />
238
- </div>
239
- {decision.owner ? (
240
- <div className="mt-1 text-xs text-dashboard-text-muted">
241
- Owner:{" "}
242
- <DisplayText
243
- inline
244
- mentions={props.mentions}
245
- text={decision.owner}
246
- />
247
- </div>
248
- ) : null}
249
- </li>
250
- ))}
251
- </ul>
252
- );
253
- }
254
-
255
319
  function BriefList(props: {
256
320
  items: string[];
257
321
  mentions?: readonly ActorIdentity[];
@@ -37,9 +37,7 @@ import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
37
37
  type Brief = NonNullable<ConversationDetailReport["brief"]>;
38
38
 
39
39
  /**
40
- * Show conversation details in the order a reader asks about them: what the
41
- * work is (the Brief), what needs them, what it changed or waits on, who did
42
- * it, and what it cost. Each fact shows here only, not in the title bar.
40
+ * Surface work that needs the reader, then the Brief and its linked work.
43
41
  */
44
42
  export function ConversationDetails(props: {
45
43
  /** Viewer's auto-archive control. Null for non-participants. */
@@ -153,21 +151,22 @@ export function ConversationDetails(props: {
153
151
  }
154
152
  onClose={props.onClose}
155
153
  >
156
- <div className="grid min-w-0 content-start gap-3 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
154
+ <div className="grid min-w-0 content-start gap-2 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
155
+ <ConversationReviewItems
156
+ conversationId={props.conversationId}
157
+ highlight={props.highlightReview}
158
+ />
157
159
  {props.brief ? (
158
160
  <div className="min-w-0 break-words px-1 pb-1 font-sans text-sm leading-relaxed text-dashboard-text-muted">
159
161
  <ConversationBrief brief={props.brief} variant="summary" />
160
162
  </div>
161
163
  ) : null}
162
- <ConversationReviewItems
163
- conversationId={props.conversationId}
164
- highlight={props.highlightReview}
165
- />
166
164
  {props.links.length > 0 || watches.length > 0 ? (
167
165
  // A Watch waits on something. It shows with linked work, so the
168
166
  // work and what Junior waits on read as one list.
169
167
  <DetailsCard
170
168
  count={props.links.length || watches.length}
169
+ flat
171
170
  title={props.links.length > 0 ? "Linked work" : "Waiting on"}
172
171
  >
173
172
  {props.links.length > 0 ? (
@@ -191,7 +190,7 @@ export function ConversationDetails(props: {
191
190
  <DetailsCard>{props.autoArchive}</DetailsCard>
192
191
  ) : null}
193
192
  {agents?.length ? (
194
- <DetailsCard count={agents.length} title="Subagents">
193
+ <DetailsCard count={agents.length} flat title="Subagents">
195
194
  <ConversationChildAgents
196
195
  conversationId={props.conversationId}
197
196
  onOpenAgent={(target) => {
@@ -203,13 +202,13 @@ export function ConversationDetails(props: {
203
202
  </DetailsCard>
204
203
  ) : null}
205
204
  {props.overview != null ? (
206
- <DetailsCard>{props.overview}</DetailsCard>
207
- ) : null}
208
- {props.usage != null ? (
209
- <DetailsCard title="Usage">{props.usage}</DetailsCard>
205
+ <DetailsCard flat>{props.overview}</DetailsCard>
210
206
  ) : null}
207
+ {props.usage}
211
208
  {props.identity != null ? (
212
- <DetailsCard title="Participants">{props.identity}</DetailsCard>
209
+ <DetailsCard flat title="Participants">
210
+ {props.identity}
211
+ </DetailsCard>
213
212
  ) : null}
214
213
  {memoryList?.events.length ? (
215
214
  <ConversationMemoryCard
@@ -1,17 +1,4 @@
1
- import {
2
- defaultRangeExtractor,
3
- useVirtualizer,
4
- type Range,
5
- } from "@tanstack/react-virtual";
6
- import {
7
- memo,
8
- useCallback,
9
- useEffect,
10
- useLayoutEffect,
11
- useMemo,
12
- useRef,
13
- useState,
14
- } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
15
2
  import { createPortal } from "react-dom";
16
3
 
17
4
  import { Skeleton } from "../components/ui/skeleton";
@@ -25,11 +12,10 @@ import {
25
12
  eventLogRows,
26
13
  eventLogTone,
27
14
  filterEventLogRows,
28
- type EventLogRow,
29
15
  } from "./eventLog";
30
16
  import { ConversationEventDetails } from "./ConversationEventDetails";
31
- import { scrollRootFor } from "./transcriptBottomPinning";
32
17
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
18
+ import { useVirtualRows } from "./virtualRows";
33
19
 
34
20
  const rowClass =
35
21
  "grid grid-cols-[12ch_minmax(0,1fr)] items-baseline gap-x-3 px-4 @min-[32rem]:grid-cols-[12ch_17ch_minmax(0,1fr)]";
@@ -70,14 +56,25 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
70
56
  // An arrow key can select a row that mounts only on the next render.
71
57
  const focusSeqRef = useRef<number>(undefined);
72
58
  const focusedSeqRef = useRef<number>(undefined);
73
- const virtualizer = useEventLogVirtualizer({
74
- listRef,
75
- rows: visibleRows,
76
- keptSeqs: () => [
77
- selectedSeq,
78
- focusedSeqRef.current,
79
- anchorSeq(props.scrollAnchorKey?.()),
59
+ const indexBySeq = useMemo(
60
+ () => new Map(visibleRows.map((row, index) => [row.event.seq, index])),
61
+ [visibleRows],
62
+ );
63
+ const indexOf = (seq: number | undefined) =>
64
+ seq === undefined ? undefined : indexBySeq.get(seq);
65
+ // The open entry, the focused row, and the row that holds the reader's
66
+ // place stay mounted.
67
+ const virtualizer = useVirtualRows({
68
+ count: visibleRows.length,
69
+ defaultSize: (sizeClass) => (sizeClass === "metadata" ? 56 : 40),
70
+ getItemKey: (index) => visibleRows[index]?.event.seq ?? index,
71
+ keptIndexes: [
72
+ indexOf(selectedSeq),
73
+ indexOf(focusedSeqRef.current),
74
+ indexOf(anchorSeq(props.scrollAnchorKey?.())),
80
75
  ],
76
+ listRef,
77
+ sizeClass: (index) => (visibleRows[index]?.metadata ? "metadata" : "plain"),
81
78
  });
82
79
  const virtualRows = virtualizer.getVirtualItems();
83
80
  useEffect(() => {
@@ -268,85 +265,11 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
268
265
  );
269
266
  });
270
267
 
271
- /** Rows mounted beyond the viewport on each side. */
272
- const OVERSCAN_ROWS = 10;
273
-
274
268
  /** Read the event seq from a `data-scroll-anchor` key. */
275
269
  function anchorSeq(key: string | undefined): number | undefined {
276
270
  return key?.startsWith("event:") ? Number(key.slice(6)) : undefined;
277
271
  }
278
272
 
279
- /**
280
- * Pick and measure the rows near the viewport. The scroll owner holds the
281
- * reader's place, so this never moves the scroll position. `keptSeqs` rows
282
- * stay mounted outside the viewport: the open entry, the focused row, and
283
- * the row that holds the reader's place. Code that finds them in the DOM
284
- * then keeps working.
285
- */
286
- function useEventLogVirtualizer(input: {
287
- keptSeqs: () => (number | undefined)[];
288
- listRef: React.RefObject<HTMLOListElement | null>;
289
- rows: EventLogRow[];
290
- }) {
291
- const { keptSeqs, listRef, rows } = input;
292
- const [scrollMargin, setScrollMargin] = useState(0);
293
- const indexBySeq = useMemo(
294
- () => new Map(rows.map((row, index) => [row.event.seq, index])),
295
- [rows],
296
- );
297
- const kept = keptSeqs();
298
- const keptKey = kept.join(",");
299
- const rangeExtractor = useCallback(
300
- (range: Range) => {
301
- const indexes = new Set(defaultRangeExtractor(range));
302
- for (const seq of keptKey.split(",")) {
303
- const index = seq ? indexBySeq.get(Number(seq)) : undefined;
304
- if (index !== undefined) indexes.add(index);
305
- }
306
- // Rows stay in document order, so the scroll owner can search them.
307
- return [...indexes].sort((left, right) => left - right);
308
- },
309
- [indexBySeq, keptKey],
310
- );
311
- // The toolbar and earlier-page control sit above the rows, and the
312
- // virtualizer counts offsets from the top of the scroll content. The
313
- // margin only picks which rows mount, so a reading from the last scroll
314
- // or resize is close enough: the overscan rows cover a small error.
315
- const measureScrollMargin = useCallback(() => {
316
- const list = listRef.current;
317
- const root = scrollRootFor(list);
318
- if (!list || !(root instanceof Element)) return;
319
- const margin =
320
- list.getBoundingClientRect().top -
321
- root.getBoundingClientRect().top +
322
- root.scrollTop;
323
- setScrollMargin((current) =>
324
- Math.abs(margin - current) >= 1 ? margin : current,
325
- );
326
- }, [listRef]);
327
- useLayoutEffect(measureScrollMargin, [measureScrollMargin]);
328
- // The dashboard build does not use React Compiler, so nothing memoizes
329
- // the values that this hook returns.
330
- // oxlint-disable-next-line react/incompatible-library
331
- const virtualizer = useVirtualizer<Element, HTMLLIElement>({
332
- count: rows.length,
333
- estimateSize: (index) => (rows[index]?.metadata ? 56 : 40),
334
- getItemKey: (index) => rows[index]?.event.seq ?? index,
335
- getScrollElement: () => {
336
- const root = scrollRootFor(listRef.current);
337
- return root instanceof Element ? root : null;
338
- },
339
- onChange: measureScrollMargin,
340
- overscan: OVERSCAN_ROWS,
341
- rangeExtractor,
342
- scrollMargin,
343
- // The scroll owner moves the scroll position. This only reads it.
344
- scrollToFn: () => {},
345
- });
346
- virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
347
- return virtualizer;
348
- }
349
-
350
273
  const loadingRowWidths = ["w-3/5", "w-2/5", "w-4/5", "w-1/2", "w-2/3", "w-1/3"];
351
274
 
352
275
  /** Match the event log toolbar, columns, and rows while the transcript loads. */
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState, type ReactNode } from "react";
2
- import { Menu, PanelRight, ScrollText, X } from "lucide-react";
2
+ import { Menu, PanelRight } from "lucide-react";
3
3
 
4
4
  import { SearchInput } from "../components/SearchInput";
5
5
  import { Button } from "../components/ui/button";
@@ -171,9 +171,35 @@ export function ConversationHeader(props: {
171
171
  {props.sidebarToggle ? (
172
172
  <div className="shrink-0">{props.sidebarToggle}</div>
173
173
  ) : null}
174
- <h2 className="m-0 min-w-0 truncate font-display text-base font-medium leading-6 tracking-tight">
175
- {props.title}
176
- </h2>
174
+ {props.view === "raw" ? (
175
+ // Event log is a debug view reached from the `⋯` menu. The
176
+ // title leads back to the Conversation.
177
+ <>
178
+ <h2 className="m-0 min-w-0 font-display text-base font-medium leading-6 tracking-tight">
179
+ <button
180
+ className="block max-w-full cursor-pointer truncate text-left text-dashboard-text-muted hover:text-dashboard-text hover:underline"
181
+ onClick={() => props.onViewChange("rich")}
182
+ title="Back to conversation"
183
+ type="button"
184
+ >
185
+ {props.title}
186
+ </button>
187
+ </h2>
188
+ <span aria-hidden="true" className="text-dashboard-text-faint">
189
+ ›
190
+ </span>
191
+ <span
192
+ aria-current="page"
193
+ className="shrink-0 font-display text-base font-medium leading-6 tracking-tight"
194
+ >
195
+ Event log
196
+ </span>
197
+ </>
198
+ ) : (
199
+ <h2 className="m-0 min-w-0 truncate font-display text-base font-medium leading-6 tracking-tight">
200
+ {props.title}
201
+ </h2>
202
+ )}
177
203
  {liveIndicator}
178
204
  {props.privacy ? (
179
205
  <span className="shrink-0">{props.privacy}</span>
@@ -194,20 +220,7 @@ export function ConversationHeader(props: {
194
220
  {props.cost}
195
221
  </span>
196
222
  ) : null}
197
- {props.view === "raw" ? (
198
- // Event log is a debug view reached from the menu. Say which
199
- // view this is, and give one click back.
200
- <Button
201
- aria-label="Close event log"
202
- className="mr-1 h-8 px-2.5"
203
- onClick={() => props.onViewChange("rich")}
204
- variant="quiet"
205
- >
206
- <ScrollText aria-hidden="true" size={14} strokeWidth={2} />
207
- Event log
208
- <X aria-hidden="true" size={14} strokeWidth={2} />
209
- </Button>
210
- ) : (
223
+ {props.view === "raw" ? null : (
211
224
  <TranscriptSearchToggle
212
225
  onClick={toggleSearch}
213
226
  open={searchOpenVisible}
@@ -216,14 +229,16 @@ export function ConversationHeader(props: {
216
229
  <ConversationOverflowMenu>
217
230
  {(close) => menuActions("desktop", close)}
218
231
  </ConversationOverflowMenu>
219
- <HeaderIconButton
220
- ref={detailsButtonRef}
221
- expanded={detailsOpen}
222
- label="Conversation details"
223
- onClick={props.onDetailsClick}
224
- >
225
- <PanelRight aria-hidden="true" size={15} strokeWidth={2} />
226
- </HeaderIconButton>
232
+ {detailsOpen ? null : (
233
+ <HeaderIconButton
234
+ ref={detailsButtonRef}
235
+ expanded={false}
236
+ label="Conversation details"
237
+ onClick={props.onDetailsClick}
238
+ >
239
+ <PanelRight aria-hidden="true" size={15} strokeWidth={2} />
240
+ </HeaderIconButton>
241
+ )}
227
242
  </div>
228
243
  </div>
229
244
  {props.forks ? (
@@ -82,8 +82,10 @@ export function ConversationHeaderActions(props: {
82
82
  <Info aria-hidden="true" size={16} strokeWidth={2} />
83
83
  </MenuActionButton>
84
84
  ) : null}
85
+ {/* The copy action is a sibling keyed by the same id. Unique keys keep
86
+ React from duplicating menu rows on re-render. */}
85
87
  <ShareConversationButton
86
- key={props.conversationId}
88
+ key={`share:${props.conversationId}`}
87
89
  conversationId={props.conversationId}
88
90
  />
89
91
  {props.copyAction}
@@ -7,6 +7,11 @@ import type {
7
7
 
8
8
  import { ObjectIcon } from "../components/ObjectIcon";
9
9
  import { Collapsible } from "../components/ui/collapsible";
10
+ import {
11
+ Tooltip,
12
+ TooltipContent,
13
+ TooltipTrigger,
14
+ } from "../components/ui/tooltip";
10
15
  import { WatchDetails, WatchDetailsRow } from "./ConversationWatches";
11
16
  import { DetailsList, DetailsRow, DetailsRowLine } from "./DetailsList";
12
17
 
@@ -88,7 +93,7 @@ export function conversationLinks(
88
93
  * Show the outside work that a Conversation links to, one row each. A row
89
94
  * that Junior also watches shows the Watch details under a chevron, so the
90
95
  * same resource does not show twice. When one row has a chevron, the other
91
- * rows keep the same space, so their statuses stay aligned.
96
+ * rows keep the same space, so all rows keep the same width.
92
97
  */
93
98
  export function ConversationLinkedWork(props: {
94
99
  links: ConversationLink[];
@@ -133,20 +138,35 @@ function ChevronSpace(props: { children: ReactNode }) {
133
138
  function LinkRow(props: { link: ConversationLink }) {
134
139
  const { link } = props;
135
140
  const status = linkStatusLabel(link.status);
141
+ const icon = (
142
+ <ObjectIcon
143
+ decorative
144
+ facts={link.facts}
145
+ objectType={link.objectType}
146
+ size={14}
147
+ status={link.status}
148
+ />
149
+ );
136
150
  return (
137
151
  <DetailsRow
138
152
  href={link.url}
139
153
  icon={
140
- // The row text names the status, so the icon stays silent.
141
- <ObjectIcon
142
- decorative
143
- facts={link.facts}
144
- objectType={link.objectType}
145
- size={14}
146
- status={link.status}
147
- />
154
+ // The icon shows the status, so the row does not repeat it in text.
155
+ status ? (
156
+ <Tooltip>
157
+ <TooltipTrigger
158
+ render={
159
+ <span aria-label={status} className="inline-flex" role="img">
160
+ {icon}
161
+ </span>
162
+ }
163
+ />
164
+ <TooltipContent size="compact">{status}</TooltipContent>
165
+ </Tooltip>
166
+ ) : (
167
+ icon
168
+ )
148
169
  }
149
- meta={status}
150
170
  title={link.label}
151
171
  tooltip={[link.label, link.plugin, status, link.description]
152
172
  .filter(Boolean)