From d81f36722e182cf17403b4e3e73b0236efe454b4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 20 Aug 2026 12:48:29 -0500 Subject: [PATCH] feat(runner): add chat history sidebar --- .../issue-thread/issue-thread.spec.ts | 14 +- .../devtools/issue-thread/src/App.tsx | 115 ++++++++++++-- .../devtools/issue-thread/src/IssueHeader.tsx | 10 +- .../devtools/issue-thread/src/SurfaceNav.tsx | 61 +++++--- .../issue-thread/src/issue-thread.css | 144 +++++++++++++++--- 5 files changed, 286 insertions(+), 58 deletions(-) diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index 7e53e90175d..bc94f1dc9ce 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -678,7 +678,7 @@ test.describe("Capability clean-room chat", () => { await open(page, "thread-baseline"); const entry = page.getByTestId("surface-chat-link"); await expect(entry).toBeVisible(); - await expect(entry).toHaveAttribute("href", "#/chat"); + await expect(entry).toHaveRole("button"); await expect(entry).toContainText("New chat"); }); @@ -821,9 +821,19 @@ test.describe("Capability clean-room chat", () => { await openCleanRoom(page, ["MCK-1000", "MCK-2000"]); await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000"); - await page.getByTestId("new-chat-button").click(); + await page.getByTestId("surface-chat-link").click(); await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000"); await expect(page.getByTestId("clean-room-empty")).toBeVisible(); + const history = page.getByRole("region", { name: "Session history" }); + await expect(history.getByRole("button")).toHaveCount(2); + + await history.getByRole("button", { name: /MCK-1000/ }).click(); + await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000"); + await expect(page.getByTestId("composer-reason")).toHaveText("Historical session is read-only"); + + await history.getByRole("button", { name: /MCK-2000.*current/ }).click(); + await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000"); + await expect(page.locator("#composer-input")).toBeEnabled(); }); test("a failed live start reports the failure instead of a fixture", async ({ page }) => { diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index 71e62614b9a..90f5e734101 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -23,7 +23,7 @@ import { type CapabilityCleanRoomIdentity, } from "./live-client"; import { parseCapabilityRoute, capabilityRouteHref, type CapabilityRoute } from "./route"; -import { SurfaceNav } from "./SurfaceNav"; +import { SurfaceNav, type CapabilityChatHistoryItem } from "./SurfaceNav"; import { TurnGroup } from "./ThreadItems"; const PANEL_OPEN_KEY = "paperclip-runner.capability.panel.open"; @@ -48,6 +48,37 @@ const PENDING_ANNOUNCEMENT = "A request is waiting for your answer."; const ANSWERED_ANNOUNCEMENT = "Your answer was recorded."; const SETTLED_ANNOUNCEMENT = "The pending request is resolved."; const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts"]; +const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1"; + +interface StoredChatSession { + sessionId: string; + snapshot: CapabilityIssueThreadSnapshot; + identity: CapabilityCleanRoomIdentity | null; + updatedAt: string; +} + +function readChatHistory(): StoredChatSession[] { + try { + const parsed = JSON.parse(window.localStorage.getItem(CHAT_HISTORY_KEY) ?? "[]") as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is StoredChatSession => + typeof item === "object" && item !== null && + typeof (item as StoredChatSession).sessionId === "string" && + typeof (item as StoredChatSession).updatedAt === "string" && + typeof (item as StoredChatSession).snapshot === "object", + ).slice(0, 12); + } catch { + return []; + } +} + +function persistChatHistory(history: StoredChatSession[]): void { + try { + window.localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(history.slice(0, 12))); + } catch { + // History is a convenience; a blocked or full storage quota must not stop chat. + } +} function readStoredNumber(key: string, fallback: number): number { try { @@ -156,6 +187,9 @@ export function App() { const [snapshot, setSnapshot] = useState(null); const [devtools, setDevtools] = useState(null); const [identity, setIdentity] = useState(null); + const [chatHistory, setChatHistory] = useState(readChatHistory); + const [historicSessionId, setHistoricSessionId] = useState(null); + const liveRoomRef = useRef(null); /** * Which surface produced `snapshot`. A hash change commits the new route in * the same frame that still renders the old snapshot, so without this a @@ -219,6 +253,7 @@ export function App() { if (cancelled) return; rememberSession(response.sessionId, "cleanroom"); setIdentity(response.identity ?? null); + setHistoricSessionId(null); setSnapshot(response.view); setSnapshotSurface("chat"); } catch (cause) { @@ -318,12 +353,29 @@ export function App() { useEffect(() => { if (!panelOpen || snapshot === null || route.mode !== "live") return; + if (historicSessionId !== null) return; let cancelled = false; void capabilityLiveClient.devtools(snapshot.sessionId) .then((next) => { if (!cancelled) setDevtools(next); }) .catch((cause) => { if (!cancelled) setActionError(describe(cause)); }); return () => { cancelled = true; }; - }, [panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]); + }, [historicSessionId, panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]); + + useEffect(() => { + if (!chat || snapshot === null || historicSessionId !== null) return; + const record: StoredChatSession = { + sessionId: snapshot.sessionId, + snapshot, + identity, + updatedAt: snapshot.renderedAt, + }; + liveRoomRef.current = record; + setChatHistory((current) => { + const next = [record, ...current.filter((item) => item.sessionId !== record.sessionId)].slice(0, 12); + persistChatHistory(next); + return next; + }); + }, [chat, historicSessionId, identity, snapshot?.renderedAt, snapshot?.sessionId]); useEffect(() => { if (snapshot === null) return; @@ -517,7 +569,9 @@ export function App() { const adoptCleanRoom = useCallback((next: Awaited>) => { rememberSession(next.sessionId, "cleanroom"); setIdentity(next.identity ?? null); + setHistoricSessionId(null); setSnapshot(next.view); + setDevtools(null); setActionError(null); setAnnouncement( `New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`, @@ -531,11 +585,39 @@ export function App() { abandonTurn(); setAnnouncement("Starting a new clean-room chat…"); void capabilityLiveClient - .newCleanRoom(snapshot?.sessionId ?? null) + .newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null) .then(adoptCleanRoom) .catch((cause) => setActionError(describe(cause))); }, [abandonTurn, adoptCleanRoom, snapshot]); + const selectChatHistory = useCallback((sessionId: string) => { + const live = liveRoomRef.current; + if (live?.sessionId === sessionId) { + setSnapshot(live.snapshot); + setIdentity(live.identity); + setDevtools(null); + setHistoricSessionId(null); + setActionError(null); + return; + } + const archived = chatHistory.find((item) => item.sessionId === sessionId); + if (archived === undefined) return; + abandonTurn(); + setSnapshot({ + ...archived.snapshot, + composer: { + state: "disabled", + helper: null, + reason: "Historical session is read-only", + pendingInteractionId: null, + }, + }); + setIdentity(archived.identity); + setDevtools(null); + setHistoricSessionId(sessionId); + setActionError(null); + }, [abandonTurn, chatHistory]); + const reset = useCallback(() => { setConfirmReset(false); abandonTurn(); @@ -633,11 +715,27 @@ export function App() { () => (snapshot === null ? 0 : capabilityDenialCount(snapshot.evidence, null)), [snapshot], ); + const historyItems: CapabilityChatHistoryItem[] = chatHistory.map((item) => ({ + sessionId: item.sessionId, + identifier: item.snapshot.issue.identifier, + title: item.snapshot.issue.title, + updatedAt: item.updatedAt, + current: liveRoomRef.current?.sessionId === item.sessionId, + })); + const surfaceNav = ( + { window.location.hash = "#/chat"; }} + onSelectHistory={selectChatHistory} + /> + ); if (error !== null) { return (
- + {surfaceNav}

{chat @@ -667,7 +765,7 @@ export function App() { if (snapshot === null) { return (

- + {surfaceNav}

{chat @@ -696,14 +794,13 @@ export function App() { data-surface={route.surface} data-connection-state={snapshot.connection.state} > - + {surfaceNav} { abandonTurn(); setActionError(null); @@ -974,7 +1071,7 @@ export function App() { } onClose={closeEvidence} onJumpToThread={jumpToThread} - onInvokeTool={route.mode === "live" ? async (operationId, input) => { + onInvokeTool={route.mode === "live" && historicSessionId === null ? async (operationId, input) => { setActionError(null); const next = await capabilityLiveClient.invokeTool(snapshot.sessionId, operationId, input); setSnapshot(next.view); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx index 2b33fdacb70..493617a012d 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx @@ -17,7 +17,6 @@ export interface IssueHeaderProps { surface?: "issue" | "chat"; /** Clean-room tenant token, rendered so identity rotation is visible. */ cleanRoomToken?: string | null; - onNewChat?: () => void; evidenceOpen: boolean; denialCount: number; segment: "thread" | "evidence"; @@ -35,7 +34,6 @@ export function IssueHeader(props: IssueHeaderProps) { scenarios, surface = "issue", cleanRoomToken = null, - onNewChat, evidenceOpen, denialCount, segment, @@ -69,11 +67,7 @@ export function IssueHeader(props: IssueHeaderProps) {

- {chat ? ( - - ) : ( + {!chat ? ( <> - )} + ) : null} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx b/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx index 9941ec585ea..9c0cf47c671 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx @@ -1,4 +1,5 @@ import type { CapabilitySurface } from "./route"; +import { Icon } from "./Icons"; /** * Top-level surface switch (Capability plan revision 5). @@ -11,26 +12,46 @@ import type { CapabilitySurface } from "./route"; * These are real anchors on purpose. The entry must survive a hard refresh, be * copyable, and open in a new tab, which a button handler would not give. */ -export function SurfaceNav({ surface }: { surface: CapabilitySurface }) { +export interface CapabilityChatHistoryItem { + sessionId: string; + identifier: string; + title: string; + updatedAt: string; + current: boolean; +} + +export function SurfaceNav({ surface, history = [], activeSessionId = null, onNewChat, onSelectHistory }: { + surface: CapabilitySurface; + history?: CapabilityChatHistoryItem[]; + activeSessionId?: string | null; + onNewChat?: () => void; + onSelectHistory?: (sessionId: string) => void; +}) { return ( - + ); } diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css index 10fe90aba9a..5035723eeb3 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css +++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css @@ -164,8 +164,11 @@ textarea { * composer stays pinned to the bottom of the thread column (§2.1). */ .pit-app { + position: relative; display: flex; flex-direction: column; + box-sizing: border-box; + padding-left: 15rem; height: 100vh; height: 100dvh; overflow: hidden; @@ -184,35 +187,59 @@ textarea { border: 0; } -/* ---------------------------------------------------------- surface nav */ +/* ------------------------------------------------------------- sidebar */ -/* - * Two primary paths, one rail. The links wrap rather than scroll horizontally, - * because the narrow layout must never introduce a horizontal scrollbar (§10). - */ .pit-surface-nav { - flex: none; + position: absolute; + inset: 0 auto 0 0; + z-index: 4; display: flex; - flex-wrap: wrap; - gap: var(--pit-space-2); - padding: var(--pit-space-2) var(--pit-space-4); + flex-direction: column; + width: 15rem; + box-sizing: border-box; + padding: var(--pit-space-3); background: var(--pit-surface-sunken); + border-right: 1px solid var(--pit-border); + overflow: hidden; +} + +.pit-sidebar-brand { + display: flex; + align-items: center; + gap: var(--pit-space-2); + min-height: var(--pit-touch-target); + padding: 0 var(--pit-space-2); + color: var(--pit-foreground); + font-weight: 700; +} + +.pit-sidebar-brand .pit-icon { + color: var(--pit-live); +} + +.pit-sidebar-primary { + display: grid; + gap: var(--pit-space-1); + padding-bottom: var(--pit-space-4); border-bottom: 1px solid var(--pit-border); } .pit-surface-link { - display: inline-flex; + display: flex; align-items: center; - /* The label and its qualifier are separate flex items, so the space between - them has to be a gap: a leading space inside the qualifier collapses. */ - gap: var(--pit-space-1); + gap: var(--pit-space-2); + width: 100%; + box-sizing: border-box; min-height: var(--pit-touch-target); - padding: 0 var(--pit-space-3); - border: 1px solid var(--pit-border); + padding: 0 var(--pit-space-2); + border: 1px solid transparent; border-radius: var(--pit-radius-md); + background: transparent; color: var(--pit-muted-foreground); font-size: var(--pit-text-xs); text-decoration: none; + text-align: left; + cursor: pointer; } .pit-surface-link:hover { @@ -226,12 +253,62 @@ textarea { color: var(--pit-foreground); } -.pit-surface-note { - color: var(--pit-faint-foreground); +.pit-session-history { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + padding-top: var(--pit-space-4); } -.pit-surface-link[aria-current="page"] .pit-surface-note { - color: var(--pit-muted-foreground); +.pit-session-history h2 { + margin: 0 0 var(--pit-space-2); + padding: 0 var(--pit-space-2); + color: var(--pit-faint-foreground); + font-size: var(--pit-text-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.pit-session-history-list { + display: flex; + flex-direction: column; + gap: var(--pit-space-1); + overflow-y: auto; +} + +.pit-session-history-item { + display: flex; + flex-direction: column; + gap: 0.15rem; + width: 100%; + padding: var(--pit-space-2); + border: 1px solid transparent; + border-radius: var(--pit-radius-md); + background: transparent; + color: var(--pit-faint-foreground); + font-size: var(--pit-text-xs); + text-align: left; + cursor: pointer; +} + +.pit-session-history-item:hover, +.pit-session-history-item[data-selected="true"] { + border-color: var(--pit-border); + background: var(--pit-surface); +} + +.pit-session-history-item[data-selected="true"] { + border-color: var(--pit-accent); +} + +.pit-session-history-title { + overflow: hidden; + color: var(--pit-foreground); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; } /* A load failure and the pre-session wait share the centred single-column. */ @@ -1683,6 +1760,35 @@ textarea { } @media (max-width: 767px) { + .pit-app { + padding-left: 0; + } + + .pit-surface-nav { + position: static; + width: auto; + flex: none; + padding: var(--pit-space-1) var(--pit-space-2); + border-right: 0; + border-bottom: 1px solid var(--pit-border); + } + + .pit-sidebar-brand, + .pit-session-history { + display: none; + } + + .pit-sidebar-primary { + display: flex; + gap: var(--pit-space-1); + padding: 0; + border: 0; + } + + .pit-surface-link { + width: auto; + } + .pit-desktop-only { display: none; }