@sentry/junior-dashboard 0.279.0 → 0.280.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 (147) hide show
  1. package/dist/app.js +100 -31
  2. package/dist/client-manifest.json +1 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +100 -31
  5. package/dist/public/chunks/AdminPage-YEKZOKH6.js +1 -0
  6. package/dist/public/chunks/AutomationPage-5GS3IUAK.js +3 -0
  7. package/dist/public/chunks/AutomationRunsPage-IPYEZU4L.js +1 -0
  8. package/dist/public/chunks/AutomationsPage-HSAYPC3C.js +1 -0
  9. package/dist/public/chunks/CodePage-IPFKXWFB.js +1 -0
  10. package/dist/public/chunks/ConnectionsPage-5XUEK6FS.js +1 -0
  11. package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
  12. package/dist/public/chunks/LocationDetailPage-27ETUGR6.js +1 -0
  13. package/dist/public/chunks/LocationsPage-WOKMQAGM.js +1 -0
  14. package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
  15. package/dist/public/chunks/PeoplePage-6YQLDOUL.js +1 -0
  16. package/dist/public/chunks/PersonProfilePage-GYMAZFIK.js +1 -0
  17. package/dist/public/chunks/PersonalTokensPage-4BCR744B.js +1 -0
  18. package/dist/public/chunks/PluginUserPage-IP6WL7MD.js +1 -0
  19. package/dist/public/chunks/RepositoryPage-TRD44LQ3.js +1 -0
  20. package/dist/public/chunks/SettingsPage-4E3SLQ36.js +1 -0
  21. package/dist/public/chunks/SystemPage-C3XBKXKW.js +1 -0
  22. package/dist/public/chunks/WorkspaceFormPage-2LS2X67Q.js +2 -0
  23. package/dist/public/chunks/WorkspacesPage-5WB3CUTX.js +1 -0
  24. package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
  25. package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
  26. package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
  27. package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
  28. package/dist/public/chunks/chunk-564V7YHG.js +1 -0
  29. package/dist/public/chunks/chunk-5GZDBGAR.js +1 -0
  30. package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
  31. package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
  32. package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
  33. package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
  34. package/dist/public/chunks/chunk-6GQL3EVJ.js +1 -0
  35. package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
  36. package/dist/public/chunks/chunk-A2QWWA7O.js +41 -0
  37. package/dist/public/chunks/chunk-AYXSBMWA.js +1 -0
  38. package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
  39. package/dist/public/chunks/chunk-B6HGRBBX.js +1 -0
  40. package/dist/public/chunks/chunk-BKOACADP.js +1 -0
  41. package/dist/public/chunks/chunk-CQY6BYUB.js +1 -0
  42. package/dist/public/chunks/chunk-CR2ICLSH.js +1 -0
  43. package/dist/public/chunks/chunk-DZB4LPI6.js +1 -0
  44. package/dist/public/chunks/chunk-EFEREKX4.js +1 -0
  45. package/dist/public/chunks/chunk-FE522UEA.js +1 -0
  46. package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
  47. package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
  48. package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
  49. package/dist/public/chunks/chunk-GO45RQDL.js +1 -0
  50. package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
  51. package/dist/public/chunks/chunk-GRMX2HX6.js +1 -0
  52. package/dist/public/chunks/chunk-GYRM5ZIV.js +1 -0
  53. package/dist/public/chunks/chunk-H2ZU34CP.js +1 -0
  54. package/dist/public/chunks/chunk-H76B43OQ.js +1 -0
  55. package/dist/public/chunks/chunk-HCLRG2EC.js +1 -0
  56. package/dist/public/chunks/chunk-IA6BJDKN.js +1 -0
  57. package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
  58. package/dist/public/chunks/chunk-J37MXUED.js +1 -0
  59. package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
  60. package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
  61. package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
  62. package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
  63. package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
  64. package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
  65. package/dist/public/chunks/chunk-N4AONKD3.js +1 -0
  66. package/dist/public/chunks/chunk-NQMDED2R.js +2 -0
  67. package/dist/public/chunks/chunk-NQOJAZFQ.js +1 -0
  68. package/dist/public/chunks/chunk-PEQNT5MO.js +1 -0
  69. package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
  70. package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
  71. package/dist/public/chunks/chunk-QNA7F7QB.js +1 -0
  72. package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
  73. package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
  74. package/dist/public/chunks/chunk-R4ORRE77.js +1 -0
  75. package/dist/public/chunks/chunk-RHIXHBS5.js +1 -0
  76. package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
  77. package/dist/public/chunks/chunk-SDKV3OID.js +1 -0
  78. package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
  79. package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
  80. package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
  81. package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
  82. package/dist/public/chunks/chunk-UAH6DWNS.js +1 -0
  83. package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
  84. package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
  85. package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
  86. package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
  87. package/dist/public/chunks/chunk-V5UDSIRA.js +63 -0
  88. package/dist/public/chunks/chunk-VC6IS564.js +1 -0
  89. package/dist/public/chunks/chunk-VFEBNOPX.js +1 -0
  90. package/dist/public/chunks/chunk-VXT3FIVL.js +55 -0
  91. package/dist/public/chunks/chunk-WJ3OXMVX.js +1 -0
  92. package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
  93. package/dist/public/chunks/chunk-WZVO3HG4.js +1 -0
  94. package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
  95. package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
  96. package/dist/public/chunks/chunk-YYNMZ4WC.js +1 -0
  97. package/dist/public/chunks/chunk-Z2JPRGFT.js +1 -0
  98. package/dist/public/chunks/chunk-ZIJ6SJDN.js +1 -0
  99. package/dist/public/chunks/code-highlighter-Q7WC7AEF.js +12 -0
  100. package/dist/public/client-TYC7JKRE.js +91 -0
  101. package/dist/tailwind.css +1 -1
  102. package/package.json +5 -4
  103. package/src/api/schema.ts +1 -1
  104. package/src/assets.ts +4 -1
  105. package/src/client/App.tsx +100 -23
  106. package/src/client/TracedRoutes.tsx +8 -4
  107. package/src/client/code-highlighter.ts +26 -0
  108. package/src/client/code.tsx +2 -22
  109. package/src/client/components/Drawer.tsx +12 -71
  110. package/src/client/components/DrawerContent.tsx +76 -0
  111. package/src/client/components/ProfileMenu.tsx +12 -3
  112. package/src/client/components/UserInputForm.tsx +28 -5
  113. package/src/client/conversations/ConversationCatchUp.tsx +107 -0
  114. package/src/client/conversations/ConversationContextTags.tsx +110 -0
  115. package/src/client/conversations/ConversationEventLog.tsx +213 -71
  116. package/src/client/conversations/ConversationHeader.tsx +35 -41
  117. package/src/client/conversations/ConversationHeaderActions.tsx +34 -38
  118. package/src/client/conversations/ConversationHomeList.tsx +41 -3
  119. package/src/client/conversations/ConversationMeta.tsx +6 -12
  120. package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
  121. package/src/client/conversations/ConversationPage.tsx +188 -150
  122. package/src/client/conversations/ShareConversationButton.tsx +5 -50
  123. package/src/client/conversations/SlackMark.tsx +16 -4
  124. package/src/client/conversations/TranscriptRows.tsx +24 -11
  125. package/src/client/conversations/TranscriptView.tsx +21 -13
  126. package/src/client/conversations/conversationArchive.ts +22 -0
  127. package/src/client/conversations/conversationCatchUpState.ts +96 -0
  128. package/src/client/conversations/conversationMemory.ts +8 -1
  129. package/src/client/conversations/queries.ts +11 -3
  130. package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
  131. package/src/client/conversations/transcriptBottomPinning.ts +27 -3
  132. package/src/client/conversations/transcriptReadPosition.ts +24 -2
  133. package/src/client/conversations/turnContext.ts +1 -1
  134. package/src/client/pages/admin/AdminPage.tsx +1 -1
  135. package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
  136. package/src/client/pages/memory/memoryDashboard.ts +1 -1
  137. package/src/client/pages/memory/memoryRecord.ts +1 -1
  138. package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
  139. package/src/client/pages/user/PluginUserPage.tsx +0 -6
  140. package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
  141. package/src/client/primaryNav.ts +1 -1
  142. package/src/client/review-desk/reviewItemFormat.ts +1 -1
  143. package/src/mock-reporting/fixtures.ts +43 -0
  144. package/src/mock-reporting/routes.ts +40 -3
  145. package/src/public-assets.ts +28 -20
  146. package/src/shell.ts +12 -2
  147. package/dist/public/client-ZCZXZBTL.js +0 -460
