@sentry/junior-dashboard 0.275.0 → 0.276.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 (37) hide show
  1. package/dist/app.js +28 -9
  2. package/dist/client.js +115 -92
  3. package/dist/index.js +28 -9
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +8 -3
  7. package/src/client/conversations/ConversationComposer.tsx +42 -26
  8. package/src/client/conversations/ConversationDetails.tsx +63 -35
  9. package/src/client/conversations/ConversationHeader.tsx +10 -2
  10. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  11. package/src/client/conversations/ConversationMemories.tsx +118 -78
  12. package/src/client/conversations/ConversationMeta.tsx +47 -42
  13. package/src/client/conversations/ConversationPage.tsx +168 -58
  14. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  15. package/src/client/conversations/ConversationWatches.tsx +37 -15
  16. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  17. package/src/client/conversations/QuickQuestion.tsx +476 -0
  18. package/src/client/conversations/TelemetryMetrics.tsx +2 -1
  19. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  20. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  21. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  22. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  23. package/src/client/conversations/TranscriptRows.tsx +9 -2
  24. package/src/client/conversations/UsageCard.tsx +3 -3
  25. package/src/client/conversations/conversationMemory.ts +138 -0
  26. package/src/client/conversations/eventTranscript.ts +8 -0
  27. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  28. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  29. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  30. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  31. package/src/client/sentry.ts +77 -0
  32. package/src/client/types.ts +6 -0
  33. package/src/client.tsx +3 -0
  34. package/src/mock-reporting/watches.ts +2 -0
  35. package/src/shell.ts +27 -2
  36. package/src/tailwind.css +8 -19
  37. package/src/client/components/ActiveIndicator.tsx +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.275.0",
3
+ "version": "0.276.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -23,6 +23,7 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@base-ui/react": "^1.9.0",
26
+ "@sentry/browser": "11.10.0",
26
27
  "@tanstack/react-query": "^5.100.14",
27
28
  "@uidotdev/usehooks": "2.4.1",
28
29
  "class-variance-authority": "^0.7.1",
@@ -39,10 +40,10 @@
39
40
  "remark-gfm": "^4.0.1",
40
41
  "shiki": "4.1.0",
41
42
  "zod": "^4.5.4",
42
- "@sentry/junior-plugin-api": "0.275.0"
43
+ "@sentry/junior-plugin-api": "0.276.0"
43
44
  },
44
45
  "peerDependencies": {
45
- "@sentry/junior": "0.275.0"
46
+ "@sentry/junior": "0.276.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@oxlint/plugins": "1.79.0",
@@ -63,7 +64,7 @@
63
64
  "tw-animate-css": "^1.4.0",
64
65
  "typescript": "^7.0.2",
65
66
  "vitest": "^4.1.12",
66
- "@sentry/junior": "0.275.0"
67
+ "@sentry/junior": "0.276.0"
67
68
  },
68
69
  "scripts": {
69
70
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
package/src/app.ts CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  type JuniorApiVariables,
12
12
  } from "@sentry/junior/api";
13
13
  import { apiErrorSchema } from "@sentry/junior/api/schema";
14
- import { initSentry } from "@sentry/junior/instrumentation";
14
+ import {
15
+ getBrowserSentryConfig,
16
+ initSentry,
17
+ } from "@sentry/junior/instrumentation";
15
18
  import { JUNIOR_VERSION } from "@sentry/junior/version";
16
19
  import { DASHBOARD_VERSION_HEADER } from "./dashboard-version";
17
20
  import { revalidateConversation } from "./conversation-cache";
@@ -527,10 +530,12 @@ export function createDashboardApp(
527
530
  }
528
531
  }
529
532
 
533
+ const renderPage = () =>
534
+ renderDashboard(basePath, agentName, getBrowserSentryConfig());
530
535
  for (const { nested, path } of dashboardPagePaths(basePath)) {
531
- app.get(path, () => renderDashboard(basePath, agentName));
536
+ app.get(path, renderPage);
532
537
  if (nested) {
533
- app.get(`${path}/*`, () => renderDashboard(basePath, agentName));
538
+ app.get(`${path}/*`, renderPage);
534
539
  }
535
540
  }
