From 8167d41621dd83bfd84408fb9ef31aeb4d857fb6 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 29 Jun 2026 12:05:19 +0200 Subject: [PATCH] docs(plan): session management implementation plan (12 tasks, TDD) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-29-session-management.md | 1939 +++++++++++++++++ 1 file changed, 1939 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-session-management.md diff --git a/docs/superpowers/plans/2026-06-29-session-management.md b/docs/superpowers/plans/2026-06-29-session-management.md new file mode 100644 index 00000000..ba489736 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-session-management.md @@ -0,0 +1,1939 @@ +# Session Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add full session management — read-only document panel, rename, groups, archive, delete — driven by the existing phase-document persistence, with correct cold-start resume. + +**Architecture:** Three layers. The `tht` Python CLI owns persistence (manifest + phase artifacts on disk) and gains mutation/read subcommands. The Fastify backend proxies them as REST routes and fixes resume to send the right prompt. The React frontend adds a kebab context menu, grouping, an archive view, and a left-side read-only documents panel reusing existing viewers. + +**Tech Stack:** Python (typer, pydantic, pytest) · Node/Fastify + TypeScript (vitest) · React 18 + base-ui + Tailwind + TanStack Query + MSW (vitest + Testing Library). + +## Global Constraints + +- **No verbatim chat persistence.** The phase documents are the persistence; do not add a transcript store. (Spec: "Key architectural insight".) +- **`-c`/`--config` is a PER-COMMAND option in `tht`** — it must follow the subcommand, never precede it. (`ThtRunner.buildArgv` already appends it.) +- **`--json` output must be pristine** — only valid JSON on stdout, no color/extra lines. (Use `typer.echo(json.dumps(...))` and return early.) +- **Archive is a manifest flag**, never a directory move (preserves `sessions_root / session_id` id→path resolution). +- **Read-only rule:** `status == "finalized"` OR `archived == true` → never resumable. +- **Resume correctness is a prerequisite for the "Riprendi" action** — it must not ship as working until Task 7's verification is green. +- **Backend body-less POSTs** must send no JSON body (the client only sets `content-type` when a body is present; keep archive/unarchive/delete body-less). +- **Manifest field defaults** must keep existing manifests loading unchanged (`archived: bool = False`, `group: str | None = None`). + +--- + +## File Structure + +**Harness (`tht`)** +- Modify `harness/tht/session/models.py` — add `archived`, `group` to `SessionManifest`. +- Modify `harness/tht/session/store.py` — `_save_touched` + `set_name` / `set_group` / `set_archived` / `delete_session` / `build_documents`. +- Modify `harness/tht/cli/session_cmd.py` — `set-name` / `set-group` / `archive` / `unarchive` / `delete` / `documents` commands; `_list_sessions` includes new fields. +- Modify `harness/.pi/skills/tht-sessione/SKILL.md` — add a "Phase 0 — Resume" section. +- Create `harness/tests/test_session_mutations.py`, `harness/tests/test_session_documents.py`. + +**Backend (Fastify)** +- Modify `backend/src/tht/tht-runner.ts` — `setName` / `setGroup` / `archive` / `unarchive` / `deleteSession` / `documents` + private `ok()`. +- Modify `backend/src/routes/sessions.ts` — rename/group/archive/unarchive/delete/documents routes + resume guard. +- Modify `backend/src/pi/pi-process-manager.ts` — `spawnFor` gains `mode`, `resume` passes `"resume"`. +- Modify `backend/src/app.ts` — add `DELETE` to CORS methods. +- Modify `backend/test/tht-runner.test.ts`, `backend/test/routes-sessions.test.ts`, `backend/test/pi-process-manager.test.ts`. + +**Frontend (React)** +- Modify `frontend/src/api/types.ts` — `SessionSummary` fields + `SessionDocument`. +- Modify `frontend/src/api/sessions.ts` — mutation + documents client functions. +- Create `frontend/src/shell/SessionMenu.tsx` — kebab context menu (base-ui). +- Create `frontend/src/shell/RenameDialog.tsx`, `frontend/src/shell/MoveToGroupSubmenu.tsx`, `frontend/src/shell/DeleteConfirmDialog.tsx`. +- Create `frontend/src/shell/SessionDocumentsPanel.tsx` — left read-only panel. +- Modify `frontend/src/shell/NavSessions.tsx` — grouping, archive view, row→panel, menu wiring. +- Modify `frontend/src/shell/AppShell.tsx` — left panel column + panel/active state. +- Modify/create the matching `*.test.tsx` files. + +--- + +## Phase A — Harness data layer + +### Task 1: Manifest fields + store mutation helpers + +**Files:** +- Modify: `harness/tht/session/models.py` +- Modify: `harness/tht/session/store.py` +- Modify: `harness/tht/cli/session_cmd.py` (the `_list_sessions` dict only) +- Test: `harness/tests/test_session_mutations.py` (create) + +**Interfaces:** +- Consumes: `create_session(question, db, sessions_root) -> SessionManifest`, `load_session(id, root) -> SessionManifest`, `MANIFEST`, `current_author()`. +- Produces: + - `set_name(session_id: str, name: str | None, sessions_root: Path) -> SessionManifest` + - `set_group(session_id: str, group: str | None, sessions_root: Path) -> SessionManifest` + - `set_archived(session_id: str, archived: bool, sessions_root: Path) -> SessionManifest` + - `delete_session(session_id: str, sessions_root: Path) -> None` + - `SessionManifest.archived: bool`, `SessionManifest.group: str | None` + +- [ ] **Step 1: Write the failing test** + +Create `harness/tests/test_session_mutations.py`: + +```python +"""Tests for session manifest mutations (rename, group, archive, delete).""" +import pytest + +from tht.config import DatabaseConfig +from tht.session.store import ( + SessionError, + create_session, + delete_session, + load_session, + set_archived, + set_group, + set_name, +) + + +def _db(): + return DatabaseConfig( + database="testdb", user="u", password="p", # noqa: S106 + **{"schema": "public"}, + ) + + +def test_manifest_defaults(tmp_path): + m = create_session("domanda", _db(), tmp_path) + assert m.archived is False + assert m.group is None + assert m.name is None + + +def test_set_name(tmp_path): + m = create_session("domanda", _db(), tmp_path) + set_name(m.id, "Pazienti 2024", tmp_path) + assert load_session(m.id, tmp_path).name == "Pazienti 2024" + + +def test_set_name_empty_clears(tmp_path): + m = create_session("domanda", _db(), tmp_path) + set_name(m.id, "x", tmp_path) + set_name(m.id, "", tmp_path) + assert load_session(m.id, tmp_path).name is None + + +def test_set_group_and_clear(tmp_path): + m = create_session("domanda", _db(), tmp_path) + set_group(m.id, "Aritmologia", tmp_path) + assert load_session(m.id, tmp_path).group == "Aritmologia" + set_group(m.id, "", tmp_path) + assert load_session(m.id, tmp_path).group is None + + +def test_set_archived(tmp_path): + m = create_session("domanda", _db(), tmp_path) + set_archived(m.id, True, tmp_path) + assert load_session(m.id, tmp_path).archived is True + set_archived(m.id, False, tmp_path) + assert load_session(m.id, tmp_path).archived is False + + +def test_delete_session(tmp_path): + m = create_session("domanda", _db(), tmp_path) + delete_session(m.id, tmp_path) + assert not (tmp_path / m.id).exists() + with pytest.raises(SessionError): + load_session(m.id, tmp_path) + + +def test_delete_missing_raises(tmp_path): + with pytest.raises(SessionError): + delete_session("nope", tmp_path) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -q` +Expected: FAIL — `ImportError: cannot import name 'set_name'` (and friends). + +- [ ] **Step 3: Add the manifest fields** + +In `harness/tht/session/models.py`, inside `SessionManifest`, after the existing `name: str | None = None` line, add: + +```python + archived: bool = False + group: str | None = None +``` + +- [ ] **Step 4: Add the store helpers** + +In `harness/tht/session/store.py`, add `import shutil` at the top with the other imports, then append these functions (place after `close_session`): + +```python +def _save_touched(manifest: SessionManifest, sessions_root: Path) -> SessionManifest: + """Persist `manifest` updating updated_at/updated_by (single save path for mutations).""" + manifest.updated_at = datetime.now(UTC) + manifest.updated_by = current_author() + manifest.to_yaml(sessions_root / manifest.id / MANIFEST) + return manifest + + +def set_name(session_id: str, name: str | None, sessions_root: Path) -> SessionManifest: + """Set the descriptive name (empty/blank clears it back to None).""" + manifest = load_session(session_id, sessions_root) + manifest.name = (name or "").strip() or None + return _save_touched(manifest, sessions_root) + + +def set_group(session_id: str, group: str | None, sessions_root: Path) -> SessionManifest: + """Set the group (empty/blank clears it back to None).""" + manifest = load_session(session_id, sessions_root) + manifest.group = (group or "").strip() or None + return _save_touched(manifest, sessions_root) + + +def set_archived(session_id: str, archived: bool, sessions_root: Path) -> SessionManifest: + """Flip the archived flag. Archiving does NOT change resumability (a finalized + session stays read-only); unarchive only moves it back to the active list.""" + manifest = load_session(session_id, sessions_root) + manifest.archived = archived + return _save_touched(manifest, sessions_root) + + +def delete_session(session_id: str, sessions_root: Path) -> None: + """Hard-delete the session directory. SessionError if it does not exist.""" + load_session(session_id, sessions_root) # raises SessionError if absent + shutil.rmtree(sessions_root / session_id) +``` + +- [ ] **Step 5: Surface the new fields in `_list_sessions`** + +In `harness/tht/cli/session_cmd.py`, in `_list_sessions`, extend the appended dict (add three keys after `"author": m.author,`): + +```python + "author": m.author, + "name": m.name, + "group": m.group, + "archived": m.archived, +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -q` +Expected: PASS (7 passed). + +- [ ] **Step 7: Commit** + +```bash +git add harness/tht/session/models.py harness/tht/session/store.py harness/tht/cli/session_cmd.py harness/tests/test_session_mutations.py +git commit -m "feat(harness): manifest archived/group fields + session mutation helpers" +``` + +--- + +### Task 2: CLI mutation commands + +**Files:** +- Modify: `harness/tht/cli/session_cmd.py` +- Test: `harness/tests/test_session_mutations.py` (append CLI tests) + +**Interfaces:** +- Consumes: `set_name`/`set_group`/`set_archived`/`delete_session` (Task 1), `_load_config_or_exit`, `load_session_or_exit`, `session_app`. +- Produces CLI commands: `tht session set-name --name `, `set-group --group `, `archive `, `unarchive `, `delete `. + +- [ ] **Step 1: Write the failing test** + +Append to `harness/tests/test_session_mutations.py`: + +```python +import json +from typer.testing import CliRunner + +from tht.cli.session_cmd import session_app + + +def _patch_cfg(monkeypatch, tmp_path): + from tht.cli import session_cmd + + class FakePaths: + sessions = tmp_path + + class FakeCfg: + paths = FakePaths() + database = _db() + + monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: FakeCfg()) + + +def test_cli_set_name(tmp_path, monkeypatch): + m = create_session("q", _db(), tmp_path) + _patch_cfg(monkeypatch, tmp_path) + res = CliRunner().invoke(session_app, ["set-name", m.id, "--name", "Mio nome"]) + assert res.exit_code == 0, res.output + assert load_session(m.id, tmp_path).name == "Mio nome" + + +def test_cli_set_group(tmp_path, monkeypatch): + m = create_session("q", _db(), tmp_path) + _patch_cfg(monkeypatch, tmp_path) + res = CliRunner().invoke(session_app, ["set-group", m.id, "--group", "G1"]) + assert res.exit_code == 0, res.output + assert load_session(m.id, tmp_path).group == "G1" + + +def test_cli_archive_unarchive(tmp_path, monkeypatch): + m = create_session("q", _db(), tmp_path) + _patch_cfg(monkeypatch, tmp_path) + assert CliRunner().invoke(session_app, ["archive", m.id]).exit_code == 0 + assert load_session(m.id, tmp_path).archived is True + assert CliRunner().invoke(session_app, ["unarchive", m.id]).exit_code == 0 + assert load_session(m.id, tmp_path).archived is False + + +def test_cli_delete(tmp_path, monkeypatch): + m = create_session("q", _db(), tmp_path) + _patch_cfg(monkeypatch, tmp_path) + res = CliRunner().invoke(session_app, ["delete", m.id]) + assert res.exit_code == 0, res.output + assert not (tmp_path / m.id).exists() + + +def test_cli_list_includes_new_fields(tmp_path, monkeypatch): + from tht.cli.session_cmd import _list_sessions + + create_session("q", _db(), tmp_path) + rows = _list_sessions(tmp_path) + assert {"name", "group", "archived"}.issubset(rows[0].keys()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -k cli -q` +Expected: FAIL — `No such command 'set-name'` (CLI commands not registered). + +- [ ] **Step 3: Add the CLI commands** + +In `harness/tht/cli/session_cmd.py`, after the `close_cmd` function, add: + +```python +@session_app.command("set-name") +def set_name_cmd( + session_id: str = typer.Argument(...), + name: str = typer.Option(..., "--name", help="Nome descrittivo (vuoto = azzera)."), + config: Path = CONFIG_OPT, +) -> None: + """Imposta il nome descrittivo della sessione.""" + from tht.session.store import set_name + + cfg = _load_config_or_exit(config) + load_session_or_exit(cfg, session_id) + set_name(session_id, name, cfg.paths.sessions) + typer.secho(f"OK: nome aggiornato per {session_id}.", fg=typer.colors.GREEN) + + +@session_app.command("set-group") +def set_group_cmd( + session_id: str = typer.Argument(...), + group: str = typer.Option(..., "--group", help="Nome del gruppo (vuoto = nessun gruppo)."), + config: Path = CONFIG_OPT, +) -> None: + """Sposta la sessione in un gruppo (o la toglie da ogni gruppo).""" + from tht.session.store import set_group + + cfg = _load_config_or_exit(config) + load_session_or_exit(cfg, session_id) + set_group(session_id, group, cfg.paths.sessions) + typer.secho(f"OK: gruppo aggiornato per {session_id}.", fg=typer.colors.GREEN) + + +@session_app.command("archive") +def archive_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: + """Archivia la sessione (la toglie dalla lista attiva, sola lettura).""" + from tht.session.store import set_archived + + cfg = _load_config_or_exit(config) + load_session_or_exit(cfg, session_id) + set_archived(session_id, True, cfg.paths.sessions) + typer.secho(f"OK: sessione {session_id} archiviata.", fg=typer.colors.GREEN) + + +@session_app.command("unarchive") +def unarchive_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: + """Ripristina la sessione dall'archivio (non ne cambia la ripristinabilità).""" + from tht.session.store import set_archived + + cfg = _load_config_or_exit(config) + load_session_or_exit(cfg, session_id) + set_archived(session_id, False, cfg.paths.sessions) + typer.secho(f"OK: sessione {session_id} ripristinata.", fg=typer.colors.GREEN) + + +@session_app.command("delete") +def delete_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: + """Elimina definitivamente la cartella di sessione.""" + from tht.session.store import delete_session + + cfg = _load_config_or_exit(config) + load_session_or_exit(cfg, session_id) + delete_session(session_id, cfg.paths.sessions) + typer.secho(f"OK: sessione {session_id} eliminata.", fg=typer.colors.GREEN) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -q` +Expected: PASS (all tests, including the new CLI ones). + +- [ ] **Step 5: Commit** + +```bash +git add harness/tht/cli/session_cmd.py harness/tests/test_session_mutations.py +git commit -m "feat(harness): tht session set-name/set-group/archive/unarchive/delete commands" +``` + +--- + +### Task 3: Documents read command + +**Files:** +- Modify: `harness/tht/session/store.py` (add `build_documents`) +- Modify: `harness/tht/cli/session_cmd.py` (add `documents` command) +- Test: `harness/tests/test_session_documents.py` (create) + +**Interfaces:** +- Consumes: `load_session`, `SessionManifest`. +- Produces: + - `build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]` — each dict `{phase, key, title, format, content}`; `format ∈ {"text","markdown","sql","schema-linking","decisions"}`. + - CLI `tht session documents --json`. + +- [ ] **Step 1: Write the failing test** + +Create `harness/tests/test_session_documents.py`: + +```python +"""Tests for `tht session documents --json` and build_documents.""" +import json + +from typer.testing import CliRunner + +from tht.config import DatabaseConfig +from tht.session.store import build_documents, create_session +from tht.cli.session_cmd import session_app + + +def _db(): + return DatabaseConfig( + database="testdb", user="u", password="p", # noqa: S106 + **{"schema": "public"}, + ) + + +def test_build_documents_always_has_original_question(tmp_path): + m = create_session("quante ablazioni nel 2024", _db(), tmp_path) + docs = build_documents(m, tmp_path / m.id) + keys = [d["key"] for d in docs] + assert keys[0] == "question" + assert docs[0]["format"] == "text" + assert docs[0]["content"] == "quante ablazioni nel 2024" + # question.md is written by create_session -> revised_question present + assert "revised_question" in keys + + +def test_build_documents_includes_existing_artifacts_only(tmp_path): + m = create_session("q", _db(), tmp_path) + sdir = tmp_path / m.id + (sdir / "sql_final.sql").write_text("SELECT 1") + (sdir / "schema_linking.json").write_text('{"question":"q","candidates":[]}') + docs = {d["key"]: d for d in build_documents(m, sdir)} + assert docs["sql"]["format"] == "sql" + assert docs["sql"]["content"] == "SELECT 1" + assert docs["schema_linking"]["format"] == "schema-linking" + assert "validation_report" not in docs # not written + + +def test_cli_documents_json(tmp_path, monkeypatch): + m = create_session("q", _db(), tmp_path) + from tht.cli import session_cmd + + class FakePaths: + sessions = tmp_path + + class FakeCfg: + paths = FakePaths() + database = _db() + + monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: FakeCfg()) + res = CliRunner().invoke(session_app, ["documents", m.id, "--json"]) + assert res.exit_code == 0, res.output + docs = json.loads(res.output) + assert isinstance(docs, list) + assert docs[0]["key"] == "question" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd harness && .venv/bin/pytest tests/test_session_documents.py -q` +Expected: FAIL — `cannot import name 'build_documents'`. + +- [ ] **Step 3: Add `build_documents`** + +In `harness/tht/session/store.py`, append: + +```python +def build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]: + """Ordered, read-only document bundle for the UI panel. Only documents that exist + on disk are returned. CTE artifacts (F6) are intentionally excluded (intermediate).""" + docs: list[dict] = [{ + "phase": "—", "key": "question", "title": "Domanda originale", + "format": "text", "content": manifest.question, + }] + spec = [ + ("question.md", "F3", "revised_question", "Domanda rivista", "markdown"), + ("schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"), + ("sql_final.sql", "F7", "sql", "SQL finale", "sql"), + ("validation_report.md", "finalize", "validation_report", "Report di validazione", "markdown"), + ("review_decisions.jsonl", "—", "decisions", "Decisioni", "decisions"), + ] + for filename, phase, key, title, fmt in spec: + path = session_dir / filename + if path.exists(): + docs.append({ + "phase": phase, "key": key, "title": title, + "format": fmt, "content": path.read_text(), + }) + return docs +``` + +- [ ] **Step 4: Add the `documents` CLI command** + +In `harness/tht/cli/session_cmd.py`, after `delete_cmd`, add: + +```python +@session_app.command("documents") +def documents_cmd( + session_id: str = typer.Argument(...), + json_out: bool = typer.Option(False, "--json", help="Emetti JSON su stdout (pristine)."), + config: Path = CONFIG_OPT, +) -> None: + """Documenti di sola lettura della sessione (domanda, rivista, schema, SQL, report, decisioni).""" + from tht.session.store import build_documents + + cfg = _load_config_or_exit(config) + manifest = load_session_or_exit(cfg, session_id) + docs = build_documents(manifest, session_dir(cfg, session_id)) + if json_out: + typer.echo(json.dumps(docs, ensure_ascii=False, indent=2)) + return + for d in docs: + typer.echo(f"[{d['phase']}] {d['title']} ({d['format']})") +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd harness && .venv/bin/pytest tests/test_session_documents.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 6: Commit** + +```bash +git add harness/tht/session/store.py harness/tht/cli/session_cmd.py harness/tests/test_session_documents.py +git commit -m "feat(harness): tht session documents --json read command" +``` + +--- + +## Phase B — Backend (Fastify) + +### Task 4: ThtRunner methods + +**Files:** +- Modify: `backend/src/tht/tht-runner.ts` +- Test: `backend/test/tht-runner.test.ts` (append) + +**Interfaces:** +- Consumes: `ThtRunner.run`, `ThtRunner.buildArgv`, private `json`. +- Produces methods on `ThtRunner`: + - `setName(id: string, name: string): Promise` + - `setGroup(id: string, group: string): Promise` + - `archive(id: string): Promise` / `unarchive(id: string): Promise` + - `deleteSession(id: string): Promise` + - `documents(id: string): Promise` where + `SessionDocument = { phase: string; key: string; title: string; format: string; content: string }` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/test/tht-runner.test.ts`: + +```typescript +test("setName builds the right argv", async () => { + const calls: string[][] = []; + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" }); + r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; }; + await r.setName("sid", "Mio nome"); + expect(calls[0]).toEqual(["session", "set-name", "sid", "--name", "Mio nome"]); +}); + +test("setGroup / archive / unarchive / deleteSession build argv", async () => { + const calls: string[][] = []; + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" }); + r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; }; + await r.setGroup("sid", "G1"); + await r.archive("sid"); + await r.unarchive("sid"); + await r.deleteSession("sid"); + expect(calls).toEqual([ + ["session", "set-group", "sid", "--group", "G1"], + ["session", "archive", "sid"], + ["session", "unarchive", "sid"], + ["session", "delete", "sid"], + ]); +}); + +test("ok() throws on non-zero exit", async () => { + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" }); + r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: nope" }); + await expect(r.archive("sid")).rejects.toThrow(/nope/); +}); + +test("documents parses the JSON array", async () => { + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" }); + r.run = async () => ({ + code: 0, + stdout: '[{"phase":"—","key":"question","title":"Domanda originale","format":"text","content":"q"}]', + stderr: "", + }); + const docs = await r.documents("sid"); + expect(docs[0].key).toBe("question"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/tht-runner.test.ts` +Expected: FAIL — `r.setName is not a function`. + +- [ ] **Step 3: Add the methods** + +In `backend/src/tht/tht-runner.ts`, add a `SessionDocument` interface near `SessionRow`: + +```typescript +export interface SessionDocument { + phase: string; + key: string; + title: string; + format: string; + content: string; +} +``` + +Then add a private `ok` helper (after the existing private `json` method) and the public methods (after `sqlExport`): + +```typescript + private async ok(args: string[]): Promise { + const { code, stderr } = await this.run(args); + if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); + } + + setName(id: string, name: string) { return this.ok(["session", "set-name", id, "--name", name]); } + setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); } + archive(id: string) { return this.ok(["session", "archive", id]); } + unarchive(id: string) { return this.ok(["session", "unarchive", id]); } + deleteSession(id: string) { return this.ok(["session", "delete", id]); } + documents(id: string) { return this.json(["session", "documents", id, "--json"]); } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run test/tht-runner.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/tht/tht-runner.ts backend/test/tht-runner.test.ts +git commit -m "feat(backend): ThtRunner session mutation + documents methods" +``` + +--- + +### Task 5: Backend routes + resume guard + +**Files:** +- Modify: `backend/src/routes/sessions.ts` +- Modify: `backend/src/app.ts` (CORS methods) +- Test: `backend/test/routes-sessions.test.ts` (append) + +**Interfaces:** +- Consumes: `ThtRunner` methods from Task 4, `PiProcessManager` (`mgr.get`, `mgr.teardown`, `mgr.resume`), `tht.sessionShow`. +- Produces routes: `POST /sessions/:id/rename {name}`, `POST /sessions/:id/group {group}`, `POST /sessions/:id/archive`, `POST /sessions/:id/unarchive`, `DELETE /sessions/:id`, `GET /sessions/:id/documents`; resume guard on `POST /sessions/:id/resume` (409 when finalized/archived). + +- [ ] **Step 1: Write the failing test** + +Append to `backend/test/routes-sessions.test.ts`: + +```typescript +import { spawn as nodeSpawn } from "node:child_process"; + +function mutApp(thtRunner: any) { + return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner, + getSettings: () => ({ workspace: "w" }) as any, + spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any, + }); +} + +test("POST /sessions/:id/rename calls setName", async () => { + let arg: any; + const app = mutApp({ setName: async (id: string, name: string) => { arg = { id, name }; } }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/rename", payload: { name: "N" } }); + expect(res.statusCode).toBe(204); + expect(arg).toEqual({ id: "s1", name: "N" }); +}); + +test("POST /sessions/:id/group calls setGroup", async () => { + let arg: any; + const app = mutApp({ setGroup: async (id: string, group: string) => { arg = { id, group }; } }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/group", payload: { group: "G" } }); + expect(res.statusCode).toBe(204); + expect(arg).toEqual({ id: "s1", group: "G" }); +}); + +test("POST archive / unarchive call the runner", async () => { + const seen: string[] = []; + const app = mutApp({ + archive: async (id: string) => { seen.push(`a:${id}`); }, + unarchive: async (id: string) => { seen.push(`u:${id}`); }, + }); + expect((await app.inject({ method: "POST", url: "/sessions/s1/archive" })).statusCode).toBe(204); + expect((await app.inject({ method: "POST", url: "/sessions/s1/unarchive" })).statusCode).toBe(204); + expect(seen).toEqual(["a:s1", "u:s1"]); +}); + +test("DELETE /sessions/:id calls deleteSession", async () => { + let deleted: string | null = null; + const app = mutApp({ deleteSession: async (id: string) => { deleted = id; } }); + const res = await app.inject({ method: "DELETE", url: "/sessions/s1" }); + expect(res.statusCode).toBe(204); + expect(deleted).toBe("s1"); +}); + +test("GET /sessions/:id/documents returns the runner output", async () => { + const app = mutApp({ documents: async () => [{ phase: "—", key: "question", title: "t", format: "text", content: "q" }] }); + const res = await app.inject({ method: "GET", url: "/sessions/s1/documents" }); + expect(res.statusCode).toBe(200); + expect(res.json()[0].key).toBe("question"); +}); + +test("POST resume on a finalized session is refused with 409", async () => { + const app = mutApp({ sessionShow: async () => ({ status: "finalized", archived: false }) }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); + expect(res.statusCode).toBe(409); +}); + +test("POST resume on an archived session is refused with 409", async () => { + const app = mutApp({ sessionShow: async () => ({ status: "open", archived: true }) }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" }); + expect(res.statusCode).toBe(409); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/routes-sessions.test.ts` +Expected: FAIL — rename route 404s; resume returns 200 (no guard yet). + +- [ ] **Step 3: Add the routes + guard** + +In `backend/src/routes/sessions.ts`, replace the existing `resume` handler and add the new routes. First, **replace** the resume block: + +```typescript + app.post("/sessions/:id/resume", async (req, reply) => { + const id = (req.params as any).id; + const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null; + if (manifest?.status === "finalized" || manifest?.archived) { + return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" }); + } + const rt = await d.mgr.resume(id, d.tht); + rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); + return reply.code(200).send({ id }); + }); +``` + +Then, before the closing `}` of `sessionRoutes`, add: + +```typescript + app.post("/sessions/:id/rename", async (req, reply) => { + await d.tht.setName((req.params as any).id, (req.body as any).name); + return reply.code(204).send(); + }); + app.post("/sessions/:id/group", async (req, reply) => { + await d.tht.setGroup((req.params as any).id, (req.body as any).group); + return reply.code(204).send(); + }); + app.post("/sessions/:id/archive", async (req, reply) => { + await d.tht.archive((req.params as any).id); + return reply.code(204).send(); + }); + app.post("/sessions/:id/unarchive", async (req, reply) => { + await d.tht.unarchive((req.params as any).id); + return reply.code(204).send(); + }); + app.delete("/sessions/:id", async (req, reply) => { + const id = (req.params as any).id; + d.mgr.teardown(id); // drop any live runtime before deleting on disk + await d.tht.deleteSession(id); + return reply.code(204).send(); + }); + app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id)); +``` + +- [ ] **Step 4: Allow DELETE in CORS** + +In `backend/src/app.ts`, change the CORS `methods` array to include `DELETE`: + +```typescript + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && npx vitest run test/routes-sessions.test.ts` +Expected: PASS. (The existing two tests still pass; `mutApp` provides only the methods each test needs.) + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/routes/sessions.ts backend/src/app.ts backend/test/routes-sessions.test.ts +git commit -m "feat(backend): rename/group/archive/unarchive/delete/documents routes + resume read-only guard" +``` + +--- + +## Phase C — Resume correctness (prerequisite for "Riprendi") + +### Task 6: spawnFor new/resume prompt mode + +**Files:** +- Modify: `backend/src/pi/pi-process-manager.ts` +- Test: `backend/test/pi-process-manager.test.ts` (append) + +**Interfaces:** +- Consumes: `PiProcessManager.spawnFor`, `resume`. +- Produces: `spawnFor(sessionId, o: { provider?; model?; thinking?; author?; mode?: "new" | "resume" })` — `mode: "resume"` sends `/riprendi-sessione `, default `"new"` sends `/nuova-domanda "kickoff"`. `resume()` passes `mode: "resume"`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/test/pi-process-manager.test.ts`: + +```typescript +import { EventEmitter } from "node:events"; + +function recordingChild() { + const ch: any = new EventEmitter(); + ch.stdout = new EventEmitter(); + ch.stderr = new EventEmitter(); + ch._writes = [] as string[]; + ch.stdin = { write: (d: any) => { ch._writes.push(String(d)); return true; } }; + ch.kill = () => {}; + return ch; +} + +test("spawnFor resume mode sends /riprendi-sessione ", async () => { + const cfg = loadConfig({}); // no provider/model/thinking -> no rpc.request handshakes + const child = recordingChild(); + const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); + await mgr.spawnFor("sid-9", { mode: "resume" }); + expect(child._writes.join("")).toContain("/riprendi-sessione sid-9"); + expect(child._writes.join("")).not.toContain("/nuova-domanda"); + mgr.teardown("sid-9"); +}); + +test("spawnFor default (new) mode sends /nuova-domanda", async () => { + const cfg = loadConfig({}); + const child = recordingChild(); + const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any }); + await mgr.spawnFor("sid-10", {}); + expect(child._writes.join("")).toContain("/nuova-domanda"); + mgr.teardown("sid-10"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run test/pi-process-manager.test.ts` +Expected: FAIL — resume-mode test sees `/nuova-domanda` (mode ignored). + +- [ ] **Step 3: Implement the mode** + +In `backend/src/pi/pi-process-manager.ts`: + +In `spawnFor`, change the option type to add `mode`: + +```typescript + async spawnFor( + sessionId: string, + o: { provider?: string; model?: string; thinking?: string; author?: string; mode?: "new" | "resume" }, + ): Promise { +``` + +Replace the final prompt line: + +```typescript + rpc.send({ type: "prompt", message: `/nuova-domanda "kickoff"` }); +``` + +with: + +```typescript + const message = o.mode === "resume" + ? `/riprendi-sessione ${sessionId}` + : `/nuova-domanda "kickoff"`; + rpc.send({ type: "prompt", message }); +``` + +In `resume`, pass the mode: + +```typescript + async resume(sessionId: string, tht: ThtRunner): Promise { + const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null; + return this.spawnFor(sessionId, { + provider: manifest?.provider, + model: manifest?.model, + thinking: manifest?.thinking, + mode: "resume", + }); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run test/pi-process-manager.test.ts` +Expected: PASS (existing tests still pass — they don't assert the prompt). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/pi/pi-process-manager.ts backend/test/pi-process-manager.test.ts +git commit -m "feat(backend): spawnFor new/resume prompt mode; resume sends /riprendi-sessione" +``` + +--- + +### Task 7: Skill Resume section + end-to-end verification + +**Files:** +- Modify: `harness/.pi/skills/tht-sessione/SKILL.md` +- Manual verification (no unit test — requires real Pi + harness) + +**Interfaces:** +- Consumes: `tht session show ` (returns `phase`), the phase artifacts on disk. +- Produces: a documented cold-start resume procedure the orchestrator follows when launched via `/riprendi-sessione `. + +- [ ] **Step 1: Add the Resume section to SKILL.md** + +In `harness/.pi/skills/tht-sessione/SKILL.md`, insert a new section immediately **before** `## Phase 1 — Clarification`: + +```markdown +## Phase 0 — Resume (cold start) + +When launched with `/riprendi-sessione ` you have NO prior conversation — the +persisted state is your only context. Bootstrap before doing anything else: + +1. `tht session show --json` → read `phase` (the current phase N), `status`, and + the manifest (`question`, `database`, `schema`). +2. Load the artifacts produced so far, as needed for phase N: `question.md` (revised + question), `schema_linking.json` (F4 output), `ctes/*.sql` + `cte_tests.json` (F6), + `sql_final.sql` (F7). The decision ledger is summarized by `tht session show`. +3. **Resume at phase N reviewing the existing artifacts** (same discipline as rollback, + §Disciplines 11). Do NOT restart from Phase 1, do NOT re-run `tht` commands for + artifacts that already exist and are valid, and do NOT treat this as a new question. +4. Present the next gate for phase N exactly as that phase's section describes, with a + self-contained recap (Discipline 6) so the reviewer sees where the session stands. + +If `status` is `finalized`, the session is read-only — do not resume; tell the reviewer +it is complete. (The backend already refuses resume for finalized/archived sessions.) +``` + +- [ ] **Step 2: Verify the doc change is present** + +Run: `grep -n "Phase 0 — Resume" harness/.pi/skills/tht-sessione/SKILL.md` +Expected: one match. + +- [ ] **Step 3: Manual end-to-end verification** + +This gate requires real Pi; it cannot be a deterministic unit test. Perform it and record the result: + +1. Start backend + frontend (`backend`: `npm run dev`; `frontend`: `npm run dev`). +2. Create a session and drive it to **Phase 4 (schema linking)** — approve through F1–F3 so `question.md` exists and the phase is F4. +3. Stop the session (the composer "Stop" → `closeSession`) and confirm the Pi process is gone. +4. Reopen the session via **Riprendi** in the documents panel. +5. **Assert:** the harness re-enters at **Phase 4** (presents the schema-linking gate / continues schema linking), with the revised question and prior decisions available — NOT a fresh `/nuova-domanda` kickoff and NOT Phase 1. + +Record PASS/FAIL in the PR description. The **"Riprendi"** action must not be advertised as working until this is PASS. + +- [ ] **Step 4: Commit** + +```bash +git add harness/.pi/skills/tht-sessione/SKILL.md +git commit -m "docs(harness): add Phase 0 Resume cold-start procedure to the orchestrator skill" +``` + +--- + +## Phase D — Frontend + +### Task 8: Types + API client + +**Files:** +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/api/sessions.ts` +- Test: `frontend/src/api/sessions.test.ts` (append) + +**Interfaces:** +- Produces: + - `SessionSummary` gains `archived: boolean`, `group: string | null`, `name: string | null`. + - `SessionDocument { phase: string; key: string; title: string; format: "markdown" | "sql" | "schema-linking" | "decisions" | "text"; content: string }`. + - `renameSession(id, name)`, `setSessionGroup(id, group)`, `archiveSession(id)`, `unarchiveSession(id)`, `deleteSession(id)`, `getSessionDocuments(id): Promise`. + +- [ ] **Step 1: Write the failing test** + +Append to `frontend/src/api/sessions.test.ts`: + +```typescript +import { + renameSession, setSessionGroup, archiveSession, unarchiveSession, + deleteSession, getSessionDocuments, +} from "./sessions"; + +test("renameSession POSTs {name}", async () => { + let body: unknown = null; + server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => { + body = await request.json(); + return new HttpResponse(null, { status: 204 }); + })); + await renameSession("s1", "Nome"); + expect(body).toEqual({ name: "Nome" }); +}); + +test("setSessionGroup POSTs {group}", async () => { + let body: unknown = null; + server.use(http.post("http://localhost:8787/sessions/s1/group", async ({ request }) => { + body = await request.json(); + return new HttpResponse(null, { status: 204 }); + })); + await setSessionGroup("s1", "G"); + expect(body).toEqual({ group: "G" }); +}); + +test("archive / unarchive / delete hit the right verbs+paths", async () => { + const hits: string[] = []; + server.use( + http.post("http://localhost:8787/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }), + http.post("http://localhost:8787/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }), + http.delete("http://localhost:8787/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }), + ); + await archiveSession("s1"); + await unarchiveSession("s1"); + await deleteSession("s1"); + expect(hits).toEqual(["archive", "unarchive", "delete"]); +}); + +test("getSessionDocuments GETs the array", async () => { + server.use(http.get("http://localhost:8787/sessions/s1/documents", () => + HttpResponse.json([{ phase: "—", key: "question", title: "t", format: "text", content: "q" }]), + )); + const docs = await getSessionDocuments("s1"); + expect(docs[0].key).toBe("question"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/api/sessions.test.ts` +Expected: FAIL — `renameSession` not exported. + +- [ ] **Step 3: Extend the types** + +In `frontend/src/api/types.ts`, extend `SessionSummary` (add the three fields after `author`): + +```typescript +export interface SessionSummary { + id: string; + status: string; + question: string; + summary: string | null; + created_at: string; + updated_at: string | null; + author: string | null; + name: string | null; + group: string | null; + archived: boolean; +} + +export interface SessionDocument { + phase: string; + key: string; + title: string; + format: "markdown" | "sql" | "schema-linking" | "decisions" | "text"; + content: string; +} +``` + +- [ ] **Step 4: Add the client functions** + +In `frontend/src/api/sessions.ts`, add (update the `import type` line to include `SessionDocument`): + +```typescript +import type { SessionSummary, SessionDocument, UiResponse } from "./types"; + +export const renameSession = (id: string, name: string) => + apiFetch(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) }); + +export const setSessionGroup = (id: string, group: string) => + apiFetch(`/sessions/${id}/group`, { method: "POST", body: JSON.stringify({ group }) }); + +export const archiveSession = (id: string) => + apiFetch(`/sessions/${id}/archive`, { method: "POST" }); + +export const unarchiveSession = (id: string) => + apiFetch(`/sessions/${id}/unarchive`, { method: "POST" }); + +export const deleteSession = (id: string) => + apiFetch(`/sessions/${id}`, { method: "DELETE" }); + +export const getSessionDocuments = (id: string) => + apiFetch(`/sessions/${id}/documents`); +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/api/sessions.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/api/types.ts frontend/src/api/sessions.ts frontend/src/api/sessions.test.ts +git commit -m "feat(frontend): session mutation + documents API client and types" +``` + +--- + +### Task 9: Kebab menu component + row opens panel + +**Files:** +- Create: `frontend/src/shell/SessionMenu.tsx` +- Modify: `frontend/src/shell/NavSessions.tsx` +- Test: `frontend/src/shell/NavSessions.test.tsx` (rewrite data + add menu test) + +**Interfaces:** +- Consumes: base-ui `Menu` (`@base-ui/react/menu`), `SessionSummary`. +- Produces: + - `SessionMenu` props: `{ session: SessionSummary; groups: string[]; onView(): void; onRename(): void; onMove(group: string): void; onNewGroup(): void; onArchiveToggle(): void; onDelete(): void }`. + - `NavSessions` props change to `{ sessions: SessionSummary[]; activeSessionId: string | null; onOpenPanel(id: string): void; menuFor(session): React.ReactNode }`. (Grouping/archive added in Task 11; this task wires row-click → `onOpenPanel` and renders a `menuFor` slot.) + +> Note: from this task NavSessions receives `sessions` as a prop instead of fetching, so AppShell owns the query and the mutations. The fetch moves to AppShell in Task 12; until then the test passes sessions directly. + +- [ ] **Step 1: Write the failing test** + +Replace the body of `frontend/src/shell/NavSessions.test.tsx` with: + +```typescript +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NavSessions } from "./NavSessions"; +import type { SessionSummary } from "../api/types"; + +const SESSIONS: SessionSummary[] = [ + { id: "s1", status: "open", question: "Come va?", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false }, + { id: "s2", status: "closed", question: "Seconda domanda", summary: "r", created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: "Etichetta", group: null, archived: false }, +]; + +test("lists sessions and shows name when present", () => { + render( null} />); + expect(screen.getByText("Come va?")).toBeInTheDocument(); + expect(screen.getByText("Etichetta")).toBeInTheDocument(); // name overrides question +}); + +test("clicking a row opens the panel (does not resume)", async () => { + const onOpenPanel = vi.fn(); + render( null} />); + await userEvent.click(screen.getByText("Come va?")); + expect(onOpenPanel).toHaveBeenCalledWith("s1"); +}); + +test("active session is highlighted", () => { + render( null} />); + expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/NavSessions.test.tsx` +Expected: FAIL — NavSessions still fetches and uses the old props. + +- [ ] **Step 3: Create the SessionMenu component** + +Create `frontend/src/shell/SessionMenu.tsx`: + +```tsx +import { Menu } from "@base-ui/react/menu"; +import { MoreVertical } from "lucide-react"; +import type { SessionSummary } from "../api/types"; + +interface Props { + session: SessionSummary; + groups: string[]; + onView: () => void; + onRename: () => void; + onMove: (group: string) => void; + onNewGroup: () => void; + onArchiveToggle: () => void; + onDelete: () => void; +} + +const itemCls = + "flex cursor-default items-center justify-between gap-6 rounded-md px-2.5 py-1.5 text-sm outline-none data-highlighted:bg-accent"; + +export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) { + return ( + + e.stopPropagation()} + > + + + + + + Vista divisa + Rinomina + + Sposta nel gruppo › + + + + {groups.filter((g) => g !== session.group).map((g) => ( + onMove(g)}>{g} + ))} + {session.group && ( + onMove("")}>Senza gruppo + )} + Nuovo gruppo… + + + + + + {session.archived ? "Ripristina" : "Archivia"} + + + Elimina + + + + + ); +} +``` + +> base-ui exports `Menu.Root/Trigger/Portal/Positioner/Popup/Item/Separator/SubmenuRoot/SubmenuTrigger` (the `menu/submenu-root` and `menu/submenu-trigger` subpaths confirm the submenu parts exist). If a part name or the `data-popup-open` attribute differs in this `@base-ui/react@1.6` build, check `node_modules/@base-ui/react/menu/index.d.ts` and adjust — the menu still functions without the open-state opacity class. + +- [ ] **Step 4: Rewrite NavSessions to take props and render the row + menu slot** + +Replace `frontend/src/shell/NavSessions.tsx` with: + +```tsx +// frontend/src/shell/NavSessions.tsx +import type { ReactNode } from "react"; +import type { SessionSummary } from "../api/types"; + +interface Props { + sessions: SessionSummary[]; + activeSessionId: string | null; + onOpenPanel: (id: string) => void; + menuFor: (session: SessionSummary) => ReactNode; +} + +export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }: Props) { + if (sessions.length === 0) { + return ( +

