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:
@@ -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.
|
||||
Reference in New Issue
Block a user