@sentry/junior-dashboard 0.245.0 → 0.247.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.245.0",
3
+ "version": "0.247.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.245.0",
38
- "@sentry/junior": "0.245.0"
37
+ "@sentry/junior": "0.247.0",
38
+ "@sentry/junior-plugin-api": "0.247.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
- /** Render a spacious analytics page heading with optional controls. */
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
- {!online || props.error || imageError ? (
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
- {!online
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
- <Button
455
- aria-label={sendLocked ? "Sending message" : props.submitLabel}
456
- 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"
457
- disabled={
458
- (!canSend && !images.length) ||
459
- !online ||
460
- sendLocked ||
461
- readingImages
462
- }
463
- title={
464
- !online
465
- ? "Connect to send"
466
- : sendLocked
467
- ? "Sending message"
468
- : props.submitLabel
469
- }
470
- type="submit"
471
- >
472
- <Send aria-hidden="true" size={14} />
473
- <span className="hidden md:inline">
474
- {sendLocked ? "Sending…" : props.submitLabel}
475
- </span>
476
- </Button>
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((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
+ }
@@ -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" ? (