@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
@@ -1,11 +1,11 @@
1
- import { ChevronRight } from "lucide-react";
1
+ import { ArrowLeft } from "lucide-react";
2
2
  import { useState, type ReactNode } from "react";
3
3
  import type { ConversationDetailReport } from "@sentry/junior/api/schema";
4
4
 
5
5
  import { DetailsPanel, useDetailsPanel } from "../components/DetailsPanel";
6
6
  import { formatRelativeTime, formatTime } from "../format";
7
7
  import { ConversationReviewItems } from "../review-desk/ConversationReviewItems";
8
- import { dashboardDisclosureChevronClass } from "../styles";
8
+ import { Button } from "../components/ui/button";
9
9
  import { ConversationBrief } from "./ConversationBrief";
10
10
  import {
11
11
  ConversationChildAgents,
@@ -16,9 +16,14 @@ import {
16
16
  type ConversationLink,
17
17
  } from "./ConversationLinkedWork";
18
18
  import {
19
- ConversationMemories,
20
- useConversationMemories,
19
+ ConversationMemoryCard,
20
+ ConversationMemoryEntries,
21
21
  } from "./ConversationMemories";
22
+ import {
23
+ conversationMemoryEntries,
24
+ useConversationMemoryState,
25
+ type ConversationMemoryEntry,
26
+ } from "./conversationMemory";
22
27
  import {
23
28
  ConversationWatches,
24
29
  useConversationWatches,
@@ -34,6 +39,8 @@ type Brief = NonNullable<ConversationDetailReport["brief"]>;
34
39
  * it, and what it cost. Each fact shows here only, not in the title bar.
35
40
  */
36
41
  export function ConversationDetails(props: {
42
+ /** Viewer's auto-archive control. Null for non-participants. */
43
+ autoArchive: ReactNode;
37
44
  brief?: Brief;
38
45
  conversationId: string;
39
46
  /** True when a sidebar review chip opened details. */
@@ -43,15 +50,60 @@ export function ConversationDetails(props: {
43
50
  onClose(): void;
44
51
  /** Open one child agent transcript. A details sheet closes first. */
45
52
  onOpenSubagent(target: SubagentTranscriptTarget): void;
53
+ /** Show one memory where its Turn recalled or captured it. */
54
+ onSelectMemory(entry: ConversationMemoryEntry & { turnId: string }): void;
46
55
  overview: ReactNode;
47
56
  usage: ReactNode;
48
57
  }) {
49
58
  const panel = useDetailsPanel();
50
- const memories = useConversationMemories(props.conversationId).data?.memories;
59
+ const memoryList = useConversationMemoryState()?.list;
60
+ const [memoriesOpen, setMemoriesOpen] = useState(false);
61
+ const [memoryConversationId, setMemoryConversationId] = useState(
62
+ props.conversationId,
63
+ );
64
+ if (memoryConversationId !== props.conversationId) {
65
+ setMemoryConversationId(props.conversationId);
66
+ setMemoriesOpen(false);
67
+ }
51
68
  const agents = useConversationChildAgents(props.conversationId).data?.agents;
52
69
  const watches =
53
70
  useConversationWatches(props.conversationId).data?.watches ?? [];
54
71
 
72
+ if (memoriesOpen && memoryList) {
73
+ // The list replaces details in place, so nothing stacks over the panel.
74
+ return (
75
+ <DetailsPanel
76
+ label="Conversation details"
77
+ onClose={props.onClose}
78
+ title={
79
+ <div className="-ml-2 flex min-w-0 items-center gap-1">
80
+ <Button
81
+ aria-label="Back to details"
82
+ onClick={() => setMemoriesOpen(false)}
83
+ size="icon"
84
+ >
85
+ <ArrowLeft aria-hidden="true" size={15} />
86
+ </Button>
87
+ <h2 className="m-0 truncate font-sans text-sm font-semibold text-dashboard-text">
88
+ Memories
89
+ </h2>
90
+ </div>
91
+ }
92
+ >
93
+ <div className="px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
94
+ <ConversationMemoryEntries
95
+ entries={conversationMemoryEntries(memoryList)}
96
+ onSelect={(entry) => {
97
+ // The transcript sits under a details sheet. Close it first.
98
+ if (!panel) props.onClose();
99
+ props.onSelectMemory(entry);
100
+ }}
101
+ />
102
+ </div>
103
+ </DetailsPanel>
104
+ );
105
+ }
106
+
55
107
  return (
56
108
  <DetailsPanel
57
109
  label="Conversation details"
@@ -114,6 +166,10 @@ export function ConversationDetails(props: {
114
166
  ) : null}
115
167
  </DetailsCard>
116
168
  ) : null}
169
+ {/* Linked work decides when auto-archive runs, so it follows. */}
170
+ {props.autoArchive != null ? (
171
+ <DetailsCard>{props.autoArchive}</DetailsCard>
172
+ ) : null}
117
173
  {agents?.length ? (
118
174
  <DetailsCard count={agents.length} title="Subagents">
119
175
  <ConversationChildAgents
@@ -135,38 +191,13 @@ export function ConversationDetails(props: {
135
191
  {props.identity != null ? (
136
192
  <DetailsCard title="Participants">{props.identity}</DetailsCard>
137
193
  ) : null}
138
- {memories?.length ? (
139
- <DetailsCard count={memories.length} title="Memories">
140
- <Disclosure label="memories">
141
- <ConversationMemories conversationId={props.conversationId} />
142
- </Disclosure>
143
- </DetailsCard>
194
+ {memoryList?.events.length ? (
195
+ <ConversationMemoryCard
196
+ list={memoryList}
197
+ onOpen={() => setMemoriesOpen(true)}
198
+ />
144
199
  ) : null}
145
200
  </div>
146
201
  </DetailsPanel>
147
202
  );
148
203
  }
149
-
150
- /** Hide long, rarely read content behind one show/hide row. */
151
- function Disclosure(props: { children: ReactNode; label: string }) {
152
- const [open, setOpen] = useState(false);
153
- return (
154
- <div className="grid min-w-0 gap-2">
155
- <button
156
- aria-expanded={open}
157
- className="group -mx-1.5 -my-1 flex min-h-8 items-center gap-2.5 rounded-md px-1.5 py-1.5 text-left text-xs font-medium leading-4 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"
158
- onClick={() => setOpen((value) => !value)}
159
- type="button"
160
- >
161
- <span className="grid size-5 shrink-0 place-items-center">
162
- <ChevronRight
163
- aria-hidden="true"
164
- className={dashboardDisclosureChevronClass}
165
- />
166
- </span>
167
- {open ? `Hide ${props.label}` : `Show ${props.label}`}
168
- </button>
169
- {open ? props.children : null}
170
- </div>
171
- );
172
- }
@@ -14,20 +14,24 @@ import {
14
14
  HeaderIconButton,
15
15
  TranscriptSearchToggle,
16
16
  type ConversationArchiveAction,
17
+ type ConversationPinAction,
17
18
  } from "./ConversationHeaderActions";
18
19
  import { ConversationOverflowMenu } from "./ConversationOverflowMenu";
19
20
  import { ShareConversationButton } from "./ShareConversationButton";
20
21
  import type { TranscriptViewMode } from "./transcriptRenderModel";
21
22
 
22
23
  /**
23
- * Show the conversation title bar: where it runs, the title, and actions.
24
- * Run facts, linked work, and the Brief live in Conversation details.
24
+ * Show the conversation title bar: where it runs, the title, its cost, and
25
+ * actions. Other run facts, linked work, and the Brief live in Conversation
26
+ * details.
25
27
  */
26
28
  export function ConversationHeader(props: {
27
29
  /** Parent chain of a child Conversation. Root Conversations omit it. */
28
30
  agentTree?: ReactNode;
29
31
  /** A `menu` layout copy action for the `⋯` menu. */
30
32
  copyAction?: ReactNode;
33
+ /** Estimated cost. Desktop only; mobile shows it in Conversation details. */
34
+ cost?: ReactNode;
31
35
  archive: ConversationArchiveAction;
32
36
  conversationId: string;
33
37
  detailsOpen: boolean;
@@ -41,6 +45,7 @@ export function ConversationHeader(props: {
41
45
  location?: ReactNode;
42
46
  onSearchChange(value: string): void;
43
47
  onViewChange(value: TranscriptViewMode): void;
48
+ pin: ConversationPinAction;
44
49
  privacy: ReactNode;
45
50
  search: string;
46
51
  sentryConversationUrl?: string;
@@ -89,6 +94,7 @@ export function ConversationHeader(props: {
89
94
  <ConversationHeaderActions
90
95
  archive={props.archive}
91
96
  conversationId={props.conversationId}
97
+ pin={props.pin}
92
98
  copyAction={props.copyAction}
93
99
  detailsOpen={detailsOpen}
94
100
  onDetailsClick={() => {
@@ -113,6 +119,7 @@ export function ConversationHeader(props: {
113
119
  const showMobileHeader =
114
120
  searchOpenVisible ||
115
121
  props.archive.error ||
122
+ props.pin.error ||
116
123
  Boolean(props.linkedWork) ||
117
124
  Boolean(props.forks) ||
118
125
  Boolean(props.agentTree);
@@ -179,6 +186,11 @@ export function ConversationHeader(props: {
179
186
  <span className="shrink-0">{props.privacy}</span>
180
187
  </div>
181
188
  <div className="flex shrink-0 items-center gap-0.5">
189
+ {props.cost ? (
190
+ <span className="mr-2 text-xs tabular-nums text-dashboard-text-muted">
191
+ {props.cost}
192
+ </span>
193
+ ) : null}
182
194
  {props.view === "raw" ? (
183
195
  // Event log is a debug view reached from the menu. Say which
184
196
  // view this is, and give one click back.
@@ -251,6 +263,11 @@ export function ConversationHeader(props: {
251
263
  Could not update archive state.
252
264
  </div>
253
265
  ) : null}
266
+ {props.pin.error ? (
267
+ <div className="px-4 pb-3 text-xs text-red-300 md:px-7">
268
+ Could not update pin.
269
+ </div>
270
+ ) : null}
254
271
  </header>
255
272
  </>
256
273
  );
@@ -4,6 +4,8 @@ import {
4
4
  ArchiveRestore,
5
5
  Info,
6
6
  MessagesSquare,
7
+ Pin,
8
+ PinOff,
7
9
  ScrollText,
8
10
  Search,
9
11
  X,
@@ -28,12 +30,20 @@ export type ConversationArchiveAction = {
28
30
  pending: boolean;
29
31
  };
30
32
 
33
+ export type ConversationPinAction = {
34
+ disabled: boolean;
35
+ error: boolean;
36
+ onClick(): void;
37
+ pinned: boolean;
38
+ };
39
+
31
40
  /**
32
41
  * Show conversation actions in the `⋯` menu. On desktop the title bar shows
33
42
  * search, share, and details, so the menu leaves them out.
34
43
  */
35
44
  export function ConversationHeaderActions(props: {
36
45
  archive: ConversationArchiveAction;
46
+ pin: ConversationPinAction;
37
47
  conversationId: string;
38
48
  copyAction?: ReactNode;
39
49
  detailsOpen: boolean;
@@ -83,6 +93,17 @@ export function ConversationHeaderActions(props: {
83
93
  />
84
94
  ) : null}
85
95
  {props.copyAction}
96
+ <MenuActionButton
97
+ disabled={props.pin.disabled}
98
+ label={props.pin.pinned ? "Unpin" : "Pin"}
99
+ onClick={props.pin.onClick}
100
+ >
101
+ {props.pin.pinned ? (
102
+ <PinOff aria-hidden="true" size={16} strokeWidth={2} />
103
+ ) : (
104
+ <Pin aria-hidden="true" size={16} strokeWidth={2} />
105
+ )}
106
+ </MenuActionButton>
86
107
  <ArchiveConversationButton {...props.archive} />
87
108
  {props.sentryConversationUrl ? (
88
109
  <a
@@ -211,6 +232,7 @@ export function TranscriptSearchToggle(props: {
211
232
 
212
233
  function MenuActionButton(props: {
213
234
  children: ReactNode;
235
+ disabled?: boolean;
214
236
  label: string;
215
237
  onClick(): void;
216
238
  pressed?: boolean;
@@ -220,6 +242,7 @@ function MenuActionButton(props: {
220
242
  aria-label={props.label}
221
243
  aria-pressed={props.pressed}
222
244
  className={props.pressed ? "bg-dashboard-fill-strong" : undefined}
245
+ disabled={props.disabled}
223
246
  onClick={props.onClick}
224
247
  size="menu"
225
248
  >
@@ -20,7 +20,7 @@ import { ConversationSidebarAnnotations } from "./ConversationMeta";
20
20
  import { ConversationArchiveNotices } from "./ConversationArchiveNotices";
21
21
  import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
22
22
  import { conversationPath } from "./conversationRoutes";
23
- import { useArchiveConversation } from "./queries";
23
+ import { useArchiveConversation } from "./conversationArchive";
24
24
  import { DisplayText } from "../display-text/DisplayText";
25
25
  import {
26
26
  attentionConversationIds,
@@ -1,12 +1,13 @@
1
1
  import { LockKeyhole } from "lucide-react";
2
2
 
3
- import { ActiveIndicator } from "../components/ActiveIndicator";
3
+ import { Spinner } from "../components/ui/spinner";
4
4
  import { cn } from "../styles";
5
5
  import type { VisualStatus } from "../types";
6
6
 
7
7
  /**
8
8
  * Render a conversation status marker. "Waiting on you" replaces every other
9
- * marker, then new completions take priority.
9
+ * marker. A running conversation shows a spinner, so it never looks like an
10
+ * ended one.
10
11
  */
11
12
  export function ConversationListStatusIcon(props: {
12
13
  finishedSinceSeen: boolean;
@@ -24,6 +25,10 @@ export function ConversationListStatusIcon(props: {
24
25
  );
25
26
  }
26
27
 
28
+ if (props.status === "active") {
29
+ return <Spinner aria-label="Running" className="size-3 text-emerald-300" />;
30
+ }
31
+
27
32
  if (props.finishedSinceSeen) {
28
33
  return (
29
34
  <span
@@ -40,8 +45,6 @@ export function ConversationListStatusIcon(props: {
40
45
  aria-label="Private conversation"
41
46
  className={cn(
42
47
  "size-3 shrink-0",
43
- props.status === "active" &&
44
- "animate-[junior-active-indicator_1.8s_ease-in-out_infinite] text-emerald-300 drop-shadow-[0_0_6px_rgba(110,231,183,0.55)] motion-reduce:animate-none",
45
48
  props.status === "failed" && "text-rose-300",
46
49
  props.status === "idle" && "text-dashboard-text-muted",
47
50
  )}
@@ -49,18 +52,20 @@ export function ConversationListStatusIcon(props: {
49
52
  );
50
53
  }
51
54
 
52
- if (props.status === "active") {
53
- return <ActiveIndicator className="size-1.5" />;
55
+ if (props.status === "failed") {
56
+ return (
57
+ <span
58
+ aria-label="Failed"
59
+ className="size-1.5 shrink-0 rounded-full bg-rose-300"
60
+ role="img"
61
+ />
62
+ );
54
63
  }
55
64
 
56
65
  return (
57
66
  <span
58
67
  aria-hidden="true"
59
- className={cn(
60
- "size-1.5 shrink-0 rounded-full",
61
- props.status === "failed" && "bg-rose-300",
62
- props.status === "idle" && "bg-dashboard-text-faint",
63
- )}
68
+ className="size-1.5 shrink-0 rounded-full bg-dashboard-text-faint"
64
69
  />
65
70
  );
66
71
  }
@@ -1,90 +1,127 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import { Brain } from "lucide-react";
3
- import { z } from "zod";
1
+ import { ArrowRight, BookmarkPlus, Brain, ChevronRight } from "lucide-react";
4
2
 
5
- import { formatMessageTimestamp } from "../format";
6
- import { fetchDashboardJson } from "../http";
3
+ import { dashboardDisclosureChevronClass } from "../styles";
4
+ import type {
5
+ ConversationMemoryEntry,
6
+ ConversationMemoryList,
7
+ } from "./conversationMemory";
8
+ import { DetailsCard } from "./DetailsList";
9
+ import { PrivateMemoryIcon } from "./MemoryIcons";
7
10
 
8
- const conversationMemoryListSchema = z
9
- .object({
10
- memories: z.array(
11
- z
12
- .object({
13
- capturedAt: z.iso.datetime(),
14
- content: z.string().min(1),
15
- id: z.string().min(1),
16
- kind: z.enum(["preference", "procedure", "knowledge"]),
17
- visibility: z.enum(["private", "public"]),
18
- })
19
- .strict(),
20
- ),
21
- })
22
- .strict();
23
-
24
- /** Load the memories captured from one Conversation. */
25
- export function useConversationMemories(conversationId: string) {
26
- return useQuery({
27
- queryFn: ({ signal }) =>
28
- fetchDashboardJson(
29
- conversationMemoryListSchema,
30
- `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
31
- signal,
32
- ),
33
- queryKey: ["conversation", conversationId, "memories"],
34
- retry: false,
35
- });
11
+ /** Count the distinct memories that a Conversation recalled and captured. */
12
+ function conversationMemoryCounts(list: ConversationMemoryList) {
13
+ const ids = (source: "captured" | "recalled") =>
14
+ new Set(
15
+ list.events
16
+ .filter((event) => event.source === source)
17
+ .flatMap((event) => event.memoryIds),
18
+ ).size;
19
+ return { captured: ids("captured"), recalled: ids("recalled") };
36
20
  }
37
21
 
38
- /** Show the memories captured from one Conversation. */
39
- export function ConversationMemories(props: { conversationId: string }) {
40
- const query = useConversationMemories(props.conversationId);
22
+ /** Show memory counts in one row that opens the full memory list. */
23
+ export function ConversationMemoryCard(props: {
24
+ list: ConversationMemoryList;
25
+ onOpen(): void;
26
+ }) {
27
+ const counts = conversationMemoryCounts(props.list);
28
+ const summary = [
29
+ counts.recalled ? `${counts.recalled} recalled` : undefined,
30
+ counts.captured ? `${counts.captured} captured` : undefined,
31
+ ]
32
+ .filter(Boolean)
33
+ .join(" · ");
34
+ return (
35
+ <DetailsCard title="Memories">
36
+ <button
37
+ className="group -mx-1.5 -my-1 flex min-h-8 w-[calc(100%+0.75rem)] items-center justify-between gap-2.5 rounded-md px-1.5 py-1.5 text-left text-sm tabular-nums text-dashboard-text transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
38
+ onClick={props.onOpen}
39
+ type="button"
40
+ >
41
+ {summary}
42
+ <ChevronRight
43
+ aria-hidden="true"
44
+ className={dashboardDisclosureChevronClass}
45
+ />
46
+ </button>
47
+ </DetailsCard>
48
+ );
49
+ }
41
50
 
42
- if (query.isPending) {
43
- return (
44
- <p className="m-0 text-sm text-dashboard-text-muted" role="status">
45
- Loading memories…
46
- </p>
47
- );
48
- }
49
- if (query.error) {
50
- return (
51
- <p className="m-0 text-sm text-red-300/80">
52
- Could not load memories from this Conversation.
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}
53
63
  </p>
54
- );
55
- }
56
- if (query.data.memories.length === 0) {
57
- return (
58
- <div className="grid justify-items-center gap-2 py-10 text-center text-dashboard-text-muted">
59
- <Brain aria-hidden="true" className="size-5 opacity-60" />
60
- <p className="m-0 text-sm">No memories were captured.</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}
61
80
  </div>
62
- );
63
- }
81
+ </li>
82
+ );
83
+ }
64
84
 
85
+ /**
86
+ * List every memory that a Conversation recalled or captured, with its full
87
+ * text. Selecting one shows it in the transcript.
88
+ */
89
+ export function ConversationMemoryEntries(props: {
90
+ entries: ConversationMemoryEntry[];
91
+ onSelect(entry: ConversationMemoryEntry & { turnId: string }): void;
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);
65
99
  return (
66
- <ol className="m-0 grid list-none gap-3 p-0">
67
- {query.data.memories.map((memory) => (
68
- <li
69
- className="grid gap-2 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel px-3 py-3"
70
- key={memory.id}
71
- >
72
- <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
73
- {memory.content}
74
- </p>
75
- <div className="flex flex-wrap items-center gap-1.5 text-xs tabular-nums text-dashboard-text-muted">
76
- <span className="rounded-md border border-dashboard-border-subtle bg-dashboard-surface-raised px-1.5 py-0.5">
77
- {memory.kind}
78
- </span>
79
- <span className="rounded-md border border-dashboard-border-subtle bg-dashboard-surface-raised px-1.5 py-0.5">
80
- {memory.visibility}
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} />
106
+ ) : (
107
+ <BookmarkPlus aria-hidden="true" size={13} strokeWidth={1.8} />
108
+ )}
109
+ {group.source === "recalled" ? "Recalled" : "Captured"}
110
+ <span className="font-normal tabular-nums text-dashboard-text-muted">
111
+ {group.entries.length}
81
112
  </span>
82
- <time className="ml-auto" dateTime={memory.capturedAt}>
83
- {formatMessageTimestamp(Date.parse(memory.capturedAt), true)}
84
- </time>
85
- </div>
86
- </li>
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>
87
124
  ))}
88
- </ol>
125
+ </div>
89
126
  );
90
127
  }