+ No sessions yet. Start with New session. +

+ ); + } + return ( +
    + {sessions.map((s) => { + const active = s.id === activeSessionId; + return ( +
  • +
    onOpenPanel(s.id)} + className={[ + "group flex w-full cursor-pointer items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors", + active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent", + ].join(" ")} + > +
    + + {s.name || s.question || s.id} + + + + {s.status} + +
    + {menuFor(s)} +
    +
  • + ); + })} +
+ ); +} +``` + +- [ ] **Step 5: Keep AppShell compiling (minimal wiring)** + +NavSessions' new signature breaks AppShell's old call site, so the build won't compile until AppShell is updated. Do the MINIMAL change here (full grouping/panel/menu arrive in Task 12). In `frontend/src/shell/AppShell.tsx`: + +(a) Extend the imports: change `import { closeSession } from "../api/sessions";` to + +```tsx +import { closeSession, listSessions, resumeSession } from "../api/sessions"; +import type { SessionSummary } from "../api/types"; +import { useQuery } from "@tanstack/react-query"; +``` + +(b) Inside `AppShell()`, after the `const [activeSessionId, setActiveSessionId] = useState(null);` line, add: + +```tsx + const { data: sessions = [] } = useQuery({ + queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, + }); +``` + +(c) Replace the existing call `` with (preserves today's resume-on-click behavior; menu/panel added in Task 12): + +```tsx + { await resumeSession(id); setActiveSessionId(id); }} + menuFor={() => null} + /> +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/shell/NavSessions.test.tsx src/App.test.tsx` +Expected: PASS. (`App.test.tsx` still works because click still resumes. If it asserted the old fetch-inside-NavSessions, update it to the prop-driven flow.) + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/shell/SessionMenu.tsx frontend/src/shell/NavSessions.tsx frontend/src/shell/NavSessions.test.tsx frontend/src/shell/AppShell.tsx +git commit -m "feat(frontend): SessionMenu kebab + NavSessions prop-driven; AppShell owns sessions query" +``` + +--- + +### Task 10: Rename dialog + delete confirm + actions container + +**Files:** +- Create: `frontend/src/shell/RenameDialog.tsx` +- Create: `frontend/src/shell/DeleteConfirmDialog.tsx` +- Test: `frontend/src/shell/SessionActions.test.tsx` (create) + +**Interfaces:** +- Consumes: `Dialog`/`DialogContent`/`DialogHeader`/`DialogTitle`/`DialogFooter` from `../components/ui/dialog`, `Button`. +- Produces: + - `RenameDialog` props `{ open: boolean; initial: string; onOpenChange(open): void; onSubmit(name: string): void }`. + - `DeleteConfirmDialog` props `{ open: boolean; label: string; onOpenChange(open): void; onConfirm(): void }`. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/shell/SessionActions.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { RenameDialog } from "./RenameDialog"; +import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; + +test("RenameDialog submits the edited name", async () => { + const onSubmit = vi.fn(); + render(); + const input = screen.getByLabelText(/name/i); + await userEvent.clear(input); + await userEvent.type(input, "Nuovo"); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSubmit).toHaveBeenCalledWith("Nuovo"); +}); + +test("DeleteConfirmDialog confirms only on the destructive button", async () => { + const onConfirm = vi.fn(); + render(); + expect(screen.getByText(/Sessione X/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + expect(onConfirm).toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/SessionActions.test.tsx` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Create RenameDialog** + +Create `frontend/src/shell/RenameDialog.tsx`: + +```tsx +import { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog"; +import { Button } from "../components/ui/button"; + +interface Props { + open: boolean; + initial: string; + onOpenChange: (open: boolean) => void; + onSubmit: (name: string) => void; +} + +export function RenameDialog({ open, initial, onOpenChange, onSubmit }: Props) { + const [name, setName] = useState(initial); + useEffect(() => { if (open) setName(initial); }, [open, initial]); + + function submit(e: React.FormEvent) { + e.preventDefault(); + onSubmit(name.trim()); + onOpenChange(false); + } + + return ( + + + Rinomina sessione +
+
+ + setName(e.target.value)} + className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/50 focus:ring-3 focus:ring-ring/15" + placeholder="Session name…" + /> +
+ + + + +
+
+
+ ); +} +``` + +- [ ] **Step 4: Create DeleteConfirmDialog** + +Create `frontend/src/shell/DeleteConfirmDialog.tsx`: + +```tsx +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog"; +import { Button } from "../components/ui/button"; + +interface Props { + open: boolean; + label: string; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export function DeleteConfirmDialog({ open, label, onOpenChange, onConfirm }: Props) { + return ( + + + Elimina definitivamente + + “{label}” verrà eliminata definitivamente, inclusi tutti i suoi documenti. L'operazione non è reversibile. + + + + + + + + ); +} +``` + +> If `variant="destructive"` is not defined on `Button`, use `className="bg-destructive text-white hover:bg-destructive/90"` on a default Button instead. Verify against `frontend/src/components/ui/button.tsx` before implementing. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/shell/SessionActions.test.tsx` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/shell/RenameDialog.tsx frontend/src/shell/DeleteConfirmDialog.tsx frontend/src/shell/SessionActions.test.tsx +git commit -m "feat(frontend): RenameDialog and DeleteConfirmDialog" +``` + +--- + +### Task 11: SessionDocumentsPanel (read-only viewer) + +**Files:** +- Create: `frontend/src/shell/SessionDocumentsPanel.tsx` +- Test: `frontend/src/shell/SessionDocumentsPanel.test.tsx` (create) + +**Interfaces:** +- Consumes: `getSessionDocuments`, `SessionDocument`, `SessionSummary`, viewers `SqlViewer` (`{ blocks }`), `SchemaLinkingViewer` (`{ linking }`), `MarkdownView` (`{ source }`), TanStack Query. +- Produces: `SessionDocumentsPanel` props `{ session: SessionSummary; onClose(): void; onResume(id: string): void }`. "Riprendi" shown only when `session.status !== "finalized" && !session.archived`. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/shell/SessionDocumentsPanel.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../test/msw"; +import { SessionDocumentsPanel } from "./SessionDocumentsPanel"; +import type { SessionSummary } from "../api/types"; + +function wrap(ui: React.ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +const base: SessionSummary = { + id: "s1", status: "open", question: "Quante ablazioni?", summary: null, + created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, + name: null, group: null, archived: false, +}; + +beforeEach(() => { + server.use(http.get("http://localhost:8787/sessions/s1/documents", () => + HttpResponse.json([ + { phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Quante ablazioni?" }, + { phase: "F7", key: "sql", title: "SQL finale", format: "sql", content: "SELECT 1" }, + ]), + )); +}); + +test("renders the document titles from the API", async () => { + wrap(); + expect(await screen.findByText("Domanda originale")).toBeInTheDocument(); + expect(screen.getByText("SQL finale")).toBeInTheDocument(); +}); + +test("shows Riprendi for resumable, hides it for finalized", async () => { + const { rerender } = wrap(); + expect(await screen.findByRole("button", { name: /riprendi/i })).toBeInTheDocument(); + rerender(); + expect(screen.queryByRole("button", { name: /riprendi/i })).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/SessionDocumentsPanel.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the panel** + +Create `frontend/src/shell/SessionDocumentsPanel.tsx`: + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { getSessionDocuments } from "../api/sessions"; +import type { SessionDocument, SessionSummary } from "../api/types"; +import { Button } from "../components/ui/button"; +import { SqlViewer } from "../viewers/SqlViewer"; +import { SchemaLinkingViewer } from "../viewers/SchemaLinkingViewer"; +import { MarkdownView } from "../viewers/MarkdownView"; + +interface Props { + session: SessionSummary; + onClose: () => void; + onResume: (id: string) => void; +} + +function statusLabel(s: SessionSummary): string { + if (s.archived) return "Archiviata"; + if (s.status === "finalized") return "Completata"; + return `In corso · ${s.status}`; +} + +function DocBody({ doc }: { doc: SessionDocument }) { + if (doc.format === "sql") return ; + if (doc.format === "markdown") return ; + if (doc.format === "schema-linking") { + try { + return ; + } catch { + return
{doc.content}
; + } + } + if (doc.format === "decisions") { + const lines = doc.content.split("\n").filter(Boolean); + return ( +
+ {lines.length} decisioni +
    + {lines.map((line, i) => { + let d: { type?: string; subject?: string; detail?: string }; + try { d = JSON.parse(line); } catch { d = {}; } + return
  • [{d.type}] {d.subject}{d.detail ? ` — ${d.detail}` : ""}
  • ; + })} +
+
+ ); + } + return

{doc.content}

; +} + +export function SessionDocumentsPanel({ session, onClose, onResume }: Props) { + const { data: docs = [], isLoading } = useQuery({ + queryKey: ["session-documents", session.id], + queryFn: () => getSessionDocuments(session.id), + }); + const resumable = session.status !== "finalized" && !session.archived; + + return ( + + ); +} +``` + +> Verify `size="icon-sm"` exists on `Button` (used by `dialog.tsx`). If not, use `size="sm"`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/shell/SessionDocumentsPanel.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/shell/SessionDocumentsPanel.tsx frontend/src/shell/SessionDocumentsPanel.test.tsx +git commit -m "feat(frontend): SessionDocumentsPanel read-only viewer with Riprendi" +``` + +--- + +### Task 12: AppShell integration — grouping, archive view, panel, mutations + +**Files:** +- Modify: `frontend/src/shell/AppShell.tsx` +- Test: `frontend/src/shell/AppShell.session-mgmt.test.tsx` (create) + +**Interfaces:** +- Consumes: everything above — `NavSessions`, `SessionMenu`, `RenameDialog`, `DeleteConfirmDialog`, `SessionDocumentsPanel`, the API client, `useQuery`/`useQueryClient`/`useMutation`. +- Produces: AppShell owns the `["sessions"]` query; renders the active list grouped (collapsible group headers + "Senza gruppo") filtered by `!archived`, an "Archivio" toggle showing `archived`, the left documents panel, and wires all mutations with query invalidation. "Riprendi" calls `resumeSession` then sets the active chat session. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/shell/AppShell.session-mgmt.test.tsx`: + +```tsx +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../test/msw"; +import { AppShell } from "./AppShell"; + +function wrap() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(); +} + +const LIST = [ + { id: "s1", status: "open", question: "Attiva uno", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false }, + { id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true }, +]; + +beforeEach(() => { + server.use( + http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)), + http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([ + { phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" }, + ])), + http.post("http://localhost:8787/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })), + ); +}); + +test("active list shows group header and hides archived sessions", async () => { + wrap(); + expect(await screen.findByText("Attiva uno")).toBeInTheDocument(); + expect(screen.getByText("Aritmologia")).toBeInTheDocument(); // group header + expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); // archived hidden +}); + +test("opening the panel shows the session documents", async () => { + wrap(); + await userEvent.click(await screen.findByText("Attiva uno")); + expect(await screen.findByText("Domanda originale")).toBeInTheDocument(); +}); + +test("Archivio toggle reveals archived sessions", async () => { + wrap(); + await userEvent.click(await screen.findByRole("button", { name: /archivio/i })); + expect(await screen.findByText("Archiviata due")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx` +Expected: FAIL — AppShell does not yet fetch sessions / render groups / archive toggle. + +- [ ] **Step 3: Rewrite the AppShell right rail + add the left panel** + +In `frontend/src/shell/AppShell.tsx`: + +(a) Update imports at the top: + +```tsx +import { useSessionStream } from "../stream/useSessionStream"; +import { useSessionStore } from "../store/sessionStore"; +import { WidgetHost } from "./WidgetHost"; +import { Transcript } from "./Transcript"; +import { NavSessions } from "./NavSessions"; +import { SessionMenu } from "./SessionMenu"; +import { SessionDocumentsPanel } from "./SessionDocumentsPanel"; +import { RenameDialog } from "./RenameDialog"; +import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; +import { SteerInput, ComposerFooter } from "./SteerInput"; +import { WorkflowBar } from "./WorkflowBar"; +import { Button } from "../components/ui/button"; +import { Toaster } from "../components/ui/sonner"; +import { + closeSession, listSessions, resumeSession, + renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, +} from "../api/sessions"; +import type { SessionSummary } from "../api/types"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useRef, useState } from "react"; +``` + +(b) Inside `AppShell()`, the `sessions` query already exists from Task 9. Add `queryClient`, the remaining state, and the handlers next to it (do NOT re-declare the `sessions` query): + +```tsx + const queryClient = useQueryClient(); + const [panelSession, setPanelSession] = useState(null); + const [showArchive, setShowArchive] = useState(false); + const [renameTarget, setRenameTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const groups = useMemo( + () => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(), + [sessions], + ); + const activeList = sessions.filter((s) => !s.archived); + const archivedList = sessions.filter((s) => s.archived); + const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] }); + + function openPanel(id: string) { + const s = sessions.find((x) => x.id === id); + if (s) setPanelSession(s); + } + async function doResume(id: string) { + await resumeSession(id); + setPanelSession(null); + setActiveSessionId(id); + } + async function move(s: SessionSummary, group: string) { await setSessionGroup(s.id, group); refresh(); } + async function newGroup(s: SessionSummary) { + const name = window.prompt("Nuovo gruppo:"); + if (name && name.trim()) { await setSessionGroup(s.id, name.trim()); refresh(); } + } + async function toggleArchive(s: SessionSummary) { + await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id)); + if (panelSession?.id === s.id) setPanelSession(null); + refresh(); + } + + function menuFor(s: SessionSummary) { + return ( + setPanelSession(s)} + onRename={() => setRenameTarget(s)} + onMove={(g) => move(s, g)} + onNewGroup={() => newGroup(s)} + onArchiveToggle={() => toggleArchive(s)} + onDelete={() => setDeleteTarget(s)} + /> + ); + } +``` + +(c) Add the left panel as the FIRST child of the top-level flex row (immediately inside `
`, before the conversation column): + +```tsx + {panelSession && ( + setPanelSession(null)} + onResume={doResume} + /> + )} +``` + +(d) Replace the right-rail `` block (the one inside the scrolling `
`) with grouped rendering + the archive toggle. Replace that scrolling container's contents with: + +```tsx + {showArchive ? ( + + ) : ( +
+ {groups.map((g) => ( +
+

{g}

+ s.group === g)} + activeSessionId={activeSessionId} + onOpenPanel={openPanel} + menuFor={menuFor} + /> +
+ ))} +
+ {groups.length > 0 && ( +

Senza gruppo

+ )} + !s.group)} + activeSessionId={activeSessionId} + onOpenPanel={openPanel} + menuFor={menuFor} + /> +
+
+ )} + +``` + +(e) Before the closing `
` of the top-level row (next to ``), add the dialogs: + +```tsx + {renameTarget && ( + { if (!o) setRenameTarget(null); }} + onSubmit={async (name) => { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); }} + /> + )} + {deleteTarget && ( + { if (!o) setDeleteTarget(null); }} + onConfirm={async () => { + await deleteSession(deleteTarget.id); + if (panelSession?.id === deleteTarget.id) setPanelSession(null); + if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); } + setDeleteTarget(null); refresh(); + }} + /> + )} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Run the full frontend suite (catch regressions in App.test/NavSessions)** + +Run: `cd frontend && npx vitest run` +Expected: PASS. If `App.test.tsx` referenced the old NavSessions fetch behavior, update it to the new prop-driven flow. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx +git commit -m "feat(frontend): AppShell session management — grouping, archive view, docs panel, mutations" +``` + +--- + +## Final verification + +- [ ] **Harness:** `cd harness && .venv/bin/pytest tests/test_session_mutations.py tests/test_session_documents.py -q` → all pass. +- [ ] **Backend:** `cd backend && npx vitest run` → all pass. +- [ ] **Frontend:** `cd frontend && npx vitest run` → all pass. +- [ ] **Resume e2e (Task 7, manual):** PASS recorded — fresh-process resume lands on the correct phase. +- [ ] **Lint/build:** `cd frontend && npm run build` and `cd backend && npm run build` succeed. + +--- + +## Spec coverage check + +- Read-only document panel (status header + docs in phase order + decisions) → Tasks 3, 11. +- Resume rule (finalized/archived read-only) → Tasks 5 (guard), 11 (Riprendi visibility). +- Row click opens panel; explicit Riprendi → Tasks 9, 11, 12. +- Archive = manifest flag + Archivio view → Tasks 1, 2, 12. +- Delete = hard delete + confirm, teardown live runtime → Tasks 1, 2, 5, 10, 12. +- Groups = manifest field + submenu + grouped rail → Tasks 1, 2, 9, 12. +- Rename = manifest name → Tasks 1, 2, 10, 12. +- Resume correctness prerequisite (prompt mode + skill + e2e) → Tasks 6, 7. +- Deferred (pgvector/app-DB, keyboard shortcuts, CTE docs) → not implemented, by design.