Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
234 lines
9.0 KiB
TypeScript
234 lines
9.0 KiB
TypeScript
import { useI18n, translate } from "../i18n";
|
|
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 translate("Archived");
|
|
if (s.status === "finalized") return translate("Completed");
|
|
return translate("In progress · {status}", { status: translate(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 }) {
|
|
const { t: translate } = useI18n();
|
|
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">{translate("No memories recorded.")}</p>;
|
|
}
|
|
return (
|
|
<ul aria-label={translate("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-[0.62rem] font-semibold uppercase tracking-wide",
|
|
item.status === "approved"
|
|
? "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]"
|
|
: "bg-muted text-muted-foreground",
|
|
].join(" ")}>
|
|
{item.status === "approved" ? translate("Approved") : translate("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-2 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">{translate("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-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
|
|
{translate((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-[oklch(0.47_0.12_165)]";
|
|
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 { t: translate } = useI18n();
|
|
const { data: docs = [], isLoading, isError, refetch } = useQuery<SessionDocument[]>({
|
|
queryKey: ["session-documents", session.id],
|
|
queryFn: () => getSessionDocuments(session.id),
|
|
});
|
|
const resumable = session.status !== "finalized" && !session.archived;
|
|
|
|
return (
|
|
<aside
|
|
aria-label={translate("Session summary")}
|
|
aria-hidden={hidden || undefined}
|
|
hidden={hidden}
|
|
className={[
|
|
"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="truncate 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)}>{translate("Resume")}</Button>
|
|
)}
|
|
<Button size="icon-sm" variant="ghost" aria-label={translate("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">{translate("Loading…")}</p>
|
|
) : isError ? (
|
|
<div role="alert" className="space-y-2 text-sm text-destructive">
|
|
<p>{translate("Session documents could not be loaded.")}</p>
|
|
<Button variant="outline" size="sm" onClick={() => void refetch()}>{translate("Retry")}</Button>
|
|
</div>
|
|
) : (
|
|
<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">
|
|
{translate(doc.title)}
|
|
</h3>
|
|
{doc.phase !== "—" && (
|
|
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.6rem] 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>
|
|
);
|
|
}
|