@sentry/junior-dashboard 0.272.0 → 0.274.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 +69 -67
- package/dist/index.js +3 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +440 -430
- package/src/client/components/JuniorLogo.tsx +1 -1
- package/src/client/components/ProfileMenu.tsx +4 -41
- package/src/client/components/layout/DashboardChrome.tsx +9 -4
- package/src/client/components/layout/DashboardHeader.tsx +11 -51
- package/src/client/components/layout/NavRail.tsx +156 -0
- package/src/client/components/ui/sidebar.tsx +336 -0
- package/src/client/conversations/ConversationComposer.tsx +7 -4
- package/src/client/conversations/ConversationPage.tsx +3 -0
- package/src/client/conversations/ConversationSidebar.tsx +121 -60
- package/src/client/conversations/ConversationWorkspace.tsx +8 -3
- package/src/client/conversations/PendingMailboxStack.tsx +22 -9
- package/src/client/conversations/TranscriptMessageShell.tsx +19 -8
- package/src/client/conversations/TranscriptMessageView.tsx +8 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +1 -1
- package/src/client/conversations/composerAutocorrect.ts +12 -0
- package/src/client/conversations/conversationAge.ts +24 -0
- package/src/client/conversations/conversationOutbox.ts +19 -0
- package/src/client/conversations/conversationRoutes.ts +14 -5
- package/src/client/conversations/conversationSections.ts +64 -50
- package/src/client/conversations/eventTranscript.ts +6 -0
- package/src/client/conversations/queries.ts +1 -17
- package/src/client/conversations/transcript.ts +12 -2
- package/src/client/conversations/useConversationFinishedIndicators.ts +40 -8
- package/src/client/format.ts +8 -1
- package/src/client/pages/settings/SettingsPage.tsx +28 -0
- package/src/client/review-desk/ConversationReviewBar.tsx +20 -2
- package/src/client/review-desk/ReviewDeskPage.tsx +5 -1
- package/src/client/review-desk/queries.ts +34 -7
- package/src/client/types.ts +2 -0
- package/src/mock-reporting/routes.ts +1 -1
|
@@ -24,7 +24,10 @@ import { buildConversations, getDashboardTimeZone } from "../format";
|
|
|
24
24
|
import { useDebouncedValue } from "../debouncedValue";
|
|
25
25
|
import type { Conversation } from "../types";
|
|
26
26
|
import { ConversationPage } from "./ConversationPage";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
useWaitingConversationIds,
|
|
29
|
+
useWaitingConversations,
|
|
30
|
+
} from "../review-desk/queries";
|
|
28
31
|
import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
|
|
29
32
|
import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
|
|
30
33
|
|
|
@@ -56,8 +59,9 @@ export function ConversationWorkspace() {
|
|
|
56
59
|
if (selectedId) navigate(NEW_CONVERSATION_PATH);
|
|
57
60
|
};
|
|
58
61
|
|
|
62
|
+
const waitingConversations = useWaitingConversations();
|
|
59
63
|
const waitingConversationIds = useWaitingConversationIds();
|
|
60
|
-
const { finishedConversationIds, markRead } =
|
|
64
|
+
const { finishedConversationIds, heldConversationId, markRead } =
|
|
61
65
|
useConversationFinishedIndicators(
|
|
62
66
|
conversations,
|
|
63
67
|
selectedId,
|
|
@@ -149,13 +153,14 @@ export function ConversationWorkspace() {
|
|
|
149
153
|
conversations={conversations}
|
|
150
154
|
error={feed.error?.message}
|
|
151
155
|
finishedConversationIds={finishedConversationIds}
|
|
156
|
+
heldConversationId={heldConversationId}
|
|
152
157
|
loading={feed.isPending}
|
|
153
158
|
onNewConversation={openCreate}
|
|
154
159
|
onQueryChange={setQuery}
|
|
155
160
|
query={query}
|
|
156
161
|
selectedId={selectedId}
|
|
157
162
|
timeZone={getDashboardTimeZone()}
|
|
158
|
-
|
|
163
|
+
waitingConversations={waitingConversations}
|
|
159
164
|
/>
|
|
160
165
|
</div>
|
|
161
166
|
<section
|
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
type ReactElement,
|
|
7
7
|
type ReactNode,
|
|
8
8
|
} from "react";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
INPUT_IMAGE_TYPES,
|
|
11
|
+
type ConversationDetailReport,
|
|
12
|
+
} from "@sentry/junior/api/schema";
|
|
10
13
|
import {
|
|
11
14
|
AlertCircle,
|
|
12
15
|
Clock3,
|
|
@@ -230,14 +233,17 @@ function PendingMetaIcons(props: {
|
|
|
230
233
|
);
|
|
231
234
|
}
|
|
232
235
|
|
|
233
|
-
function PendingAuthorAvatar(props: {
|
|
236
|
+
function PendingAuthorAvatar(props: {
|
|
237
|
+
authors?: ConversationDetailReport["authors"];
|
|
238
|
+
message: ConversationMailboxMessage;
|
|
239
|
+
}) {
|
|
240
|
+
const { actorIdentityId } = props.message;
|
|
234
241
|
// Pending rows can come from web or Slack. Redacted rows hide the author.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
);
|
|
242
|
+
const author =
|
|
243
|
+
(actorIdentityId ? props.authors?.[actorIdentityId] : undefined) ??
|
|
244
|
+
props.message.actorIdentity;
|
|
245
|
+
if (!author) return <ActorAvatar name="?" size="list" />;
|
|
246
|
+
return <ParticipantAvatar participant={author} size="list" />;
|
|
241
247
|
}
|
|
242
248
|
|
|
243
249
|
/** Show small previews so the reader can tell which images a queued message has. */
|
|
@@ -294,6 +300,7 @@ function PendingAttachmentThumbnails(props: {
|
|
|
294
300
|
|
|
295
301
|
function PendingRow(props: {
|
|
296
302
|
actionsDisabled: boolean;
|
|
303
|
+
authors?: ConversationDetailReport["authors"];
|
|
297
304
|
cancelError: boolean;
|
|
298
305
|
cancelPending: boolean;
|
|
299
306
|
canEdit: boolean;
|
|
@@ -322,7 +329,10 @@ function PendingRow(props: {
|
|
|
322
329
|
className="items-center"
|
|
323
330
|
left={
|
|
324
331
|
<>
|
|
325
|
-
<PendingAuthorAvatar
|
|
332
|
+
<PendingAuthorAvatar
|
|
333
|
+
authors={props.authors}
|
|
334
|
+
message={props.message}
|
|
335
|
+
/>
|
|
326
336
|
{redacted ? null : (
|
|
327
337
|
<PendingAttachmentThumbnails
|
|
328
338
|
conversationId={props.conversationId}
|
|
@@ -438,6 +448,8 @@ function sameEmail(left: string | undefined, right: string | undefined) {
|
|
|
438
448
|
|
|
439
449
|
/** Render accepted mailbox rows as a compact stack attached above the composer. */
|
|
440
450
|
export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
|
|
451
|
+
/** Author of each row, by `actorIdentityId`. */
|
|
452
|
+
authors?: ConversationDetailReport["authors"];
|
|
441
453
|
cancelError?: boolean;
|
|
442
454
|
cancelPending?: boolean;
|
|
443
455
|
cancelTargetInboundMessageId?: string;
|
|
@@ -506,6 +518,7 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
|
|
|
506
518
|
return (
|
|
507
519
|
<PendingRow
|
|
508
520
|
actionsDisabled={actionsDisabled}
|
|
521
|
+
authors={props.authors}
|
|
509
522
|
cancelError={Boolean(props.cancelError && isTarget)}
|
|
510
523
|
cancelPending={Boolean(props.cancelPending && isTarget)}
|
|
511
524
|
canEdit={ownWebRow}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { ClipboardEventHandler, ReactNode } from "react";
|
|
2
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
|
2
3
|
import { transcriptRoleKind } from "../format";
|
|
3
4
|
import { cn } from "../styles";
|
|
4
5
|
|
|
5
6
|
/** Align message text and attachments beside their avatars. */
|
|
6
7
|
export function TranscriptMessageShell(props: {
|
|
7
8
|
actor: string;
|
|
9
|
+
/** Junior API path of the author photo. Initials show without it. */
|
|
10
|
+
avatarUrl?: string;
|
|
8
11
|
children: ReactNode;
|
|
9
12
|
onCopy?: ClipboardEventHandler<HTMLElement>;
|
|
10
13
|
role: string;
|
|
@@ -36,14 +39,22 @@ export function TranscriptMessageShell(props: {
|
|
|
36
39
|
src="/_junior/dashboard/avatar.png"
|
|
37
40
|
/>
|
|
38
41
|
) : (
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
<Avatar className="size-8 border-0 bg-violet-300 shadow-none">
|
|
43
|
+
<AvatarImage
|
|
44
|
+
alt=""
|
|
45
|
+
loading="lazy"
|
|
46
|
+
referrerPolicy="no-referrer"
|
|
47
|
+
src={props.avatarUrl}
|
|
48
|
+
/>
|
|
49
|
+
<AvatarFallback>
|
|
50
|
+
{props.actor
|
|
51
|
+
.split(/\s+/)
|
|
52
|
+
.map((word) => word[0])
|
|
53
|
+
.slice(0, 2)
|
|
54
|
+
.join("")
|
|
55
|
+
.toUpperCase()}
|
|
56
|
+
</AvatarFallback>
|
|
57
|
+
</Avatar>
|
|
47
58
|
)}
|
|
48
59
|
</div>
|
|
49
60
|
) : null}
|
|
@@ -40,6 +40,9 @@ export const TranscriptMessageView = memo(
|
|
|
40
40
|
<TranscriptMessageShell
|
|
41
41
|
role={props.message.role}
|
|
42
42
|
actor={transcriptMessageActorLabel(props.conversation, props.message)}
|
|
43
|
+
avatarUrl={
|
|
44
|
+
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
|
|
45
|
+
}
|
|
43
46
|
onCopy={(event) => {
|
|
44
47
|
const selection = event.currentTarget.ownerDocument.getSelection();
|
|
45
48
|
if (
|
|
@@ -97,7 +100,8 @@ export const TranscriptMessageView = memo(
|
|
|
97
100
|
previous.conversation.conversationId === next.conversation.conversationId &&
|
|
98
101
|
previous.conversation.surface === next.conversation.surface &&
|
|
99
102
|
previous.conversation.canFork === next.conversation.canFork &&
|
|
100
|
-
previous.conversation.actorIdentity === next.conversation.actorIdentity
|
|
103
|
+
previous.conversation.actorIdentity === next.conversation.actorIdentity &&
|
|
104
|
+
previous.conversation.authors === next.conversation.authors,
|
|
101
105
|
);
|
|
102
106
|
|
|
103
107
|
/** Render a redacted primary chat message without exposing body content. */
|
|
@@ -109,6 +113,9 @@ export function RedactedMessageView(props: {
|
|
|
109
113
|
<TranscriptMessageShell
|
|
110
114
|
role={props.message.role}
|
|
111
115
|
actor={transcriptMessageActorLabel(props.conversation, props.message)}
|
|
116
|
+
avatarUrl={
|
|
117
|
+
transcriptMessageActor(props.conversation, props.message)?.avatarUrl
|
|
118
|
+
}
|
|
112
119
|
>
|
|
113
120
|
<TranscriptMessageHeader
|
|
114
121
|
message={props.message}
|
|
@@ -203,7 +203,7 @@ function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
|
|
|
203
203
|
aria-label="Speaker"
|
|
204
204
|
className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
|
|
205
205
|
>
|
|
206
|
-
<ActorAvatar name={name} size="detail" />
|
|
206
|
+
<ActorAvatar imageUrl={props.actor.avatarUrl} name={name} size="detail" />
|
|
207
207
|
<div className="min-w-0 flex-1">
|
|
208
208
|
<div className="flex min-w-0 items-baseline justify-between gap-3">
|
|
209
209
|
{email ? (
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
1
2
|
import type {
|
|
2
3
|
ActorIdentity,
|
|
3
4
|
ConversationPendingMessage,
|
|
4
5
|
InputImage,
|
|
5
6
|
} from "@sentry/junior/api/schema";
|
|
7
|
+
import type { DashboardCoreData } from "../types";
|
|
6
8
|
|
|
7
9
|
/** Client-owned mailbox row waiting on accept, server visibility, or retry. */
|
|
8
10
|
export type ConversationOutboxMessage = {
|
|
@@ -27,6 +29,22 @@ export type ConversationMailboxMessage = ConversationPendingMessage & {
|
|
|
27
29
|
idempotencyKey?: string;
|
|
28
30
|
};
|
|
29
31
|
|
|
32
|
+
/** Identity of the viewer, for the outbox rows that they send. */
|
|
33
|
+
export function outboxActorIdentity(
|
|
34
|
+
queryClient: QueryClient,
|
|
35
|
+
): ActorIdentity | undefined {
|
|
36
|
+
const user = queryClient.getQueryData<DashboardCoreData>([
|
|
37
|
+
"dashboard",
|
|
38
|
+
"core",
|
|
39
|
+
])?.me.user;
|
|
40
|
+
if (!user?.email) return undefined;
|
|
41
|
+
return {
|
|
42
|
+
...(user.avatarUrl ? { avatarUrl: user.avatarUrl } : undefined),
|
|
43
|
+
email: user.email,
|
|
44
|
+
fullName: user.name || undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
/** Stable React Query key for one conversation's local send outbox. */
|
|
31
49
|
export function conversationOutboxQueryKey(conversationId: string | undefined) {
|
|
32
50
|
return ["conversation", conversationId, "outbox"] as const;
|
|
@@ -163,6 +181,7 @@ function sameActorIdentity(
|
|
|
163
181
|
if (left === right) return true;
|
|
164
182
|
if (!left || !right) return false;
|
|
165
183
|
return (
|
|
184
|
+
left.avatarUrl === right.avatarUrl &&
|
|
166
185
|
left.email === right.email &&
|
|
167
186
|
left.fullName === right.fullName &&
|
|
168
187
|
left.slackUserId === right.slackUserId &&
|
|
@@ -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,13 @@ export type ConversationSection = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
-
const SECTION_ORDER = [
|
|
11
|
-
"attention",
|
|
12
|
-
"priority",
|
|
13
|
-
"today",
|
|
14
|
-
"yesterday",
|
|
15
|
-
"last-week",
|
|
16
|
-
"2-weeks",
|
|
17
|
-
"3-weeks",
|
|
18
|
-
"older",
|
|
19
|
-
] as const;
|
|
20
10
|
|
|
21
11
|
/**
|
|
22
|
-
* Group conversations
|
|
12
|
+
* Group conversations by last activity: Today, Yesterday, Previous 7 days,
|
|
13
|
+
* Previous 30 days, then one section per month.
|
|
23
14
|
*
|
|
24
|
-
* Conversations in `attentionIds`
|
|
15
|
+
* Conversations in `attentionIds` lead their section, so unread and waiting
|
|
16
|
+
* rows sit above read rows without leaving their time group.
|
|
25
17
|
*/
|
|
26
18
|
export function buildConversationSections(
|
|
27
19
|
conversations: Conversation[],
|
|
@@ -32,16 +24,21 @@ export function buildConversationSections(
|
|
|
32
24
|
},
|
|
33
25
|
): ConversationSection[] {
|
|
34
26
|
const nowDay = calendarDay(options.nowMs, options.timeZone);
|
|
35
|
-
const sections = new Map<string, ConversationSection>();
|
|
36
|
-
const
|
|
27
|
+
const sections = new Map<string, ConversationSection & { order: number }>();
|
|
28
|
+
const byActivity = [...conversations].sort(
|
|
37
29
|
(left, right) => activityTime(right) - activityTime(left),
|
|
38
30
|
);
|
|
31
|
+
const sorted = options.attentionIds
|
|
32
|
+
? attentionFirst(byActivity, options.attentionIds)
|
|
33
|
+
: byActivity;
|
|
39
34
|
|
|
40
35
|
for (const conversation of sorted) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
const section = conversationSection(
|
|
37
|
+
activityTime(conversation),
|
|
38
|
+
nowDay,
|
|
39
|
+
options.nowMs,
|
|
40
|
+
options.timeZone,
|
|
41
|
+
);
|
|
45
42
|
const existing = sections.get(section.key);
|
|
46
43
|
if (existing) {
|
|
47
44
|
existing.conversations.push(conversation);
|
|
@@ -50,9 +47,9 @@ export function buildConversationSections(
|
|
|
50
47
|
}
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
return [...sections.values()]
|
|
54
|
-
(left, right) =>
|
|
55
|
-
|
|
50
|
+
return [...sections.values()]
|
|
51
|
+
.sort((left, right) => left.order - right.order)
|
|
52
|
+
.map(({ order: _order, ...section }) => section);
|
|
56
53
|
}
|
|
57
54
|
|
|
58
55
|
/** Join the conversations that wait on the viewer or finished unseen. */
|
|
@@ -82,44 +79,61 @@ function activityTime(conversation: Conversation): number {
|
|
|
82
79
|
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
|
|
83
80
|
}
|
|
84
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Pick the section for an activity time. `order` sorts sections newest
|
|
84
|
+
* first.
|
|
85
|
+
*/
|
|
85
86
|
function conversationSection(
|
|
86
|
-
conversation: Conversation,
|
|
87
87
|
time: number,
|
|
88
88
|
nowDay: number,
|
|
89
|
+
nowMs: number,
|
|
89
90
|
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" };
|
|
91
|
+
): Pick<ConversationSection, "key" | "label"> & { order: number } {
|
|
92
|
+
if (!Number.isFinite(time)) {
|
|
93
|
+
return { key: "older", label: "Older", order: Number.POSITIVE_INFINITY };
|
|
96
94
|
}
|
|
97
95
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (ageInDays
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}).format(time),
|
|
109
|
-
};
|
|
96
|
+
const ageInDays = Math.max(
|
|
97
|
+
0,
|
|
98
|
+
Math.round((nowDay - calendarDay(time, timeZone)) / DAY_MS),
|
|
99
|
+
);
|
|
100
|
+
if (ageInDays === 0) return { key: "today", label: "Today", order: 0 };
|
|
101
|
+
if (ageInDays === 1) {
|
|
102
|
+
return { key: "yesterday", label: "Yesterday", order: 1 };
|
|
103
|
+
}
|
|
104
|
+
if (ageInDays <= 7) {
|
|
105
|
+
return { key: "previous-7-days", label: "Previous 7 days", order: 2 };
|
|
110
106
|
}
|
|
111
|
-
if (ageInDays
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
if (ageInDays <= 30) {
|
|
108
|
+
return { key: "previous-30-days", label: "Previous 30 days", order: 3 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const month = yearMonth(time, timeZone);
|
|
112
|
+
const nowMonth = yearMonth(nowMs, timeZone);
|
|
113
|
+
return {
|
|
114
|
+
key: `month-${month.year}-${month.month}`,
|
|
115
|
+
label: new Intl.DateTimeFormat("en-US", {
|
|
116
|
+
month: "long",
|
|
117
|
+
timeZone,
|
|
118
|
+
...(month.year === nowMonth.year ? undefined : { year: "numeric" }),
|
|
119
|
+
}).format(time),
|
|
120
|
+
// Month sections follow the rolling windows, newest month first.
|
|
121
|
+
order:
|
|
122
|
+
4 + (nowMonth.year - month.year) * 12 + (nowMonth.month - month.month),
|
|
123
|
+
};
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
function yearMonth(
|
|
127
|
+
time: number,
|
|
128
|
+
timeZone: string,
|
|
129
|
+
): { month: number; year: number } {
|
|
130
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
131
|
+
month: "numeric",
|
|
132
|
+
timeZone,
|
|
133
|
+
year: "numeric",
|
|
134
|
+
}).formatToParts(time);
|
|
135
|
+
const values = new Map(parts.map((part) => [part.type, Number(part.value)]));
|
|
136
|
+
return { month: values.get("month") ?? 1, year: values.get("year") ?? 0 };
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
function calendarDay(time: number, timeZone: string): number {
|
|
@@ -93,6 +93,9 @@ export function pendingTranscriptMessage(
|
|
|
93
93
|
...(message.actorIdentity
|
|
94
94
|
? { actorIdentity: message.actorIdentity }
|
|
95
95
|
: undefined),
|
|
96
|
+
...(message.actorIdentityId
|
|
97
|
+
? { actorIdentityId: message.actorIdentityId }
|
|
98
|
+
: undefined),
|
|
96
99
|
...(message.mentions ? { mentions: message.mentions } : undefined),
|
|
97
100
|
};
|
|
98
101
|
}
|
|
@@ -222,6 +225,9 @@ export function transcriptMessagesFromEvents(
|
|
|
222
225
|
...(data.actorIdentity
|
|
223
226
|
? { actorIdentity: data.actorIdentity }
|
|
224
227
|
: undefined),
|
|
228
|
+
...(data.actorIdentityId
|
|
229
|
+
? { actorIdentityId: data.actorIdentityId }
|
|
230
|
+
: undefined),
|
|
225
231
|
...(data.agentInvocationId
|
|
226
232
|
? { agentInvocationId: data.agentInvocationId }
|
|
227
233
|
: undefined),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useDeferredValue, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
queryOptions,
|
|
4
|
-
type QueryClient,
|
|
5
4
|
useInfiniteQuery,
|
|
6
5
|
useMutation,
|
|
7
6
|
useMutationState,
|
|
@@ -9,7 +8,6 @@ import {
|
|
|
9
8
|
useQueryClient,
|
|
10
9
|
} from "@tanstack/react-query";
|
|
11
10
|
import type {
|
|
12
|
-
ActorIdentity,
|
|
13
11
|
ConversationDetailReport,
|
|
14
12
|
ConversationEventPage,
|
|
15
13
|
ConversationFeed,
|
|
@@ -30,7 +28,6 @@ import {
|
|
|
30
28
|
stopConversationTurnResponseSchema,
|
|
31
29
|
} from "@sentry/junior/api/schema";
|
|
32
30
|
|
|
33
|
-
import type { DashboardCoreData } from "../types";
|
|
34
31
|
import {
|
|
35
32
|
DashboardApiError,
|
|
36
33
|
del,
|
|
@@ -45,6 +42,7 @@ import {
|
|
|
45
42
|
failConversationOutboxMessage,
|
|
46
43
|
isInlineOutboxMessage,
|
|
47
44
|
mergeConversationMailboxMessages,
|
|
45
|
+
outboxActorIdentity,
|
|
48
46
|
acceptConversationOutboxMessage,
|
|
49
47
|
reuseConversationMailboxMessages,
|
|
50
48
|
splitInlineMailboxMessages,
|
|
@@ -213,20 +211,6 @@ function keepLatestReadPosition(
|
|
|
213
211
|
return { ...next, readPosition: kept };
|
|
214
212
|
}
|
|
215
213
|
|
|
216
|
-
function outboxActorIdentity(
|
|
217
|
-
queryClient: QueryClient,
|
|
218
|
-
): ActorIdentity | undefined {
|
|
219
|
-
const user = queryClient.getQueryData<DashboardCoreData>([
|
|
220
|
-
"dashboard",
|
|
221
|
-
"core",
|
|
222
|
-
])?.me.user;
|
|
223
|
-
if (!user?.email) return undefined;
|
|
224
|
-
return {
|
|
225
|
-
email: user.email,
|
|
226
|
-
fullName: user.name || undefined,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
214
|
/** Create one dashboard conversation and refresh the personal feed. */
|
|
231
215
|
export function useCreateConversation() {
|
|
232
216
|
const queryClient = useQueryClient();
|
|
@@ -223,8 +223,18 @@ export function reuseConversationEventReferences(
|
|
|
223
223
|
JSON.stringify(previous.annotations) === JSON.stringify(next.annotations)
|
|
224
224
|
? previous.annotations
|
|
225
225
|
: next.annotations;
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
const authors =
|
|
227
|
+
JSON.stringify(previous.authors) === JSON.stringify(next.authors)
|
|
228
|
+
? previous.authors
|
|
229
|
+
: next.authors;
|
|
230
|
+
if (
|
|
231
|
+
events === next.events &&
|
|
232
|
+
annotations === next.annotations &&
|
|
233
|
+
authors === next.authors
|
|
234
|
+
) {
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
return { ...next, events, annotations, authors };
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
function sameConversationEventVersion(
|
|
@@ -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,
|
package/src/client/format.ts
CHANGED
|
@@ -354,6 +354,10 @@ export function transcriptMessageActor(
|
|
|
354
354
|
): ActorIdentity | undefined {
|
|
355
355
|
if (transcriptRoleKind(message.role) !== "user") return undefined;
|
|
356
356
|
if (message.agentInvocationId) return undefined;
|
|
357
|
+
const author = message.actorIdentityId
|
|
358
|
+
? conversation.authors?.[message.actorIdentityId]
|
|
359
|
+
: undefined;
|
|
360
|
+
if (author) return author;
|
|
357
361
|
if (actorLabel(message.actorIdentity)) return message.actorIdentity;
|
|
358
362
|
if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
|
|
359
363
|
return undefined;
|
|
@@ -370,7 +374,10 @@ export function transcriptMessageActorLabel(
|
|
|
370
374
|
// The conversation actor started the child agent but did not write its
|
|
371
375
|
// input, so do not fall back to that actor here.
|
|
372
376
|
if (message.agentInvocationId) return "Agent invocation";
|
|
373
|
-
|
|
377
|
+
// Show the person's name, not their email. A web send carries both, and
|
|
378
|
+
// its durable Message carries only the name.
|
|
379
|
+
const actor = transcriptMessageActor(conversation, message);
|
|
380
|
+
return actor?.fullName?.trim() || (actorLabel(actor) ?? "User");
|
|
374
381
|
}
|
|
375
382
|
if (kind === "system") return "System";
|
|
376
383
|
if (kind === "tool") return "Tool";
|