diff --git a/docs/superpowers/plans/2026-07-23-session-summary-layout.md b/docs/superpowers/plans/2026-07-23-session-summary-layout.md new file mode 100644 index 00000000..dcca1a52 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-session-summary-layout.md @@ -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`. diff --git a/docs/superpowers/specs/2026-07-23-session-summary-layout-design.md b/docs/superpowers/specs/2026-07-23-session-summary-layout-design.md index d7affd6a..21a48e21 100644 --- a/docs/superpowers/specs/2026-07-23-session-summary-layout-design.md +++ b/docs/superpowers/specs/2026-07-23-session-summary-layout-design.md @@ -1,7 +1,7 @@ # Session view resizing and summary layout **Date:** 2026-07-23 -**Status:** Approved design, pending implementation +**Status:** Implemented and verified ## Goal diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4172cc9e..1f6b05d4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; } diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index d10051b7..71f909f9 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -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( diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 385ebe9d..6a35646d 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -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(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 : 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(null); const activeSessionIdRef = useRef(null); @@ -83,7 +98,6 @@ export function AppShell() { const composerRef = useRef(null); const queryClient = useQueryClient(); - const [panelSession, setPanelSession] = useState(null); const [showActivity, setShowActivity] = useState(false); const [activeOpen, setActiveOpen] = useState(true); const [archiveOpen, setArchiveOpen] = useState(false); @@ -448,13 +462,14 @@ export function AppShell() { return (
{panelSession && ( @@ -462,15 +477,28 @@ export function AppShell() { session={panelSession} onClose={() => setPanelSession(null)} onResume={doResume} + desktopSplit={sessionDesktopSplit} /> )} + {panelSession && sessionDesktopSplit && ( +
+
+ +
+
+ )} {showActivity && setShowActivity(false)} />} {showActivity && desktopSplit && (
diff --git a/frontend/src/shell/SessionDocumentsPanel.test.tsx b/frontend/src/shell/SessionDocumentsPanel.test.tsx index 11e58dd7..ec5e0f65 100644 --- a/frontend/src/shell/SessionDocumentsPanel.test.tsx +++ b/frontend/src/shell/SessionDocumentsPanel.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + } +}); diff --git a/frontend/src/shell/SessionDocumentsPanel.tsx b/frontend/src/shell/SessionDocumentsPanel.tsx index 920f782a..6c2ad69d 100644 --- a/frontend/src/shell/SessionDocumentsPanel.tsx +++ b/frontend/src/shell/SessionDocumentsPanel.tsx @@ -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 ; if (doc.format === "markdown") return ; @@ -31,6 +51,53 @@ function DocBody({ doc }: { doc: SessionDocument }) { return
{doc.content}
; } } + 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; + return ( + (candidate.status === "approved" || candidate.status === "declined") + && typeof candidate.subject === "string" + && typeof candidate.detail === "string" + && typeof candidate.rationale === "string" + ); + }) + : []; + if (items.length === 0) { + return

No memories recorded.

; + } + return ( +
    + {items.map((item, index) => ( +
  • + + {item.status === "approved" ? "Approved" : "Declined"} + +
    + +
    + {item.detail && ( +
    + +
    + )} + {item.rationale && ( +
    + +
    + )} +
  • + ))} +
+ ); + } 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

No decisions recorded.

; return (
    @@ -48,14 +117,22 @@ function DocBody({ doc }: { doc: SessionDocument }) { {(d.type ?? "decision").replace(/_/g, " ")} - {d.subject &&

    {d.subject}

    } - {d.detail &&

    {d.detail}

    } + {d.subject && ( +
    + +
    + )} + {d.detail && ( +
    + +
    + )} ))}
); } - return

{doc.content}

; + return ; } /** 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({ 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 ( -