@@ -0,0 +1,107 @@
1
+ import { useContext, useEffect, useRef } from "react";
2
+ import { ArrowDown, Sparkles } from "lucide-react";
3
+
4
+ import { Button } from "../components/ui/button";
5
+ import { Skeleton } from "../components/ui/skeleton";
6
+ import { InlineError } from "../components/InlineError";
7
+ import { DisplayText } from "../display-text/DisplayText";
8
+ import { useConversationCatchUpState } from "./conversationCatchUpState";
9
+ import { TranscriptJumpToLatestContext } from "./transcriptBottomPinning";
10
+
11
+ const cardClass =
12
+ "grid gap-2.5 rounded-lg border border-dashboard-accent/20 bg-dashboard-accent/10 px-3.5 py-3 font-sans text-sm leading-relaxed text-dashboard-text";
13
+
14
+ /** Offer the catch-up inside the "New" marker, so it takes no extra row. */
15
+ export function ConversationCatchUpButton() {
16
+ const state = useConversationCatchUpState();
17
+ if (!state || state.data || state.pending) return null;
18
+ return (
19
+ <button
20
+ className="inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-full border border-dashboard-accent/30 bg-transparent px-2.5 font-sans text-xs font-medium text-dashboard-accent-text transition-colors hover:border-dashboard-accent/60 hover:bg-dashboard-accent/10 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus/55"
21
+ onClick={state.request}
22
+ type="button"
23
+ >
24
+ <Sparkles aria-hidden="true" size={12} strokeWidth={2} />
25
+ Catch me up
26
+ </button>
27
+ );
28
+ }
29
+
30
+ /** Show the loading, loaded, or failed catch-up under the "New" marker. */
31
+ export function ConversationCatchUpCard() {
32
+ const state = useConversationCatchUpState();
33
+ const jumpToLatest = useContext(TranscriptJumpToLatestContext);
34
+ const cardRef = useRef<HTMLDivElement>(null);
35
+ const shown = state?.data ? "data" : state?.pending ? "pending" : undefined;
36
+ // The transcript holds the row below the reader's place, so the card grows
37
+ // up, out of view. Bring it back after that scroll fix runs.
38
+ useEffect(() => {
39
+ if (!shown) return;
40
+ const frame = requestAnimationFrame(() => {
41
+ // Keep the "New" marker above the card in view too.
42
+ const marker = cardRef.current?.closest(
43
+ "[data-transcript-unread-marker]",
44
+ );
45
+ marker?.scrollIntoView({ block: "nearest" });
46
+ });
47
+ return () => cancelAnimationFrame(frame);
48
+ }, [shown]);
49
+
50
+ if (state?.data) {
51
+ return (
52
+ <div
53
+ aria-label="Catch-up"
54
+ className={cardClass}
55
+ ref={cardRef}
56
+ role="group"
57
+ >
58
+ <DisplayText text={state.data.summary} />
59
+ {state.data.needsYou ? (
60
+ <p className="m-0 border-t border-dashboard-accent/15 pt-2.5">
61
+ <span className="font-semibold text-dashboard-accent-text">
62
+ Needs you
63
+ </span>
64
+ <span
65
+ aria-hidden="true"
66
+ className="px-1.5 text-dashboard-text-muted"
67
+ >
68
+ ·
69
+ </span>
70
+ {state.data.needsYou}
71
+ </p>
72
+ ) : null}
73
+ {jumpToLatest ? (
74
+ <Button
75
+ className="-mb-1 h-7 justify-self-center gap-1.5 px-2 text-xs"
76
+ onClick={jumpToLatest}
77
+ variant="quiet"
78
+ >
79
+ <ArrowDown aria-hidden="true" size={14} strokeWidth={2} />
80
+ Jump to latest
81
+ </Button>
82
+ ) : null}
83
+ </div>
84
+ );
85
+ }
86
+ if (state?.error) {
87
+ return (
88
+ <InlineError className="text-center text-xs">
89
+ Catch-up failed. Try again.
90
+ </InlineError>
91
+ );
92
+ }
93
+ if (state?.pending) {
94
+ return (
95
+ <div
96
+ aria-label="Summarizing new messages"
97
+ className={cardClass}
98
+ ref={cardRef}
99
+ role="status"
100
+ >
101
+ <Skeleton className="h-4 w-full" />
102
+ <Skeleton className="h-4 w-2/3" />
103
+ </div>
104
+ );
105
+ }
106
+ return null;
107
+ }
@@ -0,0 +1,110 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { slackLocationLabel } from "../format";
4
+ import { cn } from "../styles";
5
+ import type { Conversation } from "../types";
6
+ import { SlackMark } from "./SlackMark";
7
+
8
+ function githubRepositoryFromUrl(href: string) {
9
+ const url = new URL(href);
10
+ if (url.origin !== "https://github.com") return undefined;
11
+ const [, owner, repo] = url.pathname.split("/");
12
+ if (!owner || !repo) return undefined;
13
+ return {
14
+ label: `${owner}/${repo}`,
15
+ href: `${url.origin}/${owner}/${repo}`,
16
+ };
17
+ }
18
+
19
+ function singleGitHubRepository(annotations: Conversation["annotations"]) {
20
+ let repository: ReturnType<typeof githubRepositoryFromUrl>;
21
+ for (const annotation of annotations ?? []) {
22
+ if (annotation.plugin !== "github" || !annotation.url) continue;
23
+ const candidate = githubRepositoryFromUrl(annotation.url);
24
+ if (!candidate) continue;
25
+ if (
26
+ repository &&
27
+ repository.href.toLowerCase() !== candidate.href.toLowerCase()
28
+ ) {
29
+ return undefined;
30
+ }
31
+ repository ??= candidate;
32
+ }
33
+ return repository;
34
+ }
35
+
36
+ function ContextTag(props: {
37
+ href?: string;
38
+ icon: ReactNode;
39
+ label: string;
40
+ title?: string;
41
+ }) {
42
+ const className =
43
+ "group/context-tag inline-flex h-6 min-w-0 items-center gap-1 rounded-md border border-dashboard-border-subtle px-1.5 font-sans text-xs font-medium text-dashboard-text-muted";
44
+ const content = (
45
+ <>
46
+ {props.icon}
47
+ <span className="truncate">{props.label}</span>
48
+ </>
49
+ );
50
+
51
+ return props.href ? (
52
+ <a
53
+ className={cn(
54
+ className,
55
+ "no-underline transition-colors hover:border-dashboard-border-strong hover:bg-dashboard-fill-soft hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
56
+ )}
57
+ href={props.href}
58
+ rel="noreferrer"
59
+ target="_blank"
60
+ title={props.title ?? props.label}
61
+ >
62
+ {content}
63
+ </a>
64
+ ) : (
65
+ <span className={className} title={props.label}>
66
+ {content}
67
+ </span>
68
+ );
69
+ }
70
+
71
+ function GitHubMark() {
72
+ return (
73
+ <svg
74
+ aria-label="GitHub"
75
+ className="size-3 shrink-0 transition-colors group-hover/context-tag:text-dashboard-text-solid group-focus-visible/context-tag:text-dashboard-text-solid"
76
+ fill="currentColor"
77
+ role="img"
78
+ viewBox="0 0 24 24"
79
+ >
80
+ {/* Keep the geometry from packages/docs/public/integrations/github.svg. */}
81
+ <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ /** Show source context and a repository tag when linked work points to one repository. */
87
+ export function conversationContextTags(
88
+ conversation: Conversation | undefined,
89
+ ) {
90
+ const location =
91
+ conversation && slackLocationLabel(conversation, { includeId: false });
92
+ const repository = singleGitHubRepository(conversation?.annotations);
93
+ if (!location && !repository) return null;
94
+
95
+ return (
96
+ <div className="flex min-w-0 items-center gap-1.5">
97
+ {location ? (
98
+ <ContextTag
99
+ href={conversation?.locationUrl}
100
+ icon={
101
+ <SlackMark className="size-3 shrink-0 group-hover/context-tag:[&>path]:fill-(--slack-mark-color) group-focus-visible/context-tag:[&>path]:fill-(--slack-mark-color)" />
102
+ }
103
+ label={location}
104
+ title={`Open ${location} in Slack`}
105
+ />
106
+ ) : null}
107
+ {repository ? <ContextTag {...repository} icon={<GitHubMark />} /> : null}
108
+ </div>
109
+ );
110
+ }
@@ -1,4 +1,17 @@
1
- import { memo, useEffect, useMemo, useRef, useState } from "react";
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";
2
15
  import { createPortal } from "react-dom";
3
16
 
4
17
  import { Skeleton } from "../components/ui/skeleton";
@@ -12,8 +25,10 @@ import {
12
25
  eventLogRows,
13
26
  eventLogTone,
14
27
  filterEventLogRows,
28
+ type EventLogRow,
15
29
  } from "./eventLog";
16
30
  import { ConversationEventDetails } from "./ConversationEventDetails";
31
+ import { scrollRootFor } from "./transcriptBottomPinning";
17
32
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
18
33
 
19
34
  const rowClass =
@@ -28,6 +43,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
28
43
  conversation: ConversationTranscript;
29
44
  onSearchChange(value: string): void;
30
45
  onSelectedSeqChange?(seq: number | undefined): void;
46
+ /** The row that holds the reader's place, from the scroll owner. */
47
+ scrollAnchorKey?: () => string | undefined;
31
48
  selectedSeq?: number;
32
49
  }) {
33
50
  const { conversation } = props;
@@ -50,17 +67,35 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
50
67
  );
