@sentry/junior-dashboard 0.277.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 (45) hide show
  1. package/dist/app.js +30 -41
  2. package/dist/client.js +90 -90
  3. package/dist/index.js +30 -41
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +1 -1
  7. package/src/client/App.tsx +9 -4
  8. package/src/client/components/DetailsPanel.tsx +1 -1
  9. package/src/client/components/ui/resizable.tsx +50 -0
  10. package/src/client/conversations/AutoArchiveToggle.tsx +50 -14
  11. package/src/client/conversations/ConversationHeader.tsx +6 -2
  12. package/src/client/conversations/ConversationHeaderActions.tsx +4 -0
  13. package/src/client/conversations/ConversationMeta.tsx +2 -7
  14. package/src/client/conversations/ConversationPage.tsx +155 -72
  15. package/src/client/conversations/ConversationSidebar.tsx +2 -1
  16. package/src/client/conversations/ConversationTranscript.tsx +11 -8
  17. package/src/client/conversations/ConversationWorkspace.tsx +129 -41
  18. package/src/client/conversations/ConversationWorkspaceLoading.tsx +17 -3
  19. package/src/client/conversations/EventDetails.tsx +2 -62
  20. package/src/client/conversations/TranscriptActivityGroup.tsx +36 -14
  21. package/src/client/conversations/TranscriptContextEventView.tsx +7 -18
  22. package/src/client/conversations/TranscriptRailEvent.tsx +0 -8
  23. package/src/client/conversations/TranscriptView.tsx +89 -20
  24. package/src/client/conversations/conversationSidebarLayout.ts +61 -0
  25. package/src/client/conversations/eventLog.ts +2 -31
  26. package/src/client/conversations/eventTranscript.ts +9 -42
  27. package/src/client/conversations/queries.ts +76 -15
  28. package/src/client/conversations/transcript.ts +46 -16
  29. package/src/client/conversations/transcriptBottomPinning.ts +40 -24
  30. package/src/client/conversations/transcriptReadPosition.ts +31 -7
  31. package/src/client/conversations/transcriptRenderModel.ts +1 -14
  32. package/src/client/conversations/transcriptSearch.tsx +3 -11
  33. package/src/client/conversations/transcriptUnreadHistory.ts +248 -0
  34. package/src/client/http.ts +5 -5
  35. package/src/client/markdownExport.ts +32 -42
  36. package/src/client/pages/memory/MemoryDetails.tsx +6 -33
  37. package/src/client/pages/memory/MemoryPage.tsx +7 -92
  38. package/src/client/pages/memory/MemoryTimeline.tsx +16 -32
  39. package/src/client/pages/memory/memoryDashboard.ts +2 -38
  40. package/src/client/pages/memory/memoryRecord.ts +1 -6
  41. package/src/client/types.ts +18 -33
  42. package/src/conversation-cache.ts +6 -8
  43. package/src/mock-reporting/fixtures.ts +23 -33
  44. package/src/mock-reporting/routes.ts +3 -1
  45. 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,11 @@ 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
19
  type PendingArchiveConversationUpdate,
18
20
  } from "./conversationArchive";
19
- import { useConversationData } from "./queries";
20
21
  import { usePinConversation } from "./usePinConversation";
21
22
  import { conversationIsResponding } from "./transcript";
22
23
  import { buildConversationMarkdown } from "../markdownExport";
@@ -97,6 +98,10 @@ import {
97
98
  useReadPositionAtOpen,
98
99
  } from "./transcriptReadPosition";
99
100
  import { TranscriptLoading } from "./TranscriptLoading";
101
+ import {
102
+ useUnreadHistory,
103
+ type TranscriptHistory,
104
+ } from "./transcriptUnreadHistory";
100
105
  import type { TranscriptViewMode } from "./transcriptRenderModel";
