@sentry/junior-dashboard 0.275.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 (68) hide show
  1. package/dist/app.js +194 -40
  2. package/dist/client.js +120 -92
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +189 -38
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +88 -15
  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/ConversationComposer.tsx +42 -26
  16. package/src/client/conversations/ConversationDetails.tsx +66 -35
  17. package/src/client/conversations/ConversationHeader.tsx +19 -2
  18. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  19. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  20. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  21. package/src/client/conversations/ConversationMemories.tsx +114 -77
  22. package/src/client/conversations/ConversationMeta.tsx +47 -42
  23. package/src/client/conversations/ConversationPage.tsx +183 -60
  24. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  25. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  26. package/src/client/conversations/ConversationWatches.tsx +42 -26
  27. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  28. package/src/client/conversations/DetailsList.tsx +8 -14
  29. package/src/client/conversations/MemoryIcons.tsx +23 -0
  30. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  31. package/src/client/conversations/QuickQuestion.tsx +476 -0
  32. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  34. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +174 -0
  36. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  37. package/src/client/conversations/TranscriptRows.tsx +9 -2
  38. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  39. package/src/client/conversations/UsageCard.tsx +3 -3
  40. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  41. package/src/client/conversations/conversationArchive.ts +255 -0
  42. package/src/client/conversations/conversationMemory.ts +139 -0
  43. package/src/client/conversations/conversationSections.ts +24 -18
  44. package/src/client/conversations/eventTranscript.ts +8 -0
  45. package/src/client/conversations/queries.ts +48 -238
  46. package/src/client/conversations/usePinConversation.ts +88 -0
  47. package/src/client/display-text/DisplayText.tsx +7 -2
  48. package/src/client/display-text/codeBlocks.ts +92 -0
  49. package/src/client/format.ts +3 -67
  50. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  51. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  52. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  53. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  54. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  55. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  56. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  57. package/src/client/review-desk/queries.ts +2 -1
  58. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  59. package/src/client/sentry.ts +77 -0
  60. package/src/client/types.ts +7 -0
  61. package/src/client.tsx +3 -0
  62. package/src/conversation-cache.ts +1 -1
  63. package/src/mock-reporting/fixtures.ts +103 -27
  64. package/src/mock-reporting/routes.ts +47 -0
  65. package/src/mock-reporting/watches.ts +2 -0
  66. package/src/shell.ts +27 -2
  67. package/src/tailwind.css +8 -19
  68. package/src/client/components/ActiveIndicator.tsx +0 -14
@@ -6,7 +6,6 @@ import {
6
6
  CircleX,
7
7
  GitMerge,
8
8
  GitPullRequest,
9
- Globe2,
10
9
  LockKeyhole,
11
10
  TriangleAlert,
12
11
  } from "lucide-react";
@@ -72,35 +71,24 @@ export function PendingAuthorization(props: {
72
71
  );
73
72
  }
74
73
 
75
- /** Show whether the selected conversation is public or private. */
74
+ /**
75
+ * Mark a private conversation with a lock icon. Public is the default, so it
76
+ * gets no marker, which keeps the title bar short.
77
+ */
76
78
  export function ConversationPrivacyChip(props: {
77
79
  visibility: Conversation["visibility"];
78
80
  }) {
79
- if (!props.visibility) return null;
80
- const isPublic = props.visibility === "public";
81
- const Icon = isPublic ? Globe2 : LockKeyhole;
82
- const shortLabel = isPublic ? "Public" : "Private";
83
- const fullLabel = isPublic ? "Public conversation" : "Private conversation";
84
- const detail = isPublic
85
- ? "Anyone in this workspace can see this transcript."
86
- : "Only members of this conversation can see this transcript.";
81
+ if (props.visibility !== "private") return null;
82
+ const label =
83
+ "Private conversation. Only members of this conversation can see this transcript.";
87
84
  return (
88
85
  <span
89
- className={
90
- isPublic
91
- ? "inline-flex max-w-full items-center gap-1 rounded-full border border-dashboard-border-emphasis bg-dashboard-fill-soft px-2 py-0.5 font-sans text-2xs font-medium text-dashboard-text-muted"
92
- : "inline-flex max-w-full items-center gap-1 rounded-full border border-dashboard-border-strong bg-dashboard-fill-soft px-2 py-0.5 font-sans text-2xs font-medium text-dashboard-text-muted"
93
- }
86
+ className="inline-flex items-center text-dashboard-text-muted"
94
87
  role="note"
95
- title={`${fullLabel}. ${detail}`}
88
+ title={label}
96
89
  >
97
- <Icon aria-hidden="true" className="size-3 shrink-0" />
98
- <span aria-hidden="true" className="truncate">
99
- {shortLabel}
100
- </span>
101
- <span className="sr-only">
102
- {fullLabel}. {detail}
103
- </span>
90
+ <LockKeyhole aria-hidden="true" className="size-3.5 shrink-0" />
91
+ <span className="sr-only">{label}</span>
104
92
  </span>
105
93
  );
106
94
  }
