diff --git a/docs/superpowers/specs/2026-06-29-session-management-design.md b/docs/superpowers/specs/2026-06-29-session-management-design.md new file mode 100644 index 00000000..808ad2de --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-session-management-design.md @@ -0,0 +1,229 @@ +# Session Management — Design + +**Date:** 2026-06-29 +**Status:** Approved (design), pending implementation plan +**Layers:** frontend, backend (Fastify), harness (`tht` CLI) + +## Problem + +The session rail ([NavSessions.tsx](../../../frontend/src/shell/NavSessions.tsx)) is +today a flat list whose only interaction is **click = resume** (spawns a Pi process). +There is no way to read a past session, rename it, organize it, archive it, or delete +it. Inspired by Claude's desktop session context menu, we want a complete management +surface with five capabilities: + +1. **Vista divisa** — a read-only side panel showing the session's stored content. +2. **Rinomina** — rename a session. +3. **Sposta nel gruppo** — organize sessions into groups. +4. **Archivia** — archive a session out of the active list (read-only retention). +5. **Elimina** — permanently delete a session. + +## Key architectural insight (drives the whole design) + +The workflow is **phase-based**. Each phase persists its own document into the session +directory; advancing to the next phase does **not** require replaying the prior +conversation. Therefore **the phase documents ARE the persistence** — we do **not** +need to store the verbatim chat. + +This was the crux decision in brainstorming. It means: + +- The "vista divisa" is a **read-only document viewer** driven by what's on disk, not a + chat replay. No new transcript-persistence layer, no correlation of Pi's internal + JSONL logs. +- "Resuming an interrupted session" = the normal chat flow re-entering at the **last + incomplete phase**; the earlier phases' documents are the context. + +### What each phase persists (from [workflow.yaml](../../../harness/workflow.yaml)) + +| Phase | Document on disk | +|-------|------------------| +| (manifest, always) | `session_manifest.yaml` — original question, name, status, author, timestamps | +| F3 riscrittura | `question.md` — revised question + assumptions | +| F4 schema_linking | `schema_linking.json` | +| F6 cte | `cte_plan.json`, `cte_tests.json` | +| F7 sql_finale | `sql_final.sql` | +| finalize | `validation_report.md`, `evidence.json` | +| (decisions, always) | `review_decisions.jsonl` — decision ledger | + +`tht session show --json` already returns the manifest **plus** the computed `phase` +(current phase, folded from the ledger by [phase.py](../../../harness/tht/phase.py)) +and `has_schema_linking`. + +## Decisions (from brainstorming) + +- **Read panel content (option C):** a status header (`Fase N di 8 · ` / + `Completata` / `Archiviata`) + document cards **in phase order** (only those that + exist) + a collapsible **Decisioni** block. No verbatim chat. +- **Resume rule:** `status == "finalized"` OR `archived == true` → **read-only**, never + resumable. Any other state (`open`, `closed`) → **resumable** into the last incomplete + phase. +- **Row click = open the read-only panel** (it no longer resumes). **Resume** is an + explicit **"Riprendi"** button shown in the panel header only when the session is + resumable. +- **Archive = filesystem flag.** A boolean `archived` field on the manifest. The active + rail filters out archived sessions; a separate **"Archivio"** view lists them and opens + the same read-only panel. (A flag, not a directory move, so id→path resolution + `sessions_root / session_id` and resume/show keep working unchanged.) +- **Delete = hard delete** of the session directory, behind a **confirmation dialog**. + Available from both the active list and the archive. No trash/soft-delete — the archive + is the "soft" tier. +- **Groups = manifest field (model A).** A nullable `group` string on the manifest. The + rail derives the group list from distinct values. "Sposta nel gruppo ›" lists existing + groups + "Nuovo gruppo…" (type a name). No separate registry, no colors/ordering, no + persistent empty groups. +- **Rename = manifest `name`** (field already exists). The rail shows `name` instead of + the question when present. + +## Data model — manifest changes + +In [`SessionManifest`](../../../harness/tht/session/models.py) add two fields +(`name` already exists): + +```python +archived: bool = False +group: str | None = None +``` + +Both default such that existing manifests load unchanged. They are written by the new +mutating commands below and surfaced by `session list`/`session show`. + +## Harness layer (`tht` CLI) + +New/changed subcommands in +[session_cmd.py](../../../harness/tht/cli/session_cmd.py), with corresponding helpers in +[store.py](../../../harness/tht/session/store.py). All mutate the manifest via the +existing `touch_manifest`/`to_yaml` pattern and update `updated_at/updated_by`. + +- `tht session set-name --name ` → set `manifest.name`. +- `tht session set-group --group ` → set `manifest.group` + (empty string clears it back to `None`). +- `tht session archive ` / `tht session unarchive ` → flip `archived`. + (Unarchive is included so an accidental archive is recoverable; it does **not** change + resumability — a finalized session stays read-only.) +- `tht session delete ` → remove the session directory (`shutil.rmtree`). Idempotent + error if absent. +- `tht session documents --json` → **new read command** returning the ordered, + available documents for the panel: + ```json + [ { "phase": "F3", "key": "revised_question", "title": "Domanda rivista", + "format": "markdown", "content": "..." }, ... ] + ``` + Document set: original question (from manifest), `question.md`, `schema_linking.json`, + `sql_final.sql`, `validation_report.md`, and the decision ledger. Only documents that + exist on disk are returned. The F6 CTE artifacts (`cte_plan.json`, `cte_tests.json`) + are **intentionally excluded** from the v1 panel as intermediate workflow state, not + reviewer-facing deliverables; add later if needed. The backend cannot read these files directly because the + `sessions` root lives in the **workspace** YAML (resolved by `tht` config, unknown to + Node) — so reading goes through `tht`. +- `session list --json` → each row additionally carries `archived`, `group`, `name` + (in addition to the existing `id/status/question/summary/created_at/updated_at/author`). + The active vs archive split and grouping are done in the frontend from these fields. + +## Backend layer (Fastify) + +New routes in [sessions.ts](../../../backend/src/routes/sessions.ts), each delegating to a +new `ThtRunner` method that shells the matching `tht` subcommand: + +- `POST /sessions/:id/rename` `{ name }` → `tht session set-name`. +- `POST /sessions/:id/group` `{ group }` → `tht session set-group` (`group: ""` clears). +- `POST /sessions/:id/archive` / `POST /sessions/:id/unarchive`. +- `DELETE /sessions/:id` → `tht session delete`. If a Pi runtime is live for that id, + tear it down first (`mgr.teardown`). +- `GET /sessions/:id/documents` → `tht session documents --json`. +- `GET /sessions` → unchanged route; rows now include `archived/group/name`. + +**Resume guard.** `POST /sessions/:id/resume` must refuse when the session is read-only. +Before spawning, read the manifest (`tht.sessionShow`); if `status == "finalized"` or +`archived`, return `409` with a clear message and do **not** spawn Pi. + +**Resume must rebuild phase context (gap to fix).** Today `PiProcessManager.spawnFor` +unconditionally sends `/nuova-domanda "kickoff"` +([pi-process-manager.ts](../../../backend/src/pi/pi-process-manager.ts)), and `resume` +calls the same path — so a "resume" actually kicks off a *new* question instead of +re-entering the workflow. `spawnFor` must take a **mode**: new sessions send +`/nuova-domanda`, resumed sessions send `/riprendi-sessione ` (the existing prompt +[riprendi-sessione.md](../../../harness/.pi/prompts/riprendi-sessione.md)) so Pi rebuilds +state and lands on the last incomplete phase. Without this, the resume model does not +actually work. + +## Frontend layer (React + base-ui + Tailwind + React Query) + +### Types ([types.ts](../../../frontend/src/api/types.ts)) +- Extend `SessionSummary` with `archived: boolean`, `group: string | null`, + `name: string | null`. +- Add `SessionDocument { phase: string; key: string; title: string; format: "markdown" | "sql" | "schema-linking" | "decisions" | "text"; content: string }`. + +### API client ([sessions.ts](../../../frontend/src/api/sessions.ts)) +- Add `renameSession(id, name)`, `setSessionGroup(id, group)`, `archiveSession(id)`, + `unarchiveSession(id)`, `deleteSession(id)`, `getSessionDocuments(id)`. + +### NavSessions ([NavSessions.tsx](../../../frontend/src/shell/NavSessions.tsx)) +- **Row click → open the read-only panel** (via a new `onOpenPanel(id)` prop), no longer + resumes. +- Render the active list (filter `!archived`) grouped under **collapsible group headers** + derived from distinct `group` values, plus a **"Senza gruppo"** section for `group == null`. +- Each row gets a **kebab (⋮) menu on hover** (base-ui Menu) mirroring the reference + image: + - **Vista divisa** → open the read-only panel + - **Rinomina** → rename dialog (sets `name`) + - **Sposta nel gruppo ›** → submenu of existing groups + "Nuovo gruppo…" + - **Archivia** → `archiveSession` + - **Elimina** (red) → confirmation dialog → `deleteSession` +- After each mutation, invalidate the `["sessions"]` query. + +### Archive view +- An **"Archivio"** entry at the bottom of the rail. Selecting it shows archived sessions + (filter `archived`), each opening the **same** read-only panel. Archived rows offer + **Vista divisa**, **Ripristina** (unarchive), and **Elimina** — no resume. + +### SessionDocumentsPanel (new, left drawer) +- New `frontend/src/shell/SessionDocumentsPanel.tsx`. A left column/drawer, opened by + `onOpenPanel`, closed with ✕. +- **Header:** name/question, status chip (`Fase N di 8 · ` / `Completata` / + `Archiviata`), author, created/updated. A **"Riprendi"** button rendered only when + resumable; it triggers the existing resume flow and focuses the center chat. +- **Body:** one card per `SessionDocument`, in phase order, rendered by format reusing + existing viewers — `format: "sql"` → [SqlViewer](../../../frontend/src/viewers/SqlViewer.tsx), + `"schema-linking"` → [SchemaLinkingViewer](../../../frontend/src/viewers/SchemaLinkingViewer.tsx), + `"markdown"`/`"text"` → [MarkdownView](../../../frontend/src/viewers/MarkdownView.tsx). +- **Decisioni:** the `decisions` document rendered as a collapsible list at the bottom. + +### AppShell ([AppShell.tsx](../../../frontend/src/shell/AppShell.tsx)) +- Add the **left panel column** to the existing layout, giving three columns: + `[ documents panel (left) | chat (center) | sessions rail (right) ]`. +- Track two distinct ids: the **active chat session** (drives `useSessionStream` + the + center) and the **panel session** (read-only, drives the left drawer). Opening the panel + does not touch the active chat; "Riprendi" promotes the panel session to the active one. + +## UX summary + +- **Layout:** three columns, panel on the left as requested, rail stays on the right. +- **Context menu:** kebab ⋮ on hover, items as above; "Elimina" in red with confirm. +- **Rail organization:** collapsible group headers + "Senza gruppo"; "Archivio" as a + separate section, never mixed into the active list. + +## Testing approach (TDD per repo convention) + +- **Harness (pytest):** new `store.py` helpers (set-name/group, archive/unarchive, + delete) and the `session documents --json` shape; manifest round-trips with the new + fields; `session list --json` includes them. +- **Backend (vitest):** each new route maps to the right `tht` invocation (injected + `ThtRunner`/spawn double); resume guard returns 409 for finalized/archived; `spawnFor` + sends `/riprendi-sessione` in resume mode and `/nuova-domanda` in new mode. +- **Frontend (vitest + RTL + MSW):** kebab menu actions call the right endpoints and + invalidate the query; grouping/archived filtering renders correctly; the panel renders + documents via the right viewers and shows/hides "Riprendi" by resumability; + delete-confirm flow. + +## Out of scope / deferred (with triggers) + +- **Application database / pgvector.** Not introduced. The only DB is the client's + read-only DWH; an app DB is new infrastructure not justified by store/browse/view/ + no-resume. Revisit when the archive must outlive the workspace filesystem (→ app + Postgres + `archived_sessions`, full-text via `tsvector`) or when "reuse a semantically + similar past session" becomes a goal (→ add a `vector` column + pgvector). Both are + non-breaking additions later. +- **Keyboard shortcuts** (R/A/D/F in the reference image) — polish, addable later. +- **Group colors, ordering, persistent empty groups** — the model-B registry, only if + needed. +- **Verbatim chat persistence** — explicitly unnecessary given the phase-document model.