51
68
  // Resolve the selection from current data so polling cannot leave stale details.
52
69
  const selected = events.find((event) => event.seq === selectedSeq);
70
+ // An arrow key can select a row that mounts only on the next render.
71
+ const focusSeqRef = useRef<number>(undefined);
72
+ 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?.()),
80
+ ],
81
+ });
82
+ const virtualRows = virtualizer.getVirtualItems();
53
83
  useEffect(() => {
54
- if (selectedSeq === undefined || revealedSeqRef.current === selectedSeq) {
55
- return;
56
- }
84
+ if (selectedSeq === undefined) return;
57
85
  const row = listRef.current?.querySelector(
58
86
  `[data-event-seq="${selectedSeq}"]`,
59
87
  );
60
88
  if (!row) return;
89
+ if (focusSeqRef.current === selectedSeq) {
90
+ focusSeqRef.current = undefined;
91
+ row
92
+ .querySelector<HTMLButtonElement>("button")
93
+ ?.focus({ preventScroll: true });
94
+ }
95
+ if (revealedSeqRef.current === selectedSeq) return;
61
96
  revealedSeqRef.current = selectedSeq;
62
97
  row.scrollIntoView({ block: "nearest" });
63
- }, [selectedSeq, visibleRows]);
98
+ }, [selectedSeq, virtualRows]);
64
99
 