536
541
  for (const route of options.pluginRoutes ?? []) {
@@ -81,7 +81,10 @@ type ConversationComposerProps = {
81
81
  * crushed beside the mobile send button.
82
82
  */
83
83
  footerStart?: ReactNode;
84
+ /** Text only, for a narrow panel: no images, no key hint, icon-only send. */
85
+ compact?: boolean;
84
86
  label: string;
87
+ placeholder?: string;
85
88
  /**
86
89
  * Restore the submitted text after a failed accept when there is no mailbox
87
90
  * outbox (new conversation create). Existing conversations keep failures in
@@ -433,7 +436,12 @@ export const ConversationComposer = memo(function ConversationComposer(
433
436
  )}
434
437
  onDragOver={(event) => {
435
438
  if (!event.dataTransfer.types.includes("Files")) return;
439
+ // Always cancel a file drop, or the browser opens the file.
436
440
  event.preventDefault();
441
+ if (props.compact) {
442
+ event.dataTransfer.dropEffect = "none";
443
+ return;
444
+ }
437
445
  event.dataTransfer.dropEffect = "copy";
438
446
  setDragging(true);
439
447
  }}
@@ -444,12 +452,13 @@ export const ConversationComposer = memo(function ConversationComposer(
444
452
  onDrop={(event) => {
445
453
  if (!event.dataTransfer.types.includes("Files")) return;
446
454
  event.preventDefault();
455
+ if (props.compact) return;
447
456
  setDragging(false);
448
457
  void addImages(Array.from(event.dataTransfer.files));
449
458
  }}
450
459
  onPaste={(event) => {
451
460
  const files = Array.from(event.clipboardData.files);
452
- if (!files.length) return;
461
+ if (props.compact || !files.length) return;
453
462
  event.preventDefault();
454
463
  void addImages(files);
455
464
  }}
@@ -510,7 +519,7 @@ export const ConversationComposer = memo(function ConversationComposer(
510
519
  }}
511
520
  onFocus={props.onFocus}
512
521
  onKeyDown={handleKeyDown}
513
- placeholder="Message Junior…"
522
+ placeholder={props.placeholder ?? "Message Junior…"}
514
523
  ref={textareaRef}
515
524
  rows={1}
516
525
  spellCheck={autocorrect}
@@ -524,27 +533,29 @@ export const ConversationComposer = memo(function ConversationComposer(
524
533
  )}
525
534
  >
526
535
  <div className="flex min-w-0 items-center gap-3">
527
- <Tooltip>
528
- <TooltipTrigger
529
- render={
530
- <Button
531
- focusableWhenDisabled
532
- aria-label="Attach images"
533
- disabled={sendLocked || readingImages}
534
- onClick={() => fileInputRef.current?.click()}
535
- size="icon"
536
- type="button"
537
- >
538
- <ImagePlus aria-hidden="true" size={16} />
539
- </Button>
540
- }
541
- />
542
- <TooltipContent size="compact">
543
- Attach images (up to 3 MB total)
544
- </TooltipContent>
545
- </Tooltip>
536
+ {props.compact ? null : (
537
+ <Tooltip>
538
+ <TooltipTrigger
539
+ render={
540
+ <Button
541
+ focusableWhenDisabled
542
+ aria-label="Attach images"
543
+ disabled={sendLocked || readingImages}
544
+ onClick={() => fileInputRef.current?.click()}
545
+ size="icon"
546
+ type="button"
547
+ >
548
+ <ImagePlus aria-hidden="true" size={16} />
549
+ </Button>
550
+ }
551
+ />
552
+ <TooltipContent size="compact">
553
+ Attach images (up to 3 MB total)
554
+ </TooltipContent>
555
+ </Tooltip>
556
+ )}
546
557
  {props.footerStart}
547
- {props.footerStart ? null : (
558
+ {props.footerStart || props.compact ? null : (
548
559
  <div className="hidden min-w-0 font-sans text-xs leading-relaxed text-dashboard-text-muted md:block">
549
560
  Enter to send · Shift+Enter for a new line
550
561
  </div>
@@ -565,7 +576,10 @@ export const ConversationComposer = memo(function ConversationComposer(
565
576
  ) : null}
566
577
  <Button
567
578
  aria-label={sendLocked ? "Sending message" : props.submitLabel}
568
- className="disabled:!border-transparent disabled:!bg-dashboard-fill-hover disabled:!text-dashboard-text-muted disabled:!opacity-100"
579
+ className={cn(
580
+ "disabled:!border-transparent disabled:!bg-dashboard-fill-hover disabled:!text-dashboard-text-muted disabled:!opacity-100",
581
+ props.compact && "w-9 px-0",
582
+ )}
569
583
  disabled={
570
584
  (!canSend && !images.length) ||
571
585
  !online ||
@@ -576,9 +590,11 @@ export const ConversationComposer = memo(function ConversationComposer(
576
590
  type="submit"
577
591
  >
578
592
  <Send aria-hidden="true" size={14} />
579
- <span className="hidden md:inline">
580
- {sendLocked ? "Sending…" : props.submitLabel}
581
- </span>
593
+ {props.compact ? null : (
594
+ <span className="hidden md:inline">
595
+ {sendLocked ? "Sending…" : props.submitLabel}
596
+ </span>
597
+ )}
582
598
  </Button>
583
599
  </div>
584
600
  </div>
@@ -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,
@@ -43,15 +48,63 @@ export function ConversationDetails(props: {
43
48
  onClose(): void;
44
49
  /** Open one child agent transcript. A details sheet closes first. */
45
50
  onOpenSubagent(target: SubagentTranscriptTarget): void;
51
+ /** Show one memory where its Turn recalled or captured it. */
52
+ onSelectMemory(entry: ConversationMemoryEntry & { turnId: string }): void;
53
+ /** The prompt that started each loaded Turn. */
54
+ memoryPrompts: Map<string, string>;
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
+ prompts={props.memoryPrompts}
102
+ />
103
+ </div>
104
+ </DetailsPanel>
105
+ );
106
+ }
107
+
55
108
  return (
56
109
  <DetailsPanel
57
110
  label="Conversation details"
@@ -135,38 +188,13 @@ export function ConversationDetails(props: {
135
188
  {props.identity != null ? (
136
189
  <DetailsCard title="Participants">{props.identity}</DetailsCard>
137
190
  ) : null}
138
- {memories?.length ? (
139
- <DetailsCard count={memories.length} title="Memories">
140
- <Disclosure label="memories">
141
- <ConversationMemories conversationId={props.conversationId} />
142
- </Disclosure>
143
- </DetailsCard>
191
+ {memoryList?.events.length ? (
192
+ <ConversationMemoryCard
193
+ list={memoryList}
194
+ onOpen={() => setMemoriesOpen(true)}
195
+ />
144
196
  ) : null}
145
197
  </div>
146
198
  </DetailsPanel>
147
199
  );
148
200
  }
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
- }
@@ -20,14 +20,17 @@ import { ShareConversationButton } from "./ShareConversationButton";
20
20
  import type { TranscriptViewMode } from "./transcriptRenderModel";
21
21
 
22
22
  /**
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.
23
+ * Show the conversation title bar: where it runs, the title, its cost, and
24
+ * actions. Other run facts, linked work, and the Brief live in Conversation
25
+ * details.
25
26
  */
26
27
  export function ConversationHeader(props: {
27
28
  /** Parent chain of a child Conversation. Root Conversations omit it. */
28
29
  agentTree?: ReactNode;
29
30
  /** A `menu` layout copy action for the `⋯` menu. */
30
31
  copyAction?: ReactNode;
32
+ /** Estimated cost. Desktop only; mobile shows it in Conversation details. */
33
+ cost?: ReactNode;
31
34
  archive: ConversationArchiveAction;
32
35
  conversationId: string;
33
36
  detailsOpen: boolean;
@@ -179,6 +182,11 @@ export function ConversationHeader(props: {
179
182
  <span className="shrink-0">{props.privacy}</span>
180
183
  </div>
181
184
  <div className="flex shrink-0 items-center gap-0.5">
185
+ {props.cost ? (
186
+ <span className="mr-2 text-xs tabular-nums text-dashboard-text-muted">
187
+ {props.cost}
188
+ </span>
189
+ ) : null}
182
190
  {props.view === "raw" ? (
183
191
  // Event log is a debug view reached from the menu. Say which
184
192
  // view this is, and give one click back.
@@ -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,130 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import { Brain } from "lucide-react";
3
- import { z } from "zod";
1
+ import { ChevronRight } from "lucide-react";
2
+ import type { ConversationDetailReport } from "@sentry/junior/api/schema";
4
3
 
5
- import { formatMessageTimestamp } from "../format";
6
- import { fetchDashboardJson } from "../http";
4
+ import { dashboardDisclosureChevronClass } from "../styles";
5
+ import type {
6
+ ConversationMemoryEntry,
7
+ ConversationMemoryList,
8
+ } from "./conversationMemory";
9
+ import { DetailsCard } from "./DetailsList";
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.
53
- </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>
61
- </div>
62
- );
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
+ }
63
71
  }
72
+ return prompts;
73
+ }
64
74
 
75
+ /**
76
+ * List every memory that a Conversation recalled or captured, with its full
77
+ * text. Selecting one shows it in the transcript.
78
+ */
79
+ export function ConversationMemoryEntries(props: {
80
+ entries: ConversationMemoryEntry[];
81
+ onSelect(entry: ConversationMemoryEntry & { turnId: string }): void;
82
+ prompts: Map<string, string>;
83
+ }) {
65
84
  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}
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}
81
107
  </span>
82
- <time className="ml-auto" dateTime={memory.capturedAt}>
83
- {formatMessageTimestamp(Date.parse(memory.capturedAt), true)}
84
- </time>
85
- </div>
86
- </li>
87
- ))}
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>
122
+ ) : (
123
+ <div className={className}>{body}</div>
124
+ )}
125
+ </li>
126
+ );
127
+ })}
88
128
  </ol>
89
129
  );
90
130
  }