Files
ThothII/docs/superpowers/plans/2026-06-29-session-management.md
T

1940 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Session Management 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 <id> --name <name>`, `set-group <id> --group <name>`, `archive <id>`, `unarchive <id>`, `delete <id>`.
- [ ] **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 <id> --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<T>`.
- Produces methods on `ThtRunner`:
- `setName(id: string, name: string): Promise<void>`
- `setGroup(id: string, group: string): Promise<void>`
- `archive(id: string): Promise<void>` / `unarchive(id: string): Promise<void>`
- `deleteSession(id: string): Promise<void>`
- `documents(id: string): Promise<SessionDocument[]>` 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<T>` method) and the public methods (after `sqlExport`):
```typescript
private async ok(args: string[]): Promise<void> {
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<SessionDocument[]>(["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 <id>`, 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 <id>", 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<SessionRuntime> {
```
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<SessionRuntime> {
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 <id>` (returns `phase`), the phase artifacts on disk.
- Produces: a documented cold-start resume procedure the orchestrator follows when launched via `/riprendi-sessione <id>`.
- [ ] **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 <id>` you have NO prior conversation — the
persisted state is your only context. Bootstrap before doing anything else:
1. `tht session show <id> --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<SessionDocument[]>`.
- [ ] **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<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
export const setSessionGroup = (id: string, group: string) =>
apiFetch<void>(`/sessions/${id}/group`, { method: "POST", body: JSON.stringify({ group }) });
export const archiveSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/archive`, { method: "POST" });
export const unarchiveSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/unarchive`, { method: "POST" });
export const deleteSession = (id: string) =>
apiFetch<void>(`/sessions/${id}`, { method: "DELETE" });
export const getSessionDocuments = (id: string) =>
apiFetch<SessionDocument[]>(`/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(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={vi.fn()} menuFor={() => 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(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => null} />);
await userEvent.click(screen.getByText("Come va?"));
expect(onOpenPanel).toHaveBeenCalledWith("s1");
});
test("active session is highlighted", () => {
render(<NavSessions sessions={SESSIONS} activeSessionId="s2" onOpenPanel={vi.fn()} menuFor={() => 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 (
<Menu.Root>
<Menu.Trigger
aria-label="Session actions"
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 data-popup-open:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="size-4" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className={itemCls} onClick={onView}>Vista divisa</Menu.Item>
<Menu.Item className={itemCls} onClick={onRename}>Rinomina</Menu.Item>
<Menu.SubmenuRoot>
<Menu.SubmenuTrigger className={itemCls}>Sposta nel gruppo ›</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner side="right" align="start">
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
{groups.filter((g) => g !== session.group).map((g) => (
<Menu.Item key={g} className={itemCls} onClick={() => onMove(g)}>{g}</Menu.Item>
))}
{session.group && (
<Menu.Item className={itemCls} onClick={() => onMove("")}>Senza gruppo</Menu.Item>
)}
<Menu.Item className={itemCls} onClick={onNewGroup}>Nuovo gruppo…</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.SubmenuRoot>
<Menu.Item className={itemCls} onClick={onArchiveToggle}>
{session.archived ? "Ripristina" : "Archivia"}
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className={`${itemCls} text-destructive`} onClick={onDelete}>Elimina</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
```
> 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 (
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
No sessions yet. Start with <span className="font-medium text-foreground">New session</span>.
</p>
);
}
return (
<ul className="flex flex-col gap-0.5">
{sessions.map((s) => {
const active = s.id === activeSessionId;
return (
<li key={s.id}>
<div
data-testid={`session-item-${s.id}`}
data-active={active ? "true" : "false"}
onClick={() => 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(" ")}
>
<div className="min-w-0 flex-1">
<span className={[
"block truncate text-[0.8rem] leading-snug",
active ? "font-bold text-primary" : "text-foreground/90",
].join(" ")}>
{s.name || s.question || s.id}
</span>
<span className="mt-1 flex items-center gap-1.5">
<span className={[
"size-1.5 rounded-full",
s.status === "open" ? "bg-[oklch(var(--success))]"
: s.status === "finalized" ? "bg-primary"
: "bg-muted-foreground/50",
].join(" ")} />
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">{s.status}</span>
</span>
</div>
{menuFor(s)}
</div>
</li>
);
})}
</ul>
);
}
```
- [ ] **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<string | null>(null);` line, add:
```tsx
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
});
```
(c) Replace the existing call `<NavSessions activeSessionId={activeSessionId} onSessionSelect={setActiveSessionId} />` with (preserves today's resume-on-click behavior; menu/panel added in Task 12):
```tsx
<NavSessions
sessions={sessions}
activeSessionId={activeSessionId}
onOpenPanel={async (id) => { 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(<RenameDialog open initial="Vecchio" onOpenChange={vi.fn()} onSubmit={onSubmit} />);
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(<DeleteConfirmDialog open label="Sessione X" onOpenChange={vi.fn()} onConfirm={onConfirm} />);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>Rinomina sessione</DialogTitle></DialogHeader>
<form onSubmit={submit} className="flex flex-col gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="rename-name">Name</label>
<input
id="rename-name"
value={name}
onChange={(e) => 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…"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" size="sm">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
```
- [ ] **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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>Elimina definitivamente</DialogTitle></DialogHeader>
<DialogDescription>
“{label}” verrà eliminata definitivamente, inclusi tutti i suoi documenti. L'operazione non è reversibile.
</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
> 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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
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(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
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(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByRole("button", { name: /riprendi/i })).toBeInTheDocument();
rerender(<SessionDocumentsPanel session={{ ...base, status: "finalized" }} onClose={vi.fn()} onResume={vi.fn()} />);
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 <SqlViewer blocks={[{ name: doc.title, sql: doc.content }]} />;
if (doc.format === "markdown") return <MarkdownView source={doc.content} />;
if (doc.format === "schema-linking") {
try {
return <SchemaLinkingViewer linking={JSON.parse(doc.content)} />;
} catch {
return <pre className="whitespace-pre-wrap text-xs">{doc.content}</pre>;
}
}
if (doc.format === "decisions") {
const lines = doc.content.split("\n").filter(Boolean);
return (
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">{lines.length} decisioni</summary>
<ul className="mt-2 flex flex-col gap-1 text-xs">
{lines.map((line, i) => {
let d: { type?: string; subject?: string; detail?: string };
try { d = JSON.parse(line); } catch { d = {}; }
return <li key={i} className="font-mono">[{d.type}] {d.subject}{d.detail ? ` — ${d.detail}` : ""}</li>;
})}
</ul>
</details>
);
}
return <p className="whitespace-pre-wrap text-sm">{doc.content}</p>;
}
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", session.id],
queryFn: () => getSessionDocuments(session.id),
});
const resumable = session.status !== "finalized" && !session.archived;
return (
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-start justify-between gap-2 border-b border-border/70 px-4 py-3">
<div className="min-w-0">
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
{session.name || session.question}
</h2>
<p className="mt-0.5 text-[0.7rem] uppercase tracking-wide text-muted-foreground">{statusLabel(session)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{resumable && (
<Button size="sm" variant="outline" onClick={() => onResume(session.id)}>Riprendi</Button>
)}
<Button size="icon-sm" variant="ghost" aria-label="Close panel" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : (
<div className="flex flex-col gap-5">
{docs.map((doc) => (
<section key={doc.key}>
<h3 className="mb-1.5 text-xs font-bold uppercase tracking-wide text-primary">
{doc.title}{doc.phase !== "—" ? ` · ${doc.phase}` : ""}
</h3>
<DocBody doc={doc} />
</section>
))}
</div>
)}
</div>
</aside>
);
}
```
> 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(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
}
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<SessionSummary | null>(null);
const [showArchive, setShowArchive] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(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 (
<SessionMenu
session={s}
groups={groups}
onView={() => 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 `<div className="flex h-screen ...">`, before the conversation column):
```tsx
{panelSession && (
<SessionDocumentsPanel
session={panelSession}
onClose={() => setPanelSession(null)}
onResume={doResume}
/>
)}
```
(d) Replace the right-rail `<NavSessions .../>` block (the one inside the scrolling `<div className="flex-1 overflow-y-auto px-2 pb-4">`) with grouped rendering + the archive toggle. Replace that scrolling container's contents with:
```tsx
{showArchive ? (
<NavSessions sessions={archivedList} activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} />
) : (
<div className="flex flex-col gap-3">
{groups.map((g) => (
<div key={g}>
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
<NavSessions
sessions={activeList.filter((s) => s.group === g)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
</div>
))}
<div>
{groups.length > 0 && (
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">Senza gruppo</p>
)}
<NavSessions
sessions={activeList.filter((s) => !s.group)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
</div>
</div>
)}
<button
onClick={() => setShowArchive((v) => !v)}
className="mt-3 w-full rounded-md px-2 py-1.5 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-accent"
>
{showArchive ? "← Sessioni attive" : `Archivio (${archivedList.length})`}
</button>
```
(e) Before the closing `</div>` of the top-level row (next to `<Toaster />`), add the dialogs:
```tsx
{renameTarget && (
<RenameDialog
open
initial={renameTarget.name ?? ""}
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
onSubmit={async (name) => { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); }}
/>
)}
{deleteTarget && (
<DeleteConfirmDialog
open
label={deleteTarget.name || deleteTarget.question}
onOpenChange={(o) => { 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.