@sentry/junior-dashboard 0.234.0 → 0.236.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 (53) hide show
  1. package/dist/app.js +100 -18
  2. package/dist/client.js +47 -46
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +100 -18
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +3 -3
  7. package/src/app.ts +7 -1
  8. package/src/assets/junior-avatar.png +0 -0
  9. package/src/assets/junior-favicon.png +0 -0
  10. package/src/assets.ts +1 -0
  11. package/src/client/App.tsx +35 -0
  12. package/src/client/api.ts +63 -0
  13. package/src/client/components/README.md +8 -4
  14. package/src/client/components/SegmentedNav.tsx +167 -0
  15. package/src/client/components/SegmentedTabs.tsx +24 -5
  16. package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
  17. package/src/client/conversations/ConversationEventLog.tsx +45 -5
  18. package/src/client/conversations/ConversationForkLinks.tsx +51 -0
  19. package/src/client/conversations/ConversationHeader.tsx +12 -1
  20. package/src/client/conversations/ConversationPage.tsx +72 -2
  21. package/src/client/conversations/ForkConversationButton.tsx +125 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +28 -7
  23. package/src/client/conversations/TranscriptView.tsx +4 -0
  24. package/src/client/conversations/conversationRoutes.ts +10 -0
  25. package/src/client/conversations/queries.ts +45 -0
  26. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +28 -27
  27. package/src/client/pages/automations/AutomationEditPage.tsx +23 -24
  28. package/src/client/pages/automations/AutomationEditor.tsx +66 -13
  29. package/src/client/pages/automations/AutomationFilters.tsx +22 -14
  30. package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
  31. package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
  32. package/src/client/pages/automations/AutomationRow.tsx +172 -187
  33. package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
  34. package/src/client/pages/automations/AutomationsPage.tsx +40 -42
  35. package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
  36. package/src/client/pages/automations/README.md +14 -3
  37. package/src/client/pages/automations/automationFormat.ts +43 -0
  38. package/src/client/pages/code/CodeChangeRow.tsx +41 -0
  39. package/src/client/pages/code/CodePage.tsx +18 -93
  40. package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
  41. package/src/client/pages/code/RepositoryPage.tsx +227 -0
  42. package/src/client/pages/code/RepositorySections.tsx +108 -0
  43. package/src/client/pages/code/RepositoryTabs.tsx +146 -0
  44. package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
  45. package/src/client/pages/dev/ComponentsPage.tsx +5 -22
  46. package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
  47. package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
  48. package/src/client/pages/system/SystemNavigation.tsx +6 -2
  49. package/src/client/pages/system/SystemPageLayout.tsx +4 -6
  50. package/src/mock-reporting/code-repository.ts +77 -0
  51. package/src/mock-reporting/fixtures.ts +8 -2
  52. package/src/mock-reporting/routes.ts +25 -8
  53. package/src/shell.ts +21 -5
@@ -31,6 +31,8 @@ import { ConversationWorkspace } from "./conversations/ConversationWorkspace";
31
31
  import { ConversationWorkspaceLoading } from "./conversations/ConversationWorkspaceLoading";
32
32
  import { useConversationData } from "./conversations/queries";
33
33
  import { ComponentsPage } from "./pages/dev/ComponentsPage";
34
+ import { codeRepositoryTabs } from "./pages/code/codeRepositoryRoutes";
35
+ import { RepositoryPage } from "./pages/code/RepositoryPage";
34
36
  import { CodePage } from "./pages/code/CodePage";
35
37
  import { LocationDetailPage } from "./pages/locations/LocationDetailPage";
36
38
  import { LocationsPage } from "./pages/locations/LocationsPage";
@@ -48,6 +50,7 @@ import { WorkspacesPage } from "./pages/system/WorkspacesPage";
48
50
  import { MemoryRouteLoading } from "./pages/memory/MemoryPageLayout";
49
51
  import { AutomationExecutionsPage } from "./pages/automations/AutomationExecutionsPage";
50
52
  import { AutomationRunsPage } from "./pages/automations/AutomationRunsPage";
53
+ import { AutomationVersionsPage } from "./pages/automations/AutomationVersionsPage";
51
54
  import { AutomationsPage } from "./pages/automations/AutomationsPage";
