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

75 KiB
Raw Blame History

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:

"""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 (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:

"""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 documents CLI 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, 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:

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: 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:

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() passes mode: "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> (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:

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

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:
    • 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:

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 (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:

// 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/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:

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 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
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:

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 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
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:

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