@sentry/junior-dashboard 0.276.0 → 0.277.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/app.js +168 -33
  2. package/dist/client.js +88 -83
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +163 -31
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +4 -4
  7. package/src/api/schema.ts +20 -2
  8. package/src/app.ts +80 -12
  9. package/src/auth.ts +5 -1
  10. package/src/client/components/FormNotice.tsx +11 -14
  11. package/src/client/components/ProfileMenu.tsx +5 -6
  12. package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
  13. package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
  14. package/src/client/conversations/ConversationChildAgents.tsx +2 -1
  15. package/src/client/conversations/ConversationDetails.tsx +6 -3
  16. package/src/client/conversations/ConversationHeader.tsx +9 -0
  17. package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
  18. package/src/client/conversations/ConversationHomeList.tsx +1 -1
  19. package/src/client/conversations/ConversationMemories.tsx +64 -67
  20. package/src/client/conversations/ConversationPage.tsx +21 -8
  21. package/src/client/conversations/ConversationSidebar.tsx +80 -24
  22. package/src/client/conversations/ConversationWatches.tsx +15 -21
  23. package/src/client/conversations/ConversationWorkspace.tsx +4 -4
  24. package/src/client/conversations/DetailsList.tsx +8 -14
  25. package/src/client/conversations/MemoryIcons.tsx +23 -0
  26. package/src/client/conversations/TranscriptMemoryPopover.tsx +20 -25
  27. package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
  28. package/src/client/conversations/autoArchiveQueries.ts +53 -0
  29. package/src/client/conversations/conversationArchive.ts +255 -0
  30. package/src/client/conversations/conversationMemory.ts +2 -1
  31. package/src/client/conversations/conversationSections.ts +24 -18
  32. package/src/client/conversations/queries.ts +48 -238
  33. package/src/client/conversations/usePinConversation.ts +88 -0
  34. package/src/client/display-text/DisplayText.tsx +7 -2
  35. package/src/client/display-text/codeBlocks.ts +92 -0
  36. package/src/client/format.ts +3 -67
  37. package/src/client/pages/code/RepositoryTabs.tsx +2 -2
  38. package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
  39. package/src/client/pages/settings/SettingsPage.tsx +2 -0
  40. package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
  41. package/src/client/review-desk/queries.ts +2 -1
  42. package/src/client/types.ts +1 -0
  43. package/src/conversation-cache.ts +1 -1
  44. package/src/mock-reporting/fixtures.ts +103 -27
  45. package/src/mock-reporting/routes.ts +47 -0
@@ -1,5 +1,4 @@
1
- import { ChevronRight } from "lucide-react";
2
- import type { ConversationDetailReport } from "@sentry/junior/api/schema";
1
+ import { ArrowRight, BookmarkPlus, Brain, ChevronRight } from "lucide-react";
3
2
 
4
3
  import { dashboardDisclosureChevronClass } from "../styles";
