@sentry/junior-dashboard 0.276.0 → 0.277.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 (45) hide show
  1. package/dist/app.js +168 -33
  2. package/dist/client.js +88 -83
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +163 -31
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/components/FormNotice.tsx +11 -14
  11. package/src/client/components/ProfileMenu.tsx +5 -6
  12. package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
  13. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  14. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  15. package/src/client/conversations/ConversationDetails.tsx +6 -3
  16. package/src/client/conversations/ConversationHeader.tsx +9 -0
  17. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  18. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  19. package/src/client/conversations/ConversationMemories.tsx +64 -67
  20. package/src/client/conversations/ConversationPage.tsx +21 -8
  21. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  22. package/src/client/conversations/ConversationWatches.tsx +15 -21
  23. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  24. package/src/client/conversations/DetailsList.tsx +8 -14
  25. package/src/client/conversations/MemoryIcons.tsx +23 -0
  26. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  27. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  28. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  29. package/src/client/conversations/conversationArchive.ts +255 -0
  30. package/src/client/conversations/conversationMemory.ts +2 -1
  31. package/src/client/conversations/conversationSections.ts +24 -18
  32. package/src/client/conversations/queries.ts +48 -238
  33. package/src/client/conversations/usePinConversation.ts +88 -0
  34. package/src/client/display-text/DisplayText.tsx +7 -2
  35. package/src/client/display-text/codeBlocks.ts +92 -0
  36. package/src/client/format.ts +3 -67
  37. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  38. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  39. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  40. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  41. package/src/client/review-desk/queries.ts +2 -1
  42. package/src/client/types.ts +1 -0
  43. package/src/conversation-cache.ts +1 -1
  44. package/src/mock-reporting/fixtures.ts +103 -27
  45. package/src/mock-reporting/routes.ts +47 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.276.0",
3
+ "version": "0.277.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -40,10 +40,10 @@
40
40
  "remark-gfm": "^4.0.1",
41
41
  "shiki": "4.1.0",
42
42
  "zod": "^4.5.4",
43
- "@sentry/junior-plugin-api": "0.276.0"
43
+ "@sentry/junior-plugin-api": "0.277.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@sentry/junior": "0.276.0"
46
+ "@sentry/junior": "0.277.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@oxlint/plugins": "1.79.0",
@@ -64,7 +64,7 @@
64
64
  "tw-animate-css": "^1.4.0",
65
65
  "typescript": "^7.0.2",
66
66
  "vitest": "^4.1.12",
67
- "@sentry/junior": "0.276.0"
67
+ "@sentry/junior": "0.277.0"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
package/src/api/schema.ts CHANGED
@@ -13,9 +13,16 @@ export const dashboardSessionSchema = z
13
13
  .object({ user: dashboardSessionUserSchema })
14
14
  .strict();
15
15
 
16
+ /** Longest auto-archive grace period a user can pick: one week. */
17
+ export const AUTO_ARCHIVE_MAX_GRACE_HOURS = 7 * 24;
18
+
16
19
  /** Signed-in viewer returned by `/api/me`. */
17
20
  export const dashboardIdentitySchema = z
