@sentry/junior-dashboard 0.273.0 → 0.275.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 +3 -3
- package/dist/client.js +76 -76
- package/dist/index.js +3 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +11 -0
- package/src/client/components/Metric.tsx +0 -32
- package/src/client/components/ProfileMenu.tsx +56 -4
- package/src/client/components/layout/DashboardChrome.tsx +3 -3
- package/src/client/components/layout/DashboardHeader.tsx +41 -4
- package/src/client/components/layout/NavRail.tsx +43 -40
- package/src/client/conversations/ConversationComposer.tsx +7 -4
- package/src/client/conversations/ConversationDetails.tsx +117 -162
- package/src/client/conversations/ConversationHeader.tsx +119 -133
- package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
- package/src/client/conversations/ConversationMeta.tsx +49 -44
- package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
- package/src/client/conversations/ConversationPage.tsx +33 -368
- package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
- package/src/client/conversations/ConversationSidebar.tsx +119 -60
- package/src/client/conversations/ConversationWorkspace.tsx +8 -3
- package/src/client/conversations/TelemetryMetrics.tsx +0 -14
- package/src/client/conversations/TranscriptView.tsx +15 -32
- package/src/client/conversations/UsageCard.tsx +1 -15
- package/src/client/conversations/composerAutocorrect.ts +12 -0
- package/src/client/conversations/conversationAge.ts +24 -0
- package/src/client/conversations/conversationRoutes.ts +14 -5
- package/src/client/conversations/conversationSections.ts +66 -48
- package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
- package/src/client/pages/settings/SettingsPage.tsx +28 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
- package/src/client/review-desk/queries.ts +30 -6
- package/src/mock-reporting/routes.ts +1 -1
- package/src/tailwind.css +4 -0
- package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
- package/src/client/review-desk/ConversationReviewBar.tsx +0 -67
|
@@ -115,7 +115,6 @@ function modelHeading(
|
|
|
115
115
|
/** Rows shown before the rest of a list rolls into one "N more" line. */
|
|
116
116
|
const MODEL_LIMIT = 4;
|
|
117
117
|
const AUXILIARY_LIMIT = 3;
|
|
118
|
-
const DETAILS_FOOTER = "Full breakdown in Details";
|
|
119
118
|
|
|
120
119
|
type RankedRow = UsageCardRow & { amount: number };
|
|
121
120
|
|
|
@@ -222,7 +221,6 @@ function tokenCard(
|
|
|
222
221
|
compactionCount: number | undefined,
|
|
223
222
|
liveModelId: string | undefined,
|
|
224
223
|
childAgentTotals: ConversationChildAgentTotals | undefined,
|
|
225
|
-
detailsHint: boolean,
|
|
226
224
|
): UsageCardProps {
|
|
227
225
|
const models = modelRows(
|
|
228
226
|
modelUsage,
|
|
@@ -232,7 +230,6 @@ function tokenCard(
|
|
|
232
230
|
formatCompactNumber,
|
|
233
231
|
);
|
|
234
232
|
return {
|
|
235
|
-
footer: detailsHint && models.length ? DETAILS_FOOTER : undefined,
|
|
236
233
|
note: compactionCount ? plural("compaction", compactionCount) : undefined,
|
|
237
234
|
sections: models.length
|
|
238
235
|
? [
|
|
@@ -278,7 +275,6 @@ function costCard(
|
|
|
278
275
|
auxiliaryCosts: ConversationAuxiliaryCosts | undefined,
|
|
279
276
|
liveModelId: string | undefined,
|
|
280
277
|
childAgentTotals: ConversationChildAgentTotals | undefined,
|
|
281
|
-
detailsHint: boolean,
|
|
282
278
|
): UsageCardProps | undefined {
|
|
283
279
|
const total = totalConversationCost(summary, auxiliaryCosts);
|
|
284
280
|
if (!total) return undefined;
|
|
@@ -315,10 +311,6 @@ function costCard(
|
|
|
315
311
|
});
|
|
316
312
|
}
|
|
317
313
|
return {
|
|
318
|
-
footer:
|
|
319
|
-
detailsHint && (models.length || operations.length)
|
|
320
|
-
? DETAILS_FOOTER
|
|
321
|
-
: undefined,
|
|
322
314
|
sections,
|
|
323
315
|
split: agentSplit(
|
|
324
316
|
childAgentTotals,
|
|
@@ -354,8 +346,6 @@ export function CostMetric(props: {
|
|
|
354
346
|
align?: "left" | "right";
|
|
355
347
|
auxiliaryCosts?: ConversationAuxiliaryCosts;
|
|
356
348
|
childAgentTotals?: ConversationChildAgentTotals;
|
|
357
|
-
/** Point to conversation details, which has the full breakdown. */
|
|
358
|
-
detailsHint?: boolean;
|
|
359
349
|
live?: boolean;
|
|
360
350
|
liveModelId?: string;
|
|
361
351
|
modelUsage?: ConversationModelUsage[];
|
|
@@ -375,7 +365,6 @@ export function CostMetric(props: {
|
|
|
375
365
|
props.auxiliaryCosts,
|
|
376
366
|
props.liveModelId,
|
|
377
367
|
props.childAgentTotals,
|
|
378
|
-
Boolean(props.detailsHint),
|
|
379
368
|
);
|
|
380
369
|
return (
|
|
381
370
|
<MetricValue
|
|
@@ -398,8 +387,6 @@ export function TokenMetric(props: {
|
|
|
398
387
|
align?: "left" | "right";
|
|
399
388
|
childAgentTotals?: ConversationChildAgentTotals;
|
|
400
389
|
compactionCount?: number;
|
|
401
|
-
/** Point to conversation details, which has the full breakdown. */
|
|
402
|
-
detailsHint?: boolean;
|
|
403
390
|
live?: boolean;
|
|
404
391
|
liveModelId?: string;
|
|
405
392
|
modelUsage?: ConversationModelUsage[];
|
|
@@ -417,7 +404,6 @@ export function TokenMetric(props: {
|
|
|
417
404
|
props.compactionCount,
|
|
418
405
|
props.liveModelId,
|
|
419
406
|
props.childAgentTotals,
|
|
420
|
-
Boolean(props.detailsHint),
|
|
421
407
|
)}
|
|
422
408
|
/>
|
|
423
409
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
3
|
-
import {
|
|
3
|
+
import { ArrowDown } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
ConversationTranscript,
|
|
@@ -8,11 +8,6 @@ import type {
|
|
|
8
8
|
} from "../types";
|
|
9
9
|
import { EmptyTelemetry } from "../components/EmptyTelemetry";
|
|
10
10
|
import { Button } from "../components/ui/button";
|
|
11
|
-
import {
|
|
12
|
-
Tooltip,
|
|
13
|
-
TooltipContent,
|
|
14
|
-
TooltipTrigger,
|
|
15
|
-
} from "../components/ui/tooltip";
|
|
16
11
|
import { ConversationTranscriptView } from "./ConversationTranscript";
|
|
17
12
|
import { ConversationEventLog } from "./ConversationEventLog";
|
|
18
13
|
import {
|
|
@@ -191,33 +186,21 @@ function JumpToLatestButton(props: {
|
|
|
191
186
|
}) {
|
|
192
187
|
if (!props.visible) return null;
|
|
193
188
|
|
|
194
|
-
const label = props.hasPendingUpdate
|
|
195
|
-
? "Jump to latest update"
|
|
196
|
-
: "Jump to latest";
|
|
197
|
-
|
|
198
189
|
return (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
className="absolute right-1.5 top-1.5 size-2 bg-emerald-300"
|
|
214
|
-
/>
|
|
215
|
-
) : null}
|
|
216
|
-
</Button>
|
|
217
|
-
}
|
|
218
|
-
/>
|
|
219
|
-
<TooltipContent size="compact">{label}</TooltipContent>
|
|
220
|
-
</Tooltip>
|
|
190
|
+
// Sticky offsets count from inside the scroll region's bottom padding
|
|
191
|
+
// (pb-4 / md:pb-6). The negative offsets cancel most of it, so the pill
|
|
192
|
+
// sits 12px (16px on md) above the composer, centered on the transcript.
|
|
193
|
+
<div className="pointer-events-none sticky -bottom-2.5 z-20 flex h-0 justify-center md:-bottom-5">
|
|
194
|
+
<Button
|
|
195
|
+
aria-label={
|
|
196
|
+
props.hasPendingUpdate ? "Jump to latest update" : "Jump to latest"
|
|
197
|
+
}
|
|
198
|
+
className="pointer-events-auto h-8 -translate-y-full gap-1.5 rounded-full pl-3 pr-3.5 text-xs shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
|
|
199
|
+
onClick={props.onClick}
|
|
200
|
+
>
|
|
201
|
+
<ArrowDown aria-hidden="true" size={14} strokeWidth={2} />
|
|
202
|
+
Jump to latest
|
|
203
|
+
</Button>
|
|
221
204
|
</div>
|
|
222
205
|
);
|
|
223
206
|
}
|
|
@@ -27,8 +27,6 @@ export type UsageCardSplit = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export type UsageCardProps = {
|
|
30
|
-
/** Closing hint, such as where to find the full breakdown. */
|
|
31
|
-
footer?: string;
|
|
32
30
|
/** Muted detail after the title, such as a compaction count. */
|
|
33
31
|
note?: string;
|
|
34
32
|
sections: UsageCardSection[];
|
|
@@ -64,8 +62,7 @@ export function UsageCard(props: UsageCardProps) {
|
|
|
64
62
|
<span
|
|
65
63
|
className={cn(
|
|
66
64
|
"grid gap-2.5",
|
|
67
|
-
|
|
68
|
-
"border-b border-dashboard-border pb-3",
|
|
65
|
+
props.sections.length > 0 && "border-b border-dashboard-border pb-3",
|
|
69
66
|
)}
|
|
70
67
|
>
|
|
71
68
|
<span className="flex items-baseline justify-between gap-3">
|
|
@@ -156,17 +153,6 @@ export function UsageCard(props: UsageCardProps) {
|
|
|
156
153
|
) : null}
|
|
157
154
|
</span>
|
|
158
155
|
))}
|
|
159
|
-
{props.footer ? (
|
|
160
|
-
<span
|
|
161
|
-
className={cn(
|
|
162
|
-
"text-dashboard-text-faint",
|
|
163
|
-
props.sections.length > 0 &&
|
|
164
|
-
"border-t border-dashboard-border pt-3",
|
|
165
|
-
)}
|
|
166
|
-
>
|
|
167
|
-
{props.footer}
|
|
168
|
-
</span>
|
|
169
|
-
) : null}
|
|
170
156
|
</span>
|
|
171
157
|
);
|
|
172
158
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useLocalStorage } from "@uidotdev/usehooks";
|
|
2
|
+
|
|
3
|
+
const AUTOCORRECT_STORAGE_KEY = "junior:composer:autocorrect";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Edit the browser-only choice to let the keyboard autocorrect, spell check,
|
|
7
|
+
* and capitalize the message composer. Off by default because these change
|
|
8
|
+
* ids, paths, and code.
|
|
9
|
+
*/
|
|
10
|
+
export function useComposerAutocorrect() {
|
|
11
|
+
return useLocalStorage<boolean>(AUTOCORRECT_STORAGE_KEY, false);
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getDashboardTimeZone } from "../format";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format an activity time as a short age for dense lists: `now`, `12m`, `3h`,
|
|
5
|
+
* `2d`, `3w`, then a month and day.
|
|
6
|
+
*/
|
|
7
|
+
export function formatCompactAge(value: string, nowMs = Date.now()): string {
|
|
8
|
+
const time = Date.parse(value);
|
|
9
|
+
if (!Number.isFinite(time)) return "";
|
|
10
|
+
|
|
11
|
+
const minutes = Math.floor(Math.max(0, nowMs - time) / 60_000);
|
|
12
|
+
if (minutes < 1) return "now";
|
|
13
|
+
if (minutes < 60) return `${minutes}m`;
|
|
14
|
+
const hours = Math.floor(minutes / 60);
|
|
15
|
+
if (hours < 24) return `${hours}h`;
|
|
16
|
+
const days = Math.floor(hours / 24);
|
|
17
|
+
if (days < 7) return `${days}d`;
|
|
18
|
+
if (days < 30) return `${Math.floor(days / 7)}w`;
|
|
19
|
+
return new Date(time).toLocaleDateString("en-US", {
|
|
20
|
+
day: "numeric",
|
|
21
|
+
month: "short",
|
|
22
|
+
timeZone: getDashboardTimeZone(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -2,16 +2,21 @@ import type { ConversationReportEvent } from "@sentry/junior/api/schema";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Build the dashboard route for a conversation id. A `turnId` opens the
|
|
5
|
-
* start of that Turn in the event log.
|
|
5
|
+
* start of that Turn in the event log. `reviewItems` opens the Review items
|
|
6
|
+
* panel.
|
|
6
7
|
*/
|
|
7
8
|
export function conversationPath(
|
|
8
9
|
conversationId: string,
|
|
9
|
-
options?: { turnId?: string },
|
|
10
|
+
options?: { reviewItems?: boolean; turnId?: string },
|
|
10
11
|
): string {
|
|
11
12
|
const path = `/conversations/${encodeURIComponent(conversationId)}`;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const params = new URLSearchParams();
|
|
14
|
+
if (options?.turnId) params.set(CONVERSATION_TURN_PARAM, options.turnId);
|
|
15
|
+
if (options?.reviewItems) {
|
|
16
|
+
params.set(CONVERSATION_REVIEW_PARAM, CONVERSATION_REVIEW_OPEN);
|
|
17
|
+
}
|
|
18
|
+
const search = params.toString();
|
|
19
|
+
return search ? `${path}?${search}` : path;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** Dashboard home route. Legacy `/conversations/new` redirects here. */
|
|
@@ -32,6 +37,10 @@ export function parseConversationEventSeq(
|
|
|
32
37
|
return value && /^\d+$/.test(value) ? Number(value) : undefined;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
/** Query param that opens the Review items panel of a Conversation. */
|
|
41
|
+
export const CONVERSATION_REVIEW_PARAM = "review";
|
|
42
|
+
export const CONVERSATION_REVIEW_OPEN = "open";
|
|
43
|
+
|
|
35
44
|
/** Query param that opens the start of one Turn in the event log. */
|
|
36
45
|
export const CONVERSATION_TURN_PARAM = "turn";
|
|
37
46
|
|
|
@@ -7,21 +7,18 @@ export type ConversationSection = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"2-weeks",
|
|
17
|
-
"3-weeks",
|
|
18
|
-
"older",
|
|
19
|
-
] as const;
|
|
10
|
+
// Sorts before every time section.
|
|
11
|
+
const ATTENTION_SECTION = {
|
|
12
|
+
key: "attention",
|
|
13
|
+
label: "Needs attention",
|
|
14
|
+
order: Number.NEGATIVE_INFINITY,
|
|
15
|
+
};
|
|
20
16
|
|
|
21
17
|
/**
|
|
22
|
-
* Group conversations into
|
|
18
|
+
* Group conversations into Needs attention, then by last activity: Today,
|
|
19
|
+
* Yesterday, Previous 7 days, Previous 30 days, then one section per month.
|
|
23
20
|
*
|
|
24
|
-
* Conversations in `attentionIds` go to
|
|
21
|
+
* Conversations in `attentionIds` go to Needs attention, newest first.
|
|
25
22
|
*/
|
|
26
23
|
export function buildConversationSections(
|
|
27
24
|
conversations: Conversation[],
|
|
@@ -32,16 +29,20 @@ export function buildConversationSections(
|
|
|
32
29
|
},
|
|
33
30
|
): ConversationSection[] {
|
|
34
31
|
const nowDay = calendarDay(options.nowMs, options.timeZone);
|
|
35
|
-
const sections = new Map<string, ConversationSection>();
|
|
32
|
+
const sections = new Map<string, ConversationSection & { order: number }>();
|
|
36
33
|
const sorted = [...conversations].sort(
|
|
37
34
|
(left, right) => activityTime(right) - activityTime(left),
|
|
38
35
|
);
|
|
39
36
|
|
|
40
37
|
for (const conversation of sorted) {
|
|
41
|
-
const time = activityTime(conversation);
|
|
42
38
|
const section = options.attentionIds?.has(conversation.id)
|
|
43
|
-
?
|
|
44
|
-
: conversationSection(
|
|
39
|
+
? ATTENTION_SECTION
|
|
40
|
+
: conversationSection(
|
|
41
|
+
activityTime(conversation),
|
|
42
|
+
nowDay,
|
|
43
|
+
options.nowMs,
|
|
44
|
+
options.timeZone,
|
|
45
|
+
);
|
|
45
46
|
const existing = sections.get(section.key);
|
|
46
47
|
if (existing) {
|
|
47
48
|
existing.conversations.push(conversation);
|
|
@@ -50,9 +51,9 @@ export function buildConversationSections(
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
return [...sections.values()]
|
|
54
|
-
(left, right) =>
|
|
55
|
-
|
|
54
|
+
return [...sections.values()]
|
|
55
|
+
.sort((left, right) => left.order - right.order)
|
|
56
|
+
.map(({ order: _order, ...section }) => section);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** Join the conversations that wait on the viewer or finished unseen. */
|
|
@@ -82,44 +83,61 @@ function activityTime(conversation: Conversation): number {
|
|
|
82
83
|
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Pick the section for an activity time. `order` sorts sections newest
|
|
88
|
+
* first.
|
|
89
|
+
*/
|
|
85
90
|
function conversationSection(
|
|
86
|
-
conversation: Conversation,
|
|
87
91
|
time: number,
|
|
88
92
|
nowDay: number,
|
|
93
|
+
nowMs: number,
|
|
89
94
|
timeZone: string,
|
|
90
|
-
): Pick<ConversationSection, "key" | "label"> {
|
|
91
|
-
if (!Number.isFinite(time))
|
|
92
|
-
|
|
93
|
-
// Priority membership is decided by the conversation feed API.
|
|
94
|
-
if (conversation.isPriority) {
|
|
95
|
-
return { key: "priority", label: "Priority" };
|
|
95
|
+
): Pick<ConversationSection, "key" | "label"> & { order: number } {
|
|
96
|
+
if (!Number.isFinite(time)) {
|
|
97
|
+
return { key: "older", label: "Older", order: Number.POSITIVE_INFINITY };
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (ageInDays
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}).format(time),
|
|
109
|
-
};
|
|
100
|
+
const ageInDays = Math.max(
|
|
101
|
+
0,
|
|
102
|
+
Math.round((nowDay - calendarDay(time, timeZone)) / DAY_MS),
|
|
103
|
+
);
|
|
104
|
+
if (ageInDays === 0) return { key: "today", label: "Today", order: 0 };
|
|
105
|
+
if (ageInDays === 1) {
|
|
106
|
+
return { key: "yesterday", label: "Yesterday", order: 1 };
|
|
107
|
+
}
|
|
108
|
+
if (ageInDays <= 7) {
|
|
109
|
+
return { key: "previous-7-days", label: "Previous 7 days", order: 2 };
|
|
110
110
|
}
|
|
111
|
-
if (ageInDays
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
if (ageInDays <= 30) {
|
|
112
|
+
return { key: "previous-30-days", label: "Previous 30 days", order: 3 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const month = yearMonth(time, timeZone);
|
|
116
|
+
const nowMonth = yearMonth(nowMs, timeZone);
|
|
117
|
+
return {
|
|
118
|
+
key: `month-${month.year}-${month.month}`,
|
|
119
|
+
label: new Intl.DateTimeFormat("en-US", {
|
|
120
|
+
month: "long",
|
|
121
|
+
timeZone,
|
|
122
|
+
...(month.year === nowMonth.year ? undefined : { year: "numeric" }),
|
|
123
|
+
}).format(time),
|
|
124
|
+
// Month sections follow the rolling windows, newest month first.
|
|
125
|
+
order:
|
|
126
|
+
4 + (nowMonth.year - month.year) * 12 + (nowMonth.month - month.month),
|
|
127
|
+
};
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
function yearMonth(
|
|
131
|
+
time: number,
|
|
132
|
+
timeZone: string,
|
|
133
|
+
): { month: number; year: number } {
|
|
134
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
135
|
+
month: "numeric",
|
|
136
|
+
timeZone,
|
|
137
|
+
year: "numeric",
|
|
138
|
+
}).formatToParts(time);
|
|
139
|
+
const values = new Map(parts.map((part) => [part.type, Number(part.value)]));
|
|
140
|
+
return { month: values.get("month") ?? 1, year: values.get("year") ?? 0 };
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
function calendarDay(time: number, timeZone: string): number {
|
|
@@ -12,6 +12,12 @@ type StoredConversationStates = Record<string, StoredConversationState>;
|
|
|
12
12
|
|
|
13
13
|
type ConversationFinishedIndicators = {
|
|
14
14
|
finishedConversationIds: ReadonlySet<string>;
|
|
15
|
+
/**
|
|
16
|
+
* The selected conversation when it was unread at the moment it opened.
|
|
17
|
+
* Lists keep it in its unread place until another conversation opens, so
|
|
18
|
+
* the row does not move under the pointer.
|
|
19
|
+
*/
|
|
20
|
+
heldConversationId?: string;
|
|
15
21
|
markRead(conversationId: string, lastReadAt: string): void;
|
|
16
22
|
};
|
|
17
23
|
|
|
@@ -23,6 +29,23 @@ export function useConversationFinishedIndicators(
|
|
|
23
29
|
pruneMissing: boolean,
|
|
24
30
|
): ConversationFinishedIndicators {
|
|
25
31
|
const [storedStates, setStoredStates] = useState(readStoredStates);
|
|
32
|
+
const [held, setHeld] = useState<{ heldId?: string; selectedId?: string }>(
|
|
33
|
+
{},
|
|
34
|
+
);
|
|
35
|
+
// Read the unread state when the selection changes, before the effect
|
|
36
|
+
// below or the opened page marks it read. Hold only a row that the list
|
|
37
|
+
// already showed. A deep link has no row under the pointer, and the
|
|
38
|
+
// selected row never leads its section, so it does not move.
|
|
39
|
+
if (held.selectedId !== selectedId) {
|
|
40
|
+
const selected = conversations.find(({ id }) => id === selectedId);
|
|
41
|
+
setHeld({
|
|
42
|
+
heldId:
|
|
43
|
+
selected && finishedSinceRead(storedStates, selected)
|
|
44
|
+
? selected.id
|
|
45
|
+
: undefined,
|
|
46
|
+
selectedId,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
26
49
|
|
|
27
50
|
useEffect(() => {
|
|
28
51
|
if (!ready) return;
|
|
@@ -55,6 +78,7 @@ export function useConversationFinishedIndicators(
|
|
|
55
78
|
() => finishedConversationIds(storedStates, conversations, selectedId),
|
|
56
79
|
[conversations, selectedId, storedStates],
|
|
57
80
|
),
|
|
81
|
+
heldConversationId: held.heldId,
|
|
58
82
|
markRead,
|
|
59
83
|
};
|
|
60
84
|
}
|
|
@@ -67,19 +91,27 @@ export function finishedConversationIds(
|
|
|
67
91
|
): ReadonlySet<string> {
|
|
68
92
|
return new Set(
|
|
69
93
|
conversations
|
|
70
|
-
.filter(
|
|
71
|
-
|
|
72
|
-
return (
|
|
94
|
+
.filter(
|
|
95
|
+
(conversation) =>
|
|
73
96
|
conversation.id !== selectedId &&
|
|
74
|
-
conversation
|
|
75
|
-
|
|
76
|
-
isAfter(conversation.lastSeenAt, state.lastReadAt)
|
|
77
|
-
);
|
|
78
|
-
})
|
|
97
|
+
finishedSinceRead(states, conversation),
|
|
98
|
+
)
|
|
79
99
|
.map((conversation) => conversation.id),
|
|
80
100
|
);
|
|
81
101
|
}
|
|
82
102
|
|
|
103
|
+
function finishedSinceRead(
|
|
104
|
+
states: StoredConversationStates,
|
|
105
|
+
conversation: Conversation,
|
|
106
|
+
): boolean {
|
|
107
|
+
const state = states[conversation.id];
|
|
108
|
+
return (
|
|
109
|
+
conversation.status === "completed" &&
|
|
110
|
+
state !== undefined &&
|
|
111
|
+
isAfter(conversation.lastSeenAt, state.lastReadAt)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/** Record reads and remove conversations outside the visible list. */
|
|
84
116
|
export function reconcileStoredStates(
|
|
85
117
|
current: StoredConversationStates,
|
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
import { Button } from "../../components/ui/button";
|
|
13
13
|
import { Input } from "../../components/ui/input";
|
|
14
14
|
import { Textarea } from "../../components/ui/textarea";
|
|
15
|
+
import { Checkbox } from "../../components/ui/checkbox";
|
|
15
16
|
import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
|
|
16
17
|
import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
|
|
17
18
|
import {
|
|
18
19
|
useConversationStartPosition,
|
|
19
20
|
type ConversationStartPosition,
|
|
20
21
|
} from "../../conversations/transcriptReadPosition";
|
|
22
|
+
import { useComposerAutocorrect } from "../../conversations/composerAutocorrect";
|
|
21
23
|
import { InlineError } from "../../components/InlineError";
|
|
22
24
|
import { Card } from "../../components/layout/Card";
|
|
23
25
|
import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
|
|
@@ -105,6 +107,7 @@ export function SettingsPage({ identity }: SettingsPageProps) {
|
|
|
105
107
|
</Card>
|
|
106
108
|
</form>
|
|
107
109
|
<ConversationStartSetting />
|
|
110
|
+
<ComposerAutocorrectSetting />
|
|
108
111
|
<CustomizationForm />
|
|
109
112
|
</SettingsPageLayout>
|
|
110
113
|
);
|
|
@@ -166,6 +169,31 @@ function ConversationStartSetting() {
|
|
|
166
169
|
);
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
/** Let the user turn on keyboard autocorrect in the composer on this browser. */
|
|
173
|
+
function ComposerAutocorrectSetting() {
|
|
174
|
+
const [autocorrect, setAutocorrect] = useComposerAutocorrect();
|
|
175
|
+
return (
|
|
176
|
+
<Card className="mt-6 mb-0" padding="md" variant="raised">
|
|
177
|
+
<h2 className="m-0 text-lg font-bold">Message box</h2>
|
|
178
|
+
<label className="mt-4 flex cursor-pointer items-start gap-3">
|
|
179
|
+
<Checkbox
|
|
180
|
+
checked={autocorrect}
|
|
181
|
+
className="mt-0.5"
|
|
182
|
+
onCheckedChange={setAutocorrect}
|
|
183
|
+
/>
|
|
184
|
+
<span className="grid gap-0.5">
|
|
185
|
+
<span className="text-sm font-medium">Autocorrect</span>
|
|
186
|
+
<span className="text-xs text-dashboard-text-muted">
|
|
187
|
+
Let your keyboard correct spelling and capitalize sentences as you
|
|
188
|
+
type. This can change ids, paths, and code. This choice applies to
|
|
189
|
+
this browser.
|
|
190
|
+
</span>
|
|
191
|
+
</span>
|
|
192
|
+
</label>
|
|
193
|
+
</Card>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
/**
|
|
170
198
|
* Let the user save text that changes how the agent replies to them. The
|
|
171
199
|
* server returns 404 when the app does not enable this experiment.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ChevronRight, Inbox } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { LoadMorePagination } from "../components/Pagination";
|
|
6
|
+
import {
|
|
7
|
+
DetailsCard,
|
|
8
|
+
DetailsList,
|
|
9
|
+
DetailsRow,
|
|
10
|
+
} from "../conversations/DetailsList";
|
|
11
|
+
import { cn, dashboardDisclosureChevronClass } from "../styles";
|
|
12
|
+
import { ReviewItemCard } from "./ReviewItemCard";
|
|
13
|
+
import { useReviewItemsData } from "./queries";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Show the open Review items linked to a Conversation in its details. Each
|
|
17
|
+
* item is one line, like its sidebar chip, and expands to the full card in
|
|
18
|
+
* place. `highlight` marks the card when a sidebar link opened it.
|
|
19
|
+
*/
|
|
20
|
+
export function ConversationReviewItems(props: {
|
|
21
|
+
conversationId: string;
|
|
22
|
+
highlight: boolean;
|
|
23
|
+
}) {
|
|
24
|
+
const { items, query } = useReviewItemsData({
|
|
25
|
+
conversationId: props.conversationId,
|
|
26
|
+
});
|
|
27
|
+
const sectionRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const shown = items.length > 0;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (props.highlight && shown) {
|
|
31
|
+
sectionRef.current?.scrollIntoView({ block: "nearest" });
|
|
32
|
+
}
|
|
33
|
+
}, [props.highlight, shown]);
|
|
34
|
+
if (!shown) return null;
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
"rounded-lg",
|
|
39
|
+
props.highlight && "ring-2 ring-violet-300/50",
|
|
40
|
+
)}
|
|
41
|
+
ref={sectionRef}
|
|
42
|
+
>
|
|
43
|
+
{/* A count of loaded pages only would understate, so hide it. */}
|
|
44
|
+
<DetailsCard
|
|
45
|
+
count={query.hasNextPage ? undefined : items.length}
|
|
46
|
+
label="Needs you"
|
|
47
|
+
title="Needs you"
|
|
48
|
+
>
|
|
49
|
+
<DetailsList
|
|
50
|
+
itemKey={(item) => item.id}
|
|
51
|
+
items={items}
|
|
52
|
+
renderItem={(item) => (
|
|
53
|
+
<ReviewItemRow defaultExpanded={props.highlight} item={item} />
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
56
|
+
<LoadMorePagination
|
|
57
|
+
className="mt-2"
|
|
58
|
+
hasMore={Boolean(query.hasNextPage)}
|
|
59
|
+
loading={query.isFetchingNextPage}
|
|
60
|
+
onLoadMore={() => void query.fetchNextPage()}
|
|
61
|
+
/>
|
|
62
|
+
</DetailsCard>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ReviewItemRow(props: {
|
|
68
|
+
defaultExpanded: boolean;
|
|
69
|
+
item: ReviewItemPayload;
|
|
70
|
+
}) {
|
|
71
|
+
const [expanded, setExpanded] = useState(props.defaultExpanded);
|
|
72
|
+
// Details can already be open when a sidebar review chip highlights it.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (props.defaultExpanded) setExpanded(true);
|
|
75
|
+
}, [props.defaultExpanded]);
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<DetailsRow
|
|
79
|
+
expanded={expanded}
|
|
80
|
+
icon={<Inbox aria-hidden="true" className="size-3.5" />}
|
|
81
|
+
meta={
|
|
82
|
+
<span className="grid size-5 place-items-center">
|
|
83
|
+
<ChevronRight
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
className={dashboardDisclosureChevronClass}
|
|
86
|
+
/>
|
|
87
|
+
</span>
|
|
88
|
+
}
|
|
89
|
+
onToggle={() => setExpanded((value) => !value)}
|
|
90
|
+
title={props.item.title}
|
|
91
|
+
/>
|
|
92
|
+
{expanded ? (
|
|
93
|
+
<div className="px-1.5 pb-1">
|
|
94
|
+
<ReviewItemCard item={props.item} />
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|