From b917d09477ce45ec0eeae71fb4c112a5d595cd65 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:29:20 +0000 Subject: [PATCH 1/3] feat(dashboard): Show conversation memories in popovers Recalled memories open from a chip under the prompt that recalled them, and captured memories open from a row at the end of the Turn that captured them. Details show recalled and captured counts and open one list that jumps to each memory in the transcript. The memory API now returns each recall and capture with its Turn. The plugin event reader returns the Turn id to make that possible. Co-Authored-By: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com> --- .../e2e/conversation-details.spec.ts | 79 ++++++- packages/junior-dashboard/e2e/harness.ts | 22 +- .../conversations/ConversationDetails.tsx | 98 +++++---- .../conversations/ConversationMemories.tsx | 196 +++++++++++------- .../client/conversations/ConversationPage.tsx | 189 ++++++++++++----- .../conversations/ConversationTranscript.tsx | 23 +- .../conversations/TranscriptActivityGroup.tsx | 5 + .../conversations/TranscriptMemoryPopover.tsx | 157 ++++++++++++++ .../conversations/TranscriptMessageView.tsx | 8 + .../conversations/conversationMemory.ts | 138 ++++++++++++ .../client/conversations/eventTranscript.ts | 2 + packages/junior-dashboard/src/client/types.ts | 3 + packages/junior-memory/src/api.ts | 136 +++++++++--- packages/junior-memory/src/events.ts | 13 +- packages/junior-memory/src/viewer.ts | 20 ++ packages/junior-memory/tests/events.test.ts | 4 +- packages/junior-memory/tests/storage.test.ts | 61 +++++- .../src/conversation-events.ts | 2 + .../chat/plugins/conversation-event-reader.ts | 4 + 19 files changed, 942 insertions(+), 218 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx create mode 100644 packages/junior-dashboard/src/client/conversations/conversationMemory.ts diff --git a/packages/junior-dashboard/e2e/conversation-details.spec.ts b/packages/junior-dashboard/e2e/conversation-details.spec.ts index 8259cb8122..ac2ddf7b79 100644 --- a/packages/junior-dashboard/e2e/conversation-details.spec.ts +++ b/packages/junior-dashboard/e2e/conversation-details.spec.ts @@ -105,11 +105,18 @@ test("opens conversation details beside the chat", async ({ conversationUrl, ); - // Memories stay collapsed until the reader asks for them. + // Details show memory counts. The full list replaces details in place. const memories = details.getByText("Use pnpm for repository commands."); await expect(memories).toBeHidden(); - await details.getByRole("button", { name: "Show memories" }).click(); + await details + .getByRole("button", { name: "1 recalled · 2 captured" }) + .click(); await expect(memories).toBeVisible(); + await expect( + details.getByRole("heading", { name: "Memories" }), + ).toBeVisible(); + await details.getByRole("button", { name: "Back to details" }).click(); + await expect(memories).toBeHidden(); await page.setViewportSize({ width: 1024, height: 900 }); const sheet = page.getByRole("dialog", { name: "Conversation details" }); await expect(sheet).toBeVisible(); @@ -175,3 +182,71 @@ test("opens conversation details beside the chat", async ({ await expect(page.getByRole("note")).toContainText("Private conversation"); await expect(page.getByRole("note")).toContainText("Private"); }); + +test("shows recalled and captured memories in popovers", async ({ + page, + dashboard, +}) => { + await page.setViewportSize({ height: 900, width: 1600 }); + await page.goto( + `${dashboard.baseURL}/conversations/${encodeURIComponent("internal:dashboard-qa")}`, + ); + + // Captured memories stay out of the collapsed activity rows. + const captured = page.getByRole("button", { name: "Captured 2 memories" }); + await expect(captured).toBeVisible(); + await captured.click(); + const capturedPopover = page.getByRole("dialog", { + name: "Captured 2 memories", + }); + await expect( + capturedPopover.getByText("Use pnpm for repository commands."), + ).toBeVisible(); + await expect(capturedPopover.getByText("Private")).toBeVisible(); + await screenshot(page, "conversation-captured-memories-popover"); + await page.keyboard.press("Escape"); + await expect(capturedPopover).toBeHidden(); + + // A memory in details opens its Turn's popover in the transcript. + const details = page.getByRole("complementary", { + name: "Conversation details", + exact: true, + }); + await details + .getByRole("button", { name: "1 recalled · 2 captured" }) + .click(); + await details + .getByRole("button", { name: /Dashboard transcript events should remain/ }) + .click(); + await expect(capturedPopover).toBeVisible(); + await expect( + details.getByRole("heading", { name: "Memories" }), + ).toBeVisible(); + await screenshot(page, "conversation-memory-list-jump"); +}); + +test("shows recalled memories under their prompt", async ({ + page, + dashboard, +}) => { + await page.setViewportSize({ height: 900, width: 1600 }); + await page.goto( + `${dashboard.baseURL}/conversations/${encodeURIComponent("slack:CQA123:1770003600.000200")}`, + ); + + const recalled = page.getByRole("button", { name: "Recalled 1 memory" }); + await recalled.click(); + const popover = page.getByRole("dialog", { name: "Recalled 1 memory" }); + await expect( + popover.getByText( + "Checkout latency investigations start with the deployment comparison dashboard.", + ), + ).toBeVisible(); + await expect(popover.getByRole("link", { name: "Open" })).toHaveAttribute( + "href", + "/memories/memory-checkout-runbook", + ); + await screenshot(page, "conversation-recalled-memories-popover"); + await recalled.click(); + await expect(popover).toBeHidden(); +}); diff --git a/packages/junior-dashboard/e2e/harness.ts b/packages/junior-dashboard/e2e/harness.ts index 1b335d1cf9..00772eb33d 100644 --- a/packages/junior-dashboard/e2e/harness.ts +++ b/packages/junior-dashboard/e2e/harness.ts @@ -152,19 +152,33 @@ export async function mockDashboardApis( async (route) => { await route.fulfill({ json: { + events: [ + { + memoryIds: ["memory-checkout-runbook"], + source: "recalled", + turnId: "active-turn", + }, + { + memoryIds: ["captured-memory-1", "captured-memory-2"], + source: "captured", + turnId: "qa-turn", + }, + ], memories: [ { - capturedAt: "2026-08-07T07:01:00.000Z", + content: + "Checkout latency investigations start with the deployment comparison dashboard.", + id: "memory-checkout-runbook", + visibility: "public", + }, + { content: "Use pnpm for repository commands.", id: "captured-memory-1", - kind: "preference", visibility: "private", }, { - capturedAt: "2026-08-07T07:01:00.000Z", content: "Dashboard transcript events should remain expandable.", id: "captured-memory-2", - kind: "knowledge", visibility: "public", }, ], diff --git a/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx b/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx index f333072372..ba4110f08f 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationDetails.tsx @@ -1,11 +1,11 @@ -import { ChevronRight } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { useState, type ReactNode } from "react"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; import { DetailsPanel, useDetailsPanel } from "../components/DetailsPanel"; import { formatRelativeTime, formatTime } from "../format"; import { ConversationReviewItems } from "../review-desk/ConversationReviewItems"; -import { dashboardDisclosureChevronClass } from "../styles"; +import { Button } from "../components/ui/button"; import { ConversationBrief } from "./ConversationBrief"; import { ConversationChildAgents, @@ -16,9 +16,14 @@ import { type ConversationLink, } from "./ConversationLinkedWork"; import { - ConversationMemories, - useConversationMemories, + ConversationMemoryCard, + ConversationMemoryEntries, } from "./ConversationMemories"; +import { + conversationMemoryEntries, + useConversationMemoryState, + type ConversationMemoryEntry, +} from "./conversationMemory"; import { ConversationWatches, useConversationWatches, @@ -43,15 +48,63 @@ export function ConversationDetails(props: { onClose(): void; /** Open one child agent transcript. A details sheet closes first. */ onOpenSubagent(target: SubagentTranscriptTarget): void; + /** Show one memory where its Turn recalled or captured it. */ + onSelectMemory(entry: ConversationMemoryEntry & { turnId: string }): void; + /** The prompt that started each loaded Turn. */ + memoryPrompts: Map; overview: ReactNode; usage: ReactNode; }) { const panel = useDetailsPanel(); - const memories = useConversationMemories(props.conversationId).data?.memories; + const memoryList = useConversationMemoryState()?.list; + const [memoriesOpen, setMemoriesOpen] = useState(false); + const [memoryConversationId, setMemoryConversationId] = useState( + props.conversationId, + ); + if (memoryConversationId !== props.conversationId) { + setMemoryConversationId(props.conversationId); + setMemoriesOpen(false); + } const agents = useConversationChildAgents(props.conversationId).data?.agents; const watches = useConversationWatches(props.conversationId).data?.watches ?? []; + if (memoriesOpen && memoryList) { + // The list replaces details in place, so nothing stacks over the panel. + return ( + + +

+ Memories +

+ + } + > +
+ { + // The transcript sits under a details sheet. Close it first. + if (!panel) props.onClose(); + props.onSelectMemory(entry); + }} + prompts={props.memoryPrompts} + /> +
+
+ ); + } + return ( {props.identity} ) : null} - {memories?.length ? ( - - - - - + {memoryList?.events.length ? ( + setMemoriesOpen(true)} + /> ) : null} ); } - -/** Hide long, rarely read content behind one show/hide row. */ -function Disclosure(props: { children: ReactNode; label: string }) { - const [open, setOpen] = useState(false); - return ( -
- - {open ? props.children : null} -
- ); -} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx b/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx index 22603e6286..19f94217bf 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationMemories.tsx @@ -1,90 +1,130 @@ -import { useQuery } from "@tanstack/react-query"; -import { Brain } from "lucide-react"; -import { z } from "zod"; +import { ChevronRight } from "lucide-react"; +import type { ConversationDetailReport } from "@sentry/junior/api/schema"; -import { formatMessageTimestamp } from "../format"; -import { fetchDashboardJson } from "../http"; +import { dashboardDisclosureChevronClass } from "../styles"; +import type { + ConversationMemoryEntry, + ConversationMemoryList, +} from "./conversationMemory"; +import { DetailsCard } from "./DetailsList"; -const conversationMemoryListSchema = z - .object({ - memories: z.array( - z - .object({ - capturedAt: z.iso.datetime(), - content: z.string().min(1), - id: z.string().min(1), - kind: z.enum(["preference", "procedure", "knowledge"]), - visibility: z.enum(["private", "public"]), - }) - .strict(), - ), - }) - .strict(); - -/** Load the memories captured from one Conversation. */ -export function useConversationMemories(conversationId: string) { - return useQuery({ - queryFn: ({ signal }) => - fetchDashboardJson( - conversationMemoryListSchema, - `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`, - signal, - ), - queryKey: ["conversation", conversationId, "memories"], - retry: false, - }); +/** Count the distinct memories that a Conversation recalled and captured. */ +function conversationMemoryCounts(list: ConversationMemoryList) { + const ids = (source: "captured" | "recalled") => + new Set( + list.events + .filter((event) => event.source === source) + .flatMap((event) => event.memoryIds), + ).size; + return { captured: ids("captured"), recalled: ids("recalled") }; } -/** Show the memories captured from one Conversation. */ -export function ConversationMemories(props: { conversationId: string }) { - const query = useConversationMemories(props.conversationId); +/** Show memory counts in one row that opens the full memory list. */ +export function ConversationMemoryCard(props: { + list: ConversationMemoryList; + onOpen(): void; +}) { + const counts = conversationMemoryCounts(props.list); + const summary = [ + counts.recalled ? `${counts.recalled} recalled` : undefined, + counts.captured ? `${counts.captured} captured` : undefined, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + ); +} - if (query.isPending) { - return ( -

- Loading memories… -

- ); - } - if (query.error) { - return ( -

- Could not load memories from this Conversation. -

- ); - } - if (query.data.memories.length === 0) { - return ( -
-
- ); +/** + * Map each loaded Turn to the prompt that started it, so a memory can say + * where it came from. A Turn starts from its last user input message. + */ +export function turnPrompts( + events: ConversationDetailReport["events"] | undefined, +): Map { + const texts = new Map(); + const prompts = new Map(); + for (const { data } of events ?? []) { + if (data.type === "message" && data.role === "user" && data.text) { + texts.set(data.messageId, data.text); + } + if (data.type === "turn_lifecycle" && data.state === "started") { + const prompt = data.inputMessageIds + ?.map((id) => texts.get(id)) + .filter((text) => text !== undefined) + .at(-1); + if (prompt) prompts.set(data.turnId, prompt); + } } + return prompts; +} +/** + * List every memory that a Conversation recalled or captured, with its full + * text. Selecting one shows it in the transcript. + */ +export function ConversationMemoryEntries(props: { + entries: ConversationMemoryEntry[]; + onSelect(entry: ConversationMemoryEntry & { turnId: string }): void; + prompts: Map; +}) { return ( -
    - {query.data.memories.map((memory) => ( -
  1. -

    - {memory.content} -

    -
    - - {memory.kind} - - - {memory.visibility} +
      + {props.entries.map((entry) => { + const { turnId } = entry; + const prompt = turnId ? props.prompts.get(turnId) : undefined; + const source = + entry.source === "recalled" + ? prompt + ? `Recalled for “${prompt}”` + : "Recalled" + : prompt + ? `Captured after “${prompt}”` + : "Captured"; + const body = ( + <> +

      + {entry.memory.content} +

      + + {source} + {entry.memory.visibility === "private" ? ( + · Private + ) : null} - -
    -
  2. - ))} + + ); + const className = + "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"; + return ( +
  3. + {turnId ? ( + + ) : ( +
    {body}
    + )} +
  4. + ); + })}
); } diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 7b20e38bad..d283d8c173 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -31,6 +31,14 @@ import { import { ConversationHeader } from "./ConversationHeader"; import { ConversationReplyFooter } from "./ConversationReplyFooter"; import { ConversationDetails } from "./ConversationDetails"; +import { turnPrompts } from "./ConversationMemories"; +import { + ConversationMemoryContext, + memoryAnchorKey, + useConversationMemories, + type ConversationMemoryState, + type OpenMemoryPopover, +} from "./conversationMemory"; import { ConversationEventDetails } from "./ConversationEventDetails"; import { ConversationEventLogLoading } from "./ConversationEventLog"; import { @@ -155,6 +163,18 @@ export function ConversationPage(props: { ); }, [setSearchParams]); const [turnContext, setTurnContext] = useState(); + const [memoryPopover, setMemoryPopover] = useState(); + // A memory chosen in details waits here until its transcript row renders. + const [memoryJump, setMemoryJump] = useState(); + // The page stays mounted across Conversations. Memory state belongs to one. + const [memoryConversationId, setMemoryConversationId] = useState( + props.conversationId, + ); + if (memoryConversationId !== props.conversationId) { + setMemoryConversationId(props.conversationId); + setMemoryPopover(undefined); + setMemoryJump(undefined); + } const showDetails = detailsOpen && !eventOpen && !turnContext; const [viewState, setView] = useState(() => eventOpen ? "raw" : "rich", @@ -167,6 +187,15 @@ export function ConversationPage(props: { const summaries = props.data?.conversations.conversations ?? []; const conversations = buildConversations(summaries); const detail = useConversationData(conversationId); + const memoryList = useConversationMemories(conversationId).data; + const memoryState = useMemo( + () => ({ + list: memoryList, + open: memoryPopover, + setOpen: setMemoryPopover, + }), + [memoryList, memoryPopover], + ); const archive = useArchiveConversation(conversationId); const markRead = useMarkConversationRead(conversationId); const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail); @@ -288,6 +317,38 @@ export function ConversationPage(props: { if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq); else if (linkedTurnNotFound) setSelectedEventSeq(undefined); }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]); + // Show a chosen memory at its transcript row. Load earlier pages until the + // row renders, or stop when history ends. + const memoryPrompts = useMemo( + () => turnPrompts(transcript?.events), + [transcript?.events], + ); + useEffect(() => { + if (!memoryJump) return; + const anchor = pageRef.current?.querySelector( + `[data-memory-anchor="${CSS.escape(memoryAnchorKey(memoryJump))}"]`, + ); + if (anchor) { + anchor.scrollIntoView({ block: "center" }); + setMemoryPopover(memoryJump); + setMemoryJump(undefined); + } else if ( + detail.hasPreviousPage && + !detail.isLoadingPreviousPage && + !detail.historyError + ) { + loadPreviousPageRef.current(); + } else if (!detail.isLoadingPreviousPage) { + setMemoryJump(undefined); + } + }, [ + detail.hasPreviousPage, + detail.historyError, + detail.isLoadingPreviousPage, + memoryJump, + transcript?.events, + view, + ]); // Keep the panel while the transcript or a linked event loads, and for the // render before a found Turn link becomes an event link. const eventLoading = @@ -470,31 +531,35 @@ export function ConversationPage(props: { data. ) : null} - + + + )} @@ -532,37 +597,49 @@ export function ConversationPage(props: { } /> {showDetails ? ( - - ) : null - } - usage={ - hasConversationUsageBreakdown({ - conversation, - detail: detail.data, - }) ? ( - - ) : null - } - /> + + { + // Memory rows show only in the Conversation view. + if (view !== "rich") onViewChange("rich"); + setMemoryJump({ + memoryId: entry.memory.id, + source: entry.source, + turnId: entry.turnId, + }); + }} + links={conversationLinks(detail.data)} + overview={ + hasConversationStats({ + conversation, + detail: detail.data, + }) ? ( + + ) : null + } + usage={ + hasConversationUsageBreakdown({ + conversation, + detail: detail.data, + }) ? ( + + ) : null + } + /> + ) : null} {showEvent ? ( - + {isMemoryCaptureEvent(entry.part) ? ( + + } + timestamp={entry.timestamp} + turnId={entry.part.turnId} + /> + ) : ( + + )} )} renderSubagent={(entry) => ( diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx index d0ca6fb97e..6ff92af5a1 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptActivityGroup.tsx @@ -10,6 +10,7 @@ import { } from "../components/ui/tooltip"; import { formatMs } from "../format"; import { reviewItemIdFromToolCall } from "../review-desk/reviewItemFormat"; +import { isMemoryCaptureEvent } from "./conversationMemory"; import type { RenderedTranscriptEntry } from "./transcriptRenderModel"; import { cn } from "../styles"; import { useTranscriptSearch } from "./transcriptSearch"; @@ -30,6 +31,10 @@ export function isCollapsibleActivityEntry( // Child agent rows stay in the main view, like their result events. if (entry.kind === "subagent") return false; if (entry.kind === "message") return false; + // A Turn's captured memories stay visible, so the reader sees what it saved. + if (entry.kind === "structured_event" && isMemoryCaptureEvent(entry.part)) { + return false; + } // A review card stays visible where the agent added the item. if (entry.kind === "tool" && reviewItemIdFromToolCall(entry.part)) { return false; diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx new file mode 100644 index 0000000000..d8707cb079 --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/TranscriptMemoryPopover.tsx @@ -0,0 +1,157 @@ +import { Brain } from "lucide-react"; +import type { ReactElement, ReactNode } from "react"; +import { Link } from "react-router"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../components/ui/popover"; +import { formatMessageTimestamp, memoryPath } from "../format"; +import { cn } from "../styles"; +import { + anchorMemories, + memoryAnchorKey, + useConversationMemoryState, + type ConversationMemory, + type ConversationMemoryState, + type MemoryAnchor, +} from "./conversationMemory"; + +function memoryCount(count: number): string { + return `${count} ${count === 1 ? "memory" : "memories"}`; +} + +/** + * Open one transcript spot's memories in a popover, so the transcript keeps + * its length. The page owns which popover is open, so the details panel can + * open one too. + */ +function MemoryPopover(props: { + anchor: MemoryAnchor; + label: string; + memories: ConversationMemory[]; + state: ConversationMemoryState; + trigger: ReactElement; +}) { + const { anchor, state } = props; + const open = + state.open?.source === anchor.source && state.open.turnId === anchor.turnId; + const highlightId = open ? state.open?.memoryId : undefined; + return ( + state.setOpen(next ? anchor : undefined)} + open={open} + > + + +
    + {props.memories.map((memory) => ( +
  • node?.scrollIntoView({ block: "nearest" }) + : undefined + } + > +

    + {memory.content} +

    +
    + {memory.visibility === "private" ? Private : null} + + Open + +
    +
  • + ))} +
+
+
+ ); +} + +/** Show the memories that one prompt recalled, under that prompt. */ +export function TranscriptRecalledMemories(props: { turnId: string }) { + const state = useConversationMemoryState(); + const anchor = { source: "recalled", turnId: props.turnId } as const; + const memories = anchorMemories(state?.list, anchor); + if (!state || memories.length === 0) return null; + const label = `Recalled ${memoryCount(memories.length)}`; + return ( +
+ +
+ ); +} + +/** + * Show the memories that one Turn captured. Without memory state or a list + * for the Turn, such as in a nested transcript, show `fallback`. + */ +export function TranscriptCapturedMemories(props: { + fallback: ReactNode; + timestamp?: number; + turnId?: string; +}) { + const state = useConversationMemoryState(); + const anchor = props.turnId + ? ({ source: "captured", turnId: props.turnId } as const) + : undefined; + const memories = anchor ? anchorMemories(state?.list, anchor) : []; + if (!state || !anchor || memories.length === 0) return props.fallback; + const label = `Captured ${memoryCount(memories.length)}`; + const timestamp = formatMessageTimestamp(props.timestamp); + return ( + + + {label} + + {timestamp ? ( + + {timestamp} + + ) : null} + + } + /> + ); +} diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx index be0e6f232b..68c86fa28b 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx @@ -24,6 +24,8 @@ import { RedactedMarker } from "./TranscriptRedacted"; import { SlackMark } from "./SlackMark"; import { DisplayText } from "../display-text/DisplayText"; import { TranscriptTurnContextView } from "./TranscriptTurnContextView"; +import { TranscriptRecalledMemories } from "./TranscriptMemoryPopover"; +import { memoryRecallContent } from "./turnContext"; import { TranscriptTimestamp } from "./TranscriptTimestamp"; import { showsSlackSourceIcon } from "./transcriptSource"; @@ -36,6 +38,9 @@ export const TranscriptMessageView = memo( const rawText = messageRawText(props.message); const roleKind = transcriptRoleKind(props.message.role); const chatText = roleKind === "assistant" || roleKind === "user"; + const recallTurnId = props.message.contexts?.find((context) => + memoryRecallContent(context), + )?.turnId; const text = (
{props.message.parts.map((part, index) => @@ -83,6 +88,9 @@ export const TranscriptMessageView = memo( /> )) : text} + {recallTurnId ? ( + + ) : null} {props.message.attachments?.length ? ( ; +export type ConversationMemory = ConversationMemoryList["memories"][number]; +type MemorySource = ConversationMemoryList["events"][number]["source"]; + +/** One transcript spot that recalled or captured memories. */ +export type MemoryAnchor = { source: MemorySource; turnId: string }; + +/** Load what one Conversation recalled and captured. */ +export function useConversationMemories(conversationId: string) { + return useQuery({ + queryFn: ({ signal }) => + fetchDashboardJson( + conversationMemoryListSchema, + `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`, + signal, + ), + queryKey: ["conversation", conversationId, "memories"], + retry: false, + }); +} + +/** Name the transcript element that opens one anchor's popover. */ +export function memoryAnchorKey(anchor: MemoryAnchor): string { + return `${anchor.source}:${anchor.turnId}`; +} + +/** List the memories that one transcript spot recalled or captured. */ +export function anchorMemories( + list: ConversationMemoryList | undefined, + anchor: MemoryAnchor, +): ConversationMemory[] { + const event = list?.events.find( + (candidate) => + candidate.source === anchor.source && candidate.turnId === anchor.turnId, + ); + if (!event || !list) return []; + const byId = new Map(list.memories.map((memory) => [memory.id, memory])); + return event.memoryIds.flatMap((id) => { + const memory = byId.get(id); + return memory ? [memory] : []; + }); +} + +/** One row of the Conversation memory list. */ +export type ConversationMemoryEntry = { + memory: ConversationMemory; + source: MemorySource; + turnId?: string; +}; + +/** + * Flatten recalls and captures into one list in Conversation order. A memory + * shows once per source, at the first Turn that recalled or captured it. + */ +export function conversationMemoryEntries( + list: ConversationMemoryList | undefined, +): ConversationMemoryEntry[] { + if (!list) return []; + const byId = new Map(list.memories.map((memory) => [memory.id, memory])); + const seen = new Set(); + const entries: ConversationMemoryEntry[] = []; + for (const event of list.events) { + for (const id of event.memoryIds) { + const key = `${event.source}:${id}`; + const memory = byId.get(id); + if (seen.has(key) || !memory) continue; + seen.add(key); + entries.push({ + memory, + source: event.source, + ...(event.turnId ? { turnId: event.turnId } : undefined), + }); + } + } + return entries; +} + +/** The open memory popover, and the memory it highlights. */ +export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string }; + +/** Shared memory popover state for one Conversation page. */ +export type ConversationMemoryState = { + list?: ConversationMemoryList; + open?: OpenMemoryPopover; + setOpen(open: OpenMemoryPopover | undefined): void; +}; + +/** + * Let the transcript and the details panel share memory popovers. Only the + * Conversation page sets it, so nested transcripts show plain events. + */ +export const ConversationMemoryContext = createContext< + ConversationMemoryState | undefined +>(undefined); + +/** Read the memory popover state of the current Conversation page. */ +export function useConversationMemoryState() { + return useContext(ConversationMemoryContext); +} + +/** True for the memory plugin event that a Turn's capture emits. */ +export function isMemoryCaptureEvent(part: { + name: string; + namespace: string; +}): boolean { + return part.namespace === "memory" && part.name === "memories_captured"; +} diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 036ed78ab5..20676711fd 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -333,6 +333,7 @@ export function transcriptMessagesFromEvents( kind: data.kind, loadedAt: event.createdAt, pluginName: data.pluginName, + turnId: data.turnId, version: data.version, }); } @@ -417,6 +418,7 @@ export function transcriptMessagesFromEvents( name: data.name, version: data.version, presentation: data.presentation, + ...(data.turnId ? { turnId: data.turnId } : undefined), }, ]), ); diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 826b25f90f..2b206ebab2 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -91,6 +91,8 @@ export type TranscriptViewStructuredEventPart = { name: string; namespace: string; presentation: ConversationEventPresentation; + /** Turn that emitted the event. */ + turnId?: string; type: "structured_event"; version: number; }; @@ -114,6 +116,7 @@ export type TranscriptViewTurnContext = { kind: string; loadedAt: string; pluginName: string; + turnId: string; version: number; }; diff --git a/packages/junior-memory/src/api.ts b/packages/junior-memory/src/api.ts index 93b7e28962..1fa56d1269 100644 --- a/packages/junior-memory/src/api.ts +++ b/packages/junior-memory/src/api.ts @@ -15,6 +15,7 @@ import type { MemoryDb } from "./store"; import { archiveMemory, countMemories, + getMemories, getMemory, getMemoryStats, getMemoryTimeline, @@ -24,7 +25,7 @@ import { MemoryNotFoundError, type MemoryView, } from "./viewer"; -import { parseCapturedMemories } from "./events"; +import { parseCapturedMemories, parseRecalledMemoryIds } from "./events"; import { MEMORY_SOURCE_PLATFORMS } from "./types"; export const memoryApiSchema = z @@ -50,16 +51,30 @@ export const memoryListResponseSchema = z export const conversationMemorySchema = z .object({ - capturedAt: z.iso.datetime(), content: z.string().min(1), id: z.string().min(1), - kind: z.enum(["preference", "procedure", "knowledge"]), visibility: z.enum(["private", "public"]), }) .strict(); +/** One recall or capture in a Conversation, and the memories it touched. */ +const conversationMemoryEventSchema = z + .object({ + memoryIds: z.array(z.string().min(1)).min(1), + source: z.enum(["captured", "recalled"]), + turnId: z.string().min(1).optional(), + }) + .strict(); + +/** + * Memories that a Conversation recalled or captured. `events` are in + * Conversation order, and each id in them is a key of `memories`. + */ export const conversationMemoryListResponseSchema = z - .object({ memories: z.array(conversationMemorySchema) }) + .object({ + events: z.array(conversationMemoryEventSchema), + memories: z.array(conversationMemorySchema), + }) .strict(); const memoryBucketSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/); @@ -180,6 +195,89 @@ function viewerEmail(context: unknown): string | undefined { return parsed.data.auth.user.email?.trim().toLowerCase() || undefined; } +/** + * List what one Conversation recalled and captured, in Conversation order. + * + * Captured memories come from the event snapshot. Recall events store only + * ids, so recalled memories are read from the store, and the viewer sees only + * the active memories they can access. + */ +async function conversationMemories(args: { + conversationEvents: PluginConversationEventReader; + conversationId: string; + db: MemoryDb; + viewer: User; +}): Promise | undefined> { + const [captured, recalled] = await Promise.all( + (["memories_captured", "memories_recalled"] as const).map((eventName) => + args.conversationEvents.list({ + conversationId: args.conversationId, + eventName, + viewer: args.viewer, + }), + ), + ); + if (!captured || !recalled) return undefined; + + const memories = new Map>(); + const capturedEvents = captured.map((event) => { + const eventMemories = parseCapturedMemories(event.version, event.content); + for (const memory of eventMemories) { + memories.set(memory.id, { + content: memory.content, + id: memory.id, + visibility: currentMemoryVisibility(memory.scope), + }); + } + return { + createdAt: event.createdAt, + memoryIds: eventMemories.map((memory) => memory.id), + source: "captured" as const, + turnId: event.turnId, + }; + }); + const recalledEvents = recalled.map((event) => ({ + createdAt: event.createdAt, + memoryIds: parseRecalledMemoryIds(event.version, event.content), + source: "recalled" as const, + turnId: event.turnId, + })); + const recalledIds = [ + ...new Set(recalledEvents.flatMap((event) => event.memoryIds)), + ].filter((id) => !memories.has(id)); + for (const memory of await getMemories( + args.db, + args.viewer.id, + recalledIds, + )) { + memories.set(memory.id, { + content: memory.content, + id: memory.id, + visibility: memory.visibility, + }); + } + + // ISO timestamps sort as text. Recall runs before its Turn replies and + // capture runs after, so a stable sort keeps one Turn in that order. + const events = [...recalledEvents, ...capturedEvents] + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .flatMap(({ memoryIds, source, turnId }) => { + const visibleIds = memoryIds.filter((id) => memories.has(id)); + if (visibleIds.length === 0) return []; + return [ + { + memoryIds: visibleIds, + source, + ...(turnId ? { turnId } : undefined), + }, + ]; + }); + return conversationMemoryListResponseSchema.parse({ + events, + memories: [...memories.values()], + }); +} + /** Create the authenticated memory REST app. */ export function createMemoryApi(options: MemoryApiOptions): PluginRouteApp { return { @@ -294,35 +392,13 @@ export function createMemoryApi(options: MemoryApiOptions): PluginRouteApp { } if (conversationPath && isRead) { - const events = await options.conversationEvents.list({ + const body = await conversationMemories({ + conversationEvents: options.conversationEvents, conversationId: decodeURIComponent(conversationPath[1]!), - eventName: "memories_captured", + db: options.db, viewer, }); - if (!events) return json({ error: "Conversation not found." }, 404); - const memories = new Map< - string, - z.input - >(); - for (const event of events) { - for (const memory of parseCapturedMemories( - event.version, - event.content, - )) { - if (!memories.has(memory.id)) { - memories.set(memory.id, { - capturedAt: event.createdAt, - content: memory.content, - id: memory.id, - kind: memory.kind, - visibility: currentMemoryVisibility(memory.scope), - }); - } - } - } - const body = conversationMemoryListResponseSchema.parse({ - memories: [...memories.values()], - }); + if (!body) return json({ error: "Conversation not found." }, 404); return request.method === "HEAD" ? new Response(null, { headers: { "cache-control": "no-store" }, diff --git a/packages/junior-memory/src/events.ts b/packages/junior-memory/src/events.ts index 67f932aab9..615151ea31 100644 --- a/packages/junior-memory/src/events.ts +++ b/packages/junior-memory/src/events.ts @@ -63,7 +63,7 @@ function renderCapturedMemories(event: { if (count === 0) return undefined; return { icon: "brain" as const, - title: `${count} ${count === 1 ? "memory" : "memories"} captured`, + title: `Captured ${count} ${count === 1 ? "memory" : "memories"}`, details: event.memories.map((memory) => ({ title: memory.content, metadata: [memory.kind, currentScope(memory.scope)], @@ -115,6 +115,17 @@ export function parseCapturedMemories( return []; } +/** Parse the memory ids from one supported stored recall event. */ +export function parseRecalledMemoryIds( + version: number, + content: unknown, +): string[] { + if (version === memoriesRecalledEvent.version) { + return recalledMemoriesSchema.parse(content).memories; + } + return []; +} + /** Select the stable, safe memory fields retained in conversation history. */ export function capturedMemory(memory: MemoryRecord) { return { diff --git a/packages/junior-memory/src/viewer.ts b/packages/junior-memory/src/viewer.ts index 74365a1c44..9365727531 100644 --- a/packages/junior-memory/src/viewer.ts +++ b/packages/junior-memory/src/viewer.ts @@ -10,6 +10,7 @@ import { eq, gt, ilike, + inArray, isNull, like, lt, @@ -254,6 +255,25 @@ export async function getMemory( return toMemoryView(rows[0]); } +/** Read the active memories in `ids` that the authenticated User can see. */ +export async function getMemories( + db: MemoryDb, + userId: string, + ids: readonly string[], +): Promise { + if (ids.length === 0) return []; + const rows = await db + .select() + .from(juniorMemoryMemories) + .where( + and( + activeMemoryPredicate(userId, Date.now()), + inArray(juniorMemoryMemories.id, [...ids]), + ), + ); + return rows.map(toMemoryView); +} + /** List one stable page of active memory visible to the authenticated User. */ export async function listMemories( db: MemoryDb, diff --git a/packages/junior-memory/tests/events.test.ts b/packages/junior-memory/tests/events.test.ts index eb91722c67..cc72dca937 100644 --- a/packages/junior-memory/tests/events.test.ts +++ b/packages/junior-memory/tests/events.test.ts @@ -71,7 +71,7 @@ describe("memory conversation events", () => { ], }); - expect(presentation?.title).toBe("1 memory captured"); + expect(presentation?.title).toBe("Captured 1 memory"); expect(presentation?.preview).toBeUndefined(); expect(presentation?.details?.[0]?.title).toBe(content); }); @@ -96,6 +96,6 @@ describe("memory conversation events", () => { ], }); - expect(presentation?.title).toBe("2 memories captured"); + expect(presentation?.title).toBe("Captured 2 memories"); }); }); diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index d18d45814e..f9edc928bd 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -2527,6 +2527,29 @@ describe("memory plugin storage", () => { conversationEvents: { async list({ conversationId, eventName }) { if (conversationId === "missing") return undefined; + if (eventName === "memories_recalled") { + return [ + { + content: { memories: [privateMemory.memory.id] }, + createdAt: "2026-07-28T12:02:00.000Z", + version: 1, + }, + { + content: { memories: [hidden.memory.id] }, + createdAt: "2026-07-28T12:00:30.000Z", + turnId: "turn-2", + version: 1, + }, + { + content: { + memories: [publicMemory.memory.id, hidden.memory.id], + }, + createdAt: "2026-07-28T11:59:00.000Z", + turnId: "turn-1", + version: 1, + }, + ]; + } expect(eventName).toBe("memories_captured"); return [ { @@ -2542,6 +2565,7 @@ describe("memory plugin storage", () => { ], }, createdAt: "2026-07-28T12:01:00.000Z", + turnId: "turn-2", version: 2, }, { @@ -2557,6 +2581,7 @@ describe("memory plugin storage", () => { ], }, createdAt: "2026-07-28T12:00:00.000Z", + turnId: "turn-1", version: 2, }, ]; @@ -2639,15 +2664,39 @@ describe("memory plugin storage", () => { await conversationResponse.json(), ), ).toEqual({ - memories: [ + // The viewer cannot see `hidden`, so its recall drops it, and a + // recall with only hidden memories drops out. + events: [ { - capturedAt: "2026-07-28T12:01:00.000Z", - content: "Use pnpm.", - id: "captured-memory", - kind: "procedure", - visibility: "public", + memoryIds: [publicMemory.memory.id], + source: "recalled", + turnId: "turn-1", }, + { + memoryIds: ["captured-memory"], + source: "captured", + turnId: "turn-1", + }, + { + memoryIds: ["captured-memory"], + source: "captured", + turnId: "turn-2", + }, + { memoryIds: [privateMemory.memory.id], source: "recalled" }, ], + memories: expect.arrayContaining([ + { content: "Use pnpm.", id: "captured-memory", visibility: "public" }, + { + content: "Public workspace memory.", + id: publicMemory.memory.id, + visibility: "public", + }, + { + content: "Private conversation memory.", + id: privateMemory.memory.id, + visibility: "private", + }, + ]), }); const missingConversationResponse = await api.fetch( new Request("http://localhost/conversations/missing/memories"), diff --git a/packages/junior-plugin-api/src/conversation-events.ts b/packages/junior-plugin-api/src/conversation-events.ts index 652e760766..dd5d5b32e0 100644 --- a/packages/junior-plugin-api/src/conversation-events.ts +++ b/packages/junior-plugin-api/src/conversation-events.ts @@ -127,6 +127,8 @@ export interface PluginConversationEventCostDay { export interface PluginConversationEventRecord { content: Record; createdAt: string; + /** Turn that emitted the event. Events from outside a Turn have none. */ + turnId?: string; version: number; } diff --git a/packages/junior/src/chat/plugins/conversation-event-reader.ts b/packages/junior/src/chat/plugins/conversation-event-reader.ts index f974d6876c..054d8504dc 100644 --- a/packages/junior/src/chat/plugins/conversation-event-reader.ts +++ b/packages/junior/src/chat/plugins/conversation-event-reader.ts @@ -46,6 +46,9 @@ export function createPluginConversationEventReader( Record >`${juniorConversationEvents.payload}->'content'`, createdAt: juniorConversationEvents.createdAt, + turnId: sql< + string | null + >`${juniorConversationEvents.payload}->>'turnId'`, version: sql`(${juniorConversationEvents.payload}->>'version')::integer`, }) .from(juniorConversationEvents) @@ -61,6 +64,7 @@ export function createPluginConversationEventReader( return rows.map((row) => ({ content: row.content, createdAt: row.createdAt.toISOString(), + ...(row.turnId ? { turnId: row.turnId } : undefined), version: row.version, })); }, From 9b93ad88896fb88d432e32af751062dfb51be312 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:43:27 +0000 Subject: [PATCH 2/3] fix(memory): Check recall access in the store and show each Turn's recall A recalled memory that is also in a capture snapshot now still needs store access, so a captured private memory does not show as recalled to other viewers. A prompt that starts more than one Turn shows a Recalled chip for each Turn. Co-Authored-By: Thomas Jiang <20501235+thomasjiangcy@users.noreply.github.com> --- .../conversations/TranscriptMessageView.tsx | 17 +++++++++++------ packages/junior-memory/src/api.ts | 18 ++++++++++-------- packages/junior-memory/tests/storage.test.ts | 19 ++++++++++++++++--- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx index 68c86fa28b..8d55d854f5 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx @@ -38,9 +38,14 @@ export const TranscriptMessageView = memo( const rawText = messageRawText(props.message); const roleKind = transcriptRoleKind(props.message.role); const chatText = roleKind === "assistant" || roleKind === "user"; - const recallTurnId = props.message.contexts?.find((context) => - memoryRecallContent(context), - )?.turnId; + // One prompt can start more than one Turn, and each Turn recalls. + const recallTurnIds = [ + ...new Set( + props.message.contexts + ?.filter((context) => memoryRecallContent(context)) + .map((context) => context.turnId), + ), + ]; const text = (
{props.message.parts.map((part, index) => @@ -88,9 +93,9 @@ export const TranscriptMessageView = memo( /> )) : text} - {recallTurnId ? ( - - ) : null} + {recallTurnIds.map((turnId) => ( + + ))} {props.message.attachments?.length ? ( (); + for (const memory of await getMemories(args.db, args.viewer.id, [ ...new Set(recalledEvents.flatMap((event) => event.memoryIds)), - ].filter((id) => !memories.has(id)); - for (const memory of await getMemories( - args.db, - args.viewer.id, - recalledIds, - )) { + ])) { + recalledIds.add(memory.id); + if (memories.has(memory.id)) continue; memories.set(memory.id, { content: memory.content, id: memory.id, @@ -262,7 +263,8 @@ async function conversationMemories(args: { const events = [...recalledEvents, ...capturedEvents] .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .flatMap(({ memoryIds, source, turnId }) => { - const visibleIds = memoryIds.filter((id) => memories.has(id)); + const shown = source === "captured" ? capturedIds : recalledIds; + const visibleIds = memoryIds.filter((id) => shown.has(id)); if (visibleIds.length === 0) return []; return [ { diff --git a/packages/junior-memory/tests/storage.test.ts b/packages/junior-memory/tests/storage.test.ts index f9edc928bd..51b943e0a8 100644 --- a/packages/junior-memory/tests/storage.test.ts +++ b/packages/junior-memory/tests/storage.test.ts @@ -2562,6 +2562,13 @@ describe("memory plugin storage", () => { observedAtMs: TEST_NOW_MS, scope: "public", }, + { + content: "Hidden viewer memory.", + id: hidden.memory.id, + kind: "knowledge", + observedAtMs: TEST_NOW_MS, + scope: "private", + }, ], }, createdAt: "2026-07-28T12:01:00.000Z", @@ -2664,8 +2671,9 @@ describe("memory plugin storage", () => { await conversationResponse.json(), ), ).toEqual({ - // The viewer cannot see `hidden`, so its recall drops it, and a - // recall with only hidden memories drops out. + // The viewer cannot see `hidden` in the store, so its recall drops + // it even though a capture snapshot has it, and a recall with only + // hidden memories drops out. events: [ { memoryIds: [publicMemory.memory.id], @@ -2678,7 +2686,7 @@ describe("memory plugin storage", () => { turnId: "turn-1", }, { - memoryIds: ["captured-memory"], + memoryIds: ["captured-memory", hidden.memory.id], source: "captured", turnId: "turn-2", }, @@ -2686,6 +2694,11 @@ describe("memory plugin storage", () => { ], memories: expect.arrayContaining([ { content: "Use pnpm.", id: "captured-memory", visibility: "public" }, + { + content: "Hidden viewer memory.", + id: hidden.memory.id, + visibility: "private", + }, { content: "Public workspace memory.", id: publicMemory.memory.id, From 14bfb6508c4587afac46f50b7d496bf688f10f90 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:52:22 +0000 Subject: [PATCH 3/3] fix(dashboard): Show recall chips on every prompt and keep memory jumps Event, context, and Automation prompts now show their Recalled chips, so a memory list jump can find their Turns. A jump now waits for the transcript and clears a search that would hide the row. --- .../e2e/conversation-details.spec.ts | 6 +- .../client/conversations/ConversationPage.tsx | 12 ++-- .../conversations/ConversationTranscript.tsx | 24 +++++-- .../conversations/TranscriptMemoryPopover.tsx | 66 ++++++++++++------- .../conversations/TranscriptMessageView.tsx | 13 +--- 5 files changed, 76 insertions(+), 45 deletions(-) diff --git a/packages/junior-dashboard/e2e/conversation-details.spec.ts b/packages/junior-dashboard/e2e/conversation-details.spec.ts index ac2ddf7b79..2b470c6be7 100644 --- a/packages/junior-dashboard/e2e/conversation-details.spec.ts +++ b/packages/junior-dashboard/e2e/conversation-details.spec.ts @@ -207,7 +207,11 @@ test("shows recalled and captured memories in popovers", async ({ await page.keyboard.press("Escape"); await expect(capturedPopover).toBeHidden(); - // A memory in details opens its Turn's popover in the transcript. + // A memory in details opens its Turn's popover in the transcript, even + // when a search hides that row. + await page.getByRole("button", { name: "Search transcript" }).click(); + await page.getByPlaceholder("Search transcript…").fill("no such text"); + await expect(captured).toBeHidden(); const details = page.getByRole("complementary", { name: "Conversation details", exact: true, diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index d283d8c173..2afa376f9c 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -317,14 +317,14 @@ export function ConversationPage(props: { if (linkedTurnSeq !== undefined) setSelectedEventSeq(linkedTurnSeq); else if (linkedTurnNotFound) setSelectedEventSeq(undefined); }, [linkedTurnNotFound, linkedTurnSeq, setSelectedEventSeq]); - // Show a chosen memory at its transcript row. Load earlier pages until the - // row renders, or stop when history ends. + // Show a chosen memory at its transcript row. Wait for the first page, then + // load earlier pages until the row renders, or stop when history ends. const memoryPrompts = useMemo( () => turnPrompts(transcript?.events), [transcript?.events], ); useEffect(() => { - if (!memoryJump) return; + if (!memoryJump || !transcript) return; const anchor = pageRef.current?.querySelector( `[data-memory-anchor="${CSS.escape(memoryAnchorKey(memoryJump))}"]`, ); @@ -346,7 +346,7 @@ export function ConversationPage(props: { detail.historyError, detail.isLoadingPreviousPage, memoryJump, - transcript?.events, + transcript, view, ]); // Keep the panel while the transcript or a linked event loads, and for the @@ -607,8 +607,10 @@ export function ConversationPage(props: { onOpenSubagent={setSubagentTarget} memoryPrompts={memoryPrompts} onSelectMemory={(entry) => { - // Memory rows show only in the Conversation view. + // Memory rows show only in the Conversation view, and search + // can hide them. if (view !== "rich") onViewChange("rich"); + setSearch(""); setMemoryJump({ memoryId: entry.memory.id, source: entry.source, diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index 971eb05ed2..199ed22357 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -28,7 +28,10 @@ import { TranscriptToolView } from "./TranscriptToolView"; import { TranscriptReasoningView } from "./TranscriptReasoningView"; import { TranscriptAttachmentsDeliveredView } from "./TranscriptAttachmentsDeliveredView"; import { TranscriptStructuredEventView } from "./TranscriptStructuredEventView"; -import { TranscriptCapturedMemories } from "./TranscriptMemoryPopover"; +import { + TranscriptCapturedMemories, + TranscriptRecalledMemories, +} from "./TranscriptMemoryPopover"; import { isMemoryCaptureEvent } from "./conversationMemory"; import { TranscriptFailureView } from "./TranscriptFailureView"; import { @@ -250,16 +253,23 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { renderMessage={(entry) => props.conversation.sourceTask && isAutomationTriggerMessage(props.conversation, entry.message) ? ( - +
+ + +
) : entry.message.eventType ? ( + ) : entry.message.context ? ( @@ -267,6 +277,10 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { message={entry.message} conversation={props.conversation} /> + ) : ( memoryRecallContent(context)) + .map((context) => context.turnId), + ), + ].flatMap((turnId) => { + const anchor = { source: "recalled", turnId } as const; + const memories = anchorMemories(state?.list, anchor); + return memories.length > 0 ? [{ anchor, memories }] : []; + }); + if (!state || anchors.length === 0) return null; return ( -
- -