@sentry/junior-dashboard 0.270.0 → 0.272.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 (48) hide show
  1. package/dist/app.js +44 -3
  2. package/dist/client.js +77 -77
  3. package/dist/index.js +40 -3
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/app.ts +2 -0
  7. package/src/client/App.tsx +89 -61
  8. package/src/client/components/ProfileMenu.tsx +0 -24
  9. package/src/client/components/UserInputForm.tsx +38 -7
  10. package/src/client/components/layout/PageHeader.tsx +25 -10
  11. package/src/client/conversations/ConversationPage.tsx +55 -8
  12. package/src/client/conversations/ConversationTranscript.tsx +74 -5
  13. package/src/client/conversations/TranscriptRows.tsx +23 -0
  14. package/src/client/conversations/TranscriptView.tsx +22 -0
  15. package/src/client/conversations/conversationRoutes.ts +30 -3
  16. package/src/client/conversations/queries.ts +30 -5
  17. package/src/client/conversations/transcriptBottomPinning.ts +84 -13
  18. package/src/client/conversations/transcriptReadPosition.ts +267 -0
  19. package/src/client/conversations/transcriptRenderModel.ts +16 -2
  20. package/src/client/pages/automations/AutomationActions.tsx +2 -3
  21. package/src/client/pages/automations/AutomationEditor.tsx +18 -30
  22. package/src/client/pages/automations/AutomationOverview.tsx +2 -2
  23. package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
  24. package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
  25. package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
  26. package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
  27. package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +115 -61
  28. package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
  29. package/src/client/primaryNav.ts +1 -1
  30. package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
  31. package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
  32. package/src/client/review-desk/ReviewItemActions.tsx +1 -10
  33. package/src/client/review-desk/ReviewItemContext.tsx +108 -0
  34. package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
  35. package/src/client/review-desk/ReviewItemView.tsx +159 -0
  36. package/src/client/review-desk/queries.ts +3 -3
  37. package/src/client/review-desk/reviewItemFormat.ts +52 -0
  38. package/src/mock-reporting/read-positions.ts +39 -0
  39. package/src/mock-reporting/routes.ts +26 -1
  40. package/src/tailwind.css +1 -1
  41. package/src/client/components/AGENTS.md +0 -65
  42. package/src/client/components/charts/AGENTS.md +0 -15
  43. package/src/client/conversations/AGENTS.md +0 -38
  44. package/src/client/pages/ConnectionsPage.tsx +0 -207
  45. package/src/client/pages/PersonalTokensPage.tsx +0 -205
  46. package/src/client/pages/automations/AGENTS.md +0 -34
  47. package/src/client/pages/system/AGENTS.md +0 -12
  48. package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.270.0",
3
+ "version": "0.272.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,10 +39,10 @@
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "4.1.0",
41
41
  "zod": "^4.5.4",
42
- "@sentry/junior-plugin-api": "0.270.0"
42
+ "@sentry/junior-plugin-api": "0.272.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@sentry/junior": "0.270.0"
45
+ "@sentry/junior": "0.272.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@oxlint/plugins": "1.79.0",
@@ -63,7 +63,7 @@
63
63
  "tw-animate-css": "^1.4.0",
64
64
  "typescript": "^7.0.2",
65
65
  "vitest": "^4.1.12",
66
- "@sentry/junior": "0.270.0"
66
+ "@sentry/junior": "0.272.0"
67
67
  },