@@ -568,6 +556,37 @@ export function conversationLocation(
568
556
  );
569
557
  }
570
558
 
559
+ /**
560
+ * Show the estimated cost of a conversation with its breakdown tooltip.
561
+ * Returns null until there is a cost or a live run to show.
562
+ */
563
+ export function conversationCost(
564
+ conversation: Conversation | undefined,
565
+ detail: ConversationDetailReport | undefined,
566
+ tooltipPlacement?: "above" | "below",
567
+ ): ReactNode {
568
+ if (!conversation) return null;
569
+ const costSummary = summarizeCost(
570
+ detail?.cumulativeUsage ?? conversation.cumulativeUsage,
571
+ );
572
+ const auxiliaryCosts = detail?.auxiliaryCosts ?? conversation.auxiliaryCosts;
573
+ const live = (detail?.status ?? conversation.status) === "active";
574
+ if (!costSummary && !auxiliaryCosts && !live) return null;
575
+ return (
576
+ <CostMetric
577
+ auxiliaryCosts={auxiliaryCosts}
578
+ childAgentTotals={
579
+ detail?.childAgentTotals ?? conversation.childAgentTotals
580
+ }
581
+ live={live}
582
+ liveModelId={liveModelId(detail)}
583
+ modelUsage={detail?.modelUsage}
584
+ summary={costSummary}
585
+ tooltipPlacement={tooltipPlacement}
586
+ />
587
+ );
588
+ }
589
+
571
590
  function conversationStatItems(props: {
572
591
  conversation: Conversation | undefined;
573
592
  detail?: ConversationDetailReport;
@@ -580,7 +599,6 @@ function conversationStatItems(props: {
580
599
  props.detail?.cumulativeUsage ?? props.conversation.cumulativeUsage;
581
600
  const tokenSummary =
582
601
  summarizeModelUsage(props.detail?.modelUsage) ?? summarizeUsage(usage);
583
- const costSummary = summarizeCost(usage);
584
602
  const durationLabel = formatConversationDuration(props.conversation);
585
603
  const live = (props.detail?.status ?? props.conversation.status) === "active";
586
604
  const activeModelId = liveModelId(props.detail);
@@ -589,8 +607,9 @@ function conversationStatItems(props: {
589
607
  props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
590
608
  const lastActivityAt = props.conversation.lastSeenAt;
591
609
  const location = conversationLocation(props.conversation, props.detail);
610
+ const cost = conversationCost(props.conversation, props.detail);
592
611
  const rawStats: Array<ConversationStatItem | undefined> = [
593
- // The desktop title bar shows the location. Mobile hides that bar.
612
+ // The desktop title bar shows the location and cost. Mobile hides that bar.
594
613
  location
595
614
  ? {
596
615
  content: location,
@@ -660,26 +679,12 @@ function conversationStatItems(props: {
660
679
  label: "Tokens",
661
680
  }
662
681
  : undefined,
663
- costSummary ||
664
- props.detail?.auxiliaryCosts ||
665
- props.conversation.auxiliaryCosts ||
666
- live
682
+ cost
667
683
  ? {
668
- content: (
669
- <CostMetric
670
- auxiliaryCosts={
671
- props.detail?.auxiliaryCosts ??
672
- props.conversation.auxiliaryCosts
673
- }
674
- childAgentTotals={childAgentTotals}
675
- live={live}
676
- liveModelId={activeModelId}
677
- modelUsage={props.detail?.modelUsage}
678
- summary={costSummary}
679
- />
680
- ),
684
+ content: cost,
681
685
  key: "cost",
682
686
  label: "Cost",
687
+ mobileOnly: true,
683
688
  }
684
689
  : undefined,
685
690
  childAgentTotals
@@ -14,9 +14,10 @@ import type {
14
14
 
15
15
  import {
16
16
  useArchiveConversation,
17
- useConversationData,
18
17
  type PendingArchiveConversationUpdate,
19
- } from "./queries";
18
+ } from "./conversationArchive";
19
+ import { useConversationData } from "./queries";
20
+ import { usePinConversation } from "./usePinConversation";
20
21
  import { conversationIsResponding } from "./transcript";
21
22
  import { buildConversationMarkdown } from "../markdownExport";
22
23
  import { CopyButton } from "../components/CopyButton";
@@ -31,6 +32,14 @@ import {
31
32
  import { ConversationHeader } from "./ConversationHeader";
32
33
  import { ConversationReplyFooter } from "./ConversationReplyFooter";
33
34
  import { ConversationDetails } from "./ConversationDetails";
35
+ import { AutoArchiveToggle } from "./AutoArchiveToggle";
36
+ import {
37
+ ConversationMemoryContext,
38
+ memoryAnchorKey,
39
+ useConversationMemories,
40
+ type ConversationMemoryState,
41
+ type OpenMemoryPopover,
42
+ } from "./conversationMemory";
34
43
  import { ConversationEventDetails } from "./ConversationEventDetails";
35
44
  import { ConversationEventLogLoading } from "./ConversationEventLog";
36
45
  import {
@@ -50,6 +59,7 @@ import { ConversationAgentTree } from "./ConversationAgentTree";
50
59
  import { conversationLinks } from "./ConversationLinkedWork";
51
60
  import {
52
61
  ConversationAnnotations,
62
+ conversationCost,
53
63
  conversationLocation,
54
64
  ConversationPrivacyChip,
55
65
  ConversationStats,
@@ -74,6 +84,11 @@ import {
74
84
  ParticipantAvatarStack,
75
85
  } from "../components/ParticipantAvatarStack";
76
86
  import { ChatLayout } from "./ChatLayout";
87
+ import {
88
+ QuickQuestionPanel,
89
+ QuickQuestionSelectionButton,
90
+ useQuickQuestionThread,
91
+ } from "./QuickQuestion";
77
92
  import { latestReviewItemId } from "../review-desk/reviewItemFormat";
78
93
  import { Transcript } from "./TranscriptView";
79
94
  import {
@@ -155,7 +170,24 @@ export function ConversationPage(props: {
155
170
  );
156
171
  }, [setSearchParams]);
157
172
  const [turnContext, setTurnContext] = useState<TurnContextSelection>();
158
- const showDetails = detailsOpen && !eventOpen && !turnContext;
173
+ const [memoryPopover, setMemoryPopover] = useState<OpenMemoryPopover>();
174
+ // A memory chosen in details waits here until its transcript row renders.
175
+ const [memoryJump, setMemoryJump] = useState<OpenMemoryPopover>();
176
+ // The page stays mounted across Conversations. Memory state belongs to one.
177
+ const [memoryConversationId, setMemoryConversationId] = useState(
178
+ props.conversationId,
179
+ );
180
+ if (memoryConversationId !== props.conversationId) {
181
+ setMemoryConversationId(props.conversationId);
182
+ setMemoryPopover(undefined);
183
+ setMemoryJump(undefined);
184
+ }
185
+ const quickQuestion = useQuickQuestionThread(props.conversationId);
186
+ // Event details and turn context also show over a Quick question.
187
+ const showQuickQuestion =
188
+ Boolean(quickQuestion.thread) && !eventOpen && !turnContext;
189
+ const showDetails =
190
+ detailsOpen && !eventOpen && !turnContext && !quickQuestion.thread;
159
191
  const [viewState, setView] = useState<TranscriptViewMode>(() =>
160
192
  eventOpen ? "raw" : "rich",
161
193
  );
@@ -167,7 +199,17 @@ export function ConversationPage(props: {
167
199
  const summaries = props.data?.conversations.conversations ?? [];
168
200
  const conversations = buildConversations(summaries);
169
201
  const detail = useConversationData(conversationId);
202
+ const memoryList = useConversationMemories(conversationId).data;
203
+ const memoryState = useMemo<ConversationMemoryState>(
204
+ () => ({
205
+ list: memoryList,
206
+ open: memoryPopover,
207
+ setOpen: setMemoryPopover,
208
+ }),
209
+ [memoryList, memoryPopover],
210
+ );
170
211
  const archive = useArchiveConversation(conversationId);
212
+ const pin = usePinConversation(conversationId);
171
213
  const markRead = useMarkConversationRead(conversationId);
172
214
  const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
173
215
  const feedConversation = conversations.find(
@@ -288,6 +330,34 @@ export function ConversationPage(props: {
288
330
  if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
289
331
  else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
290
332
  }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
333
+ // Show a chosen memory at its transcript row. Wait for the first page, then
334
+ // load earlier pages until the row renders, or stop when history ends.
335
+ useEffect(() => {
336
+ if (!memoryJump || !transcript) return;
337
+ const anchor = pageRef.current?.querySelector(
338
+ `[data-memory-anchor="${CSS.escape(memoryAnchorKey(memoryJump))}"]`,
339
+ );
340
+ if (anchor) {
341
+ anchor.scrollIntoView({ block: "center" });
342
+ setMemoryPopover(memoryJump);
343
+ setMemoryJump(undefined);
344
+ } else if (
345
+ detail.hasPreviousPage &&
346
+ !detail.isLoadingPreviousPage &&
347
+ !detail.historyError
348
+ ) {
349
+ loadPreviousPageRef.current();
350
+ } else if (!detail.isLoadingPreviousPage) {
351
+ setMemoryJump(undefined);
352
+ }
353
+ }, [
354
+ detail.hasPreviousPage,
355
+ detail.historyError,
356
+ detail.isLoadingPreviousPage,
357
+ memoryJump,
358
+ transcript,
359
+ view,
360
+ ]);
291
361
  // Keep the panel while the transcript or a linked event loads, and for the
292
362
  // render before a found Turn link becomes an event link.
293
363
  const eventLoading =
@@ -362,7 +432,7 @@ export function ConversationPage(props: {
362
432
  ref={pageRef}
363
433
  className={cn(
364
434
  "grid h-full min-h-0 min-w-0",
365
- (showDetails || showEvent || turnContext) &&
435
+ (showDetails || showEvent || turnContext || showQuickQuestion) &&
366
436
  "xl:grid-cols-[minmax(0,1fr)_25rem]",
367
437
  )}
368
438
  >
@@ -373,10 +443,12 @@ export function ConversationPage(props: {
373
443
  agentTree ? <ConversationAgentTree nodes={agentTree} /> : null
374
444
  }
375
445
  conversationId={conversationId}
446
+ cost={conversationCost(conversation, detail.data, "below")}
376
447
  detailsOpen={showDetails}
377
448
  onDetailsClick={() => {
378
449
  if (eventOpen) setSelectedEventSeq(undefined);
379
450
  setTurnContext(undefined);
451
+ quickQuestion.close();
380
452
  if (showDetails) closeDetails();
381
453
  else setDetailsChoice(true);
382
454
  }}
@@ -414,6 +486,14 @@ export function ConversationPage(props: {
414
486
  <ConversationAnnotations detail={detail.data} />
415
487
  ) : null
416
488
  }
489
+ pin={{
490
+ // The API lets only participants pin, the same as archive.
491
+ disabled:
492
+ !conversation || !detail.data?.isParticipant || pin.isPending,
493
+ error: Boolean(pin.error),
494
+ onClick: () => pin.mutate(!conversation?.pinnedAt),
495
+ pinned: Boolean(conversation?.pinnedAt),
496
+ }}
417
497
  archive={{
418
498
  archived: Boolean(conversation?.archivedAt),
419
499
  disabled: !conversation || archive.isPending,
@@ -470,31 +550,35 @@ export function ConversationPage(props: {
470
550
  data.
471
551
  </div>
472
552
  ) : null}
473
- <Transcript
474
- hasPreviousPage={detail.hasPreviousPage}
475
- historyError={detail.historyError}
476
- historyVersion={detail.historyVersion}
477
- lastReadSeq={readAtOpen.lastReadSeq}
478
- live={live}
479
- loadingPreviousPage={detail.isLoadingPreviousPage}
480
- onLoadPreviousPage={detail.loadPreviousPage}
481
- onRead={markRead}
482
- onSelectedEventSeqChange={setSelectedEventSeq}
483
- pendingMessages={detail.inlineMessages}
484
- pinRequestVersion={pinRequestVersion}
485
- responding={
486
- !detail.error && conversationIsResponding(transcript)
487
- }
488
- onOpenSubagentTranscript={onOpenSubagentTranscript}
489
- search={search}
490
- onSearchChange={setSearch}
491
- selectedEventSeq={selectedEventSeq}
492
- startAtUnread={readConversationStartPosition() === "unread"}
493
- transcript={transcript}
494
- turnContextPanel={turnContextPanel}
495
- unread={readAtOpen.unread}
496
- view={view}
497
- />
553
+ <ConversationMemoryContext value={memoryState}>
554
+ <Transcript
555
+ hasPreviousPage={detail.hasPreviousPage}
556
+ historyError={detail.historyError}
557
+ historyVersion={detail.historyVersion}
558
+ lastReadSeq={readAtOpen.lastReadSeq}
559
+ live={live}
560
+ loadingPreviousPage={detail.isLoadingPreviousPage}
561
+ onLoadPreviousPage={detail.loadPreviousPage}
562
+ onRead={markRead}
563
+ onSelectedEventSeqChange={setSelectedEventSeq}
564
+ pendingMessages={detail.inlineMessages}
565
+ pinRequestVersion={pinRequestVersion}
566
+ responding={
567
+ !detail.error && conversationIsResponding(transcript)
568
+ }
569
+ onOpenSubagentTranscript={onOpenSubagentTranscript}
570
+ search={search}
571
+ onSearchChange={setSearch}
572
+ selectedEventSeq={selectedEventSeq}
573
+ startAtUnread={
574
+ readConversationStartPosition() === "unread"
575
+ }
576
+ transcript={transcript}
577
+ turnContextPanel={turnContextPanel}
578
+ unread={readAtOpen.unread}
579
+ view={view}
580
+ />
581
+ </ConversationMemoryContext>
498
582
  </>
499
583
  )}
500
584
  </div>
@@ -532,37 +616,58 @@ export function ConversationPage(props: {
532
616
  }
533
617
  />
534
618
  {showDetails ? (
535
- <ConversationDetails
536
- brief={detail.data?.brief}
537
- conversationId={conversationId}
538
- highlightReview={reviewOpen}
539
- identity={identity}
540
- onClose={closeDetails}
541
- onOpenSubagent={setSubagentTarget}
542
- links={conversationLinks(detail.data)}
543
- overview={
544
- hasConversationStats({
545
- conversation,
546
- detail: detail.data,
547
- }) ? (
548
- <ConversationStats
549
- conversation={conversation}
550
- detail={detail.data}
551
- />
552
- ) : null
553
- }
554
- usage={
555
- hasConversationUsageBreakdown({
556
- conversation,
557
- detail: detail.data,
558
- }) ? (
559
- <ConversationUsageBreakdown
560
- conversation={conversation}
561
- detail={detail.data}
562
- />
563
- ) : null
564
- }
565
- />
619
+ <ConversationMemoryContext value={memoryState}>
620
+ <ConversationDetails
621
+ autoArchive={
622
+ detail.data?.autoArchive === undefined ? null : (
623
+ <AutoArchiveToggle
624
+ autoArchive={detail.data.autoArchive}
625
+ conversationId={conversationId}
626
+ />
627
+ )
628
+ }
629
+ brief={detail.data?.brief}
630
+ conversationId={conversationId}
631
+ highlightReview={reviewOpen}
632
+ identity={identity}
633
+ onClose={closeDetails}
634
+ onOpenSubagent={setSubagentTarget}
635
+ onSelectMemory={(entry) => {
636
+ // Memory rows show only in the Conversation view, and search
637
+ // can hide them.
638
+ if (view !== "rich") onViewChange("rich");
639
+ setSearch("");
640
+ setMemoryJump({
641
+ memoryId: entry.memory.id,
642
+ source: entry.source,
643
+ turnId: entry.turnId,
644
+ });
645
+ }}
646
+ links={conversationLinks(detail.data)}
647
+ overview={
648
+ hasConversationStats({
649
+ conversation,
650
+ detail: detail.data,
651
+ }) ? (
652
+ <ConversationStats
653
+ conversation={conversation}
654
+ detail={detail.data}
655
+ />
656
+ ) : null
657
+ }
658
+ usage={
659
+ hasConversationUsageBreakdown({
660
+ conversation,
661
+ detail: detail.data,
662
+ }) ? (
663
+ <ConversationUsageBreakdown
664
+ conversation={conversation}
665
+ detail={detail.data}
666
+ />
667
+ ) : null
668
+ }
669
+ />
670
+ </ConversationMemoryContext>
566
671
  ) : null}
567
672
  {showEvent ? (
568
673
  <ConversationEventDetails
@@ -584,6 +689,24 @@ export function ConversationPage(props: {
584
689
  />
585
690
  </TranscriptSearchProvider>
586
691
  ) : null}
692
+ {showQuickQuestion && quickQuestion.thread ? (
693
+ <QuickQuestionPanel
694
+ conversationId={conversationId}
695
+ error={quickQuestion.error}
696
+ onClose={quickQuestion.close}
697
+ onSubmit={quickQuestion.submit}
698
+ pending={quickQuestion.pending}
699
+ thread={quickQuestion.thread}
700
+ />
701
+ ) : null}
702
+ <QuickQuestionSelectionButton
703
+ onAsk={(selection) => {
704
+ if (eventOpen) setSelectedEventSeq(undefined);
705
+ setTurnContext(undefined);
706
+ quickQuestion.open(selection);
707
+ }}
708
+ rootRef={pageRef}
709
+ />
587
710
  <SubagentTranscriptDrawer
588
711
  onClose={() => setSubagentTarget(undefined)}
589
712
  target={subagentTarget}
@@ -6,11 +6,13 @@ import {
6
6
  CircleAlert,
7
7
  Inbox,
8
8
  LockKeyhole,
9
+ Pin,
9
10
  SquarePen,
10
11
  } from "lucide-react";
11
12
  import { Link } from "react-router";
12
13
 
13
- import { useArchiveConversation } from "./queries";
14
+ import { useArchiveConversation } from "./conversationArchive";
15
+ import { usePinConversation } from "./usePinConversation";
14
16
  import { conversationPath } from "./conversationRoutes";
15
17
  import {
16
18
  conversationDisplayTitle,
@@ -51,9 +53,12 @@ type ConversationSidebarEntry =
51
53
  }
52
54
  | { conversation: Conversation; key: string; kind: "conversation" };
53
55
 
54
- // Tracks the attempted direction alongside the conversation so a failed
55
- // archive can never read the row's already-optimistic archived state.
56
- type ArchiveErrorState = { conversation: Conversation; wasArchiving: boolean };
56
+ // The title names the attempted action, so a failed archive or pin never
57
+ // reads the row's already-optimistic state.
58
+ type RowActionError = { conversation: Conversation; title: string };
59
+
60
+ const ROW_ACTION_BUTTON_CLASS =
61
+ "absolute top-0.5 z-10 grid size-7 cursor-pointer place-items-center rounded-md bg-dashboard-surface-active text-dashboard-text-muted transition hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus sm:pointer-events-none sm:opacity-0 sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 disabled:cursor-not-allowed";
57
62
 
58
63
  const conversationEntryKey = (entry: ConversationSidebarEntry) => entry.key;
59
64
 
@@ -81,23 +86,42 @@ export function ConversationSidebar(props: {
81
86
  const isLanding = variant === "landing";
82
87
  const [archivedConversation, setArchivedConversation] =
83
88
  useState<Conversation>();
84
- const [archiveError, setArchiveError] = useState<ArchiveErrorState>();
89
+ const [rowError, setRowError] = useState<RowActionError>();
85
90
  const dismissArchivedConversation = useCallback(
86
91
  () => setArchivedConversation(undefined),
87
92
  [],
88
93
  );
89
94
  const handleArchiveError = useCallback(
90
95
  (conversation: Conversation, wasArchiving: boolean) => {
91
- setArchiveError({ conversation, wasArchiving });
96
+ setRowError({
97
+ conversation,
98
+ title: wasArchiving ? "Could not archive" : "Could not restore",
99
+ });
100
+ },
101
+ [],
102
+ );
103
+ const handlePinError = useCallback(
104
+ (conversation: Conversation, wasPinning: boolean) => {
105
+ setRowError({
106
+ conversation,
107
+ title: wasPinning ? "Could not pin" : "Could not unpin",
108
+ });
92
109
  },
93
110
  [],
94
111
  );
95
- const handleArchived = useCallback((conversation: Conversation) => {
96
- setArchiveError((current) =>
112
+ // A later success on the same row replaces its error notice.
113
+ const clearRowError = useCallback((conversation: Conversation) => {
114
+ setRowError((current) =>
97
115
  current?.conversation.id === conversation.id ? undefined : current,
98
116
  );
99
- setArchivedConversation(conversation);
100
117
  }, []);
118
+ const handleArchived = useCallback(
119
+ (conversation: Conversation) => {
120
+ clearRowError(conversation);
121
+ setArchivedConversation(conversation);
122
+ },
123
+ [clearRowError],
124
+ );
101
125
  const { collapsedKeys, toggle: toggleSection } =
102
126
  useCollapsedConversationSections();
103
127
  // Rebuild section rows only when their inputs change. Avoid fresh Date.now()
@@ -217,6 +241,8 @@ export function ConversationSidebar(props: {
217
241
  conversation={entry.conversation}
218
242
  onArchiveError={handleArchiveError}
219
243
  onArchived={handleArchived}
244
+ onPinError={handlePinError}
245
+ onPinned={clearRowError}
220
246
  selected={entry.conversation.id === props.selectedId}
221
247
  unread={props.finishedConversationIds.has(
222
248
  entry.conversation.id,
@@ -231,7 +257,7 @@ export function ConversationSidebar(props: {
231
257
  />
232
258
  )}
233
259
  </div>
234
- {archivedConversation || archiveError ? (
260
+ {archivedConversation || rowError ? (
235
261
  <div
236
262
  className={cn(
237
263
  // Landing is content-tall (parent scrolls). Keep notices on the
@@ -241,11 +267,11 @@ export function ConversationSidebar(props: {
241
267
  : "absolute bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-3 right-3 z-20 grid gap-2",
242
268
  )}
243
269
  >
244
- {archiveError ? (
245
- <ArchiveConversationErrorNotice
246
- conversation={archiveError.conversation}
247
- onDismiss={() => setArchiveError(undefined)}
248
- wasArchiving={archiveError.wasArchiving}
270
+ {rowError ? (
271
+ <ConversationActionErrorNotice
272
+ conversation={rowError.conversation}
273
+ onDismiss={() => setRowError(undefined)}
274
+ title={rowError.title}
249
275
  />
250
276
  ) : null}
251
277
  {archivedConversation ? (
@@ -317,6 +343,8 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
317
343
  conversation: Conversation;
318
344
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
319
345
  onArchived(conversation: Conversation): void;
346
+ onPinError(conversation: Conversation, wasPinning: boolean): void;
347
+ onPinned(conversation: Conversation): void;
320
348
  selected: boolean;
321
349
  unread: boolean;
322
350
  waiting?: WaitingConversation;
@@ -334,6 +362,11 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
334
362
  if (archived) props.onArchived(props.conversation);
335
363
  },
336
364
  });
365
+ const pin = usePinConversation(props.conversation.id, {
366
+ onError: (pinned) => props.onPinError(props.conversation, pinned),
367
+ onSuccess: () => props.onPinned(props.conversation),
368
+ });
369
+ const pinned = Boolean(props.conversation.pinnedAt);
337
370
  const status = visualStatusForConversation(props.conversation);
338
371
  const title = conversationDisplayTitle(props.conversation);
339
372
  // Unread and waiting rows read like unread mail: a dot and a bold title.
@@ -405,12 +438,39 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
405
438
  />
406
439
  </Link>
407
440
  ) : null}
441
+ {/* Mobile rows pin from the conversation menu, so only the archive
442
+ button takes row space there. Archived rows restore before they pin. */}
443
+ {props.conversation.archivedAt ? null : (
444
+ <Tooltip>
445
+ <TooltipTrigger
446
+ render={
447
+ <button
448
+ aria-label={`${pinned ? "Unpin" : "Pin"} ${title}`}
449
+ aria-pressed={pinned}
450
+ className={cn(ROW_ACTION_BUTTON_CLASS, "right-9 max-sm:hidden")}
451
+ disabled={pin.isPending}
452
+ onClick={() => pin.mutate(!pinned)}
453
+ type="button"
454
+ >
455
+ <Pin
456
+ aria-hidden="true"
457
+ fill={pinned ? "currentColor" : "none"}
458
+ size={14}
459
+ />
460
+ </button>
461
+ }
462
+ />
463
+ <TooltipContent size="compact">
464
+ {pinned ? "Unpin" : "Pin"}
465
+ </TooltipContent>
466
+ </Tooltip>
467
+ )}
408
468
  <Tooltip>
409
469
  <TooltipTrigger
410
470
  render={
411
471
  <button
412
472
  aria-label={`${props.conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
413
- className="absolute right-1.5 top-0.5 z-10 grid size-7 cursor-pointer place-items-center rounded-md bg-dashboard-surface-active text-dashboard-text-muted transition hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus sm:pointer-events-none sm:opacity-0 sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 disabled:cursor-not-allowed"
473
+ className={cn(ROW_ACTION_BUTTON_CLASS, "right-1.5")}
414
474
  disabled={archive.isPending}
415
475
  onClick={() =>
416
476
  archive.mutate({
@@ -459,15 +519,11 @@ function ConversationRowState(props: {
459
519
  );
460
520
  }
461
521
 
462
- function ArchiveConversationErrorNotice(props: {
522
+ function ConversationActionErrorNotice(props: {
463
523
  conversation: Conversation;
464
524
  onDismiss(): void;
465
- wasArchiving: boolean;
525
+ title: string;
466
526
  }) {
467
- const title = conversationDisplayTitle(props.conversation);
468
- const actionTitle = props.wasArchiving
469
- ? "Could not archive"
470
- : "Could not restore";
471
527
  return (
472
528
  <Notice
473
529
  action={
@@ -475,9 +531,9 @@ function ArchiveConversationErrorNotice(props: {
475
531
  Dismiss
476
532
  </NoticeAction>
477
533
  }
478
- detail={title}
534
+ detail={conversationDisplayTitle(props.conversation)}
479
535
  icon={CircleAlert}
480
- title={actionTitle}
536
+ title={props.title}
481
537
  tone="error"
482
538
  />
483
539
  );