feat(runner): add chat history sidebar

This commit is contained in:
Dotta committed 2026-08-20 12:48:29 -05:00
1 parent a6b2ad79b7
commit d81f36722e
5 files changed
+286 -58

No files matched your search

@@ -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 }) => {
@@ -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<CapabilityIssueThreadSnapshot | null>(null);
const [devtools, setDevtools] = useState<CapabilityDevtoolsSnapshot | null>(null);
const [identity, setIdentity] = useState<CapabilityCleanRoomIdentity | null>(null);
const [chatHistory, setChatHistory] = useState<StoredChatSession[]>(readChatHistory);
const [historicSessionId, setHistoricSessionId] = useState<string | null>(null);
const liveRoomRef = useRef<StoredChatSession | null>(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<ReturnType<typeof capabilityLiveClient.newCleanRoom>>) => {
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 = (
<SurfaceNav
surface={route.surface}
history={historyItems}
activeSessionId={snapshot?.sessionId ?? null}
onNewChat={chat ? newChat : () => { window.location.hash = "#/chat"; }}
onSelectHistory={selectChatHistory}
/>
);
if (error !== null) {
return (
<div className="pit-app" data-thread-state="failed" data-surface={route.surface}>
<SurfaceNav surface={route.surface} />
{surfaceNav}
<main className="pit-app-error">
<p className="pit-composer-reason" role="alert" data-testid="surface-error">
{chat
@@ -667,7 +765,7 @@ export function App() {
if (snapshot === null) {
return (
<div className="pit-app" data-thread-state="loading" data-surface={route.surface}>
<SurfaceNav surface={route.surface} />
{surfaceNav}
<main className="pit-app-error">
<p className="pit-muted" role="status" data-testid="surface-loading">
{chat
@@ -696,14 +794,13 @@ export function App() {
data-surface={route.surface}
data-connection-state={snapshot.connection.state}
>
<SurfaceNav surface={route.surface} />
{surfaceNav}
<IssueHeader
snapshot={snapshot}
scenarios={SCENARIOS}
surface={route.surface}
cleanRoomToken={identity?.token ?? null}
onNewChat={newChat}
evidenceOpen={panelOpen}
denialCount={denialCount}
segment={segment}
@@ -946,7 +1043,7 @@ export function App() {
{showPanel ? (
<EvidencePanel
snapshot={snapshot}
{...(route.mode === "live" ? { devtools } : {})}
{...(route.mode === "live" && historicSessionId === null ? { devtools } : {})}
onForkRevision={(revision) => {
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);
@@ -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) {
<StatusBadge status={snapshot.issue.status} />
</div>
<div className="pit-header-controls">
{chat ? (
<button type="button" className="pit-button" onClick={onNewChat} data-testid="new-chat-button">
<Icon name="spark" /> New chat
</button>
) : (
{!chat ? (
<>
<label className="pit-visually-hidden" htmlFor="scenario-picker">Scenario</label>
<select className="pit-select pit-desktop-only" id="scenario-picker" value={snapshot.issue.fixtureProfile} onChange={(event) => onSelectScenario(event.target.value)} data-testid="scenario-picker">
@@ -81,7 +75,7 @@ export function IssueHeader(props: IssueHeaderProps) {
</select>
<button type="button" className="pit-button pit-desktop-only" onClick={onReplay} data-testid="replay-button"><Icon name="play" /> Replay</button>
</>
)}
) : null}
<button type="button" className="pit-icon-button pit-desktop-only" data-variant="destructive" onClick={onReset} data-testid="reset-button" title={chat ? "Reset chat" : "Reset scenario"} aria-label={chat ? "Reset chat" : "Reset scenario"}><Icon name="reset" /></button>
<button type="button" className="pit-icon-button" data-variant="destructive" onClick={onStop} disabled={!turnActive} data-testid="stop-button" title="Stop turn" aria-label="Stop turn"><Icon name="stop" /></button>
<button type="button" className="pit-button pit-desktop-only" aria-expanded={evidenceOpen} onClick={onToggleEvidence} data-testid="evidence-toggle"><Icon name="evidence" /> DevTools{denialCount > 0 ? ` (${denialCount})` : ""}</button>
@@ -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 (
<nav className="pit-surface-nav" aria-label="Capability surfaces" data-testid="surface-nav">
<a
className="pit-surface-link"
href="#/issue/hb-baseline"
aria-current={surface === "issue" ? "page" : undefined}
data-testid="surface-issue-link"
>
Scenario explorer
</a>
<a
className="pit-surface-link"
href="#/chat"
aria-current={surface === "chat" ? "page" : undefined}
data-testid="surface-chat-link"
>
New chat
<span className="pit-surface-note">· clean room</span>
</a>
</nav>
<aside className="pit-surface-nav" aria-label="Runner navigation" data-testid="surface-nav">
<div className="pit-sidebar-brand"><Icon name="activity" /><span>Runner lab</span></div>
<nav className="pit-sidebar-primary" aria-label="Capability surfaces">
<a className="pit-surface-link" href="#/issue/hb-baseline" aria-current={surface === "issue" ? "page" : undefined} data-testid="surface-issue-link">
<Icon name="timeline" /><span>Scenario explorer</span>
</a>
<button type="button" className="pit-surface-link" onClick={onNewChat} disabled={onNewChat === undefined} data-testid="surface-chat-link">
<Icon name="spark" /><span>New chat</span>
</button>
</nav>
{surface === "chat" ? (
<section className="pit-session-history" aria-labelledby="session-history-title">
<h2 id="session-history-title">Session history</h2>
<div className="pit-session-history-list">
{history.map((item) => (
<button type="button" key={item.sessionId} className="pit-session-history-item" data-selected={activeSessionId === item.sessionId} onClick={() => onSelectHistory?.(item.sessionId)}>
<span className="pit-session-history-title">{item.title}</span>
<span>{item.identifier}{item.current ? " · current" : ""}</span>
</button>
))}
{history.length === 0 ? <p className="pit-muted">No chats yet.</p> : null}
</div>
</section>
) : null}
</aside>
);
}
@@ -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;
}