# 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 is the explicit design contract of the orchestrator ([SKILL.md](../../../harness/.pi/skills/tht-sessione/SKILL.md)): *"the persisted state (ledger `review_decisions.jsonl` + artifacts) is the truth — what is not recorded did not happen."* The documents are not meant to *contain* the chat; they are meant to make the chat **reconstructable / unnecessary** — the documents are the *state*, the skill is the *procedure*, and together they let a fresh model continue the task. Whether that holds for a cold-start resume is a **verifiable prerequisite**, treated explicitly below. 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.** This is the backend half of the resume-correctness prerequisite: `spawnFor` gains a `new`/`resume` mode (new → `/nuova-domanda`, resume → `/riprendi-sessione `). See the dedicated **Resume correctness** section below for the full picture (backend prompt, skill cold-start procedure, end-to-end verification). ## 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. ## Resume correctness — verifiable prerequisite The whole "no chat persistence" premise rests on a fresh Pi process being able to **re-enter the workflow at the last incomplete phase using only the persisted state**. That is the design contract quoted above (ledger + artifacts are the truth; each phase's prerequisites are prior artifacts; rollback `/torna N`, discipline 11, already resumes "reviewing the existing artifacts" without replaying chat). The premise is sound — but the **cold-start resume path** (new process, zero conversation) is today thin and unproven. This feature treats resume correctness as a prerequisite and must close three gaps: 1. **Backend sends the wrong prompt.** `PiProcessManager.spawnFor` ([pi-process-manager.ts](../../../backend/src/pi/pi-process-manager.ts)) always sends `/nuova-domanda "kickoff"`, and `resume` calls the same path — so a "resume" kicks off a *new* question. `spawnFor` must take a `new`/`resume` mode: new → `/nuova-domanda`, resume → `/riprendi-sessione ` (the prompt [riprendi-sessione.md](../../../harness/.pi/prompts/riprendi-sessione.md)). 2. **No cold-start procedure in the skill.** The phases assume "you must already be in Phase 1"; there is no "Phase 0 / Resume" step telling a fresh process to read `tht session show `, take the current phase N, load that phase's artifacts, and resume its procedure. Add a short **Resume** section to [SKILL.md](../../../harness/.pi/skills/tht-sessione/SKILL.md) (or confirm the model reliably bootstraps from `tht session show` + artifact reads). The one-line resume prompt is not sufficient on its own — intra-session rollback works only because the conversation is still live; cold start has nothing to lean on. 3. **End-to-end verification.** A test that creates a session, drives it partway (e.g. into F4), tears down the Pi process, resumes in a fresh process, and asserts it lands on the **correct phase** with the prior artifacts available and presents the next gate — not a new-question kickoff. Until (1)–(3) pass, the phase-document model is a design assumption, not a proven fact. The read panel, rename, group, archive, and delete work regardless of resume; but the **"Riprendi"** action is only trustworthy once resume correctness is verified, so it must not ship as "working" before this gate is green. ## 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. - **Resume correctness (end-to-end):** the verification described in the *Resume correctness* section — partway session → teardown → fresh-process resume → lands on the correct phase with prior artifacts, not a new-question kickoff. This gate must be green before "Riprendi" is considered working. ## 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.