@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.
- package/dist/app.js +100 -31
- package/dist/client-manifest.json +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +100 -31
- package/dist/public/chunks/AdminPage-YEKZOKH6.js +1 -0
- package/dist/public/chunks/AutomationPage-5GS3IUAK.js +3 -0
- package/dist/public/chunks/AutomationRunsPage-IPYEZU4L.js +1 -0
- package/dist/public/chunks/AutomationsPage-HSAYPC3C.js +1 -0
- package/dist/public/chunks/CodePage-IPFKXWFB.js +1 -0
- package/dist/public/chunks/ConnectionsPage-5XUEK6FS.js +1 -0
- package/dist/public/chunks/DrawerContent-RYYS57ZH.js +1 -0
- package/dist/public/chunks/LocationDetailPage-27ETUGR6.js +1 -0
- package/dist/public/chunks/LocationsPage-WOKMQAGM.js +1 -0
- package/dist/public/chunks/McpSettingsPage-3RT7JPEK.js +1 -0
- package/dist/public/chunks/PeoplePage-6YQLDOUL.js +1 -0
- package/dist/public/chunks/PersonProfilePage-GYMAZFIK.js +1 -0
- package/dist/public/chunks/PersonalTokensPage-4BCR744B.js +1 -0
- package/dist/public/chunks/PluginUserPage-IP6WL7MD.js +1 -0
- package/dist/public/chunks/RepositoryPage-TRD44LQ3.js +1 -0
- package/dist/public/chunks/SettingsPage-4E3SLQ36.js +1 -0
- package/dist/public/chunks/SystemPage-C3XBKXKW.js +1 -0
- package/dist/public/chunks/WorkspaceFormPage-2LS2X67Q.js +2 -0
- package/dist/public/chunks/WorkspacesPage-5WB3CUTX.js +1 -0
- package/dist/public/chunks/chunk-2BCS5AQL.js +1 -0
- package/dist/public/chunks/chunk-3Y3ICT6K.js +1 -0
- package/dist/public/chunks/chunk-4AKUZW6R.js +1 -0
- package/dist/public/chunks/chunk-4UGZNVJM.js +1 -0
- package/dist/public/chunks/chunk-564V7YHG.js +1 -0
- package/dist/public/chunks/chunk-5GZDBGAR.js +1 -0
- package/dist/public/chunks/chunk-5ISD3QSC.js +1 -0
- package/dist/public/chunks/chunk-5LNGNJ3P.js +1 -0
- package/dist/public/chunks/chunk-5ZA674OU.js +1 -0
- package/dist/public/chunks/chunk-6FSJ3W2W.js +1 -0
- package/dist/public/chunks/chunk-6GQL3EVJ.js +1 -0
- package/dist/public/chunks/chunk-6PHJZPWD.js +19 -0
- package/dist/public/chunks/chunk-A2QWWA7O.js +41 -0
- package/dist/public/chunks/chunk-AYXSBMWA.js +1 -0
- package/dist/public/chunks/chunk-B5ZN3SWQ.js +14 -0
- package/dist/public/chunks/chunk-B6HGRBBX.js +1 -0
- package/dist/public/chunks/chunk-BKOACADP.js +1 -0
- package/dist/public/chunks/chunk-CQY6BYUB.js +1 -0
- package/dist/public/chunks/chunk-CR2ICLSH.js +1 -0
- package/dist/public/chunks/chunk-DZB4LPI6.js +1 -0
- package/dist/public/chunks/chunk-EFEREKX4.js +1 -0
- package/dist/public/chunks/chunk-FE522UEA.js +1 -0
- package/dist/public/chunks/chunk-FVX42XAD.js +1 -0
- package/dist/public/chunks/chunk-G5NWYJTV.js +1 -0
- package/dist/public/chunks/chunk-GMZIELFU.js +1 -0
- package/dist/public/chunks/chunk-GO45RQDL.js +1 -0
- package/dist/public/chunks/chunk-GOTQ4D6W.js +1 -0
- package/dist/public/chunks/chunk-GRMX2HX6.js +1 -0
- package/dist/public/chunks/chunk-GYRM5ZIV.js +1 -0
- package/dist/public/chunks/chunk-H2ZU34CP.js +1 -0
- package/dist/public/chunks/chunk-H76B43OQ.js +1 -0
- package/dist/public/chunks/chunk-HCLRG2EC.js +1 -0
- package/dist/public/chunks/chunk-IA6BJDKN.js +1 -0
- package/dist/public/chunks/chunk-IZWTFLCC.js +1 -0
- package/dist/public/chunks/chunk-J37MXUED.js +1 -0
- package/dist/public/chunks/chunk-JAZABOBA.js +0 -0
- package/dist/public/chunks/chunk-L2H3ZWZN.js +1 -0
- package/dist/public/chunks/chunk-LBELRO5P.js +1 -0
- package/dist/public/chunks/chunk-LKW7MULF.js +1 -0
- package/dist/public/chunks/chunk-MEHLQZDG.js +3 -0
- package/dist/public/chunks/chunk-MTBUV2GJ.js +1 -0
- package/dist/public/chunks/chunk-N4AONKD3.js +1 -0
- package/dist/public/chunks/chunk-NQMDED2R.js +2 -0
- package/dist/public/chunks/chunk-NQOJAZFQ.js +1 -0
- package/dist/public/chunks/chunk-PEQNT5MO.js +1 -0
- package/dist/public/chunks/chunk-PQPAPPEI.js +1 -0
- package/dist/public/chunks/chunk-PXHLJIOT.js +1 -0
- package/dist/public/chunks/chunk-QNA7F7QB.js +1 -0
- package/dist/public/chunks/chunk-QOX5RMLM.js +1 -0
- package/dist/public/chunks/chunk-QPBLZSNX.js +1 -0
- package/dist/public/chunks/chunk-R4ORRE77.js +1 -0
- package/dist/public/chunks/chunk-RHIXHBS5.js +1 -0
- package/dist/public/chunks/chunk-RHMZNAJX.js +1 -0
- package/dist/public/chunks/chunk-SDKV3OID.js +1 -0
- package/dist/public/chunks/chunk-SIOW7OOW.js +1 -0
- package/dist/public/chunks/chunk-SUX4QBVF.js +1 -0
- package/dist/public/chunks/chunk-T4M53QQT.js +1 -0
- package/dist/public/chunks/chunk-TFJOEF3Q.js +1 -0
- package/dist/public/chunks/chunk-UAH6DWNS.js +1 -0
- package/dist/public/chunks/chunk-UJD3MBMB.js +1 -0
- package/dist/public/chunks/chunk-UK2V4UBT.js +1 -0
- package/dist/public/chunks/chunk-UT6HZPGL.js +1 -0
- package/dist/public/chunks/chunk-UZ44J2LH.js +1 -0
- package/dist/public/chunks/chunk-V5UDSIRA.js +63 -0
- package/dist/public/chunks/chunk-VC6IS564.js +1 -0
- package/dist/public/chunks/chunk-VFEBNOPX.js +1 -0
- package/dist/public/chunks/chunk-VXT3FIVL.js +55 -0
- package/dist/public/chunks/chunk-WJ3OXMVX.js +1 -0
- package/dist/public/chunks/chunk-WRXMQB7X.js +25 -0
- package/dist/public/chunks/chunk-WZVO3HG4.js +1 -0
- package/dist/public/chunks/chunk-XNR4RZEC.js +1 -0
- package/dist/public/chunks/chunk-YPMUHXYT.js +147 -0
- package/dist/public/chunks/chunk-YYNMZ4WC.js +1 -0
- package/dist/public/chunks/chunk-Z2JPRGFT.js +1 -0
- package/dist/public/chunks/chunk-ZIJ6SJDN.js +1 -0
- package/dist/public/chunks/code-highlighter-Q7WC7AEF.js +12 -0
- package/dist/public/client-TYC7JKRE.js +91 -0
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +1 -1
- package/src/assets.ts +4 -1
- package/src/client/App.tsx +100 -23
- package/src/client/TracedRoutes.tsx +8 -4
- package/src/client/code-highlighter.ts +26 -0
- package/src/client/code.tsx +2 -22
- package/src/client/components/Drawer.tsx +12 -71
- package/src/client/components/DrawerContent.tsx +76 -0
- package/src/client/components/ProfileMenu.tsx +12 -3
- package/src/client/components/UserInputForm.tsx +28 -5
- package/src/client/conversations/ConversationCatchUp.tsx +107 -0
- package/src/client/conversations/ConversationContextTags.tsx +110 -0
- package/src/client/conversations/ConversationEventLog.tsx +213 -71
- package/src/client/conversations/ConversationHeader.tsx +35 -41
- package/src/client/conversations/ConversationHeaderActions.tsx +34 -38
- package/src/client/conversations/ConversationHomeList.tsx +41 -3
- package/src/client/conversations/ConversationMeta.tsx +6 -12
- package/src/client/conversations/ConversationOverflowMenu.tsx +15 -4
- package/src/client/conversations/ConversationPage.tsx +188 -150
- package/src/client/conversations/ShareConversationButton.tsx +5 -50
- package/src/client/conversations/SlackMark.tsx +16 -4
- package/src/client/conversations/TranscriptRows.tsx +24 -11
- package/src/client/conversations/TranscriptView.tsx +21 -13
- package/src/client/conversations/conversationArchive.ts +22 -0
- package/src/client/conversations/conversationCatchUpState.ts +96 -0
- package/src/client/conversations/conversationMemory.ts +8 -1
- package/src/client/conversations/queries.ts +11 -3
- package/src/client/conversations/{transcriptUnreadHistory.ts → transcriptAroundHistory.ts} +48 -42
- package/src/client/conversations/transcriptBottomPinning.ts +27 -3
- package/src/client/conversations/transcriptReadPosition.ts +24 -2
- package/src/client/conversations/turnContext.ts +1 -1
- package/src/client/pages/admin/AdminPage.tsx +1 -1
- package/src/client/pages/admin/ServiceAccountsSection.tsx +1 -1
- package/src/client/pages/memory/memoryDashboard.ts +1 -1
- package/src/client/pages/memory/memoryRecord.ts +1 -1
- package/src/client/pages/settings/ConnectionsPage.tsx +1 -1
- package/src/client/pages/user/PluginUserPage.tsx +0 -6
- package/src/client/pages/user/pluginUserPageRoutes.ts +5 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/reviewItemFormat.ts +1 -1
- package/src/mock-reporting/fixtures.ts +43 -0
- package/src/mock-reporting/routes.ts +40 -3
- package/src/public-assets.ts +28 -20
- package/src/shell.ts +12 -2
- 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 {
|
|
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
|
|
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,
|
|
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
|
|
143
|
+
className="relative m-0 list-none p-0"
|
|
107
144
|
ref={listRef}
|
|
145
|
+
style={{ height: virtualizer.getTotalSize() }}
|
|
108
146
|
>
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
<span
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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. */
|