# 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.