@sentry/junior-dashboard 0.244.0 → 0.246.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 +64 -7
- package/dist/client.js +43 -40
- package/dist/index.js +64 -7
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +3 -2
- package/src/client/components/SegmentedTabs.tsx +11 -9
- package/src/client/components/layout/PageHeader.tsx +12 -1
- package/src/client/conversations/ConversationAgentTree.tsx +42 -0
- package/src/client/conversations/ConversationChildAgents.tsx +134 -58
- package/src/client/conversations/ConversationComposer.tsx +68 -35
- package/src/client/conversations/ConversationDetailsDrawer.tsx +29 -9
- package/src/client/conversations/ConversationHeader.tsx +13 -1
- package/src/client/conversations/ConversationMemories.tsx +10 -5
- package/src/client/conversations/ConversationPage.tsx +35 -3
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +30 -23
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -11
- package/src/client/conversations/queries.ts +68 -3
- package/src/client/documentTitle.ts +28 -0
- package/src/client/format.ts +15 -0
- package/src/client/pages/PersonalTokensPage.tsx +2 -0
- package/src/client/pages/SettingsPage.tsx +2 -0
- package/src/client/pages/automations/AutomationEditPage.tsx +8 -0
- package/src/client/pages/automations/AutomationExecutionsPage.tsx +1 -0
- package/src/client/pages/automations/AutomationVersionsPage.tsx +1 -0
- package/src/client/pages/automations/AutomationsPage.tsx +7 -0
- package/src/client/pages/code/RepositoryPage.tsx +5 -0
- package/src/client/pages/dev/SegmentedFixtures.tsx +1 -1
- package/src/mock-reporting/fixtures.ts +68 -2
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
useArchiveConversation,
|
|
13
13
|
useCancelConversationPendingMessages,
|
|
14
14
|
useConversationData,
|
|
15
|
+
useStopConversationTurn,
|
|
15
16
|
type PendingArchiveConversationUpdate,
|
|
16
17
|
} from "./queries";
|
|
17
18
|
import { conversationIsResponding } from "./transcript";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
import { ConversationComposer } from "./ConversationComposer";
|
|
27
28
|
import { ConversationHeader } from "./ConversationHeader";
|
|
28
29
|
import { ConversationForkLinks } from "./ConversationForkLinks";
|
|
30
|
+
import { ConversationAgentTree } from "./ConversationAgentTree";
|
|
29
31
|
import { ConversationHeaderMeta } from "./ConversationHeaderMeta";
|
|
30
32
|
import {
|
|
31
33
|
ConversationAnnotations,
|
|
@@ -38,10 +40,12 @@ import {
|
|
|
38
40
|
import { PendingMailboxStack } from "./PendingMailboxStack";
|
|
39
41
|
import {
|
|
40
42
|
buildConversations,
|
|
43
|
+
conversationDetailTitle,
|
|
41
44
|
conversationDisplayTitle,
|
|
42
45
|
conversationFromDetail,
|
|
43
46
|
visualStatusForConversation,
|
|
44
47
|
} from "../format";
|
|
48
|
+
import { useDocumentTitle } from "../documentTitle";
|
|
45
49
|
import { Card } from "../components/layout/Card";
|
|
46
50
|
import {
|
|
47
51
|
conversationParticipants,
|
|
@@ -103,6 +107,11 @@ export function ConversationPage(props: {
|
|
|
103
107
|
<ParticipantAvatarStack participants={participants} size="detail" />
|
|
104
108
|
) : null;
|
|
105
109
|
const conversationDetail = detail.data;
|
|
110
|
+
const title =
|
|
111
|
+
conversationDetailTitle(detail.data) ??
|
|
112
|
+
conversationDisplayTitle(conversation);
|
|
113
|
+
useDocumentTitle(detail.data || conversation ? title : undefined);
|
|
114
|
+
const agentTree = detail.data?.agentTree;
|
|
106
115
|
useEffect(() => {
|
|
107
116
|
if (!conversation) return;
|
|
108
117
|
onRead?.(conversation.id, conversation.lastSeenAt);
|
|
@@ -177,7 +186,8 @@ export function ConversationPage(props: {
|
|
|
177
186
|
}) => {
|
|
178
187
|
setSubagentTarget({
|
|
179
188
|
conversationId: part.childConversationId,
|
|
180
|
-
part,
|
|
189
|
+
label: part.title ?? part.agentName,
|
|
190
|
+
status: part.status,
|
|
181
191
|
});
|
|
182
192
|
},
|
|
183
193
|
[],
|
|
@@ -192,6 +202,9 @@ export function ConversationPage(props: {
|
|
|
192
202
|
scroll={
|
|
193
203
|
<section className="min-w-0">
|
|
194
204
|
<ConversationHeader
|
|
205
|
+
agentTree={
|
|
206
|
+
agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
|
|
207
|
+
}
|
|
195
208
|
conversationId={conversationId}
|
|
196
209
|
forks={
|
|
197
210
|
detail.data?.forkedFromConversationId ||
|
|
@@ -273,6 +286,7 @@ export function ConversationPage(props: {
|
|
|
273
286
|
}
|
|
274
287
|
/>
|
|
275
288
|
}
|
|
289
|
+
onOpenSubagent={setSubagentTarget}
|
|
276
290
|
onSearchChange={setSearch}
|
|
277
291
|
onViewChange={onViewChange}
|
|
278
292
|
privacy={
|
|
@@ -292,7 +306,7 @@ export function ConversationPage(props: {
|
|
|
292
306
|
/>
|
|
293
307
|
) : null
|
|
294
308
|
}
|
|
295
|
-
title={
|
|
309
|
+
title={title}
|
|
296
310
|
view={view}
|
|
297
311
|
/>
|
|
298
312
|
|
|
@@ -341,7 +355,9 @@ export function ConversationPage(props: {
|
|
|
341
355
|
</section>
|
|
342
356
|
}
|
|
343
357
|
dock={
|
|
344
|
-
|
|
358
|
+
// A child Conversation takes input only from its parent agent, and it
|
|
359
|
+
// runs without interactive auth, so it has no approval to show.
|
|
360
|
+
view === "rich" && detail.data?.isParticipant && !agentTree ? (
|
|
345
361
|
<ConversationReplyFooter
|
|
346
362
|
conversationId={conversationId}
|
|
347
363
|
// Only pass committed ids for mailbox de-dupe. The full transcript is
|
|
@@ -353,6 +369,7 @@ export function ConversationPage(props: {
|
|
|
353
369
|
// every 2s; a prop would bust footer memo while the reader types.
|
|
354
370
|
pendingGeneratedAtRef={pendingGeneratedAtRef}
|
|
355
371
|
pendingMessages={detail.pendingMessages}
|
|
372
|
+
stoppable={live}
|
|
356
373
|
/>
|
|
357
374
|
) : undefined
|
|
358
375
|
}
|
|
@@ -379,8 +396,13 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
379
396
|
pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
|
|
380
397
|
pendingGeneratedAtRef: { current: string | undefined };
|
|
381
398
|
pendingMessages: readonly ConversationMailboxMessage[];
|
|
399
|
+
/** True while the conversation has a Turn that a stop can end. */
|
|
400
|
+
stoppable: boolean;
|
|
382
401
|
}) {
|
|
383
402
|
const appendMessage = useAppendConversationMessage(props.conversationId);
|
|
403
|
+
const stopTurn = useStopConversationTurn(props.conversationId);
|
|
404
|
+
const stopTurnRef = useRef(stopTurn);
|
|
405
|
+
stopTurnRef.current = stopTurn;
|
|
384
406
|
const cancelPendingMessages = useCancelConversationPendingMessages(
|
|
385
407
|
props.conversationId,
|
|
386
408
|
);
|
|
@@ -433,6 +455,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
433
455
|
if (!cancelPendingMessagesRef.current.isPending) {
|
|
434
456
|
cancelPendingMessagesRef.current.reset();
|
|
435
457
|
}
|
|
458
|
+
if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
|
|
436
459
|
onPinRequestRef.current();
|
|
437
460
|
}, []);
|
|
438
461
|
const cancellableMessageIds = props.pendingMessages
|
|
@@ -458,6 +481,12 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
458
481
|
),
|
|
459
482
|
);
|
|
460
483
|
|
|
484
|
+
const onStop = useCallback(async () => {
|
|
485
|
+
const result = await stopTurnRef.current.mutateAsync();
|
|
486
|
+
const restored = result.stoppedMessages.map((message) => message.text);
|
|
487
|
+
return restored.length ? restored.join("\n") : undefined;
|
|
488
|
+
}, []);
|
|
489
|
+
|
|
461
490
|
const onMailboxLayoutChange = useCallback(() => {
|
|
462
491
|
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
463
492
|
onPinRequestRef.current();
|
|
@@ -491,9 +520,12 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
491
520
|
>
|
|
492
521
|
<ConversationComposer
|
|
493
522
|
draftId={props.conversationId}
|
|
523
|
+
error={stopTurn.error ? "Could not stop Junior. Try again." : undefined}
|
|
494
524
|
label="Continue this conversation"
|
|
495
525
|
submitLabel="Send"
|
|
496
526
|
onFocus={onComposerFocus}
|
|
527
|
+
onStop={props.stoppable ? onStop : undefined}
|
|
528
|
+
stopPending={stopTurn.isPending}
|
|
497
529
|
onSubmitStart={onSubmitStart}
|
|
498
530
|
onSubmit={onSubmit}
|
|
499
531
|
/>
|
|
@@ -4,11 +4,12 @@ import { Link } from "react-router";
|
|
|
4
4
|
|
|
5
5
|
import { useConversationData } from "./queries";
|
|
6
6
|
import { conversationPath } from "./conversationRoutes";
|
|
7
|
-
import { formatMessageTimestamp } from "../format";
|
|
7
|
+
import { conversationDetailTitle, formatMessageTimestamp } from "../format";
|
|
8
8
|
import { buildConversationMarkdown } from "../markdownExport";
|
|
9
|
-
import type { TranscriptViewSubagentPart } from "../types";
|
|
10
9
|
import { Drawer } from "../components/Drawer";
|
|
11
10
|
import { SearchInput } from "../components/SearchInput";
|
|
11
|
+
import { StatusChip } from "../components/StatusChip";
|
|
12
|
+
import { subagentStatusTone } from "./ConversationChildAgents";
|
|
12
13
|
import { CopyMarkdownButton } from "./CopyMarkdownButton";
|
|
13
14
|
import {
|
|
14
15
|
TranscriptSearchToggle,
|
|
@@ -19,9 +20,13 @@ import { TranscriptLoading } from "./TranscriptLoading";
|
|
|
19
20
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
20
21
|
import { transcriptEmptyClass } from "./transcriptStyles";
|
|
21
22
|
|
|
23
|
+
/** One child Conversation to show, with what its parent already knows. */
|
|
22
24
|
export interface SubagentTranscriptTarget {
|
|
23
25
|
conversationId: string;
|
|
24
|
-
|
|
26
|
+
/** Task title or agent name, shown before the child loads. */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** Child status from the parent, which knows how the child ended. */
|
|
29
|
+
status?: string;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** Show a child conversation through the ordinary authorized detail API. */
|
|
@@ -51,13 +56,8 @@ function SubagentTranscriptDrawerContent(props: {
|
|
|
51
56
|
|
|
52
57
|
const detail = query.data;
|
|
53
58
|
const label =
|
|
54
|
-
props.target.
|
|
55
|
-
|
|
56
|
-
props.target.part.subagentKind;
|
|
57
|
-
const meta = [
|
|
58
|
-
statusLabel(props.target.part, detail?.status),
|
|
59
|
-
detail ? formatMessageTimestamp(Date.parse(detail.startedAt)) : undefined,
|
|
60
|
-
].filter(isString);
|
|
59
|
+
props.target.label ?? conversationDetailTitle(detail) ?? "Subagent";
|
|
60
|
+
const status = statusLabel(props.target.status, detail?.status);
|
|
61
61
|
const searchOpenVisible = searchOpen || search.length > 0;
|
|
62
62
|
|
|
63
63
|
const titleId = "subagent-transcript-drawer-title";
|
|
@@ -122,9 +122,20 @@ function SubagentTranscriptDrawerContent(props: {
|
|
|
122
122
|
<ExternalLink aria-hidden="true" size={12} strokeWidth={2.25} />
|
|
123
123
|
</Link>
|
|
124
124
|
</div>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
{status || detail ? (
|
|
126
|
+
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-snug text-dashboard-text-muted">
|
|
127
|
+
{status ? (
|
|
128
|
+
<StatusChip size="compact" tone={subagentStatusTone(status)}>
|
|
129
|
+
{status}
|
|
130
|
+
</StatusChip>
|
|
131
|
+
) : null}
|
|
132
|
+
{detail ? (
|
|
133
|
+
<time className="font-mono" dateTime={detail.startedAt}>
|
|
134
|
+
{formatMessageTimestamp(Date.parse(detail.startedAt))}
|
|
135
|
+
</time>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
) : null}
|
|
128
139
|
{searchOpenVisible ? (
|
|
129
140
|
<div className="mt-3 min-w-0">
|
|
130
141
|
<SearchInput
|
|
@@ -169,13 +180,13 @@ function SubagentTranscriptDrawerContent(props: {
|
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
function statusLabel(
|
|
172
|
-
|
|
173
|
-
detailStatus
|
|
174
|
-
): string {
|
|
175
|
-
if (
|
|
176
|
-
return
|
|
183
|
+
status: string | undefined,
|
|
184
|
+
detailStatus: string | undefined,
|
|
185
|
+
): string | undefined {
|
|
186
|
+
if (status && status !== "running" && status !== "completed") {
|
|
187
|
+
return status;
|
|
177
188
|
}
|
|
178
|
-
return detailStatus ??
|
|
189
|
+
return detailStatus ?? status;
|
|
179
190
|
}
|
|
180
191
|
|
|
181
192
|
function DrawerEmptyState(props: {
|
|
@@ -193,7 +204,3 @@ function DrawerEmptyState(props: {
|
|
|
193
204
|
</div>
|
|
194
205
|
);
|
|
195
206
|
}
|
|
196
|
-
|
|
197
|
-
function isString(value: string | undefined): value is string {
|
|
198
|
-
return typeof value === "string" && value.length > 0;
|
|
199
|
-
}
|
|
@@ -20,6 +20,8 @@ export function isCollapsibleActivityEntry(
|
|
|
20
20
|
if (entry.kind === "failure") return false;
|
|
21
21
|
// Delivered files are human-facing media, not collapsible tool chrome.
|
|
22
22
|
if (entry.kind === "attachments_delivered") return false;
|
|
23
|
+
// Child agent rows stay in the main view, like their result events.
|
|
24
|
+
if (entry.kind === "subagent") return false;
|
|
23
25
|
if (entry.kind === "message") return false;
|
|
24
26
|
return true;
|
|
25
27
|
}
|
|
@@ -91,9 +93,6 @@ export function activityGroupSummary(
|
|
|
91
93
|
? [entry.part.event]
|
|
92
94
|
: [],
|
|
93
95
|
);
|
|
94
|
-
const subagentCount = entries.filter(
|
|
95
|
-
(entry) => entry.kind === "subagent",
|
|
96
|
-
).length;
|
|
97
96
|
const structuredCount = entries.filter(
|
|
98
97
|
(entry) => entry.kind === "structured_event",
|
|
99
98
|
).length;
|
|
@@ -115,9 +114,6 @@ export function activityGroupSummary(
|
|
|
115
114
|
reasoningCount > 0
|
|
116
115
|
? countLabel(reasoningCount, "1 reasoning entry", "reasoning entries")
|
|
117
116
|
: undefined,
|
|
118
|
-
subagentCount > 0
|
|
119
|
-
? countLabel(subagentCount, "1 subagent", "subagents")
|
|
120
|
-
: undefined,
|
|
121
117
|
compactionCount > 0
|
|
122
118
|
? countLabel(compactionCount, "context compacted", "context compacted")
|
|
123
119
|
: undefined,
|
|
@@ -135,11 +131,9 @@ export function activityGroupSummary(
|
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
function hasLiveActivity(entries: RenderedTranscriptEntry[]): boolean {
|
|
138
|
-
return entries.some(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return false;
|
|
142
|
-
});
|
|
134
|
+
return entries.some(
|
|
135
|
+
(entry) => entry.kind === "tool" && entry.part.status === "running",
|
|
136
|
+
);
|
|
143
137
|
}
|
|
144
138
|
|
|
145
139
|
/** Keep activity collapsed until the reader explicitly opens it. */
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
conversationEventPageSchema,
|
|
27
27
|
conversationPendingMessagesReportSchema,
|
|
28
28
|
forkConversationResponseSchema,
|
|
29
|
+
stopConversationTurnResponseSchema,
|
|
29
30
|
} from "@sentry/junior/api/schema";
|
|
30
31
|
|
|
31
32
|
import type { DashboardCoreData } from "../types";
|
|
@@ -395,6 +396,49 @@ export function useCancelConversationPendingMessages(conversationId: string) {
|
|
|
395
396
|
});
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Local cache of queued rows that a stop removed. The worker drops them only
|
|
401
|
+
* when it observes the stop, so the mailbox can still list them for a while.
|
|
402
|
+
* Inbound ids are unique, so the cache never needs to forget one.
|
|
403
|
+
*/
|
|
404
|
+
function stoppedMessageIdsQueryKey(conversationId: string | undefined) {
|
|
405
|
+
return ["conversation", conversationId, "stopped-messages"] as const;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Stop the active Turn and hide its removed queued rows until polls agree. */
|
|
409
|
+
export function useStopConversationTurn(conversationId: string) {
|
|
410
|
+
const queryClient = useQueryClient();
|
|
411
|
+
return useMutation({
|
|
412
|
+
mutationFn: () =>
|
|
413
|
+
post(
|
|
414
|
+
stopConversationTurnResponseSchema,
|
|
415
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/stop`,
|
|
416
|
+
{},
|
|
417
|
+
),
|
|
418
|
+
onSuccess: (result) => {
|
|
419
|
+
const stopped = result.stoppedInboundMessageIds;
|
|
420
|
+
if (!stopped.length) return;
|
|
421
|
+
queryClient.setQueryData<readonly string[]>(
|
|
422
|
+
stoppedMessageIdsQueryKey(conversationId),
|
|
423
|
+
(current) => [...(current ?? []), ...stopped],
|
|
424
|
+
);
|
|
425
|
+
// A web row keeps its Message id as its inbound id. Drop local sends the
|
|
426
|
+
// stop removed so they do not wait for a mailbox row that never comes.
|
|
427
|
+
queryClient.setQueryData<ConversationOutboxMessage[]>(
|
|
428
|
+
conversationOutboxQueryKey(conversationId),
|
|
429
|
+
(current) =>
|
|
430
|
+
current?.filter((message) => !stopped.includes(message.messageId)),
|
|
431
|
+
);
|
|
432
|
+
},
|
|
433
|
+
onSettled: async () => {
|
|
434
|
+
await queryClient.invalidateQueries({
|
|
435
|
+
exact: true,
|
|
436
|
+
queryKey: conversationDetailQueryKey(conversationId),
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
398
442
|
/** Archive or restore one conversation with an immediate reversible cache update. */
|
|
399
443
|
export function useArchiveConversation(
|
|
400
444
|
conversationId: string,
|
|
@@ -571,6 +615,18 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
571
615
|
staleTime: Infinity,
|
|
572
616
|
gcTime: Infinity,
|
|
573
617
|
});
|
|
618
|
+
const stoppedMessageIds = useQuery({
|
|
619
|
+
enabled: Boolean(conversationId),
|
|
620
|
+
// Local-only cache written by useStopConversationTurn.
|
|
621
|
+
queryFn: async (): Promise<readonly string[]> =>
|
|
622
|
+
queryClient.getQueryData<readonly string[]>(
|
|
623
|
+
stoppedMessageIdsQueryKey(conversationId),
|
|
624
|
+
) ?? [],
|
|
625
|
+
queryKey: stoppedMessageIdsQueryKey(conversationId),
|
|
626
|
+
initialData: [],
|
|
627
|
+
staleTime: Infinity,
|
|
628
|
+
gcTime: Infinity,
|
|
629
|
+
});
|
|
574
630
|
const awaitingMessage = outbox.data.some(
|
|
575
631
|
(message) => message.status !== "failed",
|
|
576
632
|
);
|
|
@@ -643,14 +699,23 @@ export function useConversationData(conversationId: string | undefined) {
|
|
|
643
699
|
undefined,
|
|
644
700
|
);
|
|
645
701
|
const pendingMessages = useMemo(() => {
|
|
702
|
+
// Hide stopped rows from both sources, or the outbox copy of a stopped
|
|
703
|
+
// send would look unseen and come back.
|
|
704
|
+
const stopped = stoppedMessageIds.data;
|
|
646
705
|
const next = mergeConversationMailboxMessages(
|
|
647
|
-
|
|
648
|
-
|
|
706
|
+
stopped.length
|
|
707
|
+
? mailbox?.messages.filter(
|
|
708
|
+
(message) => !stopped.includes(message.inboundMessageId),
|
|
709
|
+
)
|
|
710
|
+
: mailbox?.messages,
|
|
711
|
+
stopped.length
|
|
712
|
+
? outbox.data.filter((message) => !stopped.includes(message.messageId))
|
|
713
|
+
: outbox.data,
|
|
649
714
|
pendingMessagesRef.current,
|
|
650
715
|
);
|
|
651
716
|
pendingMessagesRef.current = next;
|
|
652
717
|
return next;
|
|
653
|
-
}, [outbox.data, mailbox?.messages]);
|
|
718
|
+
}, [outbox.data, mailbox?.messages, stoppedMessageIds.data]);
|
|
654
719
|
const invalidHistoryCursor = isInvalidCursorError(history.error);
|
|
655
720
|
const shouldRefreshDetail = Boolean(
|
|
656
721
|
detailData &&
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import { getDashboardAgentName } from "./agentName";
|
|
4
|
+
|
|
5
|
+
/** Format a browser tab title as `{page} - {agent}`, or the agent name alone. */
|
|
6
|
+
export function formatDocumentTitle(
|
|
7
|
+
title: string | undefined,
|
|
8
|
+
agentName = getDashboardAgentName(),
|
|
9
|
+
): string {
|
|
10
|
+
const page = title?.trim();
|
|
11
|
+
return page ? `${page} - ${agentName}` : agentName;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Show the current page in the browser tab title.
|
|
16
|
+
*
|
|
17
|
+
* An empty title leaves the tab alone, so a parent without a title cannot
|
|
18
|
+
* replace a child's title. Unmount restores the agent name.
|
|
19
|
+
*/
|
|
20
|
+
export function useDocumentTitle(title: string | undefined): void {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!title?.trim()) return;
|
|
23
|
+
document.title = formatDocumentTitle(title);
|
|
24
|
+
return () => {
|
|
25
|
+
document.title = formatDocumentTitle(undefined);
|
|
26
|
+
};
|
|
27
|
+
}, [title]);
|
|
28
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -660,6 +660,21 @@ function compareTimeDesc(a: string | undefined, b: string | undefined): number {
|
|
|
660
660
|
return (parseTime(b) ?? 0) - (parseTime(a) ?? 0);
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Return the title of a loaded Conversation. A child agent Conversation uses
|
|
665
|
+
* its agent label from the agent tree, because it has no title of its own.
|
|
666
|
+
*/
|
|
667
|
+
export function conversationDetailTitle(
|
|
668
|
+
detail:
|
|
669
|
+
| Pick<ConversationTranscript, "agentTree" | "displayTitle">
|
|
670
|
+
| undefined,
|
|
671
|
+
): string | undefined {
|
|
672
|
+
return (
|
|
673
|
+
detail?.agentTree?.at(-1)?.label ??
|
|
674
|
+
(detail?.displayTitle?.trim() || undefined)
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
663
678
|
/** Return the display title prepared by the reporting API. */
|
|
664
679
|
export function conversationDisplayTitle(
|
|
665
680
|
conversation: Conversation | undefined,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { deleteDashboardResource, fetchDashboardJson, post } from "../http";
|
|
10
10
|
import { dashboardContainerClass } from "../styles";
|
|
11
11
|
import { getDashboardAgentName } from "../agentName";
|
|
12
|
+
import { useDocumentTitle } from "../documentTitle";
|
|
12
13
|
import { Button } from "../components/Button";
|
|
13
14
|
import { InlineError } from "../components/InlineError";
|
|
14
15
|
import { Card } from "../components/layout/Card";
|
|
@@ -18,6 +19,7 @@ const personalTokensQueryKey = ["dashboard", "personal-tokens"] as const;
|
|
|
18
19
|
|
|
19
20
|
/** Create and revoke personal API tokens for local clients. */
|
|
20
21
|
export function PersonalTokensPage() {
|
|
22
|
+
useDocumentTitle("API tokens");
|
|
21
23
|
const queryClient = useQueryClient();
|
|
22
24
|
const createTokenStarted = useRef(false);
|
|
23
25
|
const [name, setName] = useState("Local agent");
|
|
@@ -10,6 +10,7 @@ import { Field } from "../components/Field";
|
|
|
10
10
|
import { InlineError } from "../components/InlineError";
|
|
11
11
|
import { Card } from "../components/layout/Card";
|
|
12
12
|
import { TextInput } from "../components/TextInput";
|
|
13
|
+
import { useDocumentTitle } from "../documentTitle";
|
|
13
14
|
import { patch } from "../http";
|
|
14
15
|
import { dashboardContainerClass } from "../styles";
|
|
15
16
|
import type { DashboardCoreData } from "../types";
|
|
@@ -22,6 +23,7 @@ type SettingsPageProps = {
|
|
|
22
23
|
|
|
23
24
|
/** Let the signed-in user manage their dashboard profile. */
|
|
24
25
|
export function SettingsPage({ identity }: SettingsPageProps) {
|
|
26
|
+
useDocumentTitle("Settings");
|
|
25
27
|
const queryClient = useQueryClient();
|
|
26
28
|
const [displayName, setDisplayName] = useState(identity.user.name ?? "");
|
|
27
29
|
useEffect(() => {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type AutomationSummary,
|
|
7
7
|
} from "@sentry/junior/api/schema";
|
|
8
8
|
import { useAutomationData } from "../../api";
|
|
9
|
+
import { useDocumentTitle } from "../../documentTitle";
|
|
9
10
|
import { fetchDashboardJson } from "../../http";
|
|
10
11
|
import { Button } from "../../components/Button";
|
|
11
12
|
import { FormNotice } from "../../components/FormNotice";
|
|
@@ -37,6 +38,13 @@ export function AutomationEditPage(props: { enabled: boolean }) {
|
|
|
37
38
|
});
|
|
38
39
|
const returnPath = `/automations/list${location.search}`;
|
|
39
40
|
const value = summary.data;
|
|
41
|
+
useDocumentTitle(
|
|
42
|
+
value
|
|
43
|
+
? canEdit
|
|
44
|
+
? `${value.title} · Edit`
|
|
45
|
+
: value.title
|
|
46
|
+
: "Edit automation",
|
|
47
|
+
);
|
|
40
48
|
const status =
|
|
41
49
|
value?.kind === "event"
|
|
42
50
|
? value.status !== "active"
|
|
@@ -125,6 +125,7 @@ function AutomationVersionsView(props: {
|
|
|
125
125
|
<BackLink to={props.backTo} />
|
|
126
126
|
<PageHeader
|
|
127
127
|
description={`${automation.kind} automation · ${automation.destination.label} · ${data.versions.length} ${data.versions.length === 1 ? "version" : "versions"}`}
|
|
128
|
+
documentTitle={`${automation.title} · Version history`}
|
|
128
129
|
title={automation.title}
|
|
129
130
|
/>
|
|
130
131
|
</div>
|
|
@@ -27,6 +27,7 @@ import { PageHeader } from "../../components/layout/PageHeader";
|
|
|
27
27
|
import { StatCard } from "../../components/metrics/StatCard";
|
|
28
28
|
import { deleteDashboardResource } from "../../http";
|
|
29
29
|
import { automationPath } from "../../format";
|
|
30
|
+
import { useDocumentTitle } from "../../documentTitle";
|
|
30
31
|
import {
|
|
31
32
|
pathWithSearch,
|
|
32
33
|
useDebouncedSearchParam,
|
|
@@ -122,6 +123,12 @@ export function AutomationsPage(props: {
|
|
|
122
123
|
const totalPages = pageCount(visibleTaskCount, filters.pageSize);
|
|
123
124
|
const page = query.data?.page ?? filters.page;
|
|
124
125
|
const selectedTask = detail.data;
|
|
126
|
+
// Overview names the tab through PageHeader; the list has a plain heading.
|
|
127
|
+
useDocumentTitle(
|
|
128
|
+
props.view === "list"
|
|
129
|
+
? (selectedTask?.title ?? "All automations")
|
|
130
|
+
: undefined,
|
|
131
|
+
);
|
|
125
132
|
// A deletion or a shared URL can leave the requested page past the last page.
|
|
126
133
|
useEffect(() => {
|
|
127
134
|
if (
|
|
@@ -113,6 +113,11 @@ export function RepositoryPage(props: { tab: CodeRepositoryTab }) {
|
|
|
113
113
|
</div>
|
|
114
114
|
}
|
|
115
115
|
description={<RepositoryDescription data={data} />}
|
|
116
|
+
documentTitle={
|
|
117
|
+
props.tab === "overview"
|
|
118
|
+
? repository.name
|
|
119
|
+
: `${repository.name} · ${props.tab === "changes" ? "Changes" : "Conversations"}`
|
|
120
|
+
}
|
|
116
121
|
title={repository.name}
|
|
117
122
|
/>
|
|
118
123
|
{props.tab === "overview" ? (
|
|
@@ -11,7 +11,7 @@ export function SegmentedTabsFixture() {
|
|
|
11
11
|
<SegmentedTabs
|
|
12
12
|
items={[
|
|
13
13
|
{ label: "Details", value: "details" },
|
|
14
|
-
{ label: "Memories", value: "memories" },
|
|
14
|
+
{ count: 3, label: "Memories", value: "memories" },
|
|
15
15
|
{ label: "Usage", value: "usage" },
|
|
16
16
|
]}
|
|
17
17
|
label="Conversation panels"
|