feat: redesign persisted session summaries

This commit is contained in:
2026-07-23 15:35:31 +02:00
parent 12c7a976d7
commit 0db85e4e4e
12 changed files with 1090 additions and 71 deletions
@@ -0,0 +1,172 @@
# Session Summary Layout Implementation Plan
> **For Codex:** Execute this plan with `superpowers:executing-plans` and use strict
> red-green-refactor cycles from `superpowers:test-driven-development`.
**Goal:** Reorganize every persisted session summary into the approved task-oriented
order, render human-facing text as Markdown, add reliable SQL copying, and make the
session document panel independently resizable up to 50% of the application width.
**Architecture:** The harness projects filesystem and repository snapshots through
shared pure helpers so old and new sessions receive the same read-time layout without
rewriting artifacts. The frontend renders those ordered documents and owns only UI
behavior: Markdown presentation, clipboard feedback, and a dedicated persisted resize
hook wired into `AppShell`.
**Tech Stack:** Python 3.12, Typer/Pydantic, React 18, TypeScript, TanStack Query,
Vitest/Testing Library, Tailwind CSS.
---
### Task 1: Canonical session document projection
**Files:**
- Modify: `harness/tests/test_session_documents.py`
- Modify: `harness/tht/session/store.py`
**Step 1: Write failing tests**
Add focused tests that pin:
- exact ordering: original question, SQL, preview, revised question, assumptions,
memories, remaining technical documents;
- identical projection for filesystem sessions and repository snapshots;
- preview extraction only from `## Preview / aggregato`;
- split of legacy `question` Markdown at `## Assunzioni`;
- approved memories before declined memories;
- suppression of memory decisions and `phase_approved`, `table_approved`,
`table_promoted`, `column_promoted` from generic decisions.
**Step 2: Verify RED**
Run: `../harness/.venv/bin/pytest -q harness/tests/test_session_documents.py`
Expected: failures caused by the missing projection helpers and old document order.
**Step 3: Implement minimal projection helpers**
Create pure helpers in `harness/tht/session/store.py` for question splitting, preview
extraction, effective decision grouping/filtering, and canonical document assembly.
Route both `build_documents` and `build_snapshot_documents` through the same assembly
function while preserving legacy malformed-content tolerance.
**Step 4: Verify GREEN**
Run the same focused pytest command and require zero failures.
### Task 2: SQL clipboard behavior
**Files:**
- Modify: `frontend/src/viewers/SqlViewer.test.tsx`
- Modify: `frontend/src/viewers/SqlViewer.tsx`
**Step 1: Write failing tests**
Cover an always-visible accessible copy button, exact SQL clipboard payload, success
feedback, and rejection feedback that leaves SQL visible.
**Step 2: Verify RED**
Run: `npx vitest run src/viewers/SqlViewer.test.tsx`
Expected: missing copy control and feedback assertions fail.
**Step 3: Implement minimal copy control**
Add a per-block copy button using `navigator.clipboard.writeText`, with visible and
screen-reader-compatible success/failure state.
**Step 4: Verify GREEN**
Run the same focused Vitest command and require zero failures.
### Task 3: Session summary Markdown and memories
**Files:**
- Modify: `frontend/src/api/types.ts`
- Modify: `frontend/src/shell/SessionDocumentsPanel.test.tsx`
- Modify: `frontend/src/shell/SessionDocumentsPanel.tsx`
**Step 1: Write failing tests**
Cover exact API order rendering, Markdown structure for revised question and memory
subject/detail, one unified Memories section without raw decision chips, and absence of
the suppressed decision types.
**Step 2: Verify RED**
Run: `npx vitest run src/shell/SessionDocumentsPanel.test.tsx`
Expected: new memory format/Markdown assertions fail against the existing renderer.
**Step 3: Implement minimal renderer changes**
Extend the document format union if needed and render memory entries through the
existing `MarkdownView`, keeping per-document error boundaries and generic decisions
for meaningful non-memory types.
**Step 4: Verify GREEN**
Run the same focused Vitest command and require zero failures.
### Task 4: Dedicated session-panel resizing
**Files:**
- Create: `frontend/src/shell/useSessionPanelResize.ts`
- Create: `frontend/src/shell/useSessionPanelResize.test.ts`
- Modify: `frontend/src/shell/AppShell.tsx`
- Modify: `frontend/src/shell/AppShell.session-mgmt.test.tsx`
- Modify: `frontend/src/shell/SessionDocumentsPanel.tsx`
**Step 1: Write failing tests**
Pin independent storage, exact 50% maximum, 512 px right-side minimum, pointer drag,
keyboard controls, ARIA values, and hiding the divider when the desktop split is not
usable.
**Step 2: Verify RED**
Run: `npx vitest run src/shell/useSessionPanelResize.test.ts src/shell/AppShell.session-mgmt.test.tsx`
Expected: the new hook is absent and the shell lacks the session separator.
**Step 3: Implement minimal resize hook and shell wiring**
Use a session-specific local-storage key and CSS variable. Keep its drag state and
separator independent from Model activity, and apply the computed width to
`SessionDocumentsPanel` only on usable desktop layouts.
**Step 4: Verify GREEN**
Run the same focused Vitest command and require zero failures.
### Task 5: Full verification, integration, and deployment
**Files:**
- Modify only if a verification failure exposes an implementation defect.
**Step 1: Run complete gates**
- `harness/.venv/bin/pytest -q` from `harness/`
- `harness/.venv/bin/ruff check .` from `harness/`
- `node --test .pi/extensions/gate/__tests__/*.test.js` from `harness/`
- `npx vitest run` from `frontend/`
- `npx tsc -b` from `frontend/`
- `npm run build` from `frontend/`
- `git diff --check`
**Step 2: Review the diff against the approved design**
Confirm old session artifacts are projected at read time, no persisted data is
rewritten, and the session and activity separators remain independent.
**Step 3: Commit and integrate**
Commit the implementation on `codex/session-summary-layout`, merge it into `main`, and
rerun relevant verification on the merged result.
**Step 4: Deploy Docker**
From the main worktree, run `docker compose up --build --force-recreate -d core frontend`.
Verify both running image IDs match the rebuilt tags, `core` is healthy, `/` returns
HTTP 200, and `/api/health` returns HTTP 200 with status `ok`.
@@ -1,7 +1,7 @@
# Session view resizing and summary layout
**Date:** 2026-07-23
**Status:** Approved design, pending implementation
**Status:** Implemented and verified
## Goal
+1 -1
View File
@@ -133,7 +133,7 @@ export interface SessionDocument {
phase: string;
key: string;
title: string;
format: "markdown" | "sql" | "schema-linking" | "decisions" | "text";
format: "markdown" | "sql" | "schema-linking" | "decisions" | "memories" | "text";
content: string;
}
@@ -183,6 +183,62 @@ test("opening the panel shows the session documents", async () => {
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
});
test("resizes the session summary to exactly 50% and persists pointer width independently", async () => {
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await screen.findByText("Domanda originale");
const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1600);
const separator = screen.getByRole("separator", { name: /resize session summary/i });
expect(separator).toHaveAttribute("aria-orientation", "vertical");
expect(separator).toHaveAttribute("aria-valuemin", "320");
expect(separator).toHaveAttribute("aria-valuemax", "800");
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("384px");
Object.assign(separator, {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
});
fireEvent.pointerDown(separator, { pointerId: 2, clientX: 384 });
fireEvent.pointerMove(separator, { pointerId: 2, clientX: 900 });
fireEvent.pointerUp(separator, { pointerId: 2, clientX: 900 });
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("800px");
expect(localStorage.getItem("thothii:session-documents-panel-width")).toBe("800");
expect(screen.getByRole("complementary", { name: /session summary/i })).toHaveClass(
"w-[var(--session-panel-width)]",
"max-w-[50%]",
);
expect(screen.queryByRole("separator", { name: /resize model activity/i })).not.toBeInTheDocument();
});
test("supports keyboard session resizing and hides its divider when the split is unusable", async () => {
localStorage.setItem("thothii:session-documents-panel-width", "448");
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await screen.findByText("Domanda originale");
const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
const separator = screen.getByRole("separator", { name: /resize session summary/i });
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("448px");
fireEvent.keyDown(separator, { key: "ArrowRight", shiftKey: true });
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("496px");
fireEvent.keyDown(separator, { key: "End" });
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("600px");
fireEvent.keyDown(separator, { key: "Home" });
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("320px");
setContainerWidth(shell, 800);
expect(screen.queryByRole("separator", { name: /resize session summary/i })).not.toBeInTheDocument();
expect(screen.getByRole("complementary", { name: /session summary/i })).toHaveClass(
"w-[30vw]",
"max-w-[30vw]",
);
});
test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => {
let resumed: string | null = null;
server.use(
+36 -8
View File
@@ -4,6 +4,7 @@ import { WidgetHost } from "./WidgetHost";
import { CentralStatus } from "./CentralStatus";
import { ModelActivityPanel } from "./ModelActivityPanel";
import { useActivityPanelResize } from "./useActivityPanelResize";
import { useSessionPanelResize } from "./useSessionPanelResize";
import { NavSessions } from "./NavSessions";
import { SessionMenu } from "./SessionMenu";
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
@@ -37,7 +38,20 @@ import type { CSSProperties } from "react";
* portal's left sidebar, and the workflow phases ride a slim strip above the chat.
*/
export function AppShell() {
const { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const {
containerRef,
panelWidth,
desktopSplit,
resizing: activityResizing,
separatorProps: activitySeparatorProps,
} = useActivityPanelResize();
const {
panelWidth: sessionPanelWidth,
desktopSplit: sessionDesktopSplit,
resizing: sessionResizing,
separatorProps: sessionSeparatorProps,
} = useSessionPanelResize(containerRef, panelSession !== null);
// Publish the app area's horizontal geometry as CSS vars on <html>: when the app
// is embedded beside the portal's left sidebar, viewport-fixed dialogs (dialog.tsx)
// must center on the ThothII area, not on the whole browser window.
@@ -61,8 +75,9 @@ export function AppShell() {
doc.style.removeProperty("--app-area-width");
};
}, [containerRef]);
const activityWidthStyle = {
const panelWidthsStyle = {
"--activity-panel-width": `${panelWidth}px`,
"--session-panel-width": `${sessionPanelWidth}px`,
} as CSSProperties;
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
@@ -83,7 +98,6 @@ export function AppShell() {
const composerRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const [showActivity, setShowActivity] = useState(false);
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
@@ -448,13 +462,14 @@ export function AppShell() {
return (
<div
ref={containerRef}
style={activityWidthStyle}
style={panelWidthsStyle}
data-testid="app-shell"
data-activity-layout={showActivity ? "open" : "closed"}
data-activity-resizing={resizing}
data-activity-resizing={activityResizing}
data-session-resizing={sessionResizing}
className={[
"flex h-screen bg-background text-foreground",
resizing ? "select-none cursor-col-resize" : "",
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
].join(" ")}
>
{panelSession && (
@@ -462,15 +477,28 @@ export function AppShell() {
session={panelSession}
onClose={() => setPanelSession(null)}
onResume={doResume}
desktopSplit={sessionDesktopSplit}
/>
)}
{panelSession && sessionDesktopSplit && (
<div className="relative w-0 shrink-0">
<div
{...sessionSeparatorProps}
data-testid="session-resize-handle"
data-dragging={sessionResizing}
className="group absolute inset-y-0 left-1/2 z-30 w-3 -translate-x-1/2 touch-none cursor-col-resize outline-none"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary/60 group-focus-visible:bg-primary group-data-[dragging=true]:bg-primary" />
</div>
</div>
)}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
{showActivity && desktopSplit && (
<div className="relative w-0 shrink-0">
<div
{...separatorProps}
{...activitySeparatorProps}
data-testid="activity-resize-handle"
data-dragging={resizing}
data-dragging={activityResizing}
className="group absolute inset-y-0 left-1/2 z-20 w-3 -translate-x-1/2 touch-none cursor-col-resize outline-none"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary/60 group-focus-visible:bg-primary group-data-[dragging=true]:bg-primary" />
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
@@ -73,3 +73,104 @@ test("a malformed document renders a fallback without taking down its siblings",
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
errorSpy.mockRestore();
});
test("renders the canonical summary order and formats human text as Markdown", async () => {
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
{ phase: "finalize", key: "preview", title: "Data preview", format: "markdown", content: "| count |\n|---|\n| 42 |" },
{ phase: "F3", key: "revised_question", title: "Revised question", format: "markdown", content: "# Domanda\n\nConta i pazienti **attivi**." },
{ phase: "F3", key: "assumptions", title: "Assumptions", format: "markdown", content: "## Assunzioni\n\n- Solo adulti" },
{ phase: "F8", key: "memories", title: "Memories", format: "memories", content: "[]" },
{ phase: "F4", key: "schema_linking", title: "Schema linking", format: "schema-linking", content: '{"question":"q","candidates":[],"joins":[],"excluded":[],"open_questions":[]}' },
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
const panel = await screen.findByRole("complementary", { name: /session summary/i });
await within(panel).findByRole("heading", { name: "Original question", level: 3 });
expect(within(panel).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual([
"Original question",
"Final SQL",
"Data preview",
"Revised question",
"Assumptions",
"Memories",
"Schema linking",
]);
expect(screen.getByText("question").tagName).toBe("STRONG");
expect(screen.getByText("attivi").tagName).toBe("STRONG");
expect(screen.getByRole("heading", { name: "Domanda", level: 1 })).toBeInTheDocument();
expect(screen.getByText("Solo adulti").closest("li")).toBeInTheDocument();
});
test("renders one approved-then-declined memory list with Markdown details", async () => {
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{
phase: "F8",
key: "memories",
title: "Memories",
format: "memories",
content: JSON.stringify([
{
status: "approved",
subject: "ablazione **valida**",
detail: "Stato `completed`.",
rationale: "*Confermato* dal reviewer.",
},
{
status: "declined",
subject: "paziente attivo",
detail: "Usa `flag_attivo`.",
rationale: "",
},
]),
},
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
const list = await screen.findByRole("list", { name: /memories/i });
const items = within(list).getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent(/approved/i);
expect(items[1]).toHaveTextContent(/declined/i);
expect(within(items[0]).getByText("valida").tagName).toBe("STRONG");
expect(within(items[0]).getByText("completed").tagName).toBe("CODE");
expect(within(items[0]).getByText("Confermato").tagName).toBe("EM");
expect(screen.queryByText(/memory promoted/i)).not.toBeInTheDocument();
});
test("never renders technical approval, promotion, or memory decisions", async () => {
const lines = [
{ type: "phase_approved", subject: "phase:1" },
{ type: "table_approved", subject: "fact_a" },
{ type: "table_promoted", subject: "fact_a" },
{ type: "column_promoted", subject: "fact_a.id" },
{ type: "memory_promoted", subject: "concetto" },
{ type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" },
].map((decision) => JSON.stringify(decision)).join("\n");
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByText("column excluded")).toBeInTheDocument();
expect(screen.getByText("pertinente").tagName).toBe("STRONG");
for (const hidden of ["phase approved", "table approved", "table promoted", "column promoted", "memory promoted"]) {
expect(screen.queryByText(hidden)).not.toBeInTheDocument();
}
});
+91 -6
View File
@@ -12,6 +12,7 @@ interface Props {
session: SessionSummary;
onClose: () => void;
onResume: (id: string) => void;
desktopSplit?: boolean;
}
function statusLabel(s: SessionSummary): string {
@@ -20,6 +21,25 @@ function statusLabel(s: SessionSummary): string {
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",
]);
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} />;
@@ -31,6 +51,53 @@ function DocBody({ doc }: { doc: SessionDocument }) {
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-[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" ? "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-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")
@@ -39,7 +106,9 @@ function DocBody({ doc }: { doc: SessionDocument }) {
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);
.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">
@@ -48,14 +117,22 @@ function DocBody({ doc }: { doc: SessionDocument }) {
<span className={`inline-block rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
{(d.type ?? "decision").replace(/_/g, " ")}
</span>
{d.subject && <p className="mt-1.5 text-sm font-medium text-foreground">{d.subject}</p>}
{d.detail && <p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">{d.detail}</p>}
{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 <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">{doc.content}</p>;
return <MarkdownView source={doc.content} />;
}
/** Semantic tint for a decision-type chip: approvals/promotions read as progress
@@ -69,7 +146,7 @@ function decisionChipClass(type: string): string {
return "bg-[oklch(var(--primary)/0.1)] text-primary";
}
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit = false }: Props) {
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", session.id],
queryFn: () => getSessionDocuments(session.id),
@@ -77,7 +154,15 @@ export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
const resumable = session.status !== "finalized" && !session.archived;
return (
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
<aside
aria-label="Session summary"
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">
+196
View File
@@ -0,0 +1,196 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
export const SESSION_PANEL_DEFAULT_WIDTH = 384;
export const SESSION_PANEL_MIN_WIDTH = 320;
export const SESSION_PANEL_FALLBACK_MAX_WIDTH = 576;
export const SESSION_PANEL_RIGHT_MIN_WIDTH = 512;
export const SESSION_PANEL_STORAGE_KEY = "thothii:session-documents-panel-width";
type Bounds = { min: number; max: number };
export function getSessionPanelBounds(containerWidth: number): Bounds {
return {
min: SESSION_PANEL_MIN_WIDTH,
max: Math.max(
SESSION_PANEL_MIN_WIDTH,
Math.min(containerWidth * 0.5, containerWidth - SESSION_PANEL_RIGHT_MIN_WIDTH),
),
};
}
function clampWidth(width: number, bounds: Bounds): number {
const candidate = Number.isFinite(width) ? width : SESSION_PANEL_DEFAULT_WIDTH;
return Math.min(bounds.max, Math.max(bounds.min, candidate));
}
function browserStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function loadWidth(storage: Pick<Storage, "getItem"> | null): number {
if (!storage) return SESSION_PANEL_DEFAULT_WIDTH;
try {
const raw = storage.getItem(SESSION_PANEL_STORAGE_KEY);
if (!raw?.trim()) return SESSION_PANEL_DEFAULT_WIDTH;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : SESSION_PANEL_DEFAULT_WIDTH;
} catch {
return SESSION_PANEL_DEFAULT_WIDTH;
}
}
function saveWidth(width: number, storage: Pick<Storage, "setItem"> | null): void {
if (!storage) return;
try {
storage.setItem(SESSION_PANEL_STORAGE_KEY, String(Math.round(width)));
} catch {
// Storage may be unavailable in privacy-restricted browser contexts.
}
}
function widthFromKey(width: number, key: string, shiftKey: boolean, bounds: Bounds) {
const step = shiftKey ? 48 : 16;
if (key === "ArrowLeft") return clampWidth(width - step, bounds);
if (key === "ArrowRight") return clampWidth(width + step, bounds);
if (key === "Home") return bounds.min;
if (key === "End") return bounds.max;
return null;
}
type DragState = {
pointerId: number;
startX: number;
startWidth: number;
target: HTMLDivElement;
};
export function useSessionPanelResize(
containerRef: RefObject<HTMLDivElement>,
enabled: boolean,
) {
const storageRef = useRef<Storage | null>(browserStorage());
const initialWidthRef = useRef(loadWidth(storageRef.current));
const widthRef = useRef(initialWidthRef.current);
const boundsRef = useRef<Bounds>({
min: SESSION_PANEL_MIN_WIDTH,
max: SESSION_PANEL_FALLBACK_MAX_WIDTH,
});
const dragRef = useRef<DragState | null>(null);
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
const [bounds, setBounds] = useState(boundsRef.current);
const [desktopSplit, setDesktopSplit] = useState(false);
const [resizing, setResizing] = useState(false);
const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
const next = clampWidth(rawWidth, boundsRef.current);
widthRef.current = next;
setPanelWidth(next);
if (persist) saveWidth(next, storageRef.current);
return next;
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const cancelDrag = () => {
const drag = dragRef.current;
if (drag?.target.hasPointerCapture?.(drag.pointerId)) {
drag.target.releasePointerCapture?.(drag.pointerId);
}
dragRef.current = null;
setResizing(false);
};
const syncLayout = () => {
const viewportIsDesktop = window.matchMedia?.("(min-width: 1024px)").matches
?? window.innerWidth >= 1024;
const usableWidth = SESSION_PANEL_MIN_WIDTH + SESSION_PANEL_RIGHT_MIN_WIDTH;
const nextDesktopSplit = enabled && viewportIsDesktop && container.clientWidth >= usableWidth;
setDesktopSplit(nextDesktopSplit);
if (!nextDesktopSplit) {
cancelDrag();
return;
}
const nextBounds = getSessionPanelBounds(container.clientWidth);
boundsRef.current = nextBounds;
setBounds(nextBounds);
applyWidth(widthRef.current, true);
};
syncLayout();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout);
observer?.observe(container);
const media = window.matchMedia?.("(min-width: 1024px)");
media?.addEventListener?.("change", syncLayout);
window.addEventListener("resize", syncLayout);
return () => {
cancelDrag();
observer?.disconnect();
media?.removeEventListener?.("change", syncLayout);
window.removeEventListener("resize", syncLayout);
};
}, [applyWidth, containerRef, enabled]);
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: widthRef.current,
target: event.currentTarget,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
setResizing(true);
};
const onPointerMove: PointerEventHandler<HTMLDivElement> = (event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
applyWidth(drag.startWidth + event.clientX - drag.startX, false);
};
const onPointerUp: PointerEventHandler<HTMLDivElement> = (event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
applyWidth(drag.startWidth + event.clientX - drag.startX, true);
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
dragRef.current = null;
setResizing(false);
};
const onPointerCancel: PointerEventHandler<HTMLDivElement> = (event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
applyWidth(drag.startWidth, false);
dragRef.current = null;
setResizing(false);
};
const onKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
const next = widthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
if (next === null) return;
event.preventDefault();
applyWidth(next, true);
};
return {
panelWidth,
desktopSplit,
resizing,
separatorProps: {
role: "separator" as const,
tabIndex: 0,
"aria-label": "Resize session summary",
"aria-orientation": "vertical" as const,
"aria-valuemin": bounds.min,
"aria-valuemax": bounds.max,
"aria-valuenow": panelWidth,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown,
},
};
}
+36 -1
View File
@@ -22,6 +22,16 @@ const blocks = [
},
];
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
});
test("(a) renders headers with name, field count, and test-status badge", async () => {
render(<SqlViewer blocks={blocks} />);
// Drain the pending highlightSql microtasks inside act() before asserting,
@@ -51,7 +61,11 @@ test("(b) bodies start expanded and clicking header collapses then re-expands",
});
// Collapse the first block by clicking its header
const firstHeader = screen.getByRole("button", { name: /base_orders/i });
const firstHeader = screen
.getAllByRole("button", { name: /base_orders/i })
.find((button) => button.hasAttribute("aria-expanded"));
expect(firstHeader).toBeDefined();
if (!firstHeader) throw new Error("Missing SQL collapse control");
await user.click(firstHeader);
// Now only one highlighted block visible
@@ -92,3 +106,24 @@ test("a single SQL block hides the multi-block layout toggle", async () => {
expect(screen.queryByRole("button", { name: /horizontal/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /vertical/i })).not.toBeInTheDocument();
});
test("copies the exact SQL from an always-available accessible control", async () => {
render(<SqlViewer blocks={[blocks[0]]} />);
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /copy sql for base_orders/i }));
expect(writeText).toHaveBeenCalledWith("SELECT id, amount FROM orders");
expect(await screen.findByText("Copied")).toBeInTheDocument();
});
test("reports clipboard rejection and keeps the SQL visible", async () => {
writeText.mockRejectedValueOnce(new Error("clipboard unavailable"));
render(<SqlViewer blocks={[blocks[0]]} />);
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /copy sql for base_orders/i }));
expect(await screen.findByText("Copy failed")).toBeInTheDocument();
expect(screen.getByTestId("hl")).toBeInTheDocument();
});
+51 -19
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { Check, Copy, TriangleAlert } from "lucide-react";
import { highlightSql } from "./highlight";
import { statusBadgeClass } from "./statusBadge";
@@ -21,6 +22,7 @@ const STATUS_LABELS: Record<string, string> = {
function Block({ block }: { block: SqlBlock }) {
const [open, setOpen] = useState(true);
const [html, setHtml] = useState<string>("");
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
useEffect(() => {
let cancelled = false;
@@ -37,28 +39,58 @@ function Block({ block }: { block: SqlBlock }) {
? statusBadgeClass(block.testStatus)
: "";
async function copySql() {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(block.sql);
setCopyState("copied");
} catch {
setCopyState("failed");
}
}
return (
<div className="sql-block rounded-lg border border-border/70 mb-2">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left font-mono text-sm hover:bg-muted/30"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="select-none">{open ? "▾" : "▸"}</span>
<span className="font-semibold">{block.name}</span>
{block.fields !== undefined && (
<span className="text-muted-foreground text-xs">
{block.fields} campi
<div className="flex items-stretch">
<button
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left font-mono text-sm hover:bg-muted/30"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="select-none">{open ? "▾" : "▸"}</span>
<span className="truncate font-semibold">{block.name}</span>
{block.fields !== undefined && (
<span className="shrink-0 text-xs text-muted-foreground">
{block.fields} campi
</span>
)}
{badge && (
<span
className={`ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-xs ${badgeClass}`}
>
{badge}
</span>
)}
</button>
<button
type="button"
aria-label={`Copy SQL for ${block.name}`}
title="Copy SQL"
onClick={copySql}
className="flex shrink-0 items-center gap-1.5 border-l border-border/70 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{copyState === "copied" ? (
<Check className="size-3.5 text-[oklch(0.47_0.12_165)]" aria-hidden="true" />
) : copyState === "failed" ? (
<TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
<span role="status" aria-live="polite">
{copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : ""}
</span>
)}
{badge && (
<span
className={`ml-auto text-xs px-1.5 py-0.5 rounded-md ${badgeClass}`}
>
{badge}
</span>
)}
</button>
</button>
</div>
{open && (
<div className="sql-block-body px-3 pb-3">
+182 -1
View File
@@ -1,10 +1,13 @@
"""Tests for `tht session documents --json` and build_documents."""
import json
from datetime import datetime
from typer.testing import CliRunner
from tht.config import DatabaseConfig
from tht.session.store import build_documents, create_session
from tht.decisions import DecisionRecord
from tht.session.models import SessionSnapshot
from tht.session.store import build_documents, build_snapshot_documents, create_session
from tht.cli.session_cmd import session_app
@@ -38,6 +41,184 @@ def test_build_documents_includes_existing_artifacts_only(tmp_path):
assert "validation_report" not in docs # not written
def _decision(seq: int, type_: str, subject: str, detail: str = "", rationale: str = ""):
return DecisionRecord(
seq=seq,
ts=datetime(2026, 1, 1),
type=type_,
subject=subject,
detail=detail,
rationale=rationale,
)
def _rich_session(tmp_path):
manifest = create_session("domanda originale", _db(), tmp_path)
session_dir = tmp_path / manifest.id
question = (
"# Domanda\n\n"
"Conta i pazienti con **ablazione**.\n\n"
"## Assunzioni\n\n"
"- Considera solo gli adulti\n"
"- Usa l'ultima procedura disponibile\n"
)
report = (
"# Validation report\n\n"
"## Parsing e read-only\n\n- parsing: OK\n\n"
"## Preview / aggregato\n\n"
"- tempo di esecuzione: 12 ms (limit 10)\n\n"
"| count |\n|---|\n| 42 |\n\n"
"## Note del reviewer\n\nRisultato verificato.\n"
)
decisions = [
_decision(1, "concept_clarified", "paziente attivo", "`flag_attivo = TRUE`"),
_decision(2, "memory_promotion_declined", "paziente attivo", "seq:1"),
_decision(
3,
"concept_clarified",
"ablazione valida",
"Procedura con stato **completato**.",
"Definizione approvata dal reviewer.",
),
_decision(4, "memory_promoted", "ablazione valida", "seq:3"),
_decision(5, "phase_approved", "phase:1"),
_decision(6, "table_promoted", "fact_procedure"),
_decision(7, "column_promoted", "fact_procedure.status"),
_decision(8, "column_excluded", "fact_procedure.note", "Non pertinente"),
]
(session_dir / "question.md").write_text(question)
(session_dir / "sql_final.sql").write_text("SELECT COUNT(*) FROM fact_procedure")
(session_dir / "validation_report.md").write_text(report)
(session_dir / "schema_linking.json").write_text(
'{"question":"q","candidates":[],"joins":[],"excluded":[],"open_questions":[]}'
)
(session_dir / "review_decisions.jsonl").write_text(
"\n".join(d.model_dump_json() for d in decisions) + "\n"
)
return manifest, session_dir, question, report, decisions
def test_build_documents_projects_the_approved_summary_order_and_sections(tmp_path):
manifest, session_dir, _question, _report, _decisions = _rich_session(tmp_path)
docs = build_documents(manifest, session_dir)
assert [doc["key"] for doc in docs] == [
"question",
"sql",
"preview",
"revised_question",
"assumptions",
"memories",
"schema_linking",
"validation_report",
"decisions",
]
by_key = {doc["key"]: doc for doc in docs}
assert by_key["preview"]["format"] == "markdown"
assert "tempo di esecuzione: 12 ms" in by_key["preview"]["content"]
assert "| 42 |" in by_key["preview"]["content"]
assert "Parsing e read-only" not in by_key["preview"]["content"]
assert by_key["revised_question"]["content"] == (
"# Domanda\n\nConta i pazienti con **ablazione**."
)
assert by_key["assumptions"]["content"] == (
"## Assunzioni\n\n- Considera solo gli adulti\n"
"- Usa l'ultima procedura disponibile"
)
assert "Preview / aggregato" not in by_key["validation_report"]["content"]
assert "Parsing e read-only" in by_key["validation_report"]["content"]
assert "Note del reviewer" in by_key["validation_report"]["content"]
def test_build_documents_groups_memories_and_filters_summary_decisions(tmp_path):
manifest, session_dir, _question, _report, _decisions = _rich_session(tmp_path)
by_key = {doc["key"]: doc for doc in build_documents(manifest, session_dir)}
memories = json.loads(by_key["memories"]["content"])
assert memories == [
{
"status": "approved",
"subject": "ablazione valida",
"detail": "Procedura con stato **completato**.",
"rationale": "Definizione approvata dal reviewer.",
},
{
"status": "declined",
"subject": "paziente attivo",
"detail": "`flag_attivo = TRUE`",
"rationale": "",
},
]
decisions = by_key["decisions"]["content"]
assert "column_excluded" in decisions
for hidden in (
"concept_clarified",
"memory_promoted",
"memory_promotion_declined",
"phase_approved",
"table_promoted",
"column_promoted",
):
assert hidden not in decisions
def test_build_documents_keeps_validation_report_when_it_has_no_preview(tmp_path):
manifest = create_session("q", _db(), tmp_path)
session_dir = tmp_path / manifest.id
report = "# Validation report\n\n## Parsing\n\n- OK\n"
(session_dir / "validation_report.md").write_text(report)
by_key = {doc["key"]: doc for doc in build_documents(manifest, session_dir)}
assert "preview" not in by_key
assert by_key["validation_report"]["content"] == report.strip()
def test_memory_projection_does_not_repeat_detail_as_marker_rationale(tmp_path):
manifest = create_session("q", _db(), tmp_path)
session_dir = tmp_path / manifest.id
decisions = [
_decision(1, "concept_clarified", "termine", "Definizione in **Markdown**."),
_decision(
2,
"memory_promoted",
"termine",
"seq:1",
"Definizione in **Markdown**.",
),
]
(session_dir / "review_decisions.jsonl").write_text(
"\n".join(d.model_dump_json() for d in decisions) + "\n"
)
by_key = {doc["key"]: doc for doc in build_documents(manifest, session_dir)}
assert json.loads(by_key["memories"]["content"])[0] == {
"status": "approved",
"subject": "termine",
"detail": "Definizione in **Markdown**.",
"rationale": "",
}
def test_filesystem_and_snapshot_builders_share_the_same_projection(tmp_path):
manifest, session_dir, question, report, decisions = _rich_session(tmp_path)
snapshot = SessionSnapshot(
manifest=manifest,
artifacts={
"question": question,
"schema_linking": (session_dir / "schema_linking.json").read_text(),
"sql_final": (session_dir / "sql_final.sql").read_text(),
"validation_report": report,
},
decisions=decisions,
)
assert build_snapshot_documents(snapshot) == build_documents(manifest, session_dir)
def test_cli_documents_json(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
from tht.cli import session_cmd
+166 -33
View File
@@ -1,5 +1,6 @@
import json
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
@@ -143,22 +144,160 @@ def new_session_manifest(
)
def build_snapshot_documents(snapshot) -> list[dict]:
docs = [{"phase": "—", "key": "question", "title": "Original question", "format": "text", "content": snapshot.manifest.question}]
spec = [
("question", "F3", "revised_question", "Revised question", "markdown"),
("schema_linking", "F4", "schema_linking", "Schema linking", "schema-linking"),
("sql_final", "F7", "sql", "Final SQL", "sql"),
("validation_report", "finalize", "validation_report", "Validation report", "markdown"),
_ASSUMPTIONS_HEADING = re.compile(
r"^#{1,6}\s+(?:assunzioni|assumptions)\s*$", re.IGNORECASE | re.MULTILINE
)
_PREVIEW_HEADING = re.compile(
r"^##\s+preview(?:\s*/\s*aggregato)?\s*$", re.IGNORECASE | re.MULTILINE
)
_NEXT_H2 = re.compile(r"^##\s+", re.MULTILINE)
_MEMORY_TYPES = frozenset(
{"concept_clarified", "memory_promoted", "memory_promotion_declined", "memory_rejected"}
)
_SUMMARY_HIDDEN_TYPES = _MEMORY_TYPES | frozenset(
{
"phase_approved",
"phase_auto_approved",
"table_approved",
"table_promoted",
"column_promoted",
}
)
def _split_question_sections(content: str) -> tuple[str, str]:
"""Split legacy combined question Markdown without rewriting the artifact."""
match = _ASSUMPTIONS_HEADING.search(content)
if match is None:
return content.strip(), ""
return content[:match.start()].strip(), content[match.start():].strip()
def _split_validation_preview(content: str) -> tuple[str, str]:
"""Return the persisted preview section and the remaining validation report."""
match = _PREVIEW_HEADING.search(content)
if match is None:
return "", content.strip()
body_start = match.end()
next_heading = _NEXT_H2.search(content, body_start)
section_end = next_heading.start() if next_heading else len(content)
preview = content[body_start:section_end].strip()
remaining = (content[:match.start()] + content[section_end:]).strip()
return preview, remaining
def _referenced_decision(marker, by_seq: dict[int, object]):
match = re.fullmatch(r"seq:(\d+)", marker.detail.strip())
return by_seq.get(int(match.group(1))) if match else None
def _memory_projection(decisions: list) -> list[dict[str, str]]:
by_seq = {decision.seq: decision for decision in decisions}
approved = [decision for decision in decisions if decision.type == "memory_promoted"]
declined = [
decision
for decision in decisions
if decision.type in {"memory_promotion_declined", "memory_rejected"}
]
for artifact, phase, key, title, fmt in spec:
if artifact in snapshot.artifacts:
docs.append({"phase": phase, "key": key, "title": title, "format": fmt, "content": snapshot.artifacts[artifact]})
if snapshot.decisions:
docs.append({"phase": "—", "key": "decisions", "title": "Decisions", "format": "decisions", "content": "\n".join(d.model_dump_json() for d in snapshot.decisions) + "\n"})
items: list[dict[str, str]] = []
for status, markers in (("approved", approved), ("declined", declined)):
for marker in markers:
origin = _referenced_decision(marker, by_seq)
detail = origin.detail if origin is not None else marker.detail
if re.fullmatch(r"seq:\d+", detail.strip()):
detail = ""
rationale = (
origin.rationale if origin is not None and origin.rationale
else marker.rationale
)
if rationale.strip() == detail.strip():
rationale = ""
items.append(
{
"status": status,
"subject": (origin.subject if origin is not None else marker.subject),
"detail": detail,
"rationale": rationale,
}
)
return items
def _document_bundle(manifest: SessionManifest, artifacts: dict[str, str], decisions: list) -> list[dict]:
docs: list[dict] = [{
"phase": "—",
"key": "question",
"title": "Original question",
"format": "text",
"content": manifest.question,
}]
sql = artifacts.get("sql_final")
if sql is not None:
docs.append({
"phase": "F7", "key": "sql", "title": "Final SQL",
"format": "sql", "content": sql,
})
validation = artifacts.get("validation_report")
preview, validation_without_preview = _split_validation_preview(validation or "")
if preview:
docs.append({
"phase": "finalize", "key": "preview", "title": "Data preview",
"format": "markdown", "content": preview,
})
revised, assumptions = _split_question_sections(artifacts.get("question", ""))
if revised:
docs.append({
"phase": "F3", "key": "revised_question", "title": "Revised question",
"format": "markdown", "content": revised,
})
if assumptions:
docs.append({
"phase": "F3", "key": "assumptions", "title": "Assumptions",
"format": "markdown", "content": assumptions,
})
memories = _memory_projection(decisions)
if memories:
docs.append({
"phase": "F8", "key": "memories", "title": "Memories",
"format": "memories", "content": json.dumps(memories, ensure_ascii=False),
})
schema_linking = artifacts.get("schema_linking")
if schema_linking is not None:
docs.append({
"phase": "F4", "key": "schema_linking", "title": "Schema linking",
"format": "schema-linking", "content": schema_linking,
})
if validation_without_preview:
docs.append({
"phase": "finalize", "key": "validation_report", "title": "Validation report",
"format": "markdown", "content": validation_without_preview,
})
visible_decisions = [d for d in decisions if d.type not in _SUMMARY_HIDDEN_TYPES]
if visible_decisions:
docs.append({
"phase": "—", "key": "decisions", "title": "Decisions",
"format": "decisions",
"content": "\n".join(d.model_dump_json() for d in visible_decisions) + "\n",
})
return docs
def build_snapshot_documents(snapshot) -> list[dict]:
from tht.phase import effective_decisions
return _document_bundle(
snapshot.manifest,
snapshot.artifacts,
effective_decisions(snapshot),
)
def touch_manifest(
session_id: str, sessions_root: Path, *, updated_by: str | None = None
) -> SessionManifest:
@@ -379,24 +518,18 @@ def persist_verified_finalization(
def build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]:
"""Ordered, read-only document bundle for the UI panel. Only documents that exist
on disk are returned. CTE artifacts (F6) are intentionally excluded (intermediate)."""
docs: list[dict] = [{
"phase": "—", "key": "question", "title": "Original question",
"format": "text", "content": manifest.question,
}]
spec = [
("question.md", "F3", "revised_question", "Revised question", "markdown"),
("schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"),
("sql_final.sql", "F7", "sql", "Final SQL", "sql"),
("validation_report.md", "finalize", "validation_report", "Validation report", "markdown"),
("review_decisions.jsonl", "—", "decisions", "Decisions", "decisions"),
]
for filename, phase, key, title, fmt in spec:
path = session_dir / filename
if path.exists():
docs.append({
"phase": phase, "key": key, "title": title,
"format": fmt, "content": path.read_text(),
})
return docs
"""Build the canonical read-only bundle for legacy filesystem sessions."""
from tht.phase import effective_decisions
artifact_files = {
"question": "question.md",
"schema_linking": "schema_linking.json",
"sql_final": "sql_final.sql",
"validation_report": "validation_report.md",
}
artifacts = {
key: path.read_text()
for key, filename in artifact_files.items()
if (path := session_dir / filename).exists()
}
return _document_bundle(manifest, artifacts, effective_decisions(session_dir))