feat(shell): add SessionDocumentsPanel read-only left drawer (Task 11)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 13:05:40 +02:00
co-authored by Claude Sonnet 4.6
parent f8c246a1b6
commit 964bfa39ea
2 changed files with 139 additions and 0 deletions
@@ -0,0 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import { getSessionDocuments } from "../api/sessions";
import type { SessionDocument, SessionSummary } from "../api/types";
import { Button } from "../components/ui/button";
import { SqlViewer } from "../viewers/SqlViewer";
import { SchemaLinkingViewer } from "../viewers/SchemaLinkingViewer";
import { MarkdownView } from "../viewers/MarkdownView";
interface Props {
session: SessionSummary;
onClose: () => void;
onResume: (id: string) => void;
}
function statusLabel(s: SessionSummary): string {
if (s.archived) return "Archived";
if (s.status === "finalized") return "Completed";
return `In progress · ${s.status}`;
}
function DocBody({ doc }: { doc: SessionDocument }) {
if (doc.format === "sql") return <SqlViewer blocks={[{ name: doc.title, sql: doc.content }]} />;
if (doc.format === "markdown") return <MarkdownView source={doc.content} />;
if (doc.format === "schema-linking") {
try {
const linking = JSON.parse(doc.content);
return <SchemaLinkingViewer linking={linking} />;
} catch {
return <pre className="text-xs">{doc.content}</pre>;
}
}
if (doc.format === "decisions") {
const lines = doc.content.split("\n").filter(Boolean);
return (
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">{`${lines.length} decisions`}</summary>
<ul className="mt-2 flex flex-col gap-1 text-xs">
{lines.map((line, i) => {
let d: { type?: string; subject?: string; detail?: string };
try { d = JSON.parse(line); } catch { d = {}; }
return <li key={i} className="font-mono">[{d.type}] {d.subject}{d.detail ? ` — ${d.detail}` : ""}</li>;
})}
</ul>
</details>
);
}
return <p className="whitespace-pre-wrap text-sm">{doc.content}</p>;
}
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", session.id],
queryFn: () => getSessionDocuments(session.id),
});
const resumable = session.status !== "finalized" && !session.archived;
return (
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-start justify-between gap-2 border-b border-border/70 px-4 py-3">
<div className="min-w-0">
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
{session.name || session.question}
</h2>
<p className="mt-0.5 text-[0.7rem] uppercase tracking-wide text-muted-foreground">{statusLabel(session)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{resumable && (
<Button size="sm" variant="outline" onClick={() => onResume(session.id)}>Resume</Button>
)}
<Button size="icon-sm" variant="ghost" aria-label="Close panel" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : (
<div className="flex flex-col gap-5">
{docs.map((doc) => (
<section key={doc.key}>
<h3 className="mb-2 text-[0.65rem] font-bold uppercase tracking-wide text-primary">
{doc.title}{doc.phase !== "—" ? ` · ${doc.phase}` : ""}
</h3>
<DocBody doc={doc} />
</section>
))}
</div>
)}
</div>
</aside>
);
}