Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions packages/junior-dashboard/e2e/conversation-details.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -175,3 +182,75 @@ 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, 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,
});
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();
});
22 changes: 18 additions & 4 deletions packages/junior-dashboard/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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<string, string>;
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 (
<DetailsPanel
label="Conversation details"
onClose={props.onClose}
title={
<div className="-ml-2 flex min-w-0 items-center gap-1">
<Button
aria-label="Back to details"
onClick={() => setMemoriesOpen(false)}
size="icon"
>
<ArrowLeft aria-hidden="true" size={15} />
</Button>
<h2 className="m-0 truncate font-sans text-sm font-semibold text-dashboard-text">
Memories
</h2>
</div>
}
>
<div className="px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-3">
<ConversationMemoryEntries
entries={conversationMemoryEntries(memoryList)}
onSelect={(entry) => {
// The transcript sits under a details sheet. Close it first.
if (!panel) props.onClose();
props.onSelectMemory(entry);
}}
prompts={props.memoryPrompts}
/>
</div>
</DetailsPanel>
);
}

return (
<DetailsPanel
label="Conversation details"
Expand Down Expand Up @@ -135,38 +188,13 @@ export function ConversationDetails(props: {
{props.identity != null ? (
<DetailsCard title="Participants">{props.identity}</DetailsCard>
) : null}
{memories?.length ? (
<DetailsCard count={memories.length} title="Memories">
<Disclosure label="memories">
<ConversationMemories conversationId={props.conversationId} />
</Disclosure>
</DetailsCard>
{memoryList?.events.length ? (
<ConversationMemoryCard
list={memoryList}
onOpen={() => setMemoriesOpen(true)}
/>
) : null}
</div>
</DetailsPanel>
);
}

/** Hide long, rarely read content behind one show/hide row. */
function Disclosure(props: { children: ReactNode; label: string }) {
const [open, setOpen] = useState(false);
return (
<div className="grid min-w-0 gap-2">
<button
aria-expanded={open}
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"
onClick={() => setOpen((value) => !value)}
type="button"
>
<span className="grid size-5 shrink-0 place-items-center">
<ChevronRight
aria-hidden="true"
className={dashboardDisclosureChevronClass}
/>
</span>
{open ? `Hide ${props.label}` : `Show ${props.label}`}
</button>
{open ? props.children : null}
</div>
);
}
Loading
Loading