18
21
  .object({
22
+ /** Auto-archive the viewer's conversations when their work is done. */
23
+ autoArchiveDefault: z.boolean().optional(),
24
+ /** Hours done work and the conversation stay quiet before auto-archive. */
25
+ autoArchiveGraceHours: z.number().int().positive().optional(),
19
26
  user: dashboardSessionUserSchema
20
27
  .extend({
21
28
  /** Junior API path of the viewer's Slack photo, then Google photo. */
@@ -29,9 +36,20 @@ export const dashboardIdentitySchema = z
29
36
 
30
37
  export const dashboardProfileUpdateSchema = z
31
38
  .object({
32
- displayName: z.string().trim().min(1).max(80),
39
+ autoArchiveDefault: z.boolean().optional(),
40
+ autoArchiveGraceHours: z
41
+ .number()
42
+ .int()
43
+ .min(1)
44
+ .max(AUTO_ARCHIVE_MAX_GRACE_HOURS)
45
+ .optional(),
46
+ displayName: z.string().trim().min(1).max(80).optional(),
33
47
  })
34
- .strict();
48
+ .strict()
49
+ .refine(
50
+ (body) => Object.values(body).some((value) => value !== undefined),
51
+ "Change at least one setting.",
52
+ );
35
53
 
36
54
  export const dashboardConfigSchema = z
37
55
  .object({
package/src/app.ts CHANGED
@@ -5,8 +5,12 @@ import {
5
5
  createJuniorApi,
6
6
  jsonResponse,
7
7
  readViewerAvatarUrl,
8
+ DEFAULT_AUTO_ARCHIVE_SETTINGS,
9
+ readUserAutoArchiveSettings,
8
10
  resolveViewerUser,
11
+ updateUserAutoArchiveSettings,
9
12
  updateViewerDisplayName,
13
+ type AutoArchiveSettings,
10
14
  type ConversationWorkQueue,
11
15
  type JuniorApiVariables,
12
16
  } from "@sentry/junior/api";
@@ -38,7 +42,7 @@ import {
38
42
  type DashboardSession,
39
43
  } from "./auth";
40
44
  import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes";
41
- import { resetMockConversationArchiveState } from "./mock-reporting/fixtures";
45
+ import { resetMockConversationViewerState } from "./mock-reporting/fixtures";
42
46
  import { resetMockReadPositions } from "./mock-reporting/read-positions";
43
47
  import { createMockReportingApi } from "./mock-reporting/routes";
44
48
  import {
@@ -62,8 +66,9 @@ const DEFAULT_BASE_PATH = "/";
62
66
  const DEFAULT_AUTH_PATH = "/api/auth";
63
67
  const LOGIN_NEXT_PARAM = "next";
64
68
  const LOCAL_VIEWER_EMAIL = "dev@example.com";
65
- /** Process-local display names for mock reporting only. */
69
+ /** Process-local profile settings for mock reporting only. */
66
70
  const mockDisplayNamesByEmail = new Map<string, string>();
71
+ const mockAutoArchiveSettingsByEmail = new Map<string, AutoArchiveSettings>();
67
72
 
68
73
  /**
69
74
  * Clear process-local mock reporting state so tests sharing a worker do not
@@ -74,7 +79,8 @@ const mockDisplayNamesByEmail = new Map<string, string>();
74
79
  */
75
80
  export function resetMockDashboardState(): void {
76
81
  mockDisplayNamesByEmail.clear();
77
- resetMockConversationArchiveState();
82
+ resetMockConversationViewerState();
83
+ mockAutoArchiveSettingsByEmail.clear();
78
84
  resetMockReadPositions();
79
85
  }
80
86
 
@@ -255,6 +261,25 @@ function callbackUrl(
255
261
  return url.toString();
256
262
  }
257
263
 
264
+ /**
265
+ * Restart sign-in with the same return path when the Google callback fails,
266
+ * for example on a stale OAuth state cookie. Without it, Better Auth sends
267
+ * the person to `/` and the page is lost.
268
+ */
269
+ function signInRetryUrl(
270
+ request: Request,
271
+ basePath: string,
272
+ options: DashboardReturnPathOptions = {},
273
+ ): string {
274
+ const url = new URL(request.url);
275
+ const returnPath = requestedReturnPath(url, basePath, options);
276
+ url.search = "";
277
+ if (returnPath) {
278
+ url.searchParams.set(LOGIN_NEXT_PARAM, returnPath);
279
+ }
280
+ return url.toString();
281
+ }
282
+
258
283
  function unauthorized(
259
284
  request: Request,
260
285
  basePath: string,
@@ -397,7 +422,11 @@ export function createDashboardApp(
397
422
  if (session && dashboardSessionIsAuthorized(session, allowedDomains)) {
398
423
  return Response.redirect(returnUrl, 302);
399
424
  }
400
- return auth.signInWithGoogle(c.req.raw, returnUrl);
425
+ return auth.signInWithGoogle(
426
+ c.req.raw,
427
+ returnUrl,
428
+ signInRetryUrl(c.req.raw, basePath, { authenticatedRoutes }),
429
+ );
401
430
  });
402
431
 
403
432
  if (auth) {
@@ -508,7 +537,11 @@ export function createDashboardApp(
508
537
  };
509
538
 
510
539
  app.use("*", requireAuth);
511
- app.use("/api/conversations/:conversationId", revalidateConversation);
540
+ // Core detail reads set a version ETag before they build the report. Mock
541
+ // reporting has no stored version, so it revalidates on content instead.
542
+ if (options.mockConversations) {
543
+ app.use("/api/conversations/:conversationId", revalidateConversation);
544
+ }
512
545
 
513
546
  for (const route of authenticatedRoutes) {
514
547
  const handler = (c: Context<{ Variables: Variables }>) => {
@@ -576,11 +609,23 @@ export function createDashboardApp(
576
609
  userId && !options.mockConversations
577
610
  ? await readViewerAvatarUrl(userId)
578
611
  : undefined;
612
+ async function readViewerAutoArchiveSettings(viewer: {
613
+ email: string;
614
+ id: string;
615
+ }): Promise<AutoArchiveSettings> {
616
+ return (
617
+ (options.mockConversations
618
+ ? mockAutoArchiveSettingsByEmail.get(viewer.email)
619
+ : await readUserAutoArchiveSettings(viewer.id)) ??
620
+ DEFAULT_AUTO_ARCHIVE_SETTINGS
621
+ );
622
+ }
579
623
  app.get("/api/me", async (c) => {
580
624
  const session = c.get("authSession");
581
625
  const viewer = c.get("viewer");
582
626
  const avatarUrl = await viewerAvatarUrl(viewer?.id);
583
627
  return jsonResponse(dashboardIdentitySchema, {
628
+ ...(viewer ? await readViewerAutoArchiveSettings(viewer) : undefined),
584
629
  user: {
585
630
  ...(avatarUrl ? { avatarUrl } : undefined),
586
631
  email: session.user.email,
@@ -618,22 +663,34 @@ export function createDashboardApp(
618
663
  );
619
664
  }
620
665
  const session = c.get("authSession");
666
+ const { displayName, ...autoArchive } = parsed.data;
667
+ // Parsed bodies omit settings the request did not send.
668
+ const changesAutoArchive = Object.keys(autoArchive).length > 0;
621
669
  // Mock reporting keeps profile edits process-local and out of SQL.
622
670
  if (options.mockConversations) {
623
- mockDisplayNamesByEmail.set(viewer.email, parsed.data.displayName);
671
+ if (displayName !== undefined) {
672
+ mockDisplayNamesByEmail.set(viewer.email, displayName);
673
+ }
674
+ if (changesAutoArchive) {
675
+ mockAutoArchiveSettingsByEmail.set(viewer.email, {
676
+ ...(await readViewerAutoArchiveSettings(viewer)),
677
+ ...autoArchive,
678
+ });
679
+ }
624
680
  return jsonResponse(dashboardIdentitySchema, {
681
+ ...(await readViewerAutoArchiveSettings(viewer)),
625
682
  user: {
626
683
  email: session.user.email,
627
684
  emailVerified: session.user.emailVerified,
628
685
  isAdmin: viewer.isAdmin === true,
629
- name: parsed.data.displayName,
686
+ name: mockDisplayNamesByEmail.get(viewer.email) ?? viewer.displayName,
630
687
  },
631
688
  });
632
689
  }
633
- const updated = await updateViewerDisplayName(
634
- viewer.id,
635
- parsed.data.displayName,
636
- );
690
+ const updated =
691
+ displayName === undefined
692
+ ? viewer
693
+ : await updateViewerDisplayName(viewer.id, displayName);
637
694
  if (!updated) {
638
695
  return jsonResponse(
639
696
  apiErrorSchema,
@@ -642,13 +699,24 @@ export function createDashboardApp(
642
699
  );
643
700
  }
644
701
  const avatarUrl = await viewerAvatarUrl(updated.id);
702
+ const savedAutoArchive = changesAutoArchive
703
+ ? await updateUserAutoArchiveSettings(viewer.id, autoArchive)
704
+ : await readUserAutoArchiveSettings(viewer.id);
705
+ if (!savedAutoArchive) {
706
+ return jsonResponse(
707
+ apiErrorSchema,
708
+ { error: "User not found." },
709
+ { status: 404 },
710
+ );
711
+ }
645
712
  return jsonResponse(dashboardIdentitySchema, {
713
+ ...savedAutoArchive,
646
714
  user: {
647
715
  ...(avatarUrl ? { avatarUrl } : undefined),
648
716
  email: session.user.email,
649
717
  emailVerified: session.user.emailVerified,
650
718
  isAdmin: updated.isAdmin === true,
651
- name: updated.displayName,
719
+ name: updated.displayName ?? session.user.name,
652
720
  },
653
721
  });
654
722
  });
package/src/auth.ts CHANGED
@@ -9,7 +9,11 @@ export type { DashboardSession };
9
9
  export interface DashboardAuth {
10
10
  handler(request: Request): Promise<Response>;
11
11
  getSession(request: Request): Promise<DashboardSession | null>;
12
- signInWithGoogle(request: Request, callbackURL: string): Promise<Response>;
12
+ signInWithGoogle(
13
+ request: Request,
14
+ callbackURL: string,
15
+ errorCallbackURL: string,
16
+ ): Promise<Response>;
13
17
  }
14
18
 
15
19
  /** Read a personal bearer token from one dashboard API request. */
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { TriangleAlert } from "lucide-react";
3
3
  import { cn } from "../styles";
4
+ import { Alert, AlertTitle } from "./ui/alert";
4
5
 
5
6
  /** Keep form recovery text and actions visible without truncation. */
6
7
  export function FormNotice(props: {
@@ -10,31 +11,27 @@ export function FormNotice(props: {
10
11
  }) {
11
12
  const error = props.tone === "error";
12
13
  return (
13
- <div
14
+ // Flex, not the Alert grid: the text column keeps its content width and
15
+ // its margin collapses with the first paragraph. The children hold links
16
+ // and buttons, so they skip AlertDescription and its link styles.
17
+ <Alert
14
18
  role={error ? "alert" : "status"}
15
19
  className={cn(
16
- "flex min-w-0 gap-3 rounded-md border p-4",
20
+ "flex min-w-0 rounded-md p-4 has-[>svg]:gap-x-3",
17
21
  error
18
- ? "border-rose-300/25 bg-rose-300/5"
19
- : "border-amber-300/25 bg-amber-300/5",
22
+ ? "border-rose-300/25 bg-rose-300/5 *:[svg]:text-rose-300"
23
+ : "border-amber-300/25 bg-amber-300/5 *:[svg]:text-amber-300",
20
24
  )}
21
25
  >
22
- <TriangleAlert
23
- aria-hidden
24
- size={18}
25
- className={cn(
26
- "mt-0.5 shrink-0",
27
- error ? "text-rose-300" : "text-amber-300",
28
- )}
29
- />
26
+ <TriangleAlert aria-hidden className="size-4.5 shrink-0" />
30
27
  <div className="min-w-0">
31
- <p className="m-0 text-sm font-medium">{props.title}</p>
28
+ <AlertTitle>{props.title}</AlertTitle>
32
29
  {props.children ? (
33
30
  <div className="mt-1 text-sm leading-relaxed text-dashboard-text-muted">
34
31
  {props.children}
35
32
  </div>
36
33
  ) : null}
37
34
  </div>
38
- </div>
35
+ </Alert>
39
36
  );
40
37
  }
@@ -17,6 +17,7 @@ import { cn, dashboardInteractiveTextClass } from "../styles";
17
17
  import type { Identity } from "../types";
18
18
  import { ThemeSwitcher } from "./ThemeSwitcher";
19
19
  import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
20
+ import { Button, buttonVariants } from "./ui/button";
20
21
  import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
21
22
 
22
23
  type ProfileMenuProps = {
@@ -131,8 +132,7 @@ function SpendAmount({ value }: { value?: number }) {
131
132
  );
132
133
  }
133
134
 
134
- const profileLinkClass =
135
- "flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-semibold text-dashboard-text no-underline transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:bg-dashboard-fill-strong focus-visible:text-dashboard-text focus-visible:outline-none";
135
+ const profileLinkClass = buttonVariants({ size: "menu" });
136
136
 
137
137
  /** Group the signed-in identity, personal profile, and session actions. */
138
138
  export function ProfileMenu({
@@ -218,17 +218,16 @@ export function ProfileMenu({
218
218
  {page.label}
219
219
  </Link>
220
220
  ))}
221
- <button
222
- className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:bg-dashboard-fill-strong focus-visible:text-dashboard-text focus-visible:outline-none"
221
+ <Button
223
222
  onClick={() => {
224
223
  close();
225
224
  void onSignOut();
226
225
  }}
227
- type="button"
226
+ size="menu"
228
227
  >
229
228
  <LogOut aria-hidden="true" size={16} strokeWidth={2} />
230
229
  Log out
231
- </button>
230
+ </Button>
232
231
  </>
233
232
  );
234
233
 
@@ -0,0 +1,35 @@
1
+ import { Checkbox } from "../components/ui/checkbox";
2
+ import { InlineError } from "../components/InlineError";
3
+ import { useSetConversationAutoArchive } from "./autoArchiveQueries";
4
+
5
+ /** Let a participant turn auto-archive on or off for this conversation. */
6
+ export function AutoArchiveToggle(props: {
7
+ autoArchive: boolean;
8
+ conversationId: string;
9
+ }) {
10
+ const update = useSetConversationAutoArchive(props.conversationId);
11
+ return (
12
+ <div className="grid gap-1">
13
+ <label className="flex cursor-pointer items-start gap-2.5">
14
+ <Checkbox
15
+ checked={props.autoArchive}
16
+ className="mt-0.5"
17
+ disabled={update.isPending}
18
+ onCheckedChange={(checked) => update.mutate(checked)}
19
+ />
20
+ <span className="grid gap-0.5">
21
+ <span className="font-semibold text-dashboard-text">
22
+ Auto-archive when done
23
+ </span>
24
+ <span>
25
+ Archive this conversation for you when its linked work is merged or
26
+ closed and nobody replies during your wait time.
27
+ </span>
28
+ </span>
29
+ </label>
30
+ {update.isError ? (
31
+ <InlineError>Could not save auto-archive. Try again.</InlineError>
32
+ ) : null}
33
+ </div>
34
+ );
35
+ }
@@ -5,7 +5,7 @@ import { Notice, NoticeAction } from "../components/Notice";
5
5
  import { cn } from "../styles";
6
6
  import type { Conversation } from "../types";
7
7
  import { conversationDisplayTitle } from "../format";
8
- import { useArchiveConversation } from "./queries";
8
+ import { useArchiveConversation } from "./conversationArchive";
9
9
 
10
10
  /** Show archive errors and the most recent reversible archive action. */
11
11
  export function ConversationArchiveNotices(props: {
@@ -24,6 +24,7 @@ import {
24
24
  formatUsageTotal,
25
25
  } from "../format";
26
26
  import { fetchDashboardJson } from "../http";
27
+ import { conversationSideReadQueryKey } from "./queries";
27
28
  import { cn } from "../styles";
28
29
  import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
29
30
 
@@ -56,7 +57,7 @@ export function useConversationChildAgents(conversationId: string) {
56
57
  `/api/conversations/${encodeURIComponent(conversationId)}/agents`,
57
58
  signal,
58
59
  ),
59
- queryKey: ["conversation", conversationId, "agents"],
60
+ queryKey: conversationSideReadQueryKey(conversationId, "agents"),
60
61
  retry: false,
61
62
  });
62
63
  }
@@ -39,6 +39,8 @@ type Brief = NonNullable<ConversationDetailReport["brief"]>;
39
39
  * it, and what it cost. Each fact shows here only, not in the title bar.
40
40
  */
41
41
  export function ConversationDetails(props: {
42
+ /** Viewer's auto-archive control. Null for non-participants. */
43
+ autoArchive: ReactNode;
42
44
  brief?: Brief;
43
45
  conversationId: string;
44
46
  /** True when a sidebar review chip opened details. */
@@ -50,8 +52,6 @@ export function ConversationDetails(props: {
50
52
  onOpenSubagent(target: SubagentTranscriptTarget): void;
51
53
  /** Show one memory where its Turn recalled or captured it. */
52
54
  onSelectMemory(entry: ConversationMemoryEntry & { turnId: string }): void;
53
- /** The prompt that started each loaded Turn. */
54
- memoryPrompts: Map<string, string>;
55
55
  overview: ReactNode;
56
56
  usage: ReactNode;
57
57
  }) {
@@ -98,7 +98,6 @@ export function ConversationDetails(props: {
98
98
  if (!panel) props.onClose();
99
99
  props.onSelectMemory(entry);
100
100
  }}
101
- prompts={props.memoryPrompts}
102
101
  />
103
102
  </div>
104
103
  </DetailsPanel>
@@ -167,6 +166,10 @@ export function ConversationDetails(props: {
167
166
  ) : null}
168
167
  </DetailsCard>
169
168
  ) : null}
169
+ {/* Linked work decides when auto-archive runs, so it follows. */}
170
+ {props.autoArchive != null ? (
171
+ <DetailsCard>{props.autoArchive}</DetailsCard>
172
+ ) : null}
170
173
  {agents?.length ? (
171
174
  <DetailsCard count={agents.length} title="Subagents">
172
175
  <ConversationChildAgents
@@ -14,6 +14,7 @@ import {
14
14
  HeaderIconButton,
15
15
  TranscriptSearchToggle,
16
16
  type ConversationArchiveAction,
17
+ type ConversationPinAction,
17
18
  } from "./ConversationHeaderActions";
18
19
  import { ConversationOverflowMenu } from "./ConversationOverflowMenu";
19
20
  import { ShareConversationButton } from "./ShareConversationButton";
@@ -44,6 +45,7 @@ export function ConversationHeader(props: {
44
45
  location?: ReactNode;
45
46
  onSearchChange(value: string): void;
46
47
  onViewChange(value: TranscriptViewMode): void;
48
+ pin: ConversationPinAction;
47
49
  privacy: ReactNode;
48
50
  search: string;
49
51
  sentryConversationUrl?: string;
@@ -92,6 +94,7 @@ export function ConversationHeader(props: {
92
94
  <ConversationHeaderActions
93
95
  archive={props.archive}
94
96
  conversationId={props.conversationId}
97
+ pin={props.pin}
95
98
  copyAction={props.copyAction}
96
99
  detailsOpen={detailsOpen}
97
100
  onDetailsClick={() => {
@@ -116,6 +119,7 @@ export function ConversationHeader(props: {
116
119
  const showMobileHeader =
117
120
  searchOpenVisible ||
118
121
  props.archive.error ||
122
+ props.pin.error ||
119
123
  Boolean(props.linkedWork) ||
120
124
  Boolean(props.forks) ||
121
125
  Boolean(props.agentTree);
@@ -259,6 +263,11 @@ export function ConversationHeader(props: {
259
263
  Could not update archive state.
260
264
  </div>
261
265
  ) : null}
266
+ {props.pin.error ? (
267
+ <div className="px-4 pb-3 text-xs text-red-300 md:px-7">
268
+ Could not update pin.
269
+ </div>
270
+ ) : null}
262
271
  </header>
263
272
  </>
264
273
  );
@@ -4,6 +4,8 @@ import {
4
4
  ArchiveRestore,
5
5
  Info,
6
6
  MessagesSquare,
7
+ Pin,
8
+ PinOff,
7
9
  ScrollText,
8
10
  Search,
9
11
  X,
@@ -28,12 +30,20 @@ export type ConversationArchiveAction = {
28
30
  pending: boolean;
29
31
  };
30
32
 
33
+ export type ConversationPinAction = {
34
+ disabled: boolean;
35
+ error: boolean;
36
+ onClick(): void;
37
+ pinned: boolean;
38
+ };
39
+
31
40
  /**
32
41
  * Show conversation actions in the `⋯` menu. On desktop the title bar shows
33
42
  * search, share, and details, so the menu leaves them out.
34
43
  */
35
44
  export function ConversationHeaderActions(props: {
36
45
  archive: ConversationArchiveAction;
46
+ pin: ConversationPinAction;
37
47
  conversationId: string;
38
48
  copyAction?: ReactNode;
39
49
  detailsOpen: boolean;
@@ -83,6 +93,17 @@ export function ConversationHeaderActions(props: {
83
93
  />
84
94
  ) : null}
85
95
  {props.copyAction}
96
+ <MenuActionButton
97
+ disabled={props.pin.disabled}
98
+ label={props.pin.pinned ? "Unpin" : "Pin"}
99
+ onClick={props.pin.onClick}
100
+ >
101
+ {props.pin.pinned ? (
102
+ <PinOff aria-hidden="true" size={16} strokeWidth={2} />
103
+ ) : (
104
+ <Pin aria-hidden="true" size={16} strokeWidth={2} />
105
+ )}
106
+ </MenuActionButton>
86
107
  <ArchiveConversationButton {...props.archive} />
87
108
  {props.sentryConversationUrl ? (
88
109
  <a
@@ -211,6 +232,7 @@ export function TranscriptSearchToggle(props: {
211
232
 
212
233
  function MenuActionButton(props: {
213
234
  children: ReactNode;
235
+ disabled?: boolean;
214
236
  label: string;
215
237
  onClick(): void;
216
238
  pressed?: boolean;
@@ -220,6 +242,7 @@ function MenuActionButton(props: {
220
242
  aria-label={props.label}
221
243
  aria-pressed={props.pressed}
222
244
  className={props.pressed ? "bg-dashboard-fill-strong" : undefined}
245
+ disabled={props.disabled}
223
246
  onClick={props.onClick}
224
247
  size="menu"
225
248
  >
@@ -20,7 +20,7 @@ import { ConversationSidebarAnnotations } from "./ConversationMeta";
20
20
  import { ConversationArchiveNotices } from "./ConversationArchiveNotices";
21
21
  import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
22
22
  import { conversationPath } from "./conversationRoutes";
23
- import { useArchiveConversation } from "./queries";
23
+ import { useArchiveConversation } from "./conversationArchive";
24
24
  import { DisplayText } from "../display-text/DisplayText";
25
25
  import {
26
26
  attentionConversationIds,