@sentry/junior-dashboard 0.245.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 +1 -1
- package/dist/client.js +43 -40
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/client/components/layout/PageHeader.tsx +12 -1
- package/src/client/conversations/ConversationComposer.tsx +68 -35
- package/src/client/conversations/ConversationPage.tsx +23 -4
- 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.246.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"react-router": "^7.18.2",
|
|
35
35
|
"shiki": "4.1.0",
|
|
36
36
|
"zod": "^4.5.4",
|
|
37
|
-
"@sentry/junior-plugin-api": "0.
|
|
38
|
-
"@sentry/junior": "0.
|
|
37
|
+
"@sentry/junior-plugin-api": "0.246.0",
|
|
38
|
+
"@sentry/junior": "0.246.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@oxlint/plugins": "1.79.0",
|
|
@@ -4,16 +4,27 @@ import {
|
|
|
4
4
|
TimeRangeSelector,
|
|
5
5
|
type TimeRangeDays,
|
|
6
6
|
} from "../controls/TimeRangeSelector";
|
|
7
|
+
import { useDocumentTitle } from "../../documentTitle";
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Render a spacious analytics page heading with optional controls.
|
|
11
|
+
*
|
|
12
|
+
* Also names the browser tab after the page. Pass `documentTitle` when the tab
|
|
13
|
+
* needs more context than the visible heading.
|
|
14
|
+
*/
|
|
9
15
|
export function PageHeader(props: {
|
|
10
16
|
actions?: ReactNode;
|
|
11
17
|
description: ReactNode;
|
|
18
|
+
documentTitle?: string;
|
|
12
19
|
onRangeChange?(value: TimeRangeDays): void;
|
|
13
20
|
range?: TimeRangeDays;
|
|
14
21
|
rangeOptions?: TimeRangeDays[];
|
|
15
22
|
title: ReactNode;
|
|
16
23
|
}) {
|
|
24
|
+
useDocumentTitle(
|
|
25
|
+
props.documentTitle ??
|
|
26
|
+
(typeof props.title === "string" ? props.title : undefined),
|
|
27
|
+
);
|
|
17
28
|
const rangeActions =
|
|
18
29
|
props.range !== undefined && props.onRangeChange ? (
|
|
19
30
|
<TimeRangeSelector
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type KeyboardEvent,
|
|
11
11
|
type ReactNode,
|
|
12
12
|
} from "react";
|
|
13
|
-
import { ImagePlus, Send } from "lucide-react";
|
|
13
|
+
import { ImagePlus, Send, Square } from "lucide-react";
|
|
14
14
|
|
|
15
15
|
import { Button } from "../components/Button";
|
|
16
16
|
import { useDashboardOnline } from "../connection";
|
|
@@ -78,6 +78,12 @@ type ConversationComposerProps = {
|
|
|
78
78
|
images?: InputImage[],
|
|
79
79
|
): Promise<void>;
|
|
80
80
|
onSubmitStart?: () => void;
|
|
81
|
+
/**
|
|
82
|
+
* Stop the active Turn. Present only while a Turn can be stopped. Resolves
|
|
83
|
+
* with queued text that the stop removed, which returns to the draft.
|
|
84
|
+
*/
|
|
85
|
+
onStop?: () => Promise<string | undefined>;
|
|
86
|
+
stopPending?: boolean;
|
|
81
87
|
};
|
|
82
88
|
|
|
83
89
|
/** Render the dashboard message composer for a new or existing conversation. */
|
|
@@ -115,6 +121,7 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
115
121
|
});
|
|
116
122
|
// Blocks same-tick double fire. For create restore, also blocks until settle.
|
|
117
123
|
const submittingRef = useRef(false);
|
|
124
|
+
const stoppingRef = useRef(false);
|
|
118
125
|
// Monotonic token so a late failed create never restores over a newer submit.
|
|
119
126
|
const submitTokenRef = useRef(0);
|
|
120
127
|
const sendLocked =
|
|
@@ -304,6 +311,30 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
304
311
|
}
|
|
305
312
|
};
|
|
306
313
|
|
|
314
|
+
// Put queued text back ahead of anything the reader already typed.
|
|
315
|
+
const stopTurn = async () => {
|
|
316
|
+
if (!props.onStop || stoppingRef.current) return;
|
|
317
|
+
stoppingRef.current = true;
|
|
318
|
+
let restored: string | undefined;
|
|
319
|
+
try {
|
|
320
|
+
restored = await props.onStop();
|
|
321
|
+
} catch {
|
|
322
|
+
// The parent owns the stop error message.
|
|
323
|
+
return;
|
|
324
|
+
} finally {
|
|
325
|
+
stoppingRef.current = false;
|
|
326
|
+
}
|
|
327
|
+
if (!restored) return;
|
|
328
|
+
const current = draftRef.current.text;
|
|
329
|
+
const next: ConversationDraft = {
|
|
330
|
+
...draftRef.current,
|
|
331
|
+
text: current.trim() ? `${restored}\n${current}` : restored,
|
|
332
|
+
};
|
|
333
|
+
replaceMessage(next);
|
|
334
|
+
storeDraft(storageKey, next);
|
|
335
|
+
textareaRef.current?.focus({ preventScroll: true });
|
|
336
|
+
};
|
|
337
|
+
|
|
307
338
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
308
339
|
if (
|
|
309
340
|
event.key === "Enter" &&
|
|
@@ -317,20 +348,12 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
317
348
|
|
|
318
349
|
return (
|
|
319
350
|
<div className="grid min-w-0 gap-1.5">
|
|
320
|
-
{
|
|
351
|
+
{props.error || imageError ? (
|
|
321
352
|
<div
|
|
322
353
|
aria-live="polite"
|
|
323
|
-
className=
|
|
324
|
-
!online
|
|
325
|
-
? "min-w-0 font-mono text-xs leading-relaxed text-amber-100/80"
|
|
326
|
-
: "min-w-0 font-mono text-xs leading-relaxed text-red-300/80"
|
|
327
|
-
}
|
|
354
|
+
className="min-w-0 font-mono text-xs leading-relaxed text-red-300/80"
|
|
328
355
|
>
|
|
329
|
-
{
|
|
330
|
-
? images.length
|
|
331
|
-
? "Connect to send. Image drafts stay here until you leave."
|
|
332
|
-
: "Connect to send. Your draft is saved."
|
|
333
|
-
: (imageError ?? props.error)}
|
|
356
|
+
{imageError ?? props.error}
|
|
334
357
|
</div>
|
|
335
358
|
) : null}
|
|
336
359
|
<form
|
|
@@ -451,29 +474,39 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
451
474
|
</div>
|
|
452
475
|
)}
|
|
453
476
|
</div>
|
|
454
|
-
<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
478
|
+
{props.onStop ? (
|
|
479
|
+
<Button
|
|
480
|
+
aria-label={props.stopPending ? "Stopping" : "Stop"}
|
|
481
|
+
className="rounded-lg font-sans"
|
|
482
|
+
disabled={props.stopPending || !online}
|
|
483
|
+
onClick={() => void stopTurn()}
|
|
484
|
+
title="Stop Junior"
|
|
485
|
+
>
|
|
486
|
+
<Square aria-hidden="true" fill="currentColor" size={12} />
|
|
487
|
+
<span className="hidden md:inline">
|
|
488
|
+
{props.stopPending ? "Stopping…" : "Stop"}
|
|
489
|
+
</span>
|
|
490
|
+
</Button>
|
|
491
|
+
) : null}
|
|
492
|
+
<Button
|
|
493
|
+
aria-label={sendLocked ? "Sending message" : props.submitLabel}
|
|
494
|
+
className="rounded-lg !border-0 !bg-cyan-100 font-sans !text-dashboard-text-inverse hover:!bg-cyan-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-cyan-300/55 disabled:!bg-dashboard-fill-hover disabled:!text-dashboard-text-muted"
|
|
495
|
+
disabled={
|
|
496
|
+
(!canSend && !images.length) ||
|
|
497
|
+
!online ||
|
|
498
|
+
sendLocked ||
|
|
499
|
+
readingImages
|
|
500
|
+
}
|
|
501
|
+
title={sendLocked ? "Sending message" : props.submitLabel}
|
|
502
|
+
type="submit"
|
|
503
|
+
>
|
|
504
|
+
<Send aria-hidden="true" size={14} />
|
|
505
|
+
<span className="hidden md:inline">
|
|
506
|
+
{sendLocked ? "Sending…" : props.submitLabel}
|
|
507
|
+
</span>
|
|
508
|
+
</Button>
|
|
509
|
+
</div>
|
|
477
510
|
</div>
|
|
478
511
|
</form>
|
|
479
512
|
</div>
|
|
@@ -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";
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
conversationFromDetail,
|
|
45
46
|
visualStatusForConversation,
|
|
46
47
|
} from "../format";
|
|
48
|
+
import { useDocumentTitle } from "../documentTitle";
|
|
47
49
|
import { Card } from "../components/layout/Card";
|
|
48
50
|
import {
|
|
49
51
|
conversationParticipants,
|
|
@@ -105,6 +107,10 @@ export function ConversationPage(props: {
|
|
|
105
107
|
<ParticipantAvatarStack participants={participants} size="detail" />
|
|
106
108
|
) : null;
|
|
107
109
|
const conversationDetail = detail.data;
|
|
110
|
+
const title =
|
|
111
|
+
conversationDetailTitle(detail.data) ??
|
|
112
|
+
conversationDisplayTitle(conversation);
|
|
113
|
+
useDocumentTitle(detail.data || conversation ? title : undefined);
|
|
108
114
|
const agentTree = detail.data?.agentTree;
|
|
109
115
|
useEffect(() => {
|
|
110
116
|
if (!conversation) return;
|
|
@@ -300,10 +306,7 @@ export function ConversationPage(props: {
|
|
|
300
306
|
/>
|
|
301
307
|
) : null
|
|
302
308
|
}
|
|
303
|
-
title={
|
|
304
|
-
conversationDetailTitle(detail.data) ??
|
|
305
|
-
conversationDisplayTitle(conversation)
|
|
306
|
-
}
|
|
309
|
+
title={title}
|
|
307
310
|
view={view}
|
|
308
311
|
/>
|
|
309
312
|
|
|
@@ -366,6 +369,7 @@ export function ConversationPage(props: {
|
|
|
366
369
|
// every 2s; a prop would bust footer memo while the reader types.
|
|
367
370
|
pendingGeneratedAtRef={pendingGeneratedAtRef}
|
|
368
371
|
pendingMessages={detail.pendingMessages}
|
|
372
|
+
stoppable={live}
|
|
369
373
|
/>
|
|
370
374
|
) : undefined
|
|
371
375
|
}
|
|
@@ -392,8 +396,13 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
392
396
|
pendingAuthorization?: ConversationPendingMessagesReport["authorization"];
|
|
393
397
|
pendingGeneratedAtRef: { current: string | undefined };
|
|
394
398
|
pendingMessages: readonly ConversationMailboxMessage[];
|
|
399
|
+
/** True while the conversation has a Turn that a stop can end. */
|
|
400
|
+
stoppable: boolean;
|
|
395
401
|
}) {
|
|
396
402
|
const appendMessage = useAppendConversationMessage(props.conversationId);
|
|
403
|
+
const stopTurn = useStopConversationTurn(props.conversationId);
|
|
404
|
+
const stopTurnRef = useRef(stopTurn);
|
|
405
|
+
stopTurnRef.current = stopTurn;
|
|
397
406
|
const cancelPendingMessages = useCancelConversationPendingMessages(
|
|
398
407
|
props.conversationId,
|
|
399
408
|
);
|
|
@@ -446,6 +455,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
446
455
|
if (!cancelPendingMessagesRef.current.isPending) {
|
|
447
456
|
cancelPendingMessagesRef.current.reset();
|
|
448
457
|
}
|
|
458
|
+
if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
|
|
449
459
|
onPinRequestRef.current();
|
|
450
460
|
}, []);
|
|
451
461
|
const cancellableMessageIds = props.pendingMessages
|
|
@@ -471,6 +481,12 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
471
481
|
),
|
|
472
482
|
);
|
|
473
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
|
+
|
|
474
490
|
const onMailboxLayoutChange = useCallback(() => {
|
|
475
491
|
if (!window.matchMedia("(max-width: 767px)").matches) return;
|
|
476
492
|
onPinRequestRef.current();
|
|
@@ -504,9 +520,12 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
|
|
|
504
520
|
>
|
|
505
521
|
<ConversationComposer
|
|
506
522
|
draftId={props.conversationId}
|
|
523
|
+
error={stopTurn.error ? "Could not stop Junior. Try again." : undefined}
|
|
507
524
|
label="Continue this conversation"
|
|
508
525
|
submitLabel="Send"
|
|
509
526
|
onFocus={onComposerFocus}
|
|
527
|
+
onStop={props.stoppable ? onStop : undefined}
|
|
528
|
+
stopPending={stopTurn.isPending}
|
|
510
529
|
onSubmitStart={onSubmitStart}
|
|
511
530
|
onSubmit={onSubmit}
|
|
512
531
|
/>
|
|
@@ -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
|
+
}
|
|
@@ -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" ? (
|