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:
@@ -0,0 +1,45 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { server } from "../test/msw";
|
||||||
|
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||||||
|
import type { SessionSummary } from "../api/types";
|
||||||
|
|
||||||
|
function makeClient() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(ui: React.ReactElement, client = makeClient()) {
|
||||||
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return render(ui, { wrapper: Wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: SessionSummary = {
|
||||||
|
id: "s1", status: "open", question: "How many ablations?", summary: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null,
|
||||||
|
name: null, group: null, archived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
|
||||||
|
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
|
||||||
|
]),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the document titles from the API", async () => {
|
||||||
|
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||||
|
expect(await screen.findByText("Original question")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Final SQL")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows Resume for resumable, hides it for finalized", async () => {
|
||||||
|
const { rerender } = wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||||
|
expect(await screen.findByRole("button", { name: /resume/i })).toBeInTheDocument();
|
||||||
|
rerender(<SessionDocumentsPanel session={{ ...base, status: "finalized" }} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||||
|
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user