101
106
  import {
102
107
  SubagentTranscriptDrawer,
@@ -122,8 +127,14 @@ function subscribeToDetailsLayout(onChange: () => void) {
122
127
  export function ConversationPage(props: {
123
128
  conversationId: string;
124
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;
125
135
  onRead?(conversationId: string, lastReadAt: string): void;
126
136
  pendingArchiveUpdate?: PendingArchiveConversationUpdate;
137
+ sidebarToggle?: ReactNode;
127
138
  }) {
128
139
  const detailsDefaultOpen = useSyncExternalStore(
129
140
  subscribeToDetailsLayout,
@@ -198,7 +209,15 @@ export function ConversationPage(props: {
198
209
  const onRead = props.onRead;
199
210
  const summaries = props.data?.conversations.conversations ?? [];
200
211
  const conversations = buildConversations(summaries);
201
- 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;
202
221
  const memoryList = useConversationMemories(conversationId).data;
203
222
  const memoryState = useMemo<ConversationMemoryState>(
204
223
  () => ({
@@ -212,6 +231,38 @@ export function ConversationPage(props: {
212
231
  const pin = usePinConversation(conversationId);
213
232
  const markRead = useMarkConversationRead(conversationId);
214
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);
215
266
  const feedConversation = conversations.find(
216
267
  (item) => item.id === conversationId,
217
268
  );
@@ -234,7 +285,7 @@ export function ConversationPage(props: {
234
285
  if (!conversation) return;
235
286
  onRead?.(conversation.id, conversation.lastSeenAt);
236
287
  }, [conversation, onRead]);
237
- const transcript = detail.data;
288
+ const transcript = history.data;
238
289
  const selectedEvent =
239
290
  transcript?.eventHistory.status === "expired"
240
291
  ? undefined
@@ -286,27 +337,31 @@ export function ConversationPage(props: {
286
337
  },
287
338
  [eventOpen, setSelectedEventSeq],
288
339
  );
289
- // A linked event can be older than the first page. Load earlier pages until
290
- // it is present or history ends.
291
- const loadPreviousPageRef = useRef(detail.loadPreviousPage);
292
- 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);
293
350
  const oldestLoadedSeq = transcript?.events[0]?.seq;
294
- const linkedEventMissing =
295
- selectedEventSeq !== undefined &&
296
- oldestLoadedSeq !== undefined &&
297
- selectedEventSeq < oldestLoadedSeq;
298
- const loadLinkedEvent =
299
- linkedEventMissing &&
300
- detail.hasPreviousPage &&
301
- !detail.isLoadingPreviousPage &&
302
- !detail.historyError;
303
- useEffect(() => {
304
- if (loadLinkedEvent) loadPreviousPageRef.current();
305
- }, [loadLinkedEvent, oldestLoadedSeq]);
306
- // Load earlier pages until the linked Turn starts or history ends. Then
307
- // replace the Turn link with a link to its first event, or drop it when
308
- // history has no such event. A history error keeps the link, so a retry
309
- // 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.
310
365
  const linkedTurnSeq = linkedTurnId
311
366
  ? turnStartedEventSeq(transcript?.events, linkedTurnId)
312
367
  : undefined;
@@ -314,24 +369,21 @@ export function ConversationPage(props: {
314
369
  linkedTurnId !== undefined &&
315
370
  linkedTurnSeq === undefined &&
316
371
  transcript !== undefined;
317
- const loadLinkedTurn =
318
- linkedTurnMissing &&
319
- detail.hasPreviousPage &&
320
- !detail.isLoadingPreviousPage &&
321
- !detail.historyError;
322
- const linkedTurnNotFound =
323
- linkedTurnMissing &&
324
- !detail.hasPreviousPage &&
325
- !detail.isLoadingPreviousPage;
326
- useEffect(() => {
327
- if (loadLinkedTurn) loadPreviousPageRef.current();
328
- }, [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";
329
379
  useEffect(() => {
330
380
  if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq);
331
381
  else if (linkedTurnNotFound) setSelectedEventSeq(undefined);
332
382
  }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
333
383
  // 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.
384
+ // load pages toward the row until it renders, or stop when history ends.
385
+ const memorySearch =
386
+ memoryJump && transcript ? searchHistory("any") : undefined;
335
387
  useEffect(() => {
336
388
  if (!memoryJump || !transcript) return;
337
389
  const anchor = pageRef.current?.querySelector(
@@ -341,33 +393,22 @@ export function ConversationPage(props: {
341
393
  anchor.scrollIntoView({ block: "center" });
342
394
  setMemoryPopover(memoryJump);
343
395
  setMemoryJump(undefined);
344
- } else if (
345
- detail.hasPreviousPage &&
346
- !detail.isLoadingPreviousPage &&
347
- !detail.historyError
348
- ) {
349
- loadPreviousPageRef.current();
350
- } else if (!detail.isLoadingPreviousPage) {
396
+ } else if (memorySearch === "failed" || memorySearch === "end") {
351
397
  setMemoryJump(undefined);
398
+ } else {
399
+ loadToward(memorySearch);
352
400
  }
353
- }, [
354
- detail.hasPreviousPage,
355
- detail.historyError,
356
- detail.isLoadingPreviousPage,
357
- memoryJump,
358
- transcript,
359
- view,
360
- ]);
401
+ }, [loadToward, memoryJump, memorySearch, transcript, view]);
361
402
  // Keep the panel while the transcript or a linked event loads, and for the
362
403
  // render before a found Turn link becomes an event link.
363
404
  const eventLoading =
364
405
  eventOpen &&
365
406
  !selectedEvent &&
366
- (detail.isPending ||
407
+ (transcriptPending ||
367
408
  linkedTurnSeq !== undefined ||
368
- ((linkedEventMissing || linkedTurnMissing) &&
369
- (detail.hasPreviousPage || detail.isLoadingPreviousPage) &&
370
- !detail.historyError));
409
+ linkedSearch === "load-earlier" ||
410
+ linkedSearch === "load-newer" ||
411
+ linkedSearch === "wait");
371
412
  const showEvent = Boolean(selectedEvent) || eventLoading;
372
413
  // Step through the same filtered rows that the event log shows.
373
414
  const events = transcript?.events;
@@ -390,6 +431,7 @@ export function ConversationPage(props: {
390
431
  [turnContext?.id],
391
432
  );
392
433
  const requestPin = useCallback(() => {
434
+ setShowLatest(true);
393
435
  setPinRequestVersion((version) => version + 1);
394
436
  }, []);
395
437
  const onOpenSubagentTranscript = useCallback(
@@ -514,15 +556,17 @@ export function ConversationPage(props: {
514
556
  }
515
557
  search={search}
516
558
  sentryConversationUrl={detail.data?.sentryConversationUrl}
559
+ sidebarToggle={props.sidebarToggle}
517
560
  title={title}
518
561
  view={view}
519
562
  />
520
563
  }
521
564
  scrollMinTall
522
565
  scrollAriaLabel="Conversation transcript"
523
- scrollClassName={
524
- view === "raw" ? undefined : "px-4 pb-4 md:px-7 md:pb-6"
525
- }
566
+ scrollClassName={cn(
567
+ "thin-scrollbar",
568
+ view !== "raw" && "px-4 pb-4 md:px-7 md:pb-6",
569
+ )}
526
570
  scroll={
527
571
  <section className="min-w-0">
528
572
  <div
@@ -532,7 +576,7 @@ export function ConversationPage(props: {
532
576
  : "mx-auto w-full max-w-[52.5rem] pt-5 md:pt-7"
533
577
  }
534
578
  >
535
- {detail.isPending ? (
579
+ {transcriptPending ? (
536
580
  view === "raw" ? (
537
581
  <ConversationEventLogLoading />
538
582
  ) : (
@@ -552,30 +596,36 @@ export function ConversationPage(props: {
552
596
  ) : null}
553
597
  <ConversationMemoryContext value={memoryState}>
554
598
  <Transcript
555
- hasPreviousPage={detail.hasPreviousPage}
556
- historyError={detail.historyError}
557
- historyVersion={detail.historyVersion}
599
+ hasPreviousPage={history.hasPreviousPage}
600
+ historyError={history.historyError}
601
+ historyVersion={history.historyVersion}
558
602
  lastReadSeq={readAtOpen.lastReadSeq}
559
603
  live={live}
560
- loadingPreviousPage={detail.isLoadingPreviousPage}
561
- onLoadPreviousPage={detail.loadPreviousPage}
562
- onRead={markRead}
604
+ loadingPreviousPage={history.isLoadingPreviousPage}
605
+ newerHistory={history.newer}
606
+ onLoadPreviousPage={history.loadPreviousPage}
607
+ onRead={holdCachedTranscript ? undefined : markRead}
563
608
  onSelectedEventSeqChange={setSelectedEventSeq}
564
- pendingMessages={detail.inlineMessages}
609
+ // Sends and the thinking row belong after the latest event.
610
+ pendingMessages={
611
+ history.newer ? undefined : detail.inlineMessages
612
+ }
565
613
  pinRequestVersion={pinRequestVersion}
566
614
  responding={
567
- !detail.error && conversationIsResponding(transcript)
615
+ !detail.error &&
616
+ !history.newer &&
617
+ conversationIsResponding(detail.data)
568
618
  }
569
619
  onOpenSubagentTranscript={onOpenSubagentTranscript}
570
620
  search={search}
571
621
  onSearchChange={setSearch}
572
622
  selectedEventSeq={selectedEventSeq}
573
- startAtUnread={
574
- readConversationStartPosition() === "unread"
575
- }
623
+ startAtUnread={startAtUnread}
576
624
  transcript={transcript}
577
625
  turnContextPanel={turnContextPanel}
578
- unread={readAtOpen.unread}
626
+ unread={
627
+ holdCachedTranscript ? undefined : readAtOpen.unread
628
+ }
579
629
  view={view}
580
630
  />
581
631
  </ConversationMemoryContext>
@@ -604,9 +654,12 @@ export function ConversationPage(props: {
604
654
  // Without live history, an inline send would never leave the
605
655
  // transcript tail. A started Turn reads a new send only later.
606
656
  // Keep those sends in the queue. An earlier inline send that
607
- // 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.
608
660
  sendsInline={
609
661
  detail.data.eventHistory.status === "available" &&
662
+ !history.newer &&
610
663
  !conversationIsResponding(detail.data) &&
611
664
  detail.inlineMessages.length === 0
612
665
  }
@@ -753,3 +806,33 @@ function committedMessageIds(
753
806
  }
754
807
 
755
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
+ }
@@ -191,6 +191,7 @@ export function ConversationSidebar(props: {
191
191
  </div>
192
192
  <div className="px-4 pb-2">
193
193
  <SearchInput
194
+ className="border-dashboard-border-subtle bg-transparent hover:border-dashboard-border hover:bg-dashboard-fill-faint"
194
195
  label="Search your conversations"
195
196
  onChange={props.onQueryChange}
196
197
  placeholder="Search conversations…"
@@ -203,7 +204,7 @@ export function ConversationSidebar(props: {
203
204
  "px-3",
204
205
  isLanding
205
206
  ? "pb-[max(1rem,env(safe-area-inset-bottom))]"
206
- : "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))]",
207
208
  )}
208
209
  >
209
210
  {props.error ? (
@@ -22,6 +22,7 @@ import { TranscriptSubagentView } from "./TranscriptSubagentView";
22
22
  import { TranscriptContextEventView } from "./TranscriptContextEventView";
23
23
  import {
24
24
  isCollapsibleActivityEntry,
25
+ quietTurnEntryKeys,
25
26
  TranscriptActivityGroup,
26
27
  } from "./TranscriptActivityGroup";
27
28
  import { TranscriptToolView } from "./TranscriptToolView";
@@ -232,9 +233,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: {
232
233
  keyPrefix={props.conversation.conversationId}
233
234
  unread={props.unread}
234
235
  renderContext={(entry) => (
235
- <TranscriptRailEvent
236
- kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
237
- >
236
+ <TranscriptRailEvent kind="compaction">
238
237
  <TranscriptContextEventView
239
238
  part={entry.part}
240
239
  timestamp={entry.timestamp}
@@ -359,6 +358,12 @@ function TranscriptEntryList(props: {
359
358
  }) {
360
359
  const search = useTranscriptSearch();
361
360
  const activityKeys = useRef(new Map<string, string>());
361
+ const quietKeys = useMemo(
362
+ () => quietTurnEntryKeys(props.entries),
363
+ [props.entries],
364
+ );
365
+ const collapsible = (entry: TranscriptEntry) =>
366
+ isCollapsibleActivityEntry(entry) || quietKeys.has(entry.key);
362
367
  const claimedActivityKeys = new Set<string>();
363
368
  const rows: ReactNode[] = [];
364
369
  // Search results are not a reading order, so they get no marker.
@@ -394,11 +399,11 @@ function TranscriptEntryList(props: {
394
399
  for (let index = 0; index < props.entries.length; ) {
395
400
  const entry = props.entries[index]!;
396
401
 
397
- if (isCollapsibleActivityEntry(entry)) {
402
+ if (collapsible(entry)) {
398
403
  const activityEntries: RenderedTranscriptEntry[] = [];
399
404
  while (
400
405
  index < props.entries.length &&
401
- isCollapsibleActivityEntry(props.entries[index]!)
406
+ collapsible(props.entries[index]!)
402
407
  ) {
403
408
  activityEntries.push(props.entries[index]!);
404
409
  index += 1;
@@ -523,9 +528,7 @@ function RedactedTranscriptView(props: {
523
528
  entries={groupTranscriptMessages(props.messages)}
524
529
  keyPrefix={`${props.conversation.conversationId}:redacted`}
525
530
  renderContext={(entry) => (
526
- <TranscriptRailEvent
527
- kind={entry.part.event.type === "handoff" ? "handoff" : "compaction"}
528
- >
531
+ <TranscriptRailEvent kind="compaction">
529
532
  <TranscriptContextEventView
530
533
  part={entry.part}
531
534
  timestamp={entry.timestamp}
@@ -1,20 +1,27 @@
1
1
  import type { InputImage } from "@sentry/junior/api/schema";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { Globe2, LockKeyhole } from "lucide-react";
2
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
3
+ import { Globe2, LockKeyhole, PanelLeft } from "lucide-react";
4
4
  import { useNavigate, useParams } from "react-router";
5
+ import type { PanelImperativeHandle } from "react-resizable-panels";
5
6
 
6
7
  import { useConversationsData } from "../api";
7
8
  import { ConversationSidebar } from "./ConversationSidebar";
8
9
  import { SearchInput } from "../components/SearchInput";
10
+ import {
11
+ ResizableHandle,
12
+ ResizablePanel,
13
+ ResizablePanelGroup,
14
+ } from "../components/ui/resizable";
9
15
  import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
10
16
  import { pageCount, pageItems, PagePagination } from "../components/Pagination";
11
17
  import { ConversationHomeList } from "./ConversationHomeList";
12
18
  import { ConversationComposer } from "./ConversationComposer";
19
+ import { HeaderIconButton } from "./ConversationHeaderActions";
13
20
  import {
14
21
  usePendingArchiveConversationUpdates,
15
22
  type PendingArchiveConversationUpdate,
16
23
  } from "./conversationArchive";
17
- import { useCreateConversation } from "./queries";
24
+ import { useCreateConversation, type ConversationData } from "./queries";
18
25
  import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes";
19
26
  import {
20
27
  attentionConversationIds,
@@ -22,6 +29,7 @@ import {
22
29
  } from "./conversationSections";
23
30
  import { buildConversations, getDashboardTimeZone } from "../format";
24
31
  import { useDebouncedValue } from "../debouncedValue";
32
+ import { cn } from "../styles";
25
33
  import type { Conversation } from "../types";
26
34
  import { ConversationPage } from "./ConversationPage";
27
35
  import {
@@ -30,13 +38,29 @@ import {
30
38
  } from "../review-desk/queries";
31
39
  import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
32
40
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
41
+ import {
42
+ readSidebarWidth,
43
+ saveSidebarWidth,
44
+ SIDEBAR_WIDTH,
45
+ useConversationSidebarOpen,
46
+ useSidebarFits,
47
+ } from "./conversationSidebarLayout";
33
48
 
34
49
  const CONVERSATION_PAGE_SIZE = 20;
50
+ const SIDEBAR_PANEL_ID = "conversation-list";
35
51
 
36
52
  /** Render the conversation home page or one selected conversation. */
37
- export function ConversationWorkspace() {
53
+ export function ConversationWorkspace(props: {
54
+ /** The selected Conversation's data, from the shell. */
55
+ detail: ConversationData;
56
+ }) {
38
57
  const [page, setPage] = useState(1);
39
58
  const [query, setQuery] = useState("");
59
+ const [sidebarOpen, setSidebarOpen] = useConversationSidebarOpen();
60
+ const sidebarFits = useSidebarFits();
61
+ const sidebarPanel = useRef<PanelImperativeHandle>(null);
62
+ const sidebarGroup = useRef<HTMLDivElement>(null);
63
+ const sidebarId = useId();
40
64
  const search = useDebouncedValue(query.trim());
41
65
  const params = useParams();
42
66
  const navigate = useNavigate();
@@ -146,45 +170,109 @@ export function ConversationWorkspace() {
146
170
  );
147
171
  }
148
172
 
173
+ const toggleSidebar = () => {
174
+ const panel = sidebarPanel.current;
175
+ if (!panel) return;
176
+ if (panel.isCollapsed()) panel.resize(readSidebarWidth());
177
+ else panel.collapse();
178
+ };
179
+
149
180
  return (
150
- <div className="grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]">
151
- <div className="hidden h-full min-h-0 overflow-hidden md:block">
152
- <ConversationSidebar
153
- conversations={conversations}
154
- error={feed.error?.message}
155
- finishedConversationIds={finishedConversationIds}
156
- heldConversationId={heldConversationId}
157
- loading={feed.isPending}
158
- onNewConversation={openCreate}
159
- onQueryChange={setQuery}
160
- query={query}
161
- selectedId={selectedId}
162
- timeZone={getDashboardTimeZone()}
163
- waitingConversations={waitingConversations}
164
- />
165
- </div>
166
- <section
167
- aria-label="Selected conversation"
168
- className="grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-dashboard-bg"
169
- >
170
- <UnsentAnswersProvider conversationId={selectedId} key={selectedId}>
171
- <ConversationPage
172
- conversationId={selectedId}
173
- data={
174
- feed.data
175
- ? {
176
- conversations: feed.data,
177
- }
178
- : undefined
179
- }
180
- onRead={markRead}
181
- pendingArchiveUpdate={pendingArchiveUpdates.find(
182
- (update) => update.conversationId === selectedId,
183
- )}
181
+ <ResizablePanelGroup
182
+ className="min-h-0 overflow-hidden bg-dashboard-bg"
183
+ elementRef={sidebarGroup}
184
+ onLayoutChanged={(layout) => {
185
+ // Save after the drag ends. A drag that collapses the list passes
186
+ // the minimum width first, and that width is not the reader's pick.
187
+ const percent = layout[SIDEBAR_PANEL_ID] ?? 0;
188
+ const groupWidth = sidebarGroup.current?.offsetWidth ?? 0;
189
+ if (percent > 0) saveSidebarWidth((percent / 100) * groupWidth);
190
+ }}
191
+ >
192
+ {sidebarFits ? (
193
+ <>
194
+ <ResizablePanel
195
+ collapsible
196
+ defaultSize={sidebarOpen ? readSidebarWidth() : 0}
197
+ groupResizeBehavior="preserve-pixel-size"
198
+ id={SIDEBAR_PANEL_ID}
199
+ maxSize={SIDEBAR_WIDTH.max}
200
+ minSize={SIDEBAR_WIDTH.min}
201
+ onResize={(size) => {
202
+ // Dragging past the minimum width collapses the list too, so the
203
+ // panel size is the one source for the open state.
204
+ const open = size.inPixels > 0;
205
+ if (open !== sidebarOpen) setSidebarOpen(open);
206
+ }}
207
+ panelRef={sidebarPanel}
208
+ >
209
+ <div
210
+ className={cn(
211
+ "h-full min-h-0 overflow-hidden",
212
+ !sidebarOpen && "hidden",
213
+ )}
214
+ id={sidebarId}
215
+ >
216
+ <ConversationSidebar
217
+ conversations={conversations}
218
+ error={feed.error?.message}
219
+ finishedConversationIds={finishedConversationIds}
220
+ heldConversationId={heldConversationId}
221
+ loading={feed.isPending}
222
+ onNewConversation={openCreate}
223
+ onQueryChange={setQuery}
224
+ query={query}
225
+ selectedId={selectedId}
226
+ timeZone={getDashboardTimeZone()}
227
+ waitingConversations={waitingConversations}
228
+ />
229
+ </div>
230
+ </ResizablePanel>
231
+ <ResizableHandle
232
+ aria-label="Resize conversation list"
233
+ className="w-px bg-transparent transition-colors after:w-2 focus-visible:ring-dashboard-border-interactive data-[separator=active]:bg-dashboard-border-interactive data-[separator=hover]:bg-dashboard-border-interactive"
184
234
  />
185
- </UnsentAnswersProvider>
186
- </section>
187
- </div>
235
+ </>
236
+ ) : null}
237
+ <ResizablePanel id="selected-conversation">
238
+ <section
239
+ aria-label="Selected conversation"
240
+ className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-dashboard-bg"
241
+ >
242
+ <UnsentAnswersProvider conversationId={selectedId} key={selectedId}>
243
+ <ConversationPage
244
+ conversationId={selectedId}
245
+ detail={props.detail}
246
+ data={
247
+ feed.data
248
+ ? {
249
+ conversations: feed.data,
250
+ }
251
+ : undefined
252
+ }
253
+ onRead={markRead}
254
+ pendingArchiveUpdate={pendingArchiveUpdates.find(
255
+ (update) => update.conversationId === selectedId,
256
+ )}
257
+ sidebarToggle={
258
+ <HeaderIconButton
259
+ controls={sidebarId}
260
+ expanded={sidebarOpen}
261
+ label={
262
+ sidebarOpen
263
+ ? "Hide conversation list"
264
+ : "Show conversation list"
265
+ }
266
+ onClick={toggleSidebar}
267
+ >
268
+ <PanelLeft aria-hidden="true" size={15} strokeWidth={2} />
269
+ </HeaderIconButton>
270
+ }
271
+ />
272
+ </UnsentAnswersProvider>
273
+ </section>
274
+ </ResizablePanel>
275
+ </ResizablePanelGroup>
188
276
  );
189
277
  }
190
278