75 KiB
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/--configis a PER-COMMAND option intht— it must follow the subcommand, never precede it. (ThtRunner.buildArgvalready appends it.)--jsonoutput must be pristine — only valid JSON on stdout, no color/extra lines. (Usetyper.echo(json.dumps(...))and return early.)- Archive is a manifest flag, never a directory move (preserves
sessions_root / session_idid→path resolution). - Read-only rule:
status == "finalized"ORarchived == 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-typewhen 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— addarchived,grouptoSessionManifest. - 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/documentscommands;_list_sessionsincludes 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+ privateok(). - Modify
backend/src/routes/sessions.ts— rename/group/archive/unarchive/delete/documents routes + resume guard. - Modify
backend/src/pi/pi-process-manager.ts—spawnForgainsmode,resumepasses"resume". - Modify
backend/src/app.ts— addDELETEto 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—SessionSummaryfields +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.tsxfiles.
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_sessionsdict 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) -> SessionManifestset_group(session_id: str, group: str | None, sessions_root: Path) -> SessionManifestset_archived(session_id: str, archived: bool, sessions_root: Path) -> SessionManifestdelete_session(session_id: str, sessions_root: Path) -> NoneSessionManifest.archived: bool,SessionManifest.group: str | None
-
Step 1: Write the failing test
Create harness/tests/test_session_mutations.py:
"""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:
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):
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,):
"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
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:
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:
@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
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(addbuild_documents) - Modify:
harness/tht/cli/session_cmd.py(adddocumentscommand) - 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:
"""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:
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
documentsCLI command
In harness/tht/cli/session_cmd.py, after delete_cmd, add:
@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
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, privatejson<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[]>whereSessionDocument = { phase: string; key: string; title: string; format: string; content: string }
-
Step 1: Write the failing test
Append to backend/test/tht-runner.test.ts:
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:
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):
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
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:
ThtRunnermethods 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 onPOST /sessions/:id/resume(409 when finalized/archived). -
Step 1: Write the failing test
Append to backend/test/routes-sessions.test.ts:
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:
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:
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:
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
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()passesmode: "resume". -
Step 1: Write the failing test
Append to backend/test/pi-process-manager.test.ts:
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:
async spawnFor(
sessionId: string,
o: { provider?: string; model?: string; thinking?: string; author?: string; mode?: "new" | "resume" },
): Promise<SessionRuntime> {
Replace the final prompt line:
rpc.send({ type: "prompt", message: `/nuova-domanda "kickoff"` });
with:
const message = o.mode === "resume"
? `/riprendi-sessione ${sessionId}`
: `/nuova-domanda "kickoff"`;
rpc.send({ type: "prompt", message });
In resume, pass the mode:
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
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>(returnsphase), 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:
## 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:
- Start backend + frontend (
backend:npm run dev;frontend:npm run dev). - Create a session and drive it to Phase 4 (schema linking) — approve through F1–F3 so
question.mdexists and the phase is F4. - Stop the session (the composer "Stop" →
closeSession) and confirm the Pi process is gone. - Reopen the session via Riprendi in the documents panel.
- 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-domandakickoff 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
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:
SessionSummarygainsarchived: 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:
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):
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):
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
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:
SessionMenuprops:{ session: SessionSummary; groups: string[]; onView(): void; onRename(): void; onMove(group: string): void; onNewGroup(): void; onArchiveToggle(): void; onDelete(): void }.NavSessionsprops 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 →onOpenPaneland renders amenuForslot.)
Note: from this task NavSessions receives
sessionsas 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:
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:
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(themenu/submenu-rootandmenu/submenu-triggersubpaths confirm the submenu parts exist). If a part name or thedata-popup-openattribute differs in this@base-ui/react@1.6build, checknode_modules/@base-ui/react/menu/index.d.tsand 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:
// 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
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:
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):
<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
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/DialogFooterfrom../components/ui/dialog,Button. -
Produces:
RenameDialogprops{ open: boolean; initial: string; onOpenChange(open): void; onSubmit(name: string): void }.DeleteConfirmDialogprops{ open: boolean; label: string; onOpenChange(open): void; onConfirm(): void }.
-
Step 1: Write the failing test
Create frontend/src/shell/SessionActions.test.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:
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:
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 onButton, useclassName="bg-destructive text-white hover:bg-destructive/90"on a default Button instead. Verify againstfrontend/src/components/ui/button.tsxbefore 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
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, viewersSqlViewer({ blocks }),SchemaLinkingViewer({ linking }),MarkdownView({ source }), TanStack Query. -
Produces:
SessionDocumentsPanelprops{ session: SessionSummary; onClose(): void; onResume(id: string): void }. "Riprendi" shown only whensession.status !== "finalized" && !session.archived. -
Step 1: Write the failing test
Create frontend/src/shell/SessionDocumentsPanel.test.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:
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 onButton(used bydialog.tsx). If not, usesize="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
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 showingarchived, the left documents panel, and wires all mutations with query invalidation. "Riprendi" callsresumeSessionthen sets the active chat session. -
Step 1: Write the failing test
Create frontend/src/shell/AppShell.session-mgmt.test.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:
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):
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):
{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:
{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:
{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
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 buildandcd backend && npm run buildsucceed.
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.