@sentry/junior-dashboard 0.276.0 → 0.278.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 (73) hide show
  1. package/dist/app.js +196 -72
  2. package/dist/client.js +94 -89
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +191 -70
  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 +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/App.tsx +9 -4
  11. package/src/client/components/DetailsPanel.tsx +1 -1
  12. package/src/client/components/FormNotice.tsx +11 -14
  13. package/src/client/components/ProfileMenu.tsx +5 -6
  14. package/src/client/components/ui/resizable.tsx +50 -0
  15. package/src/client/conversations/AutoArchiveToggle.tsx +71 -0
  16. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  17. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  18. package/src/client/conversations/ConversationDetails.tsx +6 -3
  19. package/src/client/conversations/ConversationHeader.tsx +15 -2
  20. package/src/client/conversations/ConversationHeaderActions.tsx +27 -0
  21. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  22. package/src/client/conversations/ConversationMemories.tsx +64 -67
  23. package/src/client/conversations/ConversationMeta.tsx +2 -7
  24. package/src/client/conversations/ConversationPage.tsx +175 -79
  25. package/src/client/conversations/ConversationSidebar.tsx +82 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  27. package/src/client/conversations/ConversationWatches.tsx +15 -21
  28. package/src/client/conversations/ConversationWorkspace.tsx +132 -44
  29. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  30. package/src/client/conversations/DetailsList.tsx +8 -14
  31. package/src/client/conversations/EventDetails.tsx +2 -62
  32. package/src/client/conversations/MemoryIcons.tsx +23 -0
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  34. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  35. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  36. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  37. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  38. package/src/client/conversations/TranscriptView.tsx +89 -20
  39. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  40. package/src/client/conversations/conversationArchive.ts +255 -0
  41. package/src/client/conversations/conversationMemory.ts +2 -1
  42. package/src/client/conversations/conversationSections.ts +24 -18
  43. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  44. package/src/client/conversations/eventLog.ts +2 -31
  45. package/src/client/conversations/eventTranscript.ts +9 -42
  46. package/src/client/conversations/queries.ts +121 -250
  47. package/src/client/conversations/transcript.ts +46 -16
  48. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  49. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  50. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  51. package/src/client/conversations/transcriptSearch.tsx +3 -11
  52. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  53. package/src/client/conversations/usePinConversation.ts +88 -0
  54. package/src/client/display-text/DisplayText.tsx +7 -2
  55. package/src/client/display-text/codeBlocks.ts +92 -0
  56. package/src/client/format.ts +3 -67
  57. package/src/client/http.ts +5 -5
  58. package/src/client/markdownExport.ts +32 -42
  59. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  60. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  61. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  62. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  63. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  64. package/src/client/pages/memory/memoryRecord.ts +1 -6
  65. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  66. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  67. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  68. package/src/client/review-desk/queries.ts +2 -1
  69. package/src/client/types.ts +19 -33
  70. package/src/conversation-cache.ts +7 -9
  71. package/src/mock-reporting/fixtures.ts +126 -60
  72. package/src/mock-reporting/routes.ts +50 -1
  73. package/src/tailwind.css +30 -0
@@ -5,6 +5,7 @@ import {
5
5
  useRef,
6
6
  useState,
7
7
  useSyncExternalStore,
8
+ type ReactNode,
8
9
  } from "react";
9
10
  import { useSearchParams } from "react-router";
10
11
  import type {
@@ -12,11 +13,12 @@ import type {
12
13
  ConversationFeed,
13
14
  } from "@sentry/junior/api/schema";
14
15
 
16
+ import type { ConversationData } from "./queries";
15
17
  import {
16
18
  useArchiveConversation,
17
- useConversationData,
18
19
  type PendingArchiveConversationUpdate,
19
- } from "./queries";
20
+ } from "./conversationArchive";
21
+ import { usePinConversation } from "./usePinConversation";
20
22
  import { conversationIsResponding } from "./transcript";
21
23
  import { buildConversationMarkdown } from "../markdownExport";
22
24
  import { CopyButton } from "../components/CopyButton";