52
55
  import {
53
56
  AutomationsPageLayout,
@@ -228,6 +231,27 @@ export function DashboardShell() {
228
231
  }
229
232
  path="/automations/:kind/:automationId/executions"
230
233
  />
234
+ <Route
235
+ element={
236
+ loading ? (
237
+ <AutomationsPageLayout>
238
+ <AutomationsRouteLoading
239
+ description="Saved settings for this automation, newest first."
240
+ label="Loading version history"
241
+ title="Version history"
242
+ variant="list"
243
+ />
244
+ </AutomationsPageLayout>
245
+ ) : loggedIn ? (
246
+ <AutomationsPageLayout>
247
+ <AutomationVersionsPage enabled={loggedIn} />
248
+ </AutomationsPageLayout>
249
+ ) : (
250
+ <Navigate replace to="/" />
251
+ )
252
+ }
253
+ path="/automations/:kind/:automationId/versions"
254
+ />
231
255
  <Route
232
256
  element={
233
257
  loading ? (
@@ -372,6 +396,17 @@ export function DashboardShell() {
372
396
  path="/"
373
397
  />
374
398
  <Route element={<CodePage />} path="/code" />
399
+ {codeRepositoryTabs.map((tab) => (
400
+ <Route
401
+ element={<RepositoryPage tab={tab} />}
402
+ key={tab}
403
+ path={
404
+ tab === "overview"
405
+ ? "/code/:repositoryId"
406
+ : `/code/:repositoryId/${tab}`
407
+ }
408
+ />
409
+ ))}
375
410
  <Route
376
411
  element={<Navigate replace to="/" />}
377
412
  path="/conversations/new"
package/src/client/api.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  conversationStatsReportSchema,
7
7
  codeOverviewReportSchema,
8
8
  codePersonReportSchema,
9
+ codeRepositoryReportSchema,
9
10
  statsReportSchema,
10
11
  } from "@sentry/junior/api/schema";
11
12
  import {
@@ -18,6 +19,7 @@ import {
18
19
  automationListSchema,
19
20
  automationSummarySchema,
20
21
  automationRunListSchema,
22
+ automationVersionListSchema,
21
23
  } from "@sentry/junior/api/schema";
22
24
  import {
23
25
  pluginOperationalReportFeedSchema,
@@ -115,6 +117,48 @@ export function useCodeOverviewData() {
115
117
  });
116
118
  }
117
119
 
120
+ /** Fetch code activity, recent changes, and Workspaces for one repository. */
121
+ export function useCodeRepositoryData(repositoryId: string | undefined) {
122
+ return useQuery({
123
+ enabled: Boolean(repositoryId),
124
+ queryKey: ["dashboard", "code", "repositories", repositoryId],
125
+ queryFn: ({ signal }) =>
126
+ fetchDashboardJson(
127
+ codeRepositoryReportSchema,
128
+ `/api/code/repositories/${encodeURIComponent(repositoryId!)}`,
129
+ signal,
130
+ ),
131
+ retry: false,
132
+ });
133
+ }
134
+
135
+ /** Fetch conversations linked to one repository through its code changes. */
136
+ export function useCodeRepositoryConversationsData(
137
+ repositoryId: string | undefined,
138
+ search = "",
139
+ ) {
140
+ return useQuery({
141
+ enabled: Boolean(repositoryId),
142
+ queryKey: [
143
+ "dashboard",
144
+ "conversations",
145
+ "code-repository",
146
+ { repositoryId, search },
147
+ ],
148
+ queryFn: ({ signal }) => {
149
+ const params = new URLSearchParams({ codeRepositoryId: repositoryId! });
150
+ if (search) params.set("q", search);
151
+ return fetchDashboardJson(
152
+ conversationFeedSchema,
153
+ `/api/conversations?${params.toString()}`,
154
+ signal,
155
+ );
156
+ },
157
+ placeholderData: keepPreviousData,
158
+ retry: false,
159
+ });
160
+ }
161
+
118
162
  /** Fetch the signed-in viewer's scheduled and event automations. */
119
163
  export function useAutomationsData(enabled: boolean, search: string) {
120
164
  return useQuery({
@@ -180,6 +224,25 @@ export function useAutomationExecutionsData(
180
224
  });
181
225
  }
182
226
 
227
+ /** Fetch saved definitions for one viewer-visible Automation, newest first. */
228
+ export function useAutomationVersionsData(
229
+ enabled: boolean,
230
+ kind: "scheduled" | "event" | undefined,
231
+ automationId: string | undefined,
232
+ ) {
233
+ return useQuery({
234
+ enabled: enabled && Boolean(kind && automationId),
235
+ queryKey: ["dashboard", "automations", kind, automationId, "versions"],
236
+ queryFn: ({ signal }) =>
237
+ fetchDashboardJson(
238
+ automationVersionListSchema,
239
+ `/api/automations/${kind}/${encodeURIComponent(automationId!)}/versions`,
240
+ signal,
241
+ ),
242
+ retry: false,
243
+ });
244
+ }
245
+
183
246
  /** Fetch the actor directory used by the People dashboard route. */
184
247
  export function useActorDirectoryData() {
185
248
  return useQuery({
@@ -7,10 +7,14 @@ instead of copying Tailwind blocks.
7
7
 
8
8
  1. **Primitives** — `Button` / `ToggleButton`, `Field`, `TextInput`,
9
9
  `StatusChip`, `StatusDot`, `Notice`, `FormNotice`, `Select`, `Drawer`, `Tooltip`, `Skeleton`,
10
- `PageContentSkeleton`, `SegmentedTabs`, and other small reusable controls.
11
- Use `SegmentedTabs` for equal-width panel navigation. Pass the selected value,
12
- items, change handler, and selected panel content. It owns tab/panel labels
13
- and keyboard focus. Keep collection filters in `FilterTabList`. Prefer
10
+ `PageContentSkeleton`, `SegmentedTabs`, `SegmentedNav`, and other small reusable controls.
11
+ Use the segmented pill track to pick one of a few siblings. Use
12
+ `SegmentedTabs` to switch panels in place. Pass the selected value, items,
13
+ change handler, and selected panel content. It owns tab/panel labels and
14
+ keyboard focus. Use `SegmentedNav` for links between sibling pages. With
15
+ `fill`, links share the row width equally while their labels fit. Otherwise
16
+ each link keeps its label width. Rows that do not fit scroll sideways. Keep
17
+ collection filters in `FilterTabList`. Prefer
14
18
  `ToggleButton` variants (`pill`, `segment`, `text`) over one-off pressed
15
19
  styles. Prefer `Field` sizes (`default`, `compact`) over handwritten labels.
16
20
  Prefer `StatusDot` for dense table or list status markers and `StatusChip`
@@ -0,0 +1,167 @@
1
+ import { useLayoutEffect, useRef, useState, type CSSProperties } from "react";
2
+ import { Link, NavLink, useLocation } from "react-router";
3
+
4
+ import { pathWithSearch } from "../searchParams";
5
+ import {
6
+ segmentedTabClass,
7
+ segmentedTabsFrameClass,
8
+ segmentedTabsInsetClass,
9
+ } from "./SegmentedTabs";
10
+ import { cn } from "../styles";
11
+
12
+ export type SegmentedNavItem = {
13
+ end?: boolean;
14
+ /** Override default NavLink matching when a path family should stay active. */
15
+ isActive?: (pathname: string) => boolean;
16
+ label: string;
17
+ to: string;
18
+ };
19
+
20
+ /** Link to a sibling page and keep page filters (range, scope, q). */
21
+ export function SiblingPageLink(props: {
22
+ className: (state: { isActive: boolean }) => string;
23
+ item: SegmentedNavItem;
24
+ }) {
25
+ const { className, item } = props;
26
+ const location = useLocation();
27
+ const to = pathWithSearch(item.to, location.search);
28
+ if (item.isActive) {
29
+ const isActive = item.isActive(location.pathname);
30
+ return (
31
+ <Link
32
+ aria-current={isActive ? "page" : undefined}
33
+ className={className({ isActive })}
34
+ to={to}
35
+ >
36
+ {item.label}
37
+ </Link>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <NavLink className={className} end={item.end} to={to}>
43
+ {item.label}
44
+ </NavLink>
45
+ );
46
+ }
47
+
48
+ /** Width of the fade on an edge that hides more links. */
49
+ const EDGE_FADE_PX = 24;
50
+
51
+ type HiddenEdges = { end: boolean; start: boolean };
52
+
53
+ function hiddenEdges(row: HTMLElement): HiddenEdges {
54
+ const maxScroll = row.scrollWidth - row.clientWidth;
55
+ return {
56
+ end: row.scrollLeft < maxScroll - 1,
57
+ start: row.scrollLeft > 1,
58
+ };
59
+ }
60
+
61
+ /** Scroll the current page link into view when the row or a fade covers it. */
62
+ function revealCurrentLink(row: HTMLElement) {
63
+ const link = row.querySelector<HTMLElement>('[aria-current="page"]');
64
+ if (!link || row.clientWidth === 0) return;
65
+ const edges = hiddenEdges(row);
66
+ const visibleStart = row.scrollLeft + (edges.start ? EDGE_FADE_PX : 0);
67
+ const visibleEnd =
68
+ row.scrollLeft + row.clientWidth - (edges.end ? EDGE_FADE_PX : 0);
69
+ const start = link.offsetLeft;
70
+ const end = start + link.offsetWidth;
71
+ if (start >= visibleStart && end <= visibleEnd) return;
72
+ // scrollIntoView can also scroll the page, so set scrollLeft on the row.
73
+ row.scrollLeft = start - (row.clientWidth - link.offsetWidth) / 2;
74
+ }
75
+
76
+ /** Fade each edge of the row that hides more links. */
77
+ function edgeFade(edges: HiddenEdges): CSSProperties | undefined {
78
+ if (!edges.start && !edges.end) return undefined;
79
+ const fade = `${EDGE_FADE_PX}px`;
80
+ const start = edges.start ? `transparent, black ${fade}` : "black";
81
+ const end = edges.end ? `black calc(100% - ${fade}), transparent` : "black";
82
+ const mask = `linear-gradient(to right, ${start}, ${end})`;
83
+ return { maskImage: mask, WebkitMaskImage: mask };
84
+ }
85
+
86
+ /**
87
+ * Show links between sibling pages in the segmented pill track.
88
+ *
89
+ * With `fill`, links share the row width equally while their labels fit.
90
+ * Otherwise each link keeps its label width. When the links do not fit, the
91
+ * row scrolls sideways and fades the edge that hides more links.
92
+ */
93
+ export function SegmentedNav(props: {
94
+ ariaLabel: string;
95
+ fill?: boolean;
96
+ items: readonly SegmentedNavItem[];
97
+ }) {
98
+ const { pathname } = useLocation();
99
+ const rowRef = useRef<HTMLDivElement>(null);
100
+ const [edges, setEdges] = useState<HiddenEdges>({
101
+ end: false,
102
+ start: false,
103
+ });
104
+
105
+ useLayoutEffect(() => {
106
+ const row = rowRef.current;
107
+ if (!row) return;
108
+ const updateEdges = () => {
109
+ const { end, start } = hiddenEdges(row);
110
+ setEdges((previous) =>
111
+ previous.start === start && previous.end === end
112
+ ? previous
113
+ : { end, start },
114
+ );
115
+ };
116
+ // A row hidden at this breakpoint has no width. Reveal the current link
117
+ // when it gets one.
118
+ const observer = new ResizeObserver(() => {
119
+ revealCurrentLink(row);
120
+ updateEdges();
121
+ });
122
+ updateEdges();
123
+ row.addEventListener("scroll", updateEdges, { passive: true });
124
+ observer.observe(row);
125
+ return () => {
126
+ row.removeEventListener("scroll", updateEdges);
127
+ observer.disconnect();
128
+ };
129
+ }, []);
130
+
131
+ useLayoutEffect(() => {
132
+ if (rowRef.current) revealCurrentLink(rowRef.current);
133
+ }, [pathname]);
134
+
135
+ const linkClass = ({ isActive }: { isActive: boolean }) =>
136
+ cn(
137
+ segmentedTabClass(isActive),
138
+ "flex items-center justify-center whitespace-nowrap no-underline",
139
+ props.fill ? "min-w-max flex-[1_0_0]" : "shrink-0",
140
+ );
141
+
142
+ return (
143
+ <nav
144
+ aria-label={props.ariaLabel}
145
+ className={cn(
146
+ segmentedTabsFrameClass,
147
+ "flex min-w-0 max-w-full",
148
+ !props.fill && "w-fit",
149
+ )}
150
+ >
151
+ {/* Pad the scrolling row, not the frame, so the last link keeps its
152
+ padding when the row is scrolled to the end. */}
153
+ <div
154
+ className={cn(
155
+ segmentedTabsInsetClass,
156
+ "relative flex min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
157
+ )}
158
+ ref={rowRef}
159
+ style={edgeFade(edges)}
160
+ >
161
+ {props.items.map((item) => (
162
+ <SiblingPageLink className={linkClass} item={item} key={item.to} />
163
+ ))}
164
+ </div>
165
+ </nav>
166
+ );
167
+ }
@@ -2,6 +2,23 @@ import { useId, type ReactNode } from "react";
2
2
 
3
3
  import { cn } from "../styles";
4
4
 
5
+ /** Style the border and fill of the segmented pill track. */
6
+ export const segmentedTabsFrameClass =
7
+ "rounded-lg border border-dashboard-border bg-dashboard-surface-panel";
8
+
9
+ /** Style the spacing inside the segmented pill track. */
10
+ export const segmentedTabsInsetClass = "gap-1 p-1";
11
+
12
+ /** Style one tab or link in the segmented pill track. */
13
+ export function segmentedTabClass(selected: boolean): string {
14
+ return cn(
15
+ "min-h-9 cursor-pointer rounded-md border-0 px-3 py-2 font-sans text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
16
+ selected
17
+ ? "bg-dashboard-fill-strong text-dashboard-text"
18
+ : "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
19
+ );
20
+ }
21
+
5
22
  /** Switch between related panels with equal-width tabs and keyboard navigation. */
6
23
  export function SegmentedTabs<const Value extends string>(props: {
7
24
  children: ReactNode;
@@ -16,7 +33,11 @@ export function SegmentedTabs<const Value extends string>(props: {
16
33
  <div className="grid min-w-0 gap-4">
17
34
  <div
18
35
  aria-label={props.label}
19
- className="grid auto-cols-fr grid-flow-col gap-1 rounded-lg border border-dashboard-border bg-dashboard-surface-panel p-1"
36
+ className={cn(
37
+ "grid auto-cols-fr grid-flow-col",
38
+ segmentedTabsFrameClass,
39
+ segmentedTabsInsetClass,
40
+ )}
20
41
  role="tablist"
21
42
  >
22
43
  {props.items.map((item, index) => (
@@ -24,10 +45,8 @@ export function SegmentedTabs<const Value extends string>(props: {
24
45
  aria-controls={`${id}-panel`}
25
46
  aria-selected={props.value === item.value}
26
47
  className={cn(
27
- "min-h-9 min-w-0 cursor-pointer rounded-md border-0 px-3 py-2 font-sans text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus",
28
- props.value === item.value
29
- ? "bg-dashboard-fill-strong text-dashboard-text"
30
- : "bg-transparent text-dashboard-text-muted hover:bg-dashboard-fill-faint hover:text-dashboard-text",
48
+ "min-w-0",
49
+ segmentedTabClass(props.value === item.value),
31
50
  )}
32
51
  id={`${id}-${item.value}`}
33
52
  key={item.value}
@@ -1,6 +1,10 @@
1
- import { Link, NavLink, useLocation } from "react-router";
1
+ import type { ReactNode } from "react";
2
2
 
3
- import { pathWithSearch } from "../../searchParams";
3
+ import {
4
+ SegmentedNav,
5
+ SiblingPageLink,
6
+ type SegmentedNavItem,
7
+ } from "../SegmentedNav";
4
8
  import {
5
9
  cn,
6
10
  dashboardContainerClass,
@@ -8,62 +12,20 @@ import {
8
12
  } from "../../styles";
9
13
  import { SecondaryNavigationPortal } from "./DashboardChrome";
10
14
 
11
- export type SecondaryNavigationItem = {
12
- end?: boolean;
13
- /** Override default NavLink matching when a path family should stay active. */
14
- isActive?: (pathname: string) => boolean;
15
- label: string;
16
- to: string;
17
- };
18
-
19
- type LinkClassState = { isActive: boolean };
20
-
21
- function SecondaryNavItem(props: {
22
- className: (state: LinkClassState) => string;
23
- item: SecondaryNavigationItem;
24
- pathname: string;
25
- search: string;
26
- }) {
27
- const { className, item, pathname, search } = props;
28
- // Keep page filters (range, scope, q) when moving across secondary sections.
29
- const to = pathWithSearch(item.to, search);
30
- if (item.isActive) {
31
- const isActive = item.isActive(pathname);
32
- return (
33
- <Link
34
- aria-current={isActive ? "page" : undefined}
35
- className={className({ isActive })}
36
- to={to}
37
- >
38
- {item.label}
39
- </Link>
40
- );
41
- }
42
-
43
- return (
44
- <NavLink className={className} end={item.end} to={to}>
45
- {item.label}
46
- </NavLink>
47
- );
48
- }
15
+ export type SecondaryNavigationItem = SegmentedNavItem;
49
16
 
50
- /** Render page navigation in the desktop chrome and mobile drawer. */
17
+ /**
18
+ * Render page navigation above the page, in the desktop chrome, and in the
19
+ * mobile menu. On mobile the links sit at the top of the page and scroll with
20
+ * it. Pass the page as children so the links and the page share one layout
21
+ * row.
22
+ */
51
23
  export function SecondaryNavigation(props: {
52
24
  ariaLabel: string;
25
+ children: ReactNode;
53
26
  items: SecondaryNavigationItem[];
54
27
  }) {
55
- const location = useLocation();
56
- const desktopLinkClass = ({ isActive }: LinkClassState) =>
57
- cn(
58
- "relative flex h-12 shrink-0 items-center px-3 font-display text-xs font-medium no-underline transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:transition-colors sm:text-sm",
59
- isActive
60
- ? "text-dashboard-text after:bg-cyan-300"
61
- : cn(
62
- "after:bg-transparent hover:bg-white/[0.025]",
63
- dashboardInteractiveTextClass,
64
- ),
65
- );
66
- const mobileLinkClass = ({ isActive }: LinkClassState) =>
28
+ const menuLinkClass = ({ isActive }: { isActive: boolean }) =>
67
29
  cn(
68
30
  "rounded-lg px-3 py-3 pl-6 font-mono text-sm font-medium no-underline transition-colors",
69
31
  isActive
@@ -72,44 +34,39 @@ export function SecondaryNavigation(props: {
72
34
  );
73
35
 
74
36
  return (
75
- <SecondaryNavigationPortal
76
- desktop={
77
- <div className="border-b border-white/[0.06] bg-white/[0.018]">
37
+ <div className="min-w-0">
38
+ <SecondaryNavigationPortal
39
+ desktop={
40
+ <div className="border-b border-white/[0.06] bg-white/[0.018]">
41
+ <div
42
+ className={cn(
43
+ dashboardContainerClass,
44
+ "min-w-0 px-4 py-2 md:px-8",
45
+ )}
46
+ >
47
+ <SegmentedNav ariaLabel={props.ariaLabel} items={props.items} />
48
+ </div>
49
+ </div>
50
+ }
51
+ mobile={
78
52
  <nav
79
53
  aria-label={props.ariaLabel}
80
- className={cn(
81
- dashboardContainerClass,
82
- "flex min-w-0 gap-1 overflow-x-auto px-4 [scrollbar-width:none] md:px-8 [&::-webkit-scrollbar]:hidden",
83
- )}
54
+ className="mt-3 grid gap-1 border-t border-white/[0.07] pt-3"
84
55
  >
85
56
  {props.items.map((item) => (
86
- <SecondaryNavItem
87
- className={desktopLinkClass}
57
+ <SiblingPageLink
58
+ className={menuLinkClass}
88
59
  item={item}
89
60
  key={item.to}
90
- pathname={location.pathname}
91
- search={location.search}
92
61
  />
93
62
  ))}
94
63
  </nav>
95
- </div>
96
- }
97
- mobile={
98
- <nav
99
- aria-label={props.ariaLabel}
100
- className="mt-3 grid gap-1 border-t border-white/[0.07] pt-3"
101
- >
102
- {props.items.map((item) => (
103
- <SecondaryNavItem
104
- className={mobileLinkClass}
105
- item={item}
106
- key={item.to}
107
- pathname={location.pathname}
108
- search={location.search}
109
- />
110
- ))}
111
- </nav>
112
- }
113
- />
64
+ }
65
+ />
66
+ <div className="px-4 pt-4 sm:px-8 md:hidden">
67
+ <SegmentedNav ariaLabel={props.ariaLabel} fill items={props.items} />
68
+ </div>
69
+ {props.children}
70
+ </div>
114
71
  );
115
72
  }
@@ -1,5 +1,5 @@
1
1
  import { ChevronRight } from "lucide-react";
2
- import { memo, useId, useMemo, useState } from "react";
2
+ import { memo, useEffect, useId, useMemo, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
 
5
5
  import { Drawer } from "../components/Drawer";
@@ -12,18 +12,31 @@ import {
12
12
  eventLogUsage,
13
13
  } from "./eventLog";
14
14
  import { EventDetails } from "./EventDetails";
15
+ import { ForkConversationButton } from "./ForkConversationButton";
15
16
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
16
17
 
17
18
  const rowClass =
18
19
  "grid grid-cols-[12ch_minmax(0,1fr)_1rem] items-center gap-x-3 px-3 @min-[48rem]:grid-cols-[12ch_19ch_minmax(0,1fr)_1rem]";
19
20
 
20
- /** Show every reporting event in sequence order, outside the transcript reducer. */
21
+ /**
22
+ * Show every reporting event in sequence order, outside the transcript reducer.
23
+ * Pass `onSelectedSeqChange` to own the open entry, for example in the URL.
24
+ */
21
25
  export const ConversationEventLog = memo(function ConversationEventLog(props: {
22
26
  conversation: ConversationTranscript;
27
+ onSelectedSeqChange?(seq: number | undefined): void;
28
+ selectedSeq?: number;
23
29
  }) {
24
30
  const { conversation } = props;
25
31
  const search = useTranscriptSearch();
26
- const [selectedSeq, setSelectedSeq] = useState<number>();
32
+ const [localSelectedSeq, setLocalSelectedSeq] = useState<number>();
33
+ const selectedSeq = props.onSelectedSeqChange
34
+ ? props.selectedSeq
35
+ : localSelectedSeq;
36
+ const setSelectedSeq = props.onSelectedSeqChange ?? setLocalSelectedSeq;
37
+ const listRef = useRef<HTMLOListElement>(null);
38
+ // Rows opened by a click are already visible. Scroll only for a linked entry.
39
+ const revealedSeqRef = useRef<number>(undefined);
27
40
  const titleId = useId();
28
41
  const events = conversation.events;
29
42
  const rows = useMemo(
@@ -50,6 +63,17 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
50
63
  );
51
64
  // Resolve the selection from current data so polling cannot leave stale details.
52
65
  const selected = events.find((event) => event.seq === selectedSeq);
66
+ useEffect(() => {
67
+ if (selectedSeq === undefined || revealedSeqRef.current === selectedSeq) {
68
+ return;
69
+ }
70
+ const row = listRef.current?.querySelector(
71
+ `[data-event-seq="${selectedSeq}"]`,
72
+ );
73
+ if (!row) return;
74
+ revealedSeqRef.current = selectedSeq;
75
+ row.scrollIntoView({ block: "center" });
76
+ }, [selectedSeq, visibleRows]);
53
77
 
54
78
  if (conversation.eventHistory.status === "expired") {
55
79
  return (
@@ -85,9 +109,10 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
85
109
  <ol
86
110
  aria-label="Events"
87
111
  className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
112
+ ref={listRef}
88
113
  >
89
114
  {visibleRows.map(({ event, summary, model, usage }) => (
90
- <li key={event.seq}>
115
+ <li data-event-seq={event.seq} key={event.seq}>
91
116
  <button
92
117
  aria-haspopup="dialog"
93
118
  aria-label={`Event ${event.seq}: ${event.data.type}`}
@@ -96,7 +121,10 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
96
121
  "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-[48rem]:min-h-8 @min-[48rem]:py-1",
97
122
  selectedSeq === event.seq && "bg-dashboard-fill-strong",
98
123
  )}
99
- onClick={() => setSelectedSeq(event.seq)}
124
+ onClick={() => {
125
+ revealedSeqRef.current = event.seq;
126
+ setSelectedSeq(event.seq);
127
+ }}
100
128
  type="button"
101
129
  >
102
130
  <time
@@ -191,6 +219,18 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
191
219
  titleId={titleId}
192
220
  width="wide"
193
221
  >
222
+ {conversation.canFork &&
223
+ selected.data.type === "message" &&
224
+ selected.data.role === "assistant" &&
225
+ !selected.data.redacted ? (
226
+ <div className="mb-3 flex items-center gap-2 text-sm">
227
+ <ForkConversationButton
228
+ conversationId={conversation.conversationId}
229
+ messageId={selected.data.messageId}
230
+ />
231
+ <span>Fork after this reply</span>
232
+ </div>
233
+ ) : null}
194
234
  <EventDetails key={selected.seq} event={selected} />
195
235
  </Drawer>,
196
236
  document.body,