227 lines
8.4 KiB
TypeScript
227 lines
8.4 KiB
TypeScript
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 { ErrorBoundary } from "../components/ErrorBoundary";
|
|
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;
|
|
desktopSplit?: boolean;
|
|
hidden?: boolean;
|
|
}
|
|
|
|
function statusLabel(s: SessionSummary): string {
|
|
if (s.archived) return "Archived";
|
|
if (s.status === "finalized") return "Completed";
|
|
return `In progress · ${s.status}`;
|
|
}
|
|
|
|
type MemoryItem = {
|
|
status: "approved" | "declined";
|
|
subject: string;
|
|
detail: string;
|
|
rationale: string;
|
|
};
|
|
|
|
const HIDDEN_DECISION_TYPES = new Set([
|
|
"concept_clarified",
|
|
"memory_promoted",
|
|
"memory_promotion_declined",
|
|
"memory_rejected",
|
|
"phase_approved",
|
|
"phase_auto_approved",
|
|
"table_approved",
|
|
"table_promoted",
|
|
"column_promoted",
|
|
"cte_approved",
|
|
"sql_approved",
|
|
"datamart_requested",
|
|
"datamart_declined",
|
|
]);
|
|
|
|
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="whitespace-pre-wrap text-xs">{doc.content}</pre>;
|
|
}
|
|
}
|
|
if (doc.format === "memories") {
|
|
const raw = JSON.parse(doc.content) as unknown;
|
|
const items = Array.isArray(raw)
|
|
? raw.filter((item): item is MemoryItem => {
|
|
if (!item || typeof item !== "object") return false;
|
|
const candidate = item as Partial<MemoryItem>;
|
|
return (
|
|
(candidate.status === "approved" || candidate.status === "declined")
|
|
&& typeof candidate.subject === "string"
|
|
&& typeof candidate.detail === "string"
|
|
&& typeof candidate.rationale === "string"
|
|
);
|
|
})
|
|
: [];
|
|
if (items.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No memories recorded.</p>;
|
|
}
|
|
return (
|
|
<ul aria-label="Memories" className="flex flex-col divide-y divide-border/50">
|
|
{items.map((item, index) => (
|
|
<li key={`${item.status}:${item.subject}:${index}`} className="py-4 first:pt-0">
|
|
<span className={[
|
|
"inline-flex rounded-md px-2 py-0.5 text-xs font-semibold",
|
|
item.status === "approved"
|
|
? "bg-[oklch(var(--success)/0.15)] text-success"
|
|
: "bg-muted text-muted-foreground",
|
|
].join(" ")}>
|
|
{item.status === "approved" ? "Approved" : "Declined"}
|
|
</span>
|
|
<div className="mt-2 font-medium text-foreground">
|
|
<MarkdownView source={item.subject} />
|
|
</div>
|
|
{item.detail && (
|
|
<div className="mt-1 text-sm text-muted-foreground">
|
|
<MarkdownView source={item.detail} />
|
|
</div>
|
|
)}
|
|
{item.rationale && (
|
|
<div className="mt-1 border-l border-border pl-3 text-sm text-muted-foreground">
|
|
<MarkdownView source={item.rationale} />
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
if (doc.format === "decisions") {
|
|
const items = doc.content
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
try { return JSON.parse(line) as { type?: string; subject?: string; detail?: string }; }
|
|
catch { return null; }
|
|
})
|
|
.filter((d): d is { type?: string; subject?: string; detail?: string } => (
|
|
d !== null && !HIDDEN_DECISION_TYPES.has(d.type ?? "")
|
|
));
|
|
if (items.length === 0) return <p className="text-sm text-muted-foreground">No decisions recorded.</p>;
|
|
return (
|
|
<ul className="flex flex-col divide-y divide-border/50">
|
|
{items.map((d, i) => (
|
|
<li key={i} className="py-3 first:pt-0">
|
|
<span className={`inline-block rounded-md px-2 py-0.5 text-xs font-semibold ${decisionChipClass(d.type ?? "")}`}>
|
|
{(d.type ?? "decision").replace(/_/g, " ")}
|
|
</span>
|
|
{d.subject && (
|
|
<div className="mt-1.5 text-sm font-medium text-foreground">
|
|
<MarkdownView source={d.subject} />
|
|
</div>
|
|
)}
|
|
{d.detail && (
|
|
<div className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
|
<MarkdownView source={d.detail} />
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
return <MarkdownView source={doc.content} />;
|
|
}
|
|
|
|
/** Semantic tint for a decision-type chip: approvals/promotions read as progress
|
|
* (green), exclusions as neutral, everything else carries the brand accent. */
|
|
function decisionChipClass(type: string): string {
|
|
const t = type.toLowerCase();
|
|
if (t.includes("approved") || t.includes("promoted"))
|
|
return "bg-[oklch(var(--success)/0.15)] text-success";
|
|
if (t.includes("excluded") || t.includes("rejected") || t.includes("reopened"))
|
|
return "bg-muted text-muted-foreground";
|
|
return "bg-[oklch(var(--primary)/0.1)] text-primary";
|
|
}
|
|
|
|
export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit = false, hidden = false }: 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
|
|
aria-label="Session summary"
|
|
data-layout={desktopSplit ? "split" : "compact"}
|
|
aria-hidden={hidden || undefined}
|
|
hidden={hidden}
|
|
className={[
|
|
"thot-session-documents flex shrink-0 flex-col border-r border-border bg-sidebar",
|
|
desktopSplit
|
|
? "w-[var(--session-panel-width)] max-w-[50%]"
|
|
: "w-[30vw] max-w-[30vw]",
|
|
].join(" ")}
|
|
>
|
|
<div className="flex items-start justify-between gap-2 border-b border-border/60 px-3 py-3">
|
|
<div className="min-w-0">
|
|
<h2 className="break-words font-heading text-sm font-semibold text-foreground">
|
|
{session.name || session.question}
|
|
</h2>
|
|
<p className="thot-label mt-0.5">{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-3 py-5">
|
|
{isLoading ? (
|
|
<p className="text-xs text-muted-foreground">Loading…</p>
|
|
) : (
|
|
<div className="flex w-full flex-col gap-7">
|
|
{docs.map((doc, idx) => (
|
|
<section
|
|
key={doc.key}
|
|
className={[
|
|
"w-full",
|
|
idx > 0 ? "border-t border-border/50 pt-7" : "",
|
|
].join(" ")}
|
|
>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<h3 className="thot-label">
|
|
{doc.title}
|
|
</h3>
|
|
{doc.phase !== "—" && (
|
|
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs font-medium text-muted-foreground">
|
|
{doc.phase}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Isolate each document: a viewer that crashes on malformed
|
|
content degrades to a fallback in place, leaving the rest of
|
|
the panel intact instead of blanking the whole tree. */}
|
|
<ErrorBoundary resetKeys={[doc.key, doc.content]} label="document">
|
|
<DocBody doc={doc} />
|
|
</ErrorBoundary>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|