@@ -31,7 +33,7 @@ import {
31
33
  import { ConversationHeader } from "./ConversationHeader";
32
34
  import { ConversationReplyFooter } from "./ConversationReplyFooter";
33
35
  import { ConversationDetails } from "./ConversationDetails";
34
- import { turnPrompts } from "./ConversationMemories";
36
+ import { AutoArchiveToggle } from "./AutoArchiveToggle";
35
37
  import {
36
38
  ConversationMemoryContext,
37
39
  memoryAnchorKey,
@@ -96,6 +98,10 @@ import {
96
98
  useReadPositionAtOpen,
97
99
  } from "./transcriptReadPosition";
98
100
  import { TranscriptLoading } from "./TranscriptLoading";
101
+ import {
102
+ useUnreadHistory,
103
+ type TranscriptHistory,
104
+ } from "./transcriptUnreadHistory";
99
105
  import type { TranscriptViewMode } from "./transcriptRenderModel";
100
106
  import {
101
107
  SubagentTranscriptDrawer,
@@ -121,8 +127,14 @@ function subscribeToDetailsLayout(onChange: () => void) {
121
127
  export function ConversationPage(props: {
122
128
  conversationId: string;
123
129
  data?: { conversations: ConversationFeed };
130
+ /**
131
+ * This Conversation's data. The shell fetches it, so a direct load does
132
+ * not wait for the dashboard core reads, and each visit fetches it once.
133
+ */
134
+ detail: ConversationData;
124
135
  onRead?(conversationId: string, lastReadAt: string): void;
125
136
  pendingArchiveUpdate?: PendingArchiveConversationUpdate;
137
+ sidebarToggle?: ReactNode;
126
138
  }) {
127
139
  const detailsDefaultOpen = useSyncExternalStore(
128
140
  subscribeToDetailsLayout,
@@ -197,7 +209,15 @@ export function ConversationPage(props: {
197
209
  const onRead = props.onRead;
198
210
  const summaries = props.data?.conversations.conversations ?? [];
199
211
  const conversations = buildConversations(summaries);
200
- const detail = useConversationData(conversationId);
212
+ // The page keeps the start preference from when the Conversation opened.
213
+ const [startAtUnread] = useState(
214
+ () => readConversationStartPosition() === "unread",
215
+ );
216
+ const detail = props.detail;
217
+ // Jump to latest, a send, or the mobile keyboard skips the newer pages for
218
+ // the rest of the visit. It can come before the unread page loads.
219
+ const [showLatest, setShowLatest] = useState(false);
220
+ const opensAtUnread = startAtUnread && !showLatest;
201
221
  const memoryList = useConversationMemories(conversationId).data;
202
222
  const memoryState = useMemo<ConversationMemoryState>(
203
223
  () => ({
@@ -208,8 +228,41 @@ export function ConversationPage(props: {
208
228
  [memoryList, memoryPopover],
209
229
  );
210
230
  const archive = useArchiveConversation(conversationId);
231
+ const pin = usePinConversation(conversationId);
211
232
  const markRead = useMarkConversationRead(conversationId);
212
233
  const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
234
+ const unreadHistory = useUnreadHistory({
235
+ conversationId,
236
+ // Cached earlier pages from a past visit would look like newer events.
237
+ detail: detail.latest,
238
+ onShowLatest: () => setShowLatest(true),
239
+ unreadCursor: opensAtUnread ? readAtOpen.unreadCursor : undefined,
240
+ unreadPage: readAtOpen.unreadPage,
241
+ });
242
+ // The page is keyed by Conversation, so this is the cached copy at open.
243
+ const [cachedAtOpen] = useState(() => detail.data);
244
+ // A reader far behind sees the events from their read position. Others
245
+ // see the latest page and the pages before it. A cached transcript stays
246
+ // on screen until this visit's fetch tells where to open and the unread
247
+ // events load. It loads no earlier pages and shows no marker: both would
248
+ // come from the cached read position. It reports no reads, because it sits
249
+ // at rows the reader has not read yet. Then the transcript opens at the
250
+ // "New" marker, if the reader has not moved.
251
+ const holdCachedTranscript = Boolean(
252
+ cachedAtOpen &&
253
+ (unreadHistory?.isPending || (opensAtUnread && !detail.fetchedAtOpen)),
254
+ );
255
+ const history: TranscriptHistory = holdCachedTranscript
256
+ ? {
257
+ ...detail,
258
+ data: cachedAtOpen,
259
+ hasPreviousPage: false,
260
+ isLoadingPreviousPage: false,
261
+ loadPreviousPage: () => {},
262
+ }
263
+ : (unreadHistory ?? detail);
264
+ const transcriptPending =
265
+ detail.isPending || Boolean(unreadHistory?.isPending && !cachedAtOpen);
213
266
  const feedConversation = conversations.find(
214
267
  (item) => item.id === conversationId,
215
268
  );
@@ -232,7 +285,7 @@ export function ConversationPage(props: {
232
285
  if (!conversation) return;
233
286
  onRead?.(conversation.id, conversation.lastSeenAt);
234
287
  }, [conversation, onRead]);
235
- const transcript = detail.data;
288
+ const transcript = history.data;
236
289
  const selectedEvent =
237
290
  transcript?.eventHistory.status === "expired"
238
291
  ? undefined
@@ -284,27 +337,31 @@ export function ConversationPage(props: {
284
337
  },
285
338
  [eventOpen, setSelectedEventSeq],
286
339
  );
287
- // A linked event can be older than the first page. Load earlier pages until
288
- // it is present or history ends.
289
- const loadPreviousPageRef = useRef(detail.loadPreviousPage);
290
- loadPreviousPageRef.current = detail.loadPreviousPage;
340
+ // A linked event, Turn, or memory can be outside the loaded pages. Look
341
+ // for it one page at a time, in the direction where it can be.
342
+ const historyRef = useRef(history);
343
+ historyRef.current = history;
344
+ const loadToward = useCallback((search: HistorySearch | undefined) => {
345
+ if (search === "load-earlier") historyRef.current.loadPreviousPage();
346
+ else if (search === "load-newer") historyRef.current.newer?.load();
347
+ }, []);
348
+ const searchHistory = (direction: HistorySearchDirection) =>
349
+ nextHistorySearch(history, direction, holdCachedTranscript);
291
350
  const oldestLoadedSeq = transcript?.events[0]?.seq;
292
- const linkedEventMissing =
293
- selectedEventSeq !== undefined &&
294
- oldestLoadedSeq !== undefined &&
295
- selectedEventSeq < oldestLoadedSeq;
296
- const loadLinkedEvent =
297
- linkedEventMissing &&
298
- detail.hasPreviousPage &&
299
- !detail.isLoadingPreviousPage &&
300
- !detail.historyError;
301
- useEffect(() => {
302
- if (loadLinkedEvent) loadPreviousPageRef.current();
303
- }, [loadLinkedEvent, oldestLoadedSeq]);
304
- // Load earlier pages until the linked Turn starts or history ends. Then
305
- // replace the Turn link with a link to its first event, or drop it when
306
- // history has no such event. A history error keeps the link, so a retry
307
- // or reload can still find the Turn.
351
+ const newestLoadedSeq = transcript?.events.at(-1)?.seq;
352
+ const linkedEventDirection: HistorySearchDirection | undefined =
353
+ selectedEventSeq === undefined ||
354
+ oldestLoadedSeq === undefined ||
355
+ newestLoadedSeq === undefined
356
+ ? undefined
357
+ : selectedEventSeq < oldestLoadedSeq
358
+ ? "earlier"
359
+ : selectedEventSeq > newestLoadedSeq &&
360
+ (history.newer || holdCachedTranscript)
361
+ ? "newer"
362
+ : undefined;
363
+ // Replace a found Turn link with a link to its first event, or drop it
364
+ // when history has no such event.
308
365
  const linkedTurnSeq = linkedTurnId
309
366
  ? turnStartedEventSeq(transcript?.events, linkedTurnId)
310
367
  : undefined;
@@ -312,28 +369,21 @@ export function ConversationPage(props: {
312
369
  linkedTurnId !== undefined &&
313
370
  linkedTurnSeq === undefined &&
314
371
  transcript !== undefined;
315
- const loadLinkedTurn =
316
- linkedTurnMissing &&
317
- detail.hasPreviousPage &&
318
- !detail.isLoadingPreviousPage &&
319
- !detail.historyError;
320
- const linkedTurnNotFound =
321
- linkedTurnMissing &&
322
- !detail.hasPreviousPage &&
323
- !detail.isLoadingPreviousPage;
324
- useEffect(() => {
325
- if (loadLinkedTurn) loadPreviousPageRef.current();
326
- }, [loadLinkedTurn, oldestLoadedSeq]);
372
+ const linkedSearch = linkedEventDirection
373
+ ? searchHistory(linkedEventDirection)
374
+ : linkedTurnMissing
375
+ ? searchHistory("any")
376
+ : undefined;
377
+ useEffect(() => loadToward(linkedSearch), [linkedSearch, loadToward]);
378
+ const linkedTurnNotFound = linkedTurnMissing && linkedSearch === "end";
327
379
  useEffect(() => {
328
380
  if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
329
381
  else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
330
382
  }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
331
383
  // Show a chosen memory at its transcript row. Wait for the first page, then
332
- // load earlier pages until the row renders, or stop when history ends.
333
- const memoryPrompts = useMemo(
334
- () => turnPrompts(transcript?.events),
335
- [transcript?.events],
336
- );
384
+ // load pages toward the row until it renders, or stop when history ends.
385
+ const memorySearch =
386
+ memoryJump && transcript ? searchHistory("any") : undefined;
337
387
  useEffect(() => {
338
388
  if (!memoryJump || !transcript) return;
339
389
  const anchor = pageRef.current?.querySelector(
@@ -343,33 +393,22 @@ export function ConversationPage(props: {
343
393
  anchor.scrollIntoView({ block: "center" });
344
394
  setMemoryPopover(memoryJump);
345
395
  setMemoryJump(undefined);
346
- } else if (
347
- detail.hasPreviousPage &&
348
- !detail.isLoadingPreviousPage &&
349
- !detail.historyError
350
- ) {
351
- loadPreviousPageRef.current();
352
- } else if (!detail.isLoadingPreviousPage) {
396
+ } else if (memorySearch === "failed" || memorySearch === "end") {
353
397
  setMemoryJump(undefined);
398
+ } else {
399
+ loadToward(memorySearch);
354
400
  }
355
- }, [
356
- detail.hasPreviousPage,
357
- detail.historyError,
358
- detail.isLoadingPreviousPage,
359
- memoryJump,
360
- transcript,
361
- view,
362
- ]);
401
+ }, [loadToward, memoryJump, memorySearch, transcript, view]);
363
402
  // Keep the panel while the transcript or a linked event loads, and for the
364
403
  // render before a found Turn link becomes an event link.
365
404
  const eventLoading =
366
405
  eventOpen &&
367
406
  !selectedEvent &&
368
- (detail.isPending ||
407
+ (transcriptPending ||
369
408
  linkedTurnSeq !== undefined ||
370
- ((linkedEventMissing || linkedTurnMissing) &&
371
- (detail.hasPreviousPage || detail.isLoadingPreviousPage) &&
372
- !detail.historyError));
409
+ linkedSearch === "load-earlier" ||
410
+ linkedSearch === "load-newer" ||
411
+ linkedSearch === "wait");
373
412
  const showEvent = Boolean(selectedEvent) || eventLoading;
374
413
  // Step through the same filtered rows that the event log shows.
375
414
  const events = transcript?.events;
@@ -392,6 +431,7 @@ export function ConversationPage(props: {
392
431
  [turnContext?.id],
393
432
  );
394
433
  const requestPin = useCallback(() => {
434
+ setShowLatest(true);
395
435
  setPinRequestVersion((version) => version + 1);
396
436
  }, []);
397
437
  const onOpenSubagentTranscript = useCallback(
@@ -488,6 +528,14 @@ export function ConversationPage(props: {
488
528
  <ConversationAnnotations detail={detail.data} />
489
529
  ) : null
490
530
  }
531
+ pin={{
532
+ // The API lets only participants pin, the same as archive.
533
+ disabled:
534
+ !conversation || !detail.data?.isParticipant || pin.isPending,
535
+ error: Boolean(pin.error),
536
+ onClick: () => pin.mutate(!conversation?.pinnedAt),
537
+ pinned: Boolean(conversation?.pinnedAt),
538
+ }}
491
539
  archive={{
492
540
  archived: Boolean(conversation?.archivedAt),
493
541
  disabled: !conversation || archive.isPending,
@@ -508,15 +556,17 @@ export function ConversationPage(props: {
508
556
  }
509
557
  search={search}
510
558
  sentryConversationUrl={detail.data?.sentryConversationUrl}
559
+ sidebarToggle={props.sidebarToggle}
511
560
  title={title}
512
561
  view={view}
513
562
  />
514
563
  }
515
564
  scrollMinTall
516
565
  scrollAriaLabel="Conversation transcript"
517
- scrollClassName={
518
- view === "raw" ? undefined : "px-4 pb-4 md:px-7 md:pb-6"
519
- }
566
+ scrollClassName={cn(
567
+ "thin-scrollbar",
568
+ view !== "raw" && "px-4 pb-4 md:px-7 md:pb-6",
569
+ )}
520
570
  scroll={
521
571
  <section className="min-w-0">
522
572
  <div
@@ -526,7 +576,7 @@ export function ConversationPage(props: {
526
576
  : "mx-auto w-full max-w-[52.5rem] pt-5 md:pt-7"
527
577
  }
528
578
  >
529
- {detail.isPending ? (
579
+ {transcriptPending ? (
530
580
  view === "raw" ? (
531
581
  <ConversationEventLogLoading />
532
582
  ) : (
@@ -546,30 +596,36 @@ export function ConversationPage(props: {
546
596
  ) : null}
547
597
  <ConversationMemoryContext value={memoryState}>
548
598
  <Transcript
549
- hasPreviousPage={detail.hasPreviousPage}
550
- historyError={detail.historyError}
551
- historyVersion={detail.historyVersion}
599
+ hasPreviousPage={history.hasPreviousPage}
600
+ historyError={history.historyError}
601
+ historyVersion={history.historyVersion}
552
602
  lastReadSeq={readAtOpen.lastReadSeq}
553
603
  live={live}
554
- loadingPreviousPage={detail.isLoadingPreviousPage}
555
- onLoadPreviousPage={detail.loadPreviousPage}
556
- onRead={markRead}
604
+ loadingPreviousPage={history.isLoadingPreviousPage}
605
+ newerHistory={history.newer}
606
+ onLoadPreviousPage={history.loadPreviousPage}
607
+ onRead={holdCachedTranscript ? undefined : markRead}
557
608
  onSelectedEventSeqChange={setSelectedEventSeq}
558
- pendingMessages={detail.inlineMessages}
609
+ // Sends and the thinking row belong after the latest event.
610
+ pendingMessages={
611
+ history.newer ? undefined : detail.inlineMessages
612
+ }
559
613
  pinRequestVersion={pinRequestVersion}
560
614
  responding={
561
- !detail.error && conversationIsResponding(transcript)
615
+ !detail.error &&
616
+ !history.newer &&
617
+ conversationIsResponding(detail.data)
562
618
  }
563
619
  onOpenSubagentTranscript={onOpenSubagentTranscript}
564
620
  search={search}
565
621
  onSearchChange={setSearch}
566
622
  selectedEventSeq={selectedEventSeq}
567
- startAtUnread={
568
- readConversationStartPosition() === "unread"
569
- }
623
+ startAtUnread={startAtUnread}
570
624
  transcript={transcript}
571
625
  turnContextPanel={turnContextPanel}
572
- unread={readAtOpen.unread}
626
+ unread={
627
+ holdCachedTranscript ? undefined : readAtOpen.unread
628
+ }
573
629
  view={view}
574
630
  />
575
631
  </ConversationMemoryContext>
@@ -598,9 +654,12 @@ export function ConversationPage(props: {
598
654
  // Without live history, an inline send would never leave the
599
655
  // transcript tail. A started Turn reads a new send only later.
600
656
  // Keep those sends in the queue. An earlier inline send that
601
- // history does not have yet can start that Turn first.
657
+ // history does not have yet can start that Turn first. While
658
+ // newer pages are not loaded, the transcript tail is not the
659
+ // latest event, so a send waits in the queue too.
602
660
  sendsInline={
603
661
  detail.data.eventHistory.status === "available" &&
662
+ !history.newer &&
604
663
  !conversationIsResponding(detail.data) &&
605
664
  detail.inlineMessages.length === 0
606
665
  }
@@ -612,13 +671,20 @@ export function ConversationPage(props: {
612
671
  {showDetails ? (
613
672
  <ConversationMemoryContext value={memoryState}>
614
673
  <ConversationDetails
674
+ autoArchive={
675
+ detail.data?.autoArchive === undefined ? null : (
676
+ <AutoArchiveToggle
677
+ autoArchive={detail.data.autoArchive}
678
+ conversationId={conversationId}
679
+ />
680
+ )
681
+ }
615
682
  brief={detail.data?.brief}
616
683
  conversationId={conversationId}
617
684
  highlightReview={reviewOpen}
618
685
  identity={identity}
619
686
  onClose={closeDetails}
620
687
  onOpenSubagent={setSubagentTarget}
621
- memoryPrompts={memoryPrompts}
622
688
  onSelectMemory={(entry) => {
623
689
  // Memory rows show only in the Conversation view, and search
624
690
  // can hide them.
@@ -740,3 +806,33 @@ function committedMessageIds(
740
806
  }
741
807
 
742
808
  const EMPTY_MESSAGE_IDS: readonly string[] = [];
809
+
810
+ type HistorySearchDirection = "any" | "earlier" | "newer";
811
+ type HistorySearch = "end" | "failed" | "load-earlier" | "load-newer" | "wait";
812
+
813
+ /**
814
+ * The next step to find a row that the loaded pages do not have: load one
815
+ * more page toward it, or wait for the page that loads. "any" looks at newer
816
+ * pages first. A failed page stops the search, but the link stays, so a
817
+ * retry can still find the row.
818
+ */
819
+ function nextHistorySearch(
820
+ history: TranscriptHistory,
821
+ direction: HistorySearchDirection,
822
+ held: boolean,
823
+ ): HistorySearch {
824
+ if (held || history.isLoadingPreviousPage || history.newer?.loading) {
825
+ return "wait";
826
+ }
827
+ if (direction !== "earlier" && history.newer && !history.newer.error) {
828
+ return "load-newer";
829
+ }
830
+ if (
831
+ direction !== "newer" &&
832
+ history.hasPreviousPage &&
833
+ !history.historyError
834
+ ) {
835
+ return "load-earlier";
836
+ }
837
+ return history.historyError || history.newer?.error ? "failed" : "end";
838
+ }
@@ -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()
@@ -167,6 +191,7 @@ export function ConversationSidebar(props: {
167
191
  </div>
168
192
  <div className="px-4 pb-2">
169
193
  <SearchInput
194
+ className="border-dashboard-border-subtle bg-transparent hover:border-dashboard-border hover:bg-dashboard-fill-faint"
170
195
  label="Search your conversations"
171
196
  onChange={props.onQueryChange}
172
197
  placeholder="Search conversations…"
@@ -179,7 +204,7 @@ export function ConversationSidebar(props: {
179
204
  "px-3",
180
205
  isLanding
181
206
  ? "pb-[max(1rem,env(safe-area-inset-bottom))]"
182
- : "min-h-0 overflow-y-auto overscroll-contain pb-[max(0.5rem,env(safe-area-inset-bottom))]",
207
+ : "thin-scrollbar min-h-0 overflow-y-auto overscroll-contain pb-[max(0.5rem,env(safe-area-inset-bottom))]",
183
208
  )}
184
209
  >
185
210
  {props.error ? (
@@ -217,6 +242,8 @@ export function ConversationSidebar(props: {
217
242
  conversation={entry.conversation}
218
243
  onArchiveError={handleArchiveError}
219
244
  onArchived={handleArchived}
245
+ onPinError={handlePinError}
246
+ onPinned={clearRowError}
220
247
  selected={entry.conversation.id === props.selectedId}
221
248
  unread={props.finishedConversationIds.has(
222
249
  entry.conversation.id,
@@ -231,7 +258,7 @@ export function ConversationSidebar(props: {
231
258
  />
232
259
  )}
233
260
  </div>
234
- {archivedConversation || archiveError ? (
261
+ {archivedConversation || rowError ? (
235
262
  <div
236
263
  className={cn(
237
264
  // Landing is content-tall (parent scrolls). Keep notices on the
@@ -241,11 +268,11 @@ export function ConversationSidebar(props: {
241
268
  : "absolute bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-3 right-3 z-20 grid gap-2",
242
269
  )}
243
270
  >
244
- {archiveError ? (
245
- <ArchiveConversationErrorNotice
246
- conversation={archiveError.conversation}
247
- onDismiss={() => setArchiveError(undefined)}
248
- wasArchiving={archiveError.wasArchiving}
271
+ {rowError ? (
272
+ <ConversationActionErrorNotice
273
+ conversation={rowError.conversation}
274
+ onDismiss={() => setRowError(undefined)}
275
+ title={rowError.title}
249
276
  />
250
277
  ) : null}
251
278
  {archivedConversation ? (
@@ -317,6 +344,8 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
317
344
  conversation: Conversation;
318
345
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
319
346
  onArchived(conversation: Conversation): void;
347
+ onPinError(conversation: Conversation, wasPinning: boolean): void;
348
+ onPinned(conversation: Conversation): void;
320
349
  selected: boolean;
321
350
  unread: boolean;
322
351
  waiting?: WaitingConversation;
@@ -334,6 +363,11 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
334
363
  if (archived) props.onArchived(props.conversation);
335
364
  },
336
365
  });
366
+ const pin = usePinConversation(props.conversation.id, {
367
+ onError: (pinned) => props.onPinError(props.conversation, pinned),
368
+ onSuccess: () => props.onPinned(props.conversation),
369
+ });
370
+ const pinned = Boolean(props.conversation.pinnedAt);
337
371
  const status = visualStatusForConversation(props.conversation);
338
372
  const title = conversationDisplayTitle(props.conversation);
339
373
  // Unread and waiting rows read like unread mail: a dot and a bold title.
@@ -405,12 +439,39 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
405
439
  />
406
440
  </Link>
407
441
  ) : null}
442
+ {/* Mobile rows pin from the conversation menu, so only the archive
443
+ button takes row space there. Archived rows restore before they pin. */}
444
+ {props.conversation.archivedAt ? null : (
445
+ <Tooltip>
446
+ <TooltipTrigger
447
+ render={
448
+ <button
449
+ aria-label={`${pinned ? "Unpin" : "Pin"} ${title}`}
450
+ aria-pressed={pinned}
451
+ className={cn(ROW_ACTION_BUTTON_CLASS, "right-9 max-sm:hidden")}
452
+ disabled={pin.isPending}
453
+ onClick={() => pin.mutate(!pinned)}
454
+ type="button"
455
+ >
456
+ <Pin
457
+ aria-hidden="true"
458
+ fill={pinned ? "currentColor" : "none"}
459
+ size={14}
460
+ />
461
+ </button>
462
+ }
463
+ />
464
+ <TooltipContent size="compact">
465
+ {pinned ? "Unpin" : "Pin"}
466
+ </TooltipContent>
467
+ </Tooltip>
468
+ )}
408
469
  <Tooltip>
409
470
  <TooltipTrigger
410
471
  render={
411
472
  <button
412
473
  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"
474
+ className={cn(ROW_ACTION_BUTTON_CLASS, "right-1.5")}
414
475
  disabled={archive.isPending}
415
476
  onClick={() =>
416
477
  archive.mutate({
@@ -459,15 +520,11 @@ function ConversationRowState(props: {
459
520
  );
460
521
  }
461
522
 
462
- function ArchiveConversationErrorNotice(props: {
523
+ function ConversationActionErrorNotice(props: {
463
524
  conversation: Conversation;
464
525
  onDismiss(): void;
465
- wasArchiving: boolean;
526
+ title: string;
466
527
  }) {
467
- const title = conversationDisplayTitle(props.conversation);
468
- const actionTitle = props.wasArchiving
469
- ? "Could not archive"
470
- : "Could not restore";
471
528
  return (
472
529
  <Notice
473
530
  action={
@@ -475,9 +532,9 @@ function ArchiveConversationErrorNotice(props: {
475
532
  Dismiss
476
533
  </NoticeAction>
477
534
  }
478
- detail={title}
535
+ detail={conversationDisplayTitle(props.conversation)}
479
536
  icon={CircleAlert}
480
- title={actionTitle}
537
+ title={props.title}
481
538
  tone="error"
482
539
  />
483
540
  );