@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.
Files changed (29) hide show
  1. package/dist/app.js +64 -7
  2. package/dist/client.js +43 -40
  3. package/dist/index.js +64 -7
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/App.tsx +3 -2
  7. package/src/client/components/SegmentedTabs.tsx +11 -9
  8. package/src/client/components/layout/PageHeader.tsx +12 -1
  9. package/src/client/conversations/ConversationAgentTree.tsx +42 -0
  10. package/src/client/conversations/ConversationChildAgents.tsx +134 -58
  11. package/src/client/conversations/ConversationComposer.tsx +68 -35
  12. package/src/client/conversations/ConversationDetailsDrawer.tsx +29 -9
  13. package/src/client/conversations/ConversationHeader.tsx +13 -1
  14. package/src/client/conversations/ConversationMemories.tsx +10 -5
  15. package/src/client/conversations/ConversationPage.tsx +35 -3
  16. package/src/client/conversations/SubagentTranscriptDrawer.tsx +30 -23
  17. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -11
  18. package/src/client/conversations/queries.ts +68 -3
  19. package/src/client/documentTitle.ts +28 -0
  20. package/src/client/format.ts +15 -0
  21. package/src/client/pages/PersonalTokensPage.tsx +2 -0
  22. package/src/client/pages/SettingsPage.tsx +2 -0
  23. package/src/client/pages/automations/AutomationEditPage.tsx +8 -0
  24. package/src/client/pages/automations/AutomationExecutionsPage.tsx +1 -0
  25. package/src/client/pages/automations/AutomationVersionsPage.tsx +1 -0
  26. package/src/client/pages/automations/AutomationsPage.tsx +7 -0
  27. package/src/client/pages/code/RepositoryPage.tsx +5 -0
  28. package/src/client/pages/dev/SegmentedFixtures.tsx +1 -1
  29. 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={conversationDisplayTitle(conversation)}
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
- view === "rich" && detail.data?.isParticipant ? (
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
- part: TranscriptViewSubagentPart;
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.part.title ||
55
- detail?.displayTitle ||
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
- <div className="mt-1 break-words font-mono text-xs leading-snug text-dashboard-text-muted">
126
- {meta.join(" · ")}
127
- </div>
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
- part: TranscriptViewSubagentPart,
173
- detailStatus?: string,
174
- ): string {
175
- if (part.status !== "running" && part.status !== "completed") {
176
- return part.status;
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 ?? part.status;
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((entry) => {
139
- if (entry.kind === "tool") return entry.part.status === "running";
140
- if (entry.kind === "subagent") return entry.part.status === "running";
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
- mailbox?.messages,
648
- outbox.data,
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
+ }
@@ -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"
@@ -133,6 +133,7 @@ function AutomationExecutionsView(props: {
133
133
  {...(data.executionDays.length > 0
134
134
  ? { onRangeChange: setRange, range }
135
135
  : {})}
136
+ documentTitle={`${data.automation.title} · Executions`}
136
137
  title={data.automation.title}
137
138
  />
138
139
  </div>
@@ -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"