68
68
  "scripts": {
69
69
  "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",
package/src/app.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from "./auth";
37
37
  import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes";
38
38
  import { resetMockConversationArchiveState } from "./mock-reporting/fixtures";
39
+ import { resetMockReadPositions } from "./mock-reporting/read-positions";
39
40
  import { createMockReportingApi } from "./mock-reporting/routes";
40
41
  import {
41
42
  DASHBOARD_AVATAR_HEADER_PATH,
@@ -71,6 +72,7 @@ const mockDisplayNamesByEmail = new Map<string, string>();
71
72
  export function resetMockDashboardState(): void {
72
73
  mockDisplayNamesByEmail.clear();
73
74
  resetMockConversationArchiveState();
75
+ resetMockReadPositions();
74
76
  }
75
77
 
76
78
  export interface JuniorDashboardOptions {
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
2
  import { Navigate, Route, Routes, useLocation, useParams } from "react-router";
3
3
 
4
4
  import {
@@ -11,7 +11,10 @@ import {
11
11
  import { ConnectionBanner } from "./components/ConnectionBanner";
12
12
  import { LoadingView } from "./components/LoadingView";
13
13
  import { VersionDriftBanner } from "./components/VersionDriftBanner";
14
- import { PageRouteLoading } from "./components/PageRouteLoading";
14
+ import {
15
+ PageContentSkeleton,
16
+ type PageContentSkeletonVariant,
17
+ } from "./components/PageContentSkeleton";
15
18
  import { ProfileMenu } from "./components/ProfileMenu";
16
19
  import {
17
20
  DashboardChrome,
@@ -36,10 +39,12 @@ import { CodePage } from "./pages/code/CodePage";
36
39
  import { LocationDetailPage } from "./pages/locations/LocationDetailPage";
37
40
  import { LocationsPage } from "./pages/locations/LocationsPage";
38
41
  import { PeoplePage } from "./pages/people/PeoplePage";
39
- import { ConnectionsPage } from "./pages/ConnectionsPage";
40
- import { PersonalTokensPage } from "./pages/PersonalTokensPage";
41
42
  import { PersonProfilePage } from "./pages/people/PersonProfilePage";
42
- import { SettingsPage } from "./pages/SettingsPage";
43
+ import { ConnectionsPage } from "./pages/settings/ConnectionsPage";
44
+ import { McpSettingsPage } from "./pages/settings/McpSettingsPage";
45
+ import { PersonalTokensPage } from "./pages/settings/PersonalTokensPage";
46
+ import { SettingsPage } from "./pages/settings/SettingsPage";
47
+ import { SettingsPageLayout } from "./pages/settings/SettingsPageLayout";
43
48
  import { SystemPage } from "./pages/system/SystemPage";
44
49
  import {
45
50
  SystemPageLayout,
@@ -65,7 +70,10 @@ import {
65
70
  } from "./pages/user/PluginUserPage";
66
71
  import { AdminPage } from "./pages/admin/AdminPage";
67
72
  import { buildPrimaryNavItems } from "./primaryNav";
68
- import { ReviewDeskPage } from "./review-desk/ReviewDeskPage";
73
+ import {
74
+ ReviewDeskLoading,
75
+ ReviewDeskPage,
76
+ } from "./review-desk/ReviewDeskPage";
69
77
  import { useReviewItemCountData } from "./review-desk/queries";
70
78
  import type { DashboardCoreData } from "./types";
71
79
 
@@ -74,6 +82,56 @@ const dashboardNoise = {
74
82
  "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitchTiles'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.7'/%3E%3C/svg%3E\")",
75
83
  };
76
84
 
85
+ const settingsRoutes: {
86
+ loading: {
87
+ description: string;
88
+ label: string;
89
+ title: string;
90
+ variant?: PageContentSkeletonVariant;
91
+ };
92
+ path: string;
93
+ render(data: DashboardCoreData): ReactNode;
94
+ }[] = [
95
+ {
96
+ loading: {
97
+ description: "Manage your profile and preferences.",
98
+ label: "Loading settings",
99
+ title: "General",
100
+ },
101
+ path: "/settings",
102
+ render: (data) => <SettingsPage identity={data.me} />,
103
+ },
104
+ {
105
+ loading: {
106
+ description: "Create and revoke personal API tokens.",
107
+ label: "Loading API tokens",
108
+ title: "API Tokens",
109
+ variant: "list",
110
+ },
111
+ path: "/settings/api-tokens",
112
+ render: () => <PersonalTokensPage />,
113
+ },
114
+ {
115
+ loading: {
116
+ description: "Connect and disconnect your accounts.",
117
+ label: "Loading identities",
118
+ title: "Identities",
119
+ variant: "list",
120
+ },
121
+ path: "/settings/connections",
122
+ render: () => <ConnectionsPage />,
123
+ },
124
+ {
125
+ loading: {
126
+ description: "Connect an MCP client to your conversations.",
127
+ label: "Loading MCP",
128
+ title: "MCP",
129
+ },
130
+ path: "/settings/mcp",
131
+ render: () => <McpSettingsPage />,
132
+ },
133
+ ];
134
+
77
135
  /** Render the dashboard SPA shell and route-level loading states. */
78
136
  export function DashboardShell() {
79
137
  const location = useLocation();
@@ -147,6 +205,7 @@ export function DashboardShell() {
147
205
  className="bg-dashboard-bg"
148
206
  enabled={
149
207
  workspace ||
208
+ location.pathname === "/review" ||
150
209
  hasAutomationDetailsFrame(location.pathname) ||
151
210
  isMemoryLibraryPath(location.pathname)
152
211
  }
@@ -375,11 +434,7 @@ export function DashboardShell() {
375
434
  <Route
376
435
  element={
377
436
  loading ? (
378
- <PageRouteLoading
379
- description="Items that need a person, and who can act on them."
380
- label="Loading review desk"
381
- title="Review desk"
382
- />
437
+ <ReviewDeskLoading />
383
438
  ) : loggedIn ? (
384
439
  <ReviewDeskPage />
385
440
  ) : (
@@ -523,56 +578,29 @@ export function DashboardShell() {
523
578
  }
524
579
  path="/system/*"
525
580
  />
526
- <Route
527
- element={
528
- loading ? (
529
- <PageRouteLoading
530
- description="Manage your Junior profile and preferences."
531
- label="Loading settings"
532
- title="Settings"
533
- />
534
- ) : loggedIn ? (
535
- <SettingsPage identity={data!.me} />
536
- ) : (
537
- <Navigate replace to="/" />
538
- )
539
- }
540
- path="/settings"
541
- />
542
- <Route
543
- element={
544
- loading ? (
545
- <PageRouteLoading
546
- description="Create and revoke personal API tokens."
547
- label="Loading API tokens"
548
- title="API tokens"
549
- variant="list"
550
- />
551
- ) : loggedIn ? (
552
- <PersonalTokensPage />
553
- ) : (
554
- <Navigate replace to="/" />
555
- )
556
- }
557
- path="/settings/api-tokens"
558
- />
559
- <Route
560
- element={
561
- loading ? (
562
- <PageRouteLoading
563
- description="Connect and disconnect your accounts."
564
- label="Loading connected accounts"
565
- title="Connected accounts"
566
- variant="list"
567
- />
568
- ) : loggedIn ? (
569
- <ConnectionsPage />
570
- ) : (
571
- <Navigate replace to="/" />
572
- )
573
- }
574
- path="/settings/connections"
575
- />
581
+ {settingsRoutes.map((route) => (
582
+ <Route
583
+ element={
584
+ loading ? (
585
+ <SettingsPageLayout
586
+ description={route.loading.description}
587
+ title={route.loading.title}
588
+ >
589
+ <PageContentSkeleton
590
+ label={route.loading.label}
591
+ variant={route.loading.variant}
592
+ />
593
+ </SettingsPageLayout>
594
+ ) : loggedIn ? (
595
+ route.render(data!)
596
+ ) : (
597
+ <Navigate replace to="/" />
598
+ )
599
+ }
600
+ key={route.path}
601
+ path={route.path}
602
+ />
603
+ ))}
576
604
  <Route
577
605
  element={
578
606
  loading || userPagesQuery.isPending ? (
@@ -1,9 +1,7 @@
1
1
  import {
2
2
  Boxes,
3
3
  ChevronDown,
4
- KeyRound,
5
4
  LogOut,
6
- Plug,
7
5
  Settings,
8
6
  ShieldCheck,
9
7
  UserRound,
@@ -197,22 +195,6 @@ export function ProfileMenu({
197
195
  <Settings aria-hidden="true" size={16} strokeWidth={2} />
198
196
  Settings
199
197
  </Link>
200
- <Link
201
- className={profileLinkClass}
202
- onClick={close}
203
- to="/settings/api-tokens"
204
- >
205
- <KeyRound aria-hidden="true" size={16} strokeWidth={2} />
206
- API tokens
207
- </Link>
208
- <Link
209
- className={profileLinkClass}
210
- onClick={close}
211
- to="/settings/connections"
212
- >
213
- <Plug aria-hidden="true" size={16} strokeWidth={2} />
214
- Connected accounts
215
- </Link>
216
198
  {identity.user.isAdmin ? (
217
199
  <Link className={profileLinkClass} onClick={close} to="/admin">
218
200
  <ShieldCheck aria-hidden="true" size={16} strokeWidth={2} />
@@ -282,12 +264,6 @@ export function ProfileMenu({
282
264
  <Link className={sheetItemClass} to="/settings">
283
265
  Settings
284
266
  </Link>
285
- <Link className={sheetItemClass} to="/settings/api-tokens">
286
- API tokens
287
- </Link>
288
- <Link className={sheetItemClass} to="/settings/connections">
289
- Connected accounts
290
- </Link>
291
267
  {identity.user.isAdmin ? (
292
268
  <Link className={sheetItemClass} to="/admin">
293
269
  Admin
@@ -35,10 +35,31 @@ const optionClass =
35
35
  const choiceButtonClass =
36
36
  "group flex w-full cursor-pointer items-center gap-3 rounded-md border border-dashboard-border bg-dashboard-surface-raised px-3 py-2.5 text-left font-sans text-sm transition-colors hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus disabled:cursor-not-allowed disabled:opacity-60 data-[picked]:border-dashboard-accent/60 data-[picked]:bg-dashboard-accent/10 data-[picked]:opacity-100";
37
37
 
38
- function OptionText(props: { description?: string; label: string }) {
38
+ // Marks the choice that Junior recommends, next to its label.
39
+ function SuggestedLabel(props: { className?: string }) {
40
+ return (
41
+ <span
42
+ className={cn(
43
+ "ml-2 text-xs font-normal text-dashboard-accent-text/80",
44
+ props.className,
45
+ )}
46
+ >
47
+ Suggested
48
+ </span>
49
+ );
50
+ }
51
+
52
+ function OptionText(props: {
53
+ description?: string;
54
+ label: string;
55
+ recommended?: boolean;
56
+ }) {
39
57
  return (
40
58
  <span className="grid min-w-0 gap-0.5 break-words">
41
- <span className="font-medium text-dashboard-text">{props.label}</span>
59
+ <span className="font-medium text-dashboard-text">
60
+ {props.label}
61
+ {props.recommended ? <SuggestedLabel /> : null}
62
+ </span>
42
63
  {props.description ? (
43
64
  <span className="text-xs text-dashboard-text-muted">
44
65
  {props.description}
@@ -56,13 +77,15 @@ function ChoiceField(props: {
56
77
  }) {
57
78
  const id = useId();
58
79
  const { field } = props;
59
- // Say how many choices count, so a reader knows when a field is done.
80
+ // Radios already say that one choice counts. Say it for checkboxes only.
60
81
  const legend = (
61
82
  <legend className="mb-2 p-0 text-sm font-semibold text-dashboard-text">
62
83
  {field.label}
63
- <span className="ml-2 text-xs font-normal text-dashboard-text-muted">
64
- {field.kind === "choice" ? "Pick one" : "Pick any"}
65
- </span>
84
+ {field.kind === "multi_choice" ? (
85
+ <span className="ml-2 text-xs font-normal text-dashboard-text-muted">
86
+ Pick any
87
+ </span>
88
+ ) : null}
66
89
  </legend>
67
90
  );
68
91
  const description = field.description ? (
@@ -90,6 +113,7 @@ function ChoiceField(props: {
90
113
  <OptionText
91
114
  description={option.description}
92
115
  label={option.label}
116
+ recommended={option.recommended}
93
117
  />
94
118
  </label>
95
119
  ))}
@@ -119,7 +143,11 @@ function ChoiceField(props: {
119
143
  )
120
144
  }
121
145
  />
122
- <OptionText description={option.description} label={option.label} />
146
+ <OptionText
147
+ description={option.description}
148
+ label={option.label}
149
+ recommended={option.recommended}
150
+ />
123
151
  </label>
124
152
  ))}
125
153
  </div>
@@ -261,6 +289,7 @@ export function UserInputForm(props: {
261
289
  id={`${choicesId}-${index}-label`}
262
290
  >
263
291
  {option.label}
292
+ {option.recommended ? <SuggestedLabel /> : null}
264
293
  </span>
265
294
  {option.description ? (
266
295
  <span
@@ -292,6 +321,8 @@ export function UserInputForm(props: {
292
321
  onClick={() => pick(buttonField.id, option.label)}
293
322
  >
294
323
  {option.label}
324
+ {/* The button gap already spaces the label. */}
325
+ {option.recommended ? <SuggestedLabel className="ml-0" /> : null}
295
326
  </Button>
296
327
  ))}
297
328
  {secondary}
@@ -5,17 +5,20 @@ import {
5
5
  type TimeRangeDays,
6
6
  } from "../controls/TimeRangeSelector";
7
7
  import { useDocumentTitle } from "../../documentTitle";
8
+ import { cn } from "../../styles";
8
9
 
9
10
  /**
10
11
  * Render a spacious analytics page heading with optional controls.
11
12
  *
12
13
  * Also names the browser tab after the page. Pass `documentTitle` when the tab
13
- * needs more context than the visible heading.
14
+ * needs more context than the visible heading. Pass `navigation` to put page
15
+ * links under the heading, with their underline on the header border.
14
16
  */
15
17
  export function PageHeader(props: {
16
18
  actions?: ReactNode;
17
- description: ReactNode;
19
+ description?: ReactNode;
18
20
  documentTitle?: string;
21
+ navigation?: ReactNode;
19
22
  onRangeChange?(value: TimeRangeDays): void;
20
23
  range?: TimeRangeDays;
21
24
  rangeOptions?: TimeRangeDays[];
@@ -36,16 +39,28 @@ export function PageHeader(props: {
36
39
  const actions = props.actions ?? rangeActions;
37
40
 
38
41
  return (
39
- <header className="flex min-w-0 items-center justify-between gap-6 border-b border-dashboard-border-subtle pb-4 sm:pb-6 max-md:flex-col max-md:items-stretch">
40
- <div className="min-w-0">
41
- <h2 className="m-0 font-display text-3xl font-light leading-none tracking-[-0.03em] text-dashboard-text sm:text-4xl">
42
- {props.title}
43
- </h2>
44
- <div className="mt-2 max-w-2xl font-sans text-xs leading-relaxed text-dashboard-text-muted sm:text-sm">
45
- {props.description}
42
+ <header
43
+ className={cn(
44
+ "min-w-0 border-b border-dashboard-border-subtle",
45
+ !props.navigation && "pb-4 sm:pb-6",
46
+ )}
47
+ >
48
+ <div className="flex min-w-0 items-center justify-between gap-6 max-md:flex-col max-md:items-stretch">
49
+ <div className="min-w-0">
50
+ <h2 className="m-0 font-display text-3xl font-light leading-none tracking-[-0.03em] text-dashboard-text sm:text-4xl">
51
+ {props.title}
52
+ </h2>
53
+ {props.description ? (
54
+ <div className="mt-2 max-w-2xl font-sans text-xs leading-relaxed text-dashboard-text-muted sm:text-sm">
55
+ {props.description}
56
+ </div>
57
+ ) : null}
46
58
  </div>
59
+ {actions ? <div className="shrink-0">{actions}</div> : null}
47
60
  </div>
48
- {actions ? <div className="shrink-0">{actions}</div> : null}
61
+ {props.navigation ? (
62
+ <div className="mt-4 sm:mt-6">{props.navigation}</div>
63
+ ) : null}
49
64
  </header>
50
65
  );
51
66
  }
@@ -31,7 +31,9 @@ import { CopyButton } from "../components/CopyButton";
31
31
  import { ConversationBrief } from "./ConversationBrief";
32
32
  import {
33
33
  CONVERSATION_EVENT_PARAM,
34
+ CONVERSATION_TURN_PARAM,
34
35
  parseConversationEventSeq,
36
+ turnStartedEventSeq,
35
37
  } from "./conversationRoutes";
36
38
  import {
37
39
  ConversationComposer,
@@ -96,6 +98,11 @@ import {
96
98
  } from "../review-desk/reviewItemFormat";
97
99
  import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers";
98
100
  import { Transcript } from "./TranscriptView";
101
+ import {
102
+ readConversationStartPosition,
103
+ useMarkConversationRead,
104
+ useReadPositionAtOpen,
105
+ } from "./transcriptReadPosition";
99
106
  import { TranscriptLoading } from "./TranscriptLoading";
100
107
  import type { TranscriptViewMode } from "./transcriptRenderModel";
101
108
  import {
@@ -138,20 +145,24 @@ export function ConversationPage(props: {
138
145
  const selectedEventSeq = parseConversationEventSeq(
139
146
  searchParams.get(CONVERSATION_EVENT_PARAM),
140
147
  );
148
+ // A linked Turn opens the event that started it. Until that event is
149
+ // found, it acts like a linked event that is still loading.
150
+ const linkedTurnId = searchParams.get(CONVERSATION_TURN_PARAM) ?? undefined;
151
+ const eventOpen =
152
+ selectedEventSeq !== undefined || linkedTurnId !== undefined;
141
153
  // Event details and turn context show over Conversation details. Closing
142
154
  // them shows details again when details are open. A linked event starts
143
155
  // with details closed, so closing it does not open a panel nobody chose.
144
156
  const [detailsChoice, setDetailsChoice] = useState<boolean | undefined>(() =>
145
- selectedEventSeq === undefined ? undefined : false,
157
+ eventOpen ? false : undefined,
146
158
  );
147
159
  const detailsOpen = detailsChoice ?? detailsDefaultOpen;
148
160
  const [turnContext, setTurnContext] = useState<TurnContextSelection>();
149
- const eventOpen = selectedEventSeq !== undefined;
150
161
  const showDetails = detailsOpen && !eventOpen && !turnContext;
151
162
  const [viewState, setView] = useState<TranscriptViewMode>(() =>
152
- selectedEventSeq === undefined ? "rich" : "raw",
163
+ eventOpen ? "raw" : "rich",
153
164
  );
154
- const view = selectedEventSeq === undefined ? viewState : "raw";
165
+ const view = eventOpen ? "raw" : viewState;
155
166
  const [search, setSearch] = useState("");
156
167
  const [pinRequestVersion, setPinRequestVersion] = useState(0);
157
168
  const conversationId = props.conversationId;
@@ -160,6 +171,8 @@ export function ConversationPage(props: {
160
171
  const conversations = buildConversations(summaries);
161
172
  const detail = useConversationData(conversationId);
162
173
  const archive = useArchiveConversation(conversationId);
174
+ const markRead = useMarkConversationRead(conversationId);
175
+ const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
163
176
  const feedConversation = conversations.find(
164
177
  (item) => item.id === conversationId,
165
178
  );
@@ -213,6 +226,7 @@ export function ConversationPage(props: {
213
226
  setSearchParams(
214
227
  (current) => {
215
228
  const next = new URLSearchParams(current);
229
+ next.delete(CONVERSATION_TURN_PARAM);
216
230
  if (seq === undefined) next.delete(CONVERSATION_EVENT_PARAM);
217
231
  else next.set(CONVERSATION_EVENT_PARAM, String(seq));
218
232
  return next;
@@ -224,14 +238,14 @@ export function ConversationPage(props: {
224
238
  );
225
239
  const onViewChange = useCallback(
226
240
  (value: TranscriptViewMode) => {
227
- if (value !== "raw" && selectedEventSeq !== undefined) {
241
+ if (value !== "raw" && eventOpen) {
228
242
  setSelectedEventSeq(undefined);
229
243
  }
230
244
  // Turn context belongs to a message in the Conversation view.
231
245
  setTurnContext(undefined);
232
246
  setView(value);
233
247
  },
234
- [selectedEventSeq, setSelectedEventSeq],
248
+ [eventOpen, setSelectedEventSeq],
235
249
  );
236
250
  // A linked event can be older than the first page. Load earlier pages until
237
251
  // it is present or history ends.
@@ -250,12 +264,41 @@ export function ConversationPage(props: {
250
264
  useEffect(() => {
251
265
  if (loadLinkedEvent) loadPreviousPageRef.current();
252
266
  }, [loadLinkedEvent, oldestLoadedSeq]);
253
- // Keep the panel while the transcript or a linked event loads.
267
+ // Load earlier pages until the linked Turn starts or history ends. Then
268
+ // replace the Turn link with a link to its first event, or drop it when
269
+ // history has no such event. A history error keeps the link, so a retry
270
+ // or reload can still find the Turn.
271
+ const linkedTurnSeq = linkedTurnId
272
+ ? turnStartedEventSeq(transcript?.events, linkedTurnId)
273
+ : undefined;
274
+ const linkedTurnMissing =
275
+ linkedTurnId !== undefined &&
276
+ linkedTurnSeq === undefined &&
277
+ transcript !== undefined;
278
+ const loadLinkedTurn =
279
+ linkedTurnMissing &&
280
+ detail.hasPreviousPage &&
281
+ !detail.isLoadingPreviousPage &&
282
+ !detail.historyError;
283
+ const linkedTurnNotFound =
284
+ linkedTurnMissing &&
285
+ !detail.hasPreviousPage &&
286
+ !detail.isLoadingPreviousPage;
287
+ useEffect(() => {
288
+ if (loadLinkedTurn) loadPreviousPageRef.current();
289
+ }, [loadLinkedTurn, oldestLoadedSeq]);
290
+ useEffect(() => {
291
+ if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
292
+ else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
293
+ }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
294
+ // Keep the panel while the transcript or a linked event loads, and for the
295
+ // render before a found Turn link becomes an event link.
254
296
  const eventLoading =
255
297
  eventOpen &&
256
298
  !selectedEvent &&
257
299
  (detail.isPending ||
258
- (linkedEventMissing &&
300
+ linkedTurnSeq !== undefined ||
301
+ ((linkedEventMissing || linkedTurnMissing) &&
259
302
  (detail.hasPreviousPage || detail.isLoadingPreviousPage) &&
260
303
  !detail.historyError));
261
304
  const showEvent = Boolean(selectedEvent) || eventLoading;
@@ -448,9 +491,11 @@ export function ConversationPage(props: {
448
491
  hasPreviousPage={detail.hasPreviousPage}
449
492
  historyError={detail.historyError}
450
493
  historyVersion={detail.historyVersion}
494
+ lastReadSeq={readAtOpen.lastReadSeq}
451
495
  live={live}
452
496
  loadingPreviousPage={detail.isLoadingPreviousPage}
453
497
  onLoadPreviousPage={detail.loadPreviousPage}
498
+ onRead={markRead}
454
499
  onSelectedEventSeqChange={setSelectedEventSeq}
455
500
  pendingMessages={detail.inlineMessages}
456
501
  pinRequestVersion={pinRequestVersion}
@@ -461,8 +506,10 @@ export function ConversationPage(props: {
461
506
  search={search}
462
507
  onSearchChange={setSearch}
463
508
  selectedEventSeq={selectedEventSeq}
509
+ startAtUnread={readConversationStartPosition() === "unread"}
464
510
  transcript={transcript}
465
511
  turnContextPanel={turnContextPanel}
512
+ unread={readAtOpen.unread}
466
513
  view={view}
467
514
  />
468
515
  </>