@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.
- package/dist/app.js +44 -3
- package/dist/client.js +77 -77
- package/dist/index.js +40 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/app.ts +2 -0
- package/src/client/App.tsx +89 -61
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +38 -7
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/ConversationPage.tsx +55 -8
- package/src/client/conversations/ConversationTranscript.tsx +74 -5
- package/src/client/conversations/TranscriptRows.tsx +23 -0
- package/src/client/conversations/TranscriptView.tsx +22 -0
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/conversations/queries.ts +30 -5
- package/src/client/conversations/transcriptBottomPinning.ts +84 -13
- package/src/client/conversations/transcriptReadPosition.ts +267 -0
- package/src/client/conversations/transcriptRenderModel.ts +16 -2
- package/src/client/pages/automations/AutomationActions.tsx +2 -3
- package/src/client/pages/automations/AutomationEditor.tsx +18 -30
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
- package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +115 -61
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
- package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -10
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/queries.ts +3 -3
- package/src/client/review-desk/reviewItemFormat.ts +52 -0
- package/src/mock-reporting/read-positions.ts +39 -0
- package/src/mock-reporting/routes.ts +26 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/AGENTS.md +0 -65
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/AGENTS.md +0 -38
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/automations/AGENTS.md +0 -34
- package/src/client/pages/system/AGENTS.md +0 -12
- 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.
|
|
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.
|
|
42
|
+
"@sentry/junior-plugin-api": "0.272.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@sentry/junior": "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.
|
|
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 {
|
package/src/client/App.tsx
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
-
//
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
eventOpen ? "raw" : "rich",
|
|
153
164
|
);
|
|
154
|
-
const view =
|
|
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" &&
|
|
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
|
-
[
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
</>
|