docs(spec): phase-aware session management design

Read-only document panel, rename, groups, archive (filesystem flag),
delete. No verbatim chat persistence — phase documents are the storage.
pgvector/app-DB deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:21:49 +02:00
co-authored by Claude Opus 4.8
parent 20bf181e34
commit 916075329d
@@ -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 · <nome>` /
`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 <id> --name <name>` → set `manifest.name`.
- `tht session set-group <id> --group <name>` → set `manifest.group`
(empty string clears it back to `None`).
- `tht session archive <id>` / `tht session unarchive <id>` → 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 <id>` → remove the session directory (`shutil.rmtree`). Idempotent
error if absent.
- `tht session documents <id> --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 <id>` (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 · <nome>` / `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.