5
4
  import type {
@@ -7,6 +6,7 @@ import type {
7
6
  ConversationMemoryList,
8
7
  } from "./conversationMemory";
9
8
  import { DetailsCard } from "./DetailsList";
9
+ import { PrivateMemoryIcon } from "./MemoryIcons";
10
10
 
11
11
  /** Count the distinct memories that a Conversation recalled and captured. */
12
12
  function conversationMemoryCounts(list: ConversationMemoryList) {
@@ -48,28 +48,38 @@ export function ConversationMemoryCard(props: {
48
48
  );
49
49
  }
50
50
 
51
- /**
52
- * Map each loaded Turn to the prompt that started it, so a memory can say
53
- * where it came from. A Turn starts from its last user input message.
54
- */
55
- export function turnPrompts(
56
- events: ConversationDetailReport["events"] | undefined,
57
- ): Map<string, string> {
58
- const texts = new Map<string, string>();
59
- const prompts = new Map<string, string>();
60
- for (const { data } of events ?? []) {
61
- if (data.type === "message" && data.role === "user" && data.text) {
62
- texts.set(data.messageId, data.text);
63
- }
64
- if (data.type === "turn_lifecycle" && data.state === "started") {
65
- const prompt = data.inputMessageIds
66
- ?.map((id) => texts.get(id))
67
- .filter((text) => text !== undefined)
68
- .at(-1);
69
- if (prompt) prompts.set(data.turnId, prompt);
70
- }
71
- }
72
- return prompts;
51
+ /** One memory with its full text, its visibility, and a jump to its Turn. */
52
+ function MemoryCard(props: {
53
+ entry: ConversationMemoryEntry;
54
+ onSelect(entry: ConversationMemoryEntry & { turnId: string }): void;
55
+ }) {
56
+ const { entry } = props;
57
+ const { turnId } = entry;
58
+ return (
59
+ <li className="grid overflow-hidden rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel">
60
+ {/* Break long URLs so the card keeps the panel width. */}
61
+ <p className="m-0 whitespace-pre-wrap px-3 py-2.5 text-sm leading-relaxed text-dashboard-text [overflow-wrap:anywhere]">
62
+ {entry.memory.content}
63
+ </p>
64
+ <div className="flex min-h-8 items-center justify-between gap-2 border-t border-dashboard-border-subtle bg-dashboard-fill-faint px-3 py-1 text-xs text-dashboard-text-muted">
65
+ {entry.memory.visibility === "private" ? (
66
+ <PrivateMemoryIcon />
67
+ ) : (
68
+ <span />
69
+ )}
70
+ {turnId ? (
71
+ <button
72
+ className="-mr-1.5 inline-flex cursor-pointer items-center gap-1 rounded-md border-0 bg-transparent px-1.5 py-1 font-sans text-xs font-medium text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
73
+ onClick={() => props.onSelect({ ...entry, turnId })}
74
+ type="button"
75
+ >
76
+ Go to message
77
+ <ArrowRight aria-hidden="true" size={13} />
78
+ </button>
79
+ ) : null}
80
+ </div>
81
+ </li>
82
+ );
73
83
  }
74
84
 
75
85
  /**
@@ -79,52 +89,39 @@ export function turnPrompts(
79
89
  export function ConversationMemoryEntries(props: {
80
90
  entries: ConversationMemoryEntry[];
81
91
  onSelect(entry: ConversationMemoryEntry & { turnId: string }): void;
82
- prompts: Map<string, string>;
83
92
  }) {
93
+ const groups = (["recalled", "captured"] as const)
94
+ .map((source) => ({
95
+ entries: props.entries.filter((entry) => entry.source === source),
96
+ source,
97
+ }))
98
+ .filter((group) => group.entries.length > 0);
84
99
  return (
85
- <ol className="m-0 grid list-none gap-2 p-0">
86
- {props.entries.map((entry) => {
87
- const { turnId } = entry;
88
- const prompt = turnId ? props.prompts.get(turnId) : undefined;
89
- const source =
90
- entry.source === "recalled"
91
- ? prompt
92
- ? `Recalled for “${prompt}”`
93
- : "Recalled"
94
- : prompt
95
- ? `Captured after “${prompt}”`
96
- : "Captured";
97
- const body = (
98
- <>
99
- <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
100
- {entry.memory.content}
101
- </p>
102
- <span className="flex min-w-0 items-center gap-2 text-xs text-dashboard-text-muted">
103
- <span className="min-w-0 truncate">{source}</span>
104
- {entry.memory.visibility === "private" ? (
105
- <span className="shrink-0">· Private</span>
106
- ) : null}
107
- </span>
108
- </>
109
- );
110
- const className =
111
- "grid w-full gap-1.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-2.5 text-left font-sans";
112
- return (
113
- <li key={`${entry.source}:${entry.memory.id}`}>
114
- {turnId ? (
115
- <button
116
- className={`${className} cursor-pointer transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus`}
117
- onClick={() => props.onSelect({ ...entry, turnId })}
118
- type="button"
119
- >
120
- {body}
121
- </button>
100
+ <div className="grid gap-5">
101
+ {groups.map((group) => (
102
+ <section className="grid gap-2" key={group.source}>
103
+ <h3 className="m-0 flex items-center gap-1.5 font-sans text-xs font-semibold text-dashboard-text">
104
+ {group.source === "recalled" ? (
105
+ <Brain aria-hidden="true" size={13} strokeWidth={1.8} />
122
106
  ) : (
123
- <div className={className}>{body}</div>
107
+ <BookmarkPlus aria-hidden="true" size={13} strokeWidth={1.8} />
124
108
  )}
125
- </li>
126
- );
127
- })}
128
- </ol>
109
+ {group.source === "recalled" ? "Recalled" : "Captured"}
110
+ <span className="font-normal tabular-nums text-dashboard-text-muted">
111
+ {group.entries.length}
112
+ </span>
113
+ </h3>
114
+ <ol className="m-0 grid list-none gap-2 p-0">
115
+ {group.entries.map((entry) => (
116
+ <MemoryCard
117
+ entry={entry}
118
+ key={`${entry.source}:${entry.memory.id}`}
119
+ onSelect={props.onSelect}
120
+ />
121
+ ))}
122
+ </ol>
123
+ </section>
124
+ ))}
125
+ </div>
129
126
  );
130
127
  }
@@ -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,7 +32,7 @@ import {
31
32
  import { ConversationHeader } from "./ConversationHeader";
32
33
  import { ConversationReplyFooter } from "./ConversationReplyFooter";
33
34
  import { ConversationDetails } from "./ConversationDetails";
34
- import { turnPrompts } from "./ConversationMemories";
35
+ import { AutoArchiveToggle } from "./AutoArchiveToggle";
35
36
  import {
36
37
  ConversationMemoryContext,
37
38
  memoryAnchorKey,
@@ -208,6 +209,7 @@ export function ConversationPage(props: {
208
209
  [memoryList, memoryPopover],
209
210
  );
210
211
  const archive = useArchiveConversation(conversationId);
212
+ const pin = usePinConversation(conversationId);
211
213
  const markRead = useMarkConversationRead(conversationId);
212
214
  const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail);
213
215
  const feedConversation = conversations.find(
@@ -330,10 +332,6 @@ export function ConversationPage(props: {
330
332
  }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]);
331
333
  // Show a chosen memory at its transcript row. Wait for the first page, then
332
334
  // load earlier pages until the row renders, or stop when history ends.
333
- const memoryPrompts = useMemo(
334
- () => turnPrompts(transcript?.events),
335
- [transcript?.events],
336
- );
337
335
  useEffect(() => {
338
336
  if (!memoryJump || !transcript) return;
339
337
  const anchor = pageRef.current?.querySelector(
@@ -488,6 +486,14 @@ export function ConversationPage(props: {
488
486
  <ConversationAnnotations detail={detail.data} />
489
487
  ) : null
490
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
+ }}
491
497
  archive={{
492
498
  archived: Boolean(conversation?.archivedAt),
493
499
  disabled: !conversation || archive.isPending,
@@ -612,13 +618,20 @@ export function ConversationPage(props: {
612
618
  {showDetails ? (
613
619
  <ConversationMemoryContext value={memoryState}>
614
620
  <ConversationDetails
621
+ autoArchive={
622
+ detail.data?.autoArchive === undefined ? null : (
623
+ <AutoArchiveToggle
624
+ autoArchive={detail.data.autoArchive}
625
+ conversationId={conversationId}
626
+ />
627
+ )
628
+ }
615
629
  brief={detail.data?.brief}
616
630
  conversationId={conversationId}
617
631
  highlightReview={reviewOpen}
618
632
  identity={identity}
619
633
  onClose={closeDetails}
620
634
  onOpenSubagent={setSubagentTarget}
621
- memoryPrompts={memoryPrompts}
622
635
  onSelectMemory={(entry) => {
623
636
  // Memory rows show only in the Conversation view, and search
624
637
  // can hide them.
@@ -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
  );
@@ -15,7 +15,7 @@ import {
15
15
  Timer,
16
16
  Users,
17
17
  } from "lucide-react";
18
- import { useState, type ReactNode } from "react";
18
+ import type { ReactNode } from "react";
19
19
  import {
20
20
  conversationWatchesReportSchema,
21
21
  type ConversationWatch,
@@ -24,6 +24,12 @@ import {
24
24
  import { DisplayText } from "../display-text/DisplayText";
25
25
  import { formatTime } from "../format";
26
26
  import { fetchDashboardJson } from "../http";
27
+ import { conversationSideReadQueryKey } from "./queries";
28
+ import {
29
+ Collapsible,
30
+ CollapsibleContent,
31
+ CollapsibleTrigger,
32
+ } from "../components/ui/collapsible";
27
33
  import { dashboardDisclosureChevronClass } from "../styles";
28
34
  import { DetailsList, DetailsRow } from "./DetailsList";
29
35
 
@@ -36,7 +42,7 @@ export function useConversationWatches(conversationId: string) {
36
42
  `/api/conversations/${encodeURIComponent(conversationId)}/watches`,
37
43
  signal,
38
44
  ),
39
- queryKey: ["conversation", conversationId, "watches"],
45
+ queryKey: conversationSideReadQueryKey(conversationId, "watches"),
40
46
  retry: false,
41
47
  });
42
48
  }
@@ -76,7 +82,6 @@ const resourceIcons: Partial<Record<string, LucideIcon>> = {
76
82
  */
77
83
  function WatchRow(props: { watch: ConversationWatch }) {
78
84
  const { watch } = props;
79
- const [expanded, setExpanded] = useState(false);
80
85
  const Icon = watch.redacted
81
86
  ? LockKeyhole
82
87
  : (resourceIcons[watch.resourceType] ?? Eye);
@@ -94,7 +99,6 @@ function WatchRow(props: { watch: ConversationWatch }) {
94
99
  />
95
100
  </span>
96
101
  );
97
- const toggle = () => setExpanded((value) => !value);
98
102
  const row = watch.url ? (
99
103
  // The row opens the watched resource, like a linked work row. The
100
104
  // chevron beside it shows the details.
@@ -102,32 +106,22 @@ function WatchRow(props: { watch: ConversationWatch }) {
102
106
  <div className="min-w-0 flex-1">
103
107
  <DetailsRow href={watch.url} icon={icon} title={title} />
104
108
  </div>
105
- <button
106
- aria-expanded={expanded}
109
+ <CollapsibleTrigger
107
110
  aria-label={`Details for ${title}`}
108
111
  className="group grid size-8 shrink-0 place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-surface-hover hover:text-dashboard-text focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
109
- onClick={toggle}
110
- type="button"
111
112
  >
112
113
  {chevron}
113
- </button>
114
+ </CollapsibleTrigger>
114
115
  </div>
115
116
  ) : (
116
- <DetailsRow
117
- expanded={expanded}
118
- icon={icon}
119
- meta={chevron}
120
- onToggle={toggle}
121
- title={title}
122
- />
117
+ <DetailsRow disclosure icon={icon} meta={chevron} title={title} />
123
118
  );
124
- if (!expanded) return row;
125
119
 
126
120
  return (
127
- <>
121
+ <Collapsible className="min-w-0">
128
122
  {row}
129
123
  {/* Indent under the title: icon column, column gap, and row padding. */}
130
- <div className="grid min-w-0 gap-1.5 pb-2 pl-9 pr-1.5 pt-0.5 text-xs leading-4 text-dashboard-text-muted">
124
+ <CollapsibleContent className="grid min-w-0 gap-1.5 pb-2 pl-9 pr-1.5 pt-0.5 text-xs leading-4 text-dashboard-text-muted">
131
125
  {watch.intent ? (
132
126
  <p className="m-0 break-words">
133
127
  <DisplayText inline text={watch.intent} />
@@ -166,8 +160,8 @@ function WatchRow(props: { watch: ConversationWatch }) {
166
160
  </span>
167
161
  </p>
168
162
  ) : null}
169
- </div>
170
- </>
163
+ </CollapsibleContent>
164
+ </Collapsible>
171
165
  );
172
166
  }
173
167
 
@@ -11,14 +11,14 @@ import { pageCount, pageItems, PagePagination } from "../components/Pagination";
11
11
  import { ConversationHomeList } from "./ConversationHomeList";
12
12
  import { ConversationComposer } from "./ConversationComposer";
13
13
  import {
14
- useCreateConversation,
15
14
  usePendingArchiveConversationUpdates,
16
15
  type PendingArchiveConversationUpdate,
17
- } from "./queries";
16
+ } from "./conversationArchive";
17
+ import { useCreateConversation } from "./queries";
18
18
  import { conversationPath, NEW_CONVERSATION_PATH } from "./conversationRoutes";
19
19
  import {
20
20
  attentionConversationIds,
21
- attentionFirst,
21
+ pinnedAndAttentionFirst,
22
22
  } from "./conversationSections";
23
23
  import { buildConversations, getDashboardTimeZone } from "../format";
24
24
  import { useDebouncedValue } from "../debouncedValue";
@@ -73,7 +73,7 @@ export function ConversationWorkspace() {
73
73
  const pagedConversations = useMemo(
74
74
  () =>
75
75
  pageItems(
76
- attentionFirst(
76
+ pinnedAndAttentionFirst(
77
77
  conversations,
78
78
  attentionConversationIds(
79
79
  waitingConversationIds,
@@ -1,6 +1,7 @@
1
1
  import { ChevronRight } from "lucide-react";
2
2
  import { useState, type ReactNode } from "react";
3
3
 
4
+ import { CollapsibleTrigger } from "../components/ui/collapsible";
4
5
  import { cn, dashboardDisclosureChevronClass } from "../styles";
5
6
 
6
7
  /**
@@ -87,21 +88,20 @@ export function DetailsList<T>(props: {
87
88
  /**
88
89
  * Lay out one details list row: an icon, a title, and short trailing text on
89
90
  * the first line, then optional lines under the title. A row with `href`
90
- * opens that link in a new tab. A row with `onToggle` is a disclosure button
91
- * that shows or hides content the caller renders under it.
91
+ * opens that link in a new tab. A `disclosure` row is the trigger of the
92
+ * enclosing `Collapsible`, so it shows or hides the content under it.
92
93
  */
93
94
  export function DetailsRow(props: {
94
95
  children?: ReactNode;
95
- expanded?: boolean;
96
+ disclosure?: boolean;
96
97
  href?: string;
97
98
  icon: ReactNode;
98
99
  meta?: ReactNode;
99
- onToggle?(): void;
100
100
  title: ReactNode;
101
101
  /** Native hover text with detail that the row does not show. */
102
102
  tooltip?: string;
103
103
  }) {
104
- const interactive = props.href !== undefined || props.onToggle !== undefined;
104
+ const interactive = props.href !== undefined || props.disclosure === true;
105
105
  const className = cn(
106
106
  "grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-1 rounded-md px-1.5 py-1.5 text-left text-inherit no-underline",
107
107
  interactive &&
@@ -134,17 +134,11 @@ export function DetailsRow(props: {
134
134
  </a>
135
135
  );
136
136
  }
137
- if (props.onToggle) {
137
+ if (props.disclosure) {
138
138
  return (
139
- <button
140
- aria-expanded={props.expanded ?? false}
141
- className={className}
142
- onClick={props.onToggle}
143
- title={props.tooltip}
144
- type="button"
145
- >
139
+ <CollapsibleTrigger className={className} title={props.tooltip}>
146
140
  {content}
147
- </button>
141
+ </CollapsibleTrigger>
148
142
  );
149
143
  }
150
144
  return (
@@ -0,0 +1,23 @@
1
+ import { ArrowUpRight, Lock } from "lucide-react";
2
+
3
+ /** Mark a memory that only its owner can see. */
4
+ export function PrivateMemoryIcon() {
5
+ return <Lock aria-label="Private" role="img" size={13} strokeWidth={1.8} />;
6
+ }
7
+
8
+ /**
9
+ * End a memory row that links to the memory's page. Its parent link needs
10
+ * the `group` class, so the arrow follows the row hover.
11
+ */
12
+ export function MemoryLinkIcons(props: { private: boolean }) {
13
+ return (
14
+ <span className="flex items-center gap-1.5 pt-1 text-dashboard-text-faint">
15
+ {props.private ? <PrivateMemoryIcon /> : null}
16
+ <ArrowUpRight
17
+ aria-hidden="true"
18
+ className="transition-colors group-hover:text-dashboard-text"
19
+ size={14}
20
+ />
21
+ </span>
22
+ );
23
+ }