65
100
  if (conversation.eventHistory.status === "expired") {
66
101
  return (
@@ -101,84 +136,112 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
101
136
  <EventLogColumns />
102
137
  </div>
103
138
  <div className="font-mono text-xs">
139
+ {/* Only rows near the viewport mount. Each row is placed at its
140
+ measured offset, so the list keeps its full height. */}
104
141
  <ol
105
142
  aria-label="Events"
106
- className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
143
+ className="relative m-0 list-none p-0"
107
144
  ref={listRef}
145
+ style={{ height: virtualizer.getTotalSize() }}
108
146
  >
109
- {visibleRows.map(({ event, summary, metadata }, index) => (
110
- <li
111
- className="scroll-mt-32 scroll-mb-2"
112
- data-event-seq={event.seq}
113
- data-scroll-anchor={`event:${event.seq}`}
114
- key={event.seq}
115
- >
116
- <button
117
- aria-expanded={selectedSeq === event.seq}
118
- aria-label={`Event ${event.seq}: ${event.data.type}`}
147
+ {virtualRows.map((virtualRow) => {
148
+ const { event, summary, metadata } = visibleRows[virtualRow.index]!;
149
+ const index = virtualRow.index;
150
+ return (
151
+ <li
119
152
  className={cn(
120
- rowClass,
121
- "relative min-h-11 w-full cursor-pointer border-0 bg-transparent py-2 text-left leading-5 hover:bg-dashboard-fill-hover active:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus @min-[32rem]:min-h-9 @min-[32rem]:py-1.5",
122
- selectedSeq === event.seq &&
123
- "bg-dashboard-fill-strong before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-dashboard-accent before:content-['']",
153
+ "absolute left-0 top-0 w-full scroll-mt-32 scroll-mb-2",
154
+ index > 0 && "border-t border-dashboard-border-subtle",
124
155
  )}
125
- onClick={() => {
126
- revealedSeqRef.current = event.seq;
127
- setSelectedSeq(event.seq);
156
+ data-event-seq={event.seq}
157
+ data-index={index}
158
+ data-scroll-anchor={`event:${event.seq}`}
159
+ key={virtualRow.key}
160
+ onBlur={() => {
161
+ if (focusedSeqRef.current === event.seq) {
162
+ focusedSeqRef.current = undefined;
163
+ }
128
164
  }}
129
- onKeyDown={(keyEvent) => {
130
- if (
131
- keyEvent.altKey ||
132
- keyEvent.ctrlKey ||
133
- keyEvent.metaKey ||
134
- keyEvent.shiftKey ||
135
- (keyEvent.key !== "ArrowUp" && keyEvent.key !== "ArrowDown")
136
- )
137
- return;
138
- keyEvent.preventDefault();
139
- const next =
140
- visibleRows[
141
- index + (keyEvent.key === "ArrowDown" ? 1 : -1)
142
- ];
143
- if (!next) return;
144
- setSelectedSeq(next.event.seq);
145
- listRef.current
146
- ?.querySelector<HTMLButtonElement>(
147
- `[data-event-seq="${next.event.seq}"] button`,
148
- )
149
- ?.focus({ preventScroll: true });
165
+ onFocus={() => {
166
+ focusedSeqRef.current = event.seq;
167
+ }}
168
+ ref={virtualizer.measureElement}
169
+ style={{
170
+ transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
150
171
  }}
151
- type="button"
152
172
  >
153
- <time
154
- className="tabular-nums text-dashboard-text-muted"
155
- dateTime={event.createdAt}
156
- title={time.formatTimestamp(event.createdAt)}
157
- >
158
- {time.formatClock(event.createdAt)}
159
- </time>
160
- <span
161
- className={cn("truncate", eventLogTone(event.data))}
162
- title={event.data.type}
173
+ <button
174
+ aria-expanded={selectedSeq === event.seq}
175
+ aria-label={`Event ${event.seq}: ${event.data.type}`}
176
+ className={cn(
177
+ rowClass,
178
+ "relative min-h-11 w-full cursor-pointer border-0 bg-transparent py-2 text-left leading-5 hover:bg-dashboard-fill-hover active:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus @min-[32rem]:min-h-9 @min-[32rem]:py-1.5",
179
+ selectedSeq === event.seq &&
180
+ "bg-dashboard-fill-strong before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-dashboard-accent before:content-['']",
181
+ )}
182
+ onClick={() => {
183
+ revealedSeqRef.current = event.seq;
184
+ setSelectedSeq(event.seq);
185
+ }}
186
+ onKeyDown={(keyEvent) => {
187
+ if (
188
+ keyEvent.altKey ||
189
+ keyEvent.ctrlKey ||
190
+ keyEvent.metaKey ||
191
+ keyEvent.shiftKey ||
192
+ (keyEvent.key !== "ArrowUp" &&
193
+ keyEvent.key !== "ArrowDown")
194
+ )
195
+ return;
196
+ keyEvent.preventDefault();
197
+ const next =
198
+ visibleRows[
199
+ index + (keyEvent.key === "ArrowDown" ? 1 : -1)
200
+ ];
201
+ if (!next) return;
202
+ setSelectedSeq(next.event.seq);
203
+ // Focus a mounted row now, so the next key press reaches
204
+ // it. A row past the mounted range gets focus after it
205
+ // mounts.
206
+ const nextButton =
207
+ listRef.current?.querySelector<HTMLButtonElement>(
208
+ `[data-event-seq="${next.event.seq}"] button`,
209
+ );
210
+ if (nextButton) nextButton.focus({ preventScroll: true });
211
+ else focusSeqRef.current = next.event.seq;
212
+ }}
213
+ type="button"
163
214
  >
164
- <HighlightText text={event.data.type} />
165
- </span>
166
- <span className="col-span-2 row-start-2 grid min-w-0 gap-0.5 text-dashboard-text @min-[32rem]:col-span-1 @min-[32rem]:row-auto">
167
- <span className="truncate" title={summary}>
168
- <HighlightText text={summary} />
215
+ <time
216
+ className="tabular-nums text-dashboard-text-muted"
217
+ dateTime={event.createdAt}
218
+ title={time.formatTimestamp(event.createdAt)}
219
+ >
220
+ {time.formatClock(event.createdAt)}
221
+ </time>
222
+ <span
223
+ className={cn("truncate", eventLogTone(event.data))}
224
+ title={event.data.type}
225
+ >
226
+ <HighlightText text={event.data.type} />
169
227
  </span>
170
- {metadata ? (
171
- <span
172
- className="truncate text-dashboard-text-muted"
173
- title={metadata}
174
- >
175
- <HighlightText text={metadata} />
228
+ <span className="col-span-2 row-start-2 grid min-w-0 gap-0.5 text-dashboard-text @min-[32rem]:col-span-1 @min-[32rem]:row-auto">
229
+ <span className="truncate" title={summary}>
230
+ <HighlightText text={summary} />
176
231
  </span>
177
- ) : null}
178
- </span>
179
- </button>
180
- </li>
181
- ))}
232
+ {metadata ? (
233
+ <span
234
+ className="truncate text-dashboard-text-muted"
235
+ title={metadata}
236
+ >
237
+ <HighlightText text={metadata} />
238
+ </span>
239
+ ) : null}
240
+ </span>
241
+ </button>
242
+ </li>
243
+ );
244
+ })}
182
245
  </ol>
183
246
  {visibleRows.length === 0 ? (
184
247
  <p className="m-0 px-3 py-6 text-sm text-dashboard-text-muted">
@@ -205,6 +268,85 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
205
268
  );
206
269
  });
207
270
 
271
+ /** Rows mounted beyond the viewport on each side. */
272
+ const OVERSCAN_ROWS = 10;
273
+
274
+ /** Read the event seq from a `data-scroll-anchor` key. */
275
+ function anchorSeq(key: string | undefined): number | undefined {
276
+ return key?.startsWith("event:") ? Number(key.slice(6)) : undefined;
277
+ }
278
+
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
+
208
350
  const loadingRowWidths = ["w-3/5", "w-2/5", "w-4/5", "w-1/2", "w-2/3", "w-1/3"];
209
351
 
210
352
  /** Match the event log toolbar, columns, and rows while the transcript loads. */