Files
ThothII/docs/superpowers/specs/2026-06-29-session-management-design.md
T
marcopanandClaude Opus 4.8 916075329d 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>
2026-06-29 11:21:49 +02:00

230 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.