1940 lines
75 KiB
Markdown
1940 lines
75 KiB
Markdown
# Session Management Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add full session management — read-only document panel, rename, groups, archive, delete — driven by the existing phase-document persistence, with correct cold-start resume.
|
||
|
||
**Architecture:** Three layers. The `tht` Python CLI owns persistence (manifest + phase artifacts on disk) and gains mutation/read subcommands. The Fastify backend proxies them as REST routes and fixes resume to send the right prompt. The React frontend adds a kebab context menu, grouping, an archive view, and a left-side read-only documents panel reusing existing viewers.
|
||
|
||
**Tech Stack:** Python (typer, pydantic, pytest) · Node/Fastify + TypeScript (vitest) · React 18 + base-ui + Tailwind + TanStack Query + MSW (vitest + Testing Library).
|
||
|
||
## Global Constraints
|
||
|
||
- **No verbatim chat persistence.** The phase documents are the persistence; do not add a transcript store. (Spec: "Key architectural insight".)
|
||
- **`-c`/`--config` is a PER-COMMAND option in `tht`** — it must follow the subcommand, never precede it. (`ThtRunner.buildArgv` already appends it.)
|
||
- **`--json` output must be pristine** — only valid JSON on stdout, no color/extra lines. (Use `typer.echo(json.dumps(...))` and return early.)
|
||
- **Archive is a manifest flag**, never a directory move (preserves `sessions_root / session_id` id→path resolution).
|
||
- **Read-only rule:** `status == "finalized"` OR `archived == true` → never resumable.
|
||
- **Resume correctness is a prerequisite for the "Riprendi" action** — it must not ship as working until Task 7's verification is green.
|
||
- **Backend body-less POSTs** must send no JSON body (the client only sets `content-type` when a body is present; keep archive/unarchive/delete body-less).
|
||
- **Manifest field defaults** must keep existing manifests loading unchanged (`archived: bool = False`, `group: str | None = None`).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Harness (`tht`)**
|
||
- Modify `harness/tht/session/models.py` — add `archived`, `group` to `SessionManifest`.
|
||
- Modify `harness/tht/session/store.py` — `_save_touched` + `set_name` / `set_group` / `set_archived` / `delete_session` / `build_documents`.
|
||
- Modify `harness/tht/cli/session_cmd.py` — `set-name` / `set-group` / `archive` / `unarchive` / `delete` / `documents` commands; `_list_sessions` includes new fields.
|
||
- Modify `harness/.pi/skills/tht-sessione/SKILL.md` — add a "Phase 0 — Resume" section.
|
||
- Create `harness/tests/test_session_mutations.py`, `harness/tests/test_session_documents.py`.
|
||
|
||
**Backend (Fastify)**
|
||
- Modify `backend/src/tht/tht-runner.ts` — `setName` / `setGroup` / `archive` / `unarchive` / `deleteSession` / `documents` + private `ok()`.
|
||
- Modify `backend/src/routes/sessions.ts` — rename/group/archive/unarchive/delete/documents routes + resume guard.
|
||
- Modify `backend/src/pi/pi-process-manager.ts` — `spawnFor` gains `mode`, `resume` passes `"resume"`.
|
||
- Modify `backend/src/app.ts` — add `DELETE` to CORS methods.
|
||
- Modify `backend/test/tht-runner.test.ts`, `backend/test/routes-sessions.test.ts`, `backend/test/pi-process-manager.test.ts`.
|
||
|
||
**Frontend (React)**
|
||
- Modify `frontend/src/api/types.ts` — `SessionSummary` fields + `SessionDocument`.
|
||
- Modify `frontend/src/api/sessions.ts` — mutation + documents client functions.
|
||
- Create `frontend/src/shell/SessionMenu.tsx` — kebab context menu (base-ui).
|
||
- Create `frontend/src/shell/RenameDialog.tsx`, `frontend/src/shell/MoveToGroupSubmenu.tsx`, `frontend/src/shell/DeleteConfirmDialog.tsx`.
|
||
- Create `frontend/src/shell/SessionDocumentsPanel.tsx` — left read-only panel.
|
||
- Modify `frontend/src/shell/NavSessions.tsx` — grouping, archive view, row→panel, menu wiring.
|
||
- Modify `frontend/src/shell/AppShell.tsx` — left panel column + panel/active state.
|
||
- Modify/create the matching `*.test.tsx` files.
|
||
|
||
---
|
||
|
||
## Phase A — Harness data layer
|
||
|
||
### Task 1: Manifest fields + store mutation helpers
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/session/models.py`
|
||
- Modify: `harness/tht/session/store.py`
|
||
- Modify: `harness/tht/cli/session_cmd.py` (the `_list_sessions` dict only)
|
||
- Test: `harness/tests/test_session_mutations.py` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `create_session(question, db, sessions_root) -> SessionManifest`, `load_session(id, root) -> SessionManifest`, `MANIFEST`, `current_author()`.
|
||
- Produces:
|
||
- `set_name(session_id: str, name: str | None, sessions_root: Path) -> SessionManifest`
|
||
- `set_group(session_id: str, group: str | None, sessions_root: Path) -> SessionManifest`
|
||
- `set_archived(session_id: str, archived: bool, sessions_root: Path) -> SessionManifest`
|
||
- `delete_session(session_id: str, sessions_root: Path) -> None`
|
||
- `SessionManifest.archived: bool`, `SessionManifest.group: str | None`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `harness/tests/test_session_mutations.py`:
|
||
|
||
```python
|
||
"""Tests for session manifest mutations (rename, group, archive, delete)."""
|
||
import pytest
|
||
|
||
from tht.config import DatabaseConfig
|
||
from tht.session.store import (
|
||
SessionError,
|
||
create_session,
|
||
delete_session,
|
||
load_session,
|
||
set_archived,
|
||
set_group,
|
||
set_name,
|
||
)
|
||
|
||
|
||
def _db():
|
||
return DatabaseConfig(
|
||
database="testdb", user="u", password="p", # noqa: S106
|
||
**{"schema": "public"},
|
||
)
|
||
|
||
|
||
def test_manifest_defaults(tmp_path):
|
||
m = create_session("domanda", _db(), tmp_path)
|
||
assert m.archived is False
|
||
assert m.group is None
|
||
assert m.name is None
|
||
|
||
|
||
def test_set_name(tmp_path):
|
||
m = create_session("domanda", _db(), tmp_path)
|
||
set_name(m.id, "Pazienti 2024", tmp_path)
|
||
assert load_session(m.id, tmp_path).name == "Pazienti 2024"
|
||
|
||
|
||
def test_set_name_empty_clears(tmp_path):
|
||
m = create_session("domanda", _db(), tmp_path)
|
||
set_name(m.id, "x", tmp_path)
|
||
set_name(m.id, "", tmp_path)
|
||
assert load_session(m.id, tmp_path).name is None
|
||
|
||
|
||
def test_set_group_and_clear(tmp_path):
|
||
m = create_session("domanda", _db(), tmp_path)
|
||
set_group(m.id, "Aritmologia", tmp_path)
|
||
assert load_session(m.id, tmp_path).group == "Aritmologia"
|
||
set_group(m.id, "", tmp_path)
|
||
assert load_session(m.id, tmp_path).group is None
|
||
|
||
|
||
def test_set_archived(tmp_path):
|
||
m = create_session("domanda", _db(), tmp_path)
|
||
set_archived(m.id, True, tmp_path)
|
||
assert load_session(m.id, tmp_path).archived is True
|
||
set_archived(m.id, False, tmp_path)
|
||
assert load_session(m.id, tmp_path).archived is False
|
||
|
||
|
||
def test_delete_session(tmp_path):
|
||
m = create_session("domanda", _db(), tmp_path)
|
||
delete_session(m.id, tmp_path)
|
||
assert not (tmp_path / m.id).exists()
|
||
with pytest.raises(SessionError):
|
||
load_session(m.id, tmp_path)
|
||
|
||
|
||
def test_delete_missing_raises(tmp_path):
|
||
with pytest.raises(SessionError):
|
||
delete_session("nope", tmp_path)
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -q`
|
||
Expected: FAIL — `ImportError: cannot import name 'set_name'` (and friends).
|
||
|
||
- [ ] **Step 3: Add the manifest fields**
|
||
|
||
In `harness/tht/session/models.py`, inside `SessionManifest`, after the existing `name: str | None = None` line, add:
|
||
|
||
```python
|
||
archived: bool = False
|
||
group: str | None = None
|
||
```
|
||
|
||
- [ ] **Step 4: Add the store helpers**
|
||
|
||
In `harness/tht/session/store.py`, add `import shutil` at the top with the other imports, then append these functions (place after `close_session`):
|
||
|
||
```python
|
||
def _save_touched(manifest: SessionManifest, sessions_root: Path) -> SessionManifest:
|
||
"""Persist `manifest` updating updated_at/updated_by (single save path for mutations)."""
|
||
manifest.updated_at = datetime.now(UTC)
|
||
manifest.updated_by = current_author()
|
||
manifest.to_yaml(sessions_root / manifest.id / MANIFEST)
|
||
return manifest
|
||
|
||
|
||
def set_name(session_id: str, name: str | None, sessions_root: Path) -> SessionManifest:
|
||
"""Set the descriptive name (empty/blank clears it back to None)."""
|
||
manifest = load_session(session_id, sessions_root)
|
||
manifest.name = (name or "").strip() or None
|
||
return _save_touched(manifest, sessions_root)
|
||
|
||
|
||
def set_group(session_id: str, group: str | None, sessions_root: Path) -> SessionManifest:
|
||
"""Set the group (empty/blank clears it back to None)."""
|
||
manifest = load_session(session_id, sessions_root)
|
||
manifest.group = (group or "").strip() or None
|
||
return _save_touched(manifest, sessions_root)
|
||
|
||
|
||
def set_archived(session_id: str, archived: bool, sessions_root: Path) -> SessionManifest:
|
||
"""Flip the archived flag. Archiving does NOT change resumability (a finalized
|
||
session stays read-only); unarchive only moves it back to the active list."""
|
||
manifest = load_session(session_id, sessions_root)
|
||
manifest.archived = archived
|
||
return _save_touched(manifest, sessions_root)
|
||
|
||
|
||
def delete_session(session_id: str, sessions_root: Path) -> None:
|
||
"""Hard-delete the session directory. SessionError if it does not exist."""
|
||
load_session(session_id, sessions_root) # raises SessionError if absent
|
||
shutil.rmtree(sessions_root / session_id)
|
||
```
|
||
|
||
- [ ] **Step 5: Surface the new fields in `_list_sessions`**
|
||
|
||
In `harness/tht/cli/session_cmd.py`, in `_list_sessions`, extend the appended dict (add three keys after `"author": m.author,`):
|
||
|
||
```python
|
||
"author": m.author,
|
||
"name": m.name,
|
||
"group": m.group,
|
||
"archived": m.archived,
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -q`
|
||
Expected: PASS (7 passed).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/session/models.py harness/tht/session/store.py harness/tht/cli/session_cmd.py harness/tests/test_session_mutations.py
|
||
git commit -m "feat(harness): manifest archived/group fields + session mutation helpers"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: CLI mutation commands
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/cli/session_cmd.py`
|
||
- Test: `harness/tests/test_session_mutations.py` (append CLI tests)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `set_name`/`set_group`/`set_archived`/`delete_session` (Task 1), `_load_config_or_exit`, `load_session_or_exit`, `session_app`.
|
||
- Produces CLI commands: `tht session set-name <id> --name <name>`, `set-group <id> --group <name>`, `archive <id>`, `unarchive <id>`, `delete <id>`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `harness/tests/test_session_mutations.py`:
|
||
|
||
```python
|
||
import json
|
||
from typer.testing import CliRunner
|
||
|
||
from tht.cli.session_cmd import session_app
|
||
|
||
|
||
def _patch_cfg(monkeypatch, tmp_path):
|
||
from tht.cli import session_cmd
|
||
|
||
class FakePaths:
|
||
sessions = tmp_path
|
||
|
||
class FakeCfg:
|
||
paths = FakePaths()
|
||
database = _db()
|
||
|
||
monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: FakeCfg())
|
||
|
||
|
||
def test_cli_set_name(tmp_path, monkeypatch):
|
||
m = create_session("q", _db(), tmp_path)
|
||
_patch_cfg(monkeypatch, tmp_path)
|
||
res = CliRunner().invoke(session_app, ["set-name", m.id, "--name", "Mio nome"])
|
||
assert res.exit_code == 0, res.output
|
||
assert load_session(m.id, tmp_path).name == "Mio nome"
|
||
|
||
|
||
def test_cli_set_group(tmp_path, monkeypatch):
|
||
m = create_session("q", _db(), tmp_path)
|
||
_patch_cfg(monkeypatch, tmp_path)
|
||
res = CliRunner().invoke(session_app, ["set-group", m.id, "--group", "G1"])
|
||
assert res.exit_code == 0, res.output
|
||
assert load_session(m.id, tmp_path).group == "G1"
|
||
|
||
|
||
def test_cli_archive_unarchive(tmp_path, monkeypatch):
|
||
m = create_session("q", _db(), tmp_path)
|
||
_patch_cfg(monkeypatch, tmp_path)
|
||
assert CliRunner().invoke(session_app, ["archive", m.id]).exit_code == 0
|
||
assert load_session(m.id, tmp_path).archived is True
|
||
assert CliRunner().invoke(session_app, ["unarchive", m.id]).exit_code == 0
|
||
assert load_session(m.id, tmp_path).archived is False
|
||
|
||
|
||
def test_cli_delete(tmp_path, monkeypatch):
|
||
m = create_session("q", _db(), tmp_path)
|
||
_patch_cfg(monkeypatch, tmp_path)
|
||
res = CliRunner().invoke(session_app, ["delete", m.id])
|
||
assert res.exit_code == 0, res.output
|
||
assert not (tmp_path / m.id).exists()
|
||
|
||
|
||
def test_cli_list_includes_new_fields(tmp_path, monkeypatch):
|
||
from tht.cli.session_cmd import _list_sessions
|
||
|
||
create_session("q", _db(), tmp_path)
|
||
rows = _list_sessions(tmp_path)
|
||
assert {"name", "group", "archived"}.issubset(rows[0].keys())
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -k cli -q`
|
||
Expected: FAIL — `No such command 'set-name'` (CLI commands not registered).
|
||
|
||
- [ ] **Step 3: Add the CLI commands**
|
||
|
||
In `harness/tht/cli/session_cmd.py`, after the `close_cmd` function, add:
|
||
|
||
```python
|
||
@session_app.command("set-name")
|
||
def set_name_cmd(
|
||
session_id: str = typer.Argument(...),
|
||
name: str = typer.Option(..., "--name", help="Nome descrittivo (vuoto = azzera)."),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
"""Imposta il nome descrittivo della sessione."""
|
||
from tht.session.store import set_name
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
load_session_or_exit(cfg, session_id)
|
||
set_name(session_id, name, cfg.paths.sessions)
|
||
typer.secho(f"OK: nome aggiornato per {session_id}.", fg=typer.colors.GREEN)
|
||
|
||
|
||
@session_app.command("set-group")
|
||
def set_group_cmd(
|
||
session_id: str = typer.Argument(...),
|
||
group: str = typer.Option(..., "--group", help="Nome del gruppo (vuoto = nessun gruppo)."),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
"""Sposta la sessione in un gruppo (o la toglie da ogni gruppo)."""
|
||
from tht.session.store import set_group
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
load_session_or_exit(cfg, session_id)
|
||
set_group(session_id, group, cfg.paths.sessions)
|
||
typer.secho(f"OK: gruppo aggiornato per {session_id}.", fg=typer.colors.GREEN)
|
||
|
||
|
||
@session_app.command("archive")
|
||
def archive_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None:
|
||
"""Archivia la sessione (la toglie dalla lista attiva, sola lettura)."""
|
||
from tht.session.store import set_archived
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
load_session_or_exit(cfg, session_id)
|
||
set_archived(session_id, True, cfg.paths.sessions)
|
||
typer.secho(f"OK: sessione {session_id} archiviata.", fg=typer.colors.GREEN)
|
||
|
||
|
||
@session_app.command("unarchive")
|
||
def unarchive_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None:
|
||
"""Ripristina la sessione dall'archivio (non ne cambia la ripristinabilità)."""
|
||
from tht.session.store import set_archived
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
load_session_or_exit(cfg, session_id)
|
||
set_archived(session_id, False, cfg.paths.sessions)
|
||
typer.secho(f"OK: sessione {session_id} ripristinata.", fg=typer.colors.GREEN)
|
||
|
||
|
||
@session_app.command("delete")
|
||
def delete_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None:
|
||
"""Elimina definitivamente la cartella di sessione."""
|
||
from tht.session.store import delete_session
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
load_session_or_exit(cfg, session_id)
|
||
delete_session(session_id, cfg.paths.sessions)
|
||
typer.secho(f"OK: sessione {session_id} eliminata.", fg=typer.colors.GREEN)
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd harness && .venv/bin/pytest tests/test_session_mutations.py -q`
|
||
Expected: PASS (all tests, including the new CLI ones).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/cli/session_cmd.py harness/tests/test_session_mutations.py
|
||
git commit -m "feat(harness): tht session set-name/set-group/archive/unarchive/delete commands"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Documents read command
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/session/store.py` (add `build_documents`)
|
||
- Modify: `harness/tht/cli/session_cmd.py` (add `documents` command)
|
||
- Test: `harness/tests/test_session_documents.py` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `load_session`, `SessionManifest`.
|
||
- Produces:
|
||
- `build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]` — each dict `{phase, key, title, format, content}`; `format ∈ {"text","markdown","sql","schema-linking","decisions"}`.
|
||
- CLI `tht session documents <id> --json`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `harness/tests/test_session_documents.py`:
|
||
|
||
```python
|
||
"""Tests for `tht session documents --json` and build_documents."""
|
||
import json
|
||
|
||
from typer.testing import CliRunner
|
||
|
||
from tht.config import DatabaseConfig
|
||
from tht.session.store import build_documents, create_session
|
||
from tht.cli.session_cmd import session_app
|
||
|
||
|
||
def _db():
|
||
return DatabaseConfig(
|
||
database="testdb", user="u", password="p", # noqa: S106
|
||
**{"schema": "public"},
|
||
)
|
||
|
||
|
||
def test_build_documents_always_has_original_question(tmp_path):
|
||
m = create_session("quante ablazioni nel 2024", _db(), tmp_path)
|
||
docs = build_documents(m, tmp_path / m.id)
|
||
keys = [d["key"] for d in docs]
|
||
assert keys[0] == "question"
|
||
assert docs[0]["format"] == "text"
|
||
assert docs[0]["content"] == "quante ablazioni nel 2024"
|
||
# question.md is written by create_session -> revised_question present
|
||
assert "revised_question" in keys
|
||
|
||
|
||
def test_build_documents_includes_existing_artifacts_only(tmp_path):
|
||
m = create_session("q", _db(), tmp_path)
|
||
sdir = tmp_path / m.id
|
||
(sdir / "sql_final.sql").write_text("SELECT 1")
|
||
(sdir / "schema_linking.json").write_text('{"question":"q","candidates":[]}')
|
||
docs = {d["key"]: d for d in build_documents(m, sdir)}
|
||
assert docs["sql"]["format"] == "sql"
|
||
assert docs["sql"]["content"] == "SELECT 1"
|
||
assert docs["schema_linking"]["format"] == "schema-linking"
|
||
assert "validation_report" not in docs # not written
|
||
|
||
|
||
def test_cli_documents_json(tmp_path, monkeypatch):
|
||
m = create_session("q", _db(), tmp_path)
|
||
from tht.cli import session_cmd
|
||
|
||
class FakePaths:
|
||
sessions = tmp_path
|
||
|
||
class FakeCfg:
|
||
paths = FakePaths()
|
||
database = _db()
|
||
|
||
monkeypatch.setattr(session_cmd, "_load_config_or_exit", lambda _: FakeCfg())
|
||
res = CliRunner().invoke(session_app, ["documents", m.id, "--json"])
|
||
assert res.exit_code == 0, res.output
|
||
docs = json.loads(res.output)
|
||
assert isinstance(docs, list)
|
||
assert docs[0]["key"] == "question"
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd harness && .venv/bin/pytest tests/test_session_documents.py -q`
|
||
Expected: FAIL — `cannot import name 'build_documents'`.
|
||
|
||
- [ ] **Step 3: Add `build_documents`**
|
||
|
||
In `harness/tht/session/store.py`, append:
|
||
|
||
```python
|
||
def build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]:
|
||
"""Ordered, read-only document bundle for the UI panel. Only documents that exist
|
||
on disk are returned. CTE artifacts (F6) are intentionally excluded (intermediate)."""
|
||
docs: list[dict] = [{
|
||
"phase": "—", "key": "question", "title": "Domanda originale",
|
||
"format": "text", "content": manifest.question,
|
||
}]
|
||
spec = [
|
||
("question.md", "F3", "revised_question", "Domanda rivista", "markdown"),
|
||
("schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"),
|
||
("sql_final.sql", "F7", "sql", "SQL finale", "sql"),
|
||
("validation_report.md", "finalize", "validation_report", "Report di validazione", "markdown"),
|
||
("review_decisions.jsonl", "—", "decisions", "Decisioni", "decisions"),
|
||
]
|
||
for filename, phase, key, title, fmt in spec:
|
||
path = session_dir / filename
|
||
if path.exists():
|
||
docs.append({
|
||
"phase": phase, "key": key, "title": title,
|
||
"format": fmt, "content": path.read_text(),
|
||
})
|
||
return docs
|
||
```
|
||
|
||
- [ ] **Step 4: Add the `documents` CLI command**
|
||
|
||
In `harness/tht/cli/session_cmd.py`, after `delete_cmd`, add:
|
||
|
||
```python
|
||
@session_app.command("documents")
|
||
def documents_cmd(
|
||
session_id: str = typer.Argument(...),
|
||
json_out: bool = typer.Option(False, "--json", help="Emetti JSON su stdout (pristine)."),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
"""Documenti di sola lettura della sessione (domanda, rivista, schema, SQL, report, decisioni)."""
|
||
from tht.session.store import build_documents
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
manifest = load_session_or_exit(cfg, session_id)
|
||
docs = build_documents(manifest, session_dir(cfg, session_id))
|
||
if json_out:
|
||
typer.echo(json.dumps(docs, ensure_ascii=False, indent=2))
|
||
return
|
||
for d in docs:
|
||
typer.echo(f"[{d['phase']}] {d['title']} ({d['format']})")
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd harness && .venv/bin/pytest tests/test_session_documents.py -q`
|
||
Expected: PASS (3 passed).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/session/store.py harness/tht/cli/session_cmd.py harness/tests/test_session_documents.py
|
||
git commit -m "feat(harness): tht session documents --json read command"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase B — Backend (Fastify)
|
||
|
||
### Task 4: ThtRunner methods
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/tht/tht-runner.ts`
|
||
- Test: `backend/test/tht-runner.test.ts` (append)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ThtRunner.run`, `ThtRunner.buildArgv`, private `json<T>`.
|
||
- Produces methods on `ThtRunner`:
|
||
- `setName(id: string, name: string): Promise<void>`
|
||
- `setGroup(id: string, group: string): Promise<void>`
|
||
- `archive(id: string): Promise<void>` / `unarchive(id: string): Promise<void>`
|
||
- `deleteSession(id: string): Promise<void>`
|
||
- `documents(id: string): Promise<SessionDocument[]>` where
|
||
`SessionDocument = { phase: string; key: string; title: string; format: string; content: string }`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `backend/test/tht-runner.test.ts`:
|
||
|
||
```typescript
|
||
test("setName builds the right argv", async () => {
|
||
const calls: string[][] = [];
|
||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
||
r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; };
|
||
await r.setName("sid", "Mio nome");
|
||
expect(calls[0]).toEqual(["session", "set-name", "sid", "--name", "Mio nome"]);
|
||
});
|
||
|
||
test("setGroup / archive / unarchive / deleteSession build argv", async () => {
|
||
const calls: string[][] = [];
|
||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
||
r.run = async (args) => { calls.push(args); return { code: 0, stdout: "", stderr: "" }; };
|
||
await r.setGroup("sid", "G1");
|
||
await r.archive("sid");
|
||
await r.unarchive("sid");
|
||
await r.deleteSession("sid");
|
||
expect(calls).toEqual([
|
||
["session", "set-group", "sid", "--group", "G1"],
|
||
["session", "archive", "sid"],
|
||
["session", "unarchive", "sid"],
|
||
["session", "delete", "sid"],
|
||
]);
|
||
});
|
||
|
||
test("ok() throws on non-zero exit", async () => {
|
||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
||
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: nope" });
|
||
await expect(r.archive("sid")).rejects.toThrow(/nope/);
|
||
});
|
||
|
||
test("documents parses the JSON array", async () => {
|
||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
|
||
r.run = async () => ({
|
||
code: 0,
|
||
stdout: '[{"phase":"—","key":"question","title":"Domanda originale","format":"text","content":"q"}]',
|
||
stderr: "",
|
||
});
|
||
const docs = await r.documents("sid");
|
||
expect(docs[0].key).toBe("question");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/tht-runner.test.ts`
|
||
Expected: FAIL — `r.setName is not a function`.
|
||
|
||
- [ ] **Step 3: Add the methods**
|
||
|
||
In `backend/src/tht/tht-runner.ts`, add a `SessionDocument` interface near `SessionRow`:
|
||
|
||
```typescript
|
||
export interface SessionDocument {
|
||
phase: string;
|
||
key: string;
|
||
title: string;
|
||
format: string;
|
||
content: string;
|
||
}
|
||
```
|
||
|
||
Then add a private `ok` helper (after the existing private `json<T>` method) and the public methods (after `sqlExport`):
|
||
|
||
```typescript
|
||
private async ok(args: string[]): Promise<void> {
|
||
const { code, stderr } = await this.run(args);
|
||
if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`);
|
||
}
|
||
|
||
setName(id: string, name: string) { return this.ok(["session", "set-name", id, "--name", name]); }
|
||
setGroup(id: string, group: string) { return this.ok(["session", "set-group", id, "--group", group]); }
|
||
archive(id: string) { return this.ok(["session", "archive", id]); }
|
||
unarchive(id: string) { return this.ok(["session", "unarchive", id]); }
|
||
deleteSession(id: string) { return this.ok(["session", "delete", id]); }
|
||
documents(id: string) { return this.json<SessionDocument[]>(["session", "documents", id, "--json"]); }
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && npx vitest run test/tht-runner.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/tht/tht-runner.ts backend/test/tht-runner.test.ts
|
||
git commit -m "feat(backend): ThtRunner session mutation + documents methods"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Backend routes + resume guard
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/routes/sessions.ts`
|
||
- Modify: `backend/src/app.ts` (CORS methods)
|
||
- Test: `backend/test/routes-sessions.test.ts` (append)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ThtRunner` methods from Task 4, `PiProcessManager` (`mgr.get`, `mgr.teardown`, `mgr.resume`), `tht.sessionShow`.
|
||
- Produces routes: `POST /sessions/:id/rename {name}`, `POST /sessions/:id/group {group}`, `POST /sessions/:id/archive`, `POST /sessions/:id/unarchive`, `DELETE /sessions/:id`, `GET /sessions/:id/documents`; resume guard on `POST /sessions/:id/resume` (409 when finalized/archived).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `backend/test/routes-sessions.test.ts`:
|
||
|
||
```typescript
|
||
import { spawn as nodeSpawn } from "node:child_process";
|
||
|
||
function mutApp(thtRunner: any) {
|
||
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||
thtRunner,
|
||
getSettings: () => ({ workspace: "w" }) as any,
|
||
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
||
});
|
||
}
|
||
|
||
test("POST /sessions/:id/rename calls setName", async () => {
|
||
let arg: any;
|
||
const app = mutApp({ setName: async (id: string, name: string) => { arg = { id, name }; } });
|
||
const res = await app.inject({ method: "POST", url: "/sessions/s1/rename", payload: { name: "N" } });
|
||
expect(res.statusCode).toBe(204);
|
||
expect(arg).toEqual({ id: "s1", name: "N" });
|
||
});
|
||
|
||
test("POST /sessions/:id/group calls setGroup", async () => {
|
||
let arg: any;
|
||
const app = mutApp({ setGroup: async (id: string, group: string) => { arg = { id, group }; } });
|
||
const res = await app.inject({ method: "POST", url: "/sessions/s1/group", payload: { group: "G" } });
|
||
expect(res.statusCode).toBe(204);
|
||
expect(arg).toEqual({ id: "s1", group: "G" });
|
||
});
|
||
|
||
test("POST archive / unarchive call the runner", async () => {
|
||
const seen: string[] = [];
|
||
const app = mutApp({
|
||
archive: async (id: string) => { seen.push(`a:${id}`); },
|
||
unarchive: async (id: string) => { seen.push(`u:${id}`); },
|
||
});
|
||
expect((await app.inject({ method: "POST", url: "/sessions/s1/archive" })).statusCode).toBe(204);
|
||
expect((await app.inject({ method: "POST", url: "/sessions/s1/unarchive" })).statusCode).toBe(204);
|
||
expect(seen).toEqual(["a:s1", "u:s1"]);
|
||
});
|
||
|
||
test("DELETE /sessions/:id calls deleteSession", async () => {
|
||
let deleted: string | null = null;
|
||
const app = mutApp({ deleteSession: async (id: string) => { deleted = id; } });
|
||
const res = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
||
expect(res.statusCode).toBe(204);
|
||
expect(deleted).toBe("s1");
|
||
});
|
||
|
||
test("GET /sessions/:id/documents returns the runner output", async () => {
|
||
const app = mutApp({ documents: async () => [{ phase: "—", key: "question", title: "t", format: "text", content: "q" }] });
|
||
const res = await app.inject({ method: "GET", url: "/sessions/s1/documents" });
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.json()[0].key).toBe("question");
|
||
});
|
||
|
||
test("POST resume on a finalized session is refused with 409", async () => {
|
||
const app = mutApp({ sessionShow: async () => ({ status: "finalized", archived: false }) });
|
||
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||
expect(res.statusCode).toBe(409);
|
||
});
|
||
|
||
test("POST resume on an archived session is refused with 409", async () => {
|
||
const app = mutApp({ sessionShow: async () => ({ status: "open", archived: true }) });
|
||
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||
expect(res.statusCode).toBe(409);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/routes-sessions.test.ts`
|
||
Expected: FAIL — rename route 404s; resume returns 200 (no guard yet).
|
||
|
||
- [ ] **Step 3: Add the routes + guard**
|
||
|
||
In `backend/src/routes/sessions.ts`, replace the existing `resume` handler and add the new routes. First, **replace** the resume block:
|
||
|
||
```typescript
|
||
app.post("/sessions/:id/resume", async (req, reply) => {
|
||
const id = (req.params as any).id;
|
||
const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null;
|
||
if (manifest?.status === "finalized" || manifest?.archived) {
|
||
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
||
}
|
||
const rt = await d.mgr.resume(id, d.tht);
|
||
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
|
||
return reply.code(200).send({ id });
|
||
});
|
||
```
|
||
|
||
Then, before the closing `}` of `sessionRoutes`, add:
|
||
|
||
```typescript
|
||
app.post("/sessions/:id/rename", async (req, reply) => {
|
||
await d.tht.setName((req.params as any).id, (req.body as any).name);
|
||
return reply.code(204).send();
|
||
});
|
||
app.post("/sessions/:id/group", async (req, reply) => {
|
||
await d.tht.setGroup((req.params as any).id, (req.body as any).group);
|
||
return reply.code(204).send();
|
||
});
|
||
app.post("/sessions/:id/archive", async (req, reply) => {
|
||
await d.tht.archive((req.params as any).id);
|
||
return reply.code(204).send();
|
||
});
|
||
app.post("/sessions/:id/unarchive", async (req, reply) => {
|
||
await d.tht.unarchive((req.params as any).id);
|
||
return reply.code(204).send();
|
||
});
|
||
app.delete("/sessions/:id", async (req, reply) => {
|
||
const id = (req.params as any).id;
|
||
d.mgr.teardown(id); // drop any live runtime before deleting on disk
|
||
await d.tht.deleteSession(id);
|
||
return reply.code(204).send();
|
||
});
|
||
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
|
||
```
|
||
|
||
- [ ] **Step 4: Allow DELETE in CORS**
|
||
|
||
In `backend/src/app.ts`, change the CORS `methods` array to include `DELETE`:
|
||
|
||
```typescript
|
||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd backend && npx vitest run test/routes-sessions.test.ts`
|
||
Expected: PASS. (The existing two tests still pass; `mutApp` provides only the methods each test needs.)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/routes/sessions.ts backend/src/app.ts backend/test/routes-sessions.test.ts
|
||
git commit -m "feat(backend): rename/group/archive/unarchive/delete/documents routes + resume read-only guard"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase C — Resume correctness (prerequisite for "Riprendi")
|
||
|
||
### Task 6: spawnFor new/resume prompt mode
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/pi/pi-process-manager.ts`
|
||
- Test: `backend/test/pi-process-manager.test.ts` (append)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PiProcessManager.spawnFor`, `resume`.
|
||
- Produces: `spawnFor(sessionId, o: { provider?; model?; thinking?; author?; mode?: "new" | "resume" })` — `mode: "resume"` sends `/riprendi-sessione <id>`, default `"new"` sends `/nuova-domanda "kickoff"`. `resume()` passes `mode: "resume"`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `backend/test/pi-process-manager.test.ts`:
|
||
|
||
```typescript
|
||
import { EventEmitter } from "node:events";
|
||
|
||
function recordingChild() {
|
||
const ch: any = new EventEmitter();
|
||
ch.stdout = new EventEmitter();
|
||
ch.stderr = new EventEmitter();
|
||
ch._writes = [] as string[];
|
||
ch.stdin = { write: (d: any) => { ch._writes.push(String(d)); return true; } };
|
||
ch.kill = () => {};
|
||
return ch;
|
||
}
|
||
|
||
test("spawnFor resume mode sends /riprendi-sessione <id>", async () => {
|
||
const cfg = loadConfig({}); // no provider/model/thinking -> no rpc.request handshakes
|
||
const child = recordingChild();
|
||
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
||
await mgr.spawnFor("sid-9", { mode: "resume" });
|
||
expect(child._writes.join("")).toContain("/riprendi-sessione sid-9");
|
||
expect(child._writes.join("")).not.toContain("/nuova-domanda");
|
||
mgr.teardown("sid-9");
|
||
});
|
||
|
||
test("spawnFor default (new) mode sends /nuova-domanda", async () => {
|
||
const cfg = loadConfig({});
|
||
const child = recordingChild();
|
||
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
||
await mgr.spawnFor("sid-10", {});
|
||
expect(child._writes.join("")).toContain("/nuova-domanda");
|
||
mgr.teardown("sid-10");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && npx vitest run test/pi-process-manager.test.ts`
|
||
Expected: FAIL — resume-mode test sees `/nuova-domanda` (mode ignored).
|
||
|
||
- [ ] **Step 3: Implement the mode**
|
||
|
||
In `backend/src/pi/pi-process-manager.ts`:
|
||
|
||
In `spawnFor`, change the option type to add `mode`:
|
||
|
||
```typescript
|
||
async spawnFor(
|
||
sessionId: string,
|
||
o: { provider?: string; model?: string; thinking?: string; author?: string; mode?: "new" | "resume" },
|
||
): Promise<SessionRuntime> {
|
||
```
|
||
|
||
Replace the final prompt line:
|
||
|
||
```typescript
|
||
rpc.send({ type: "prompt", message: `/nuova-domanda "kickoff"` });
|
||
```
|
||
|
||
with:
|
||
|
||
```typescript
|
||
const message = o.mode === "resume"
|
||
? `/riprendi-sessione ${sessionId}`
|
||
: `/nuova-domanda "kickoff"`;
|
||
rpc.send({ type: "prompt", message });
|
||
```
|
||
|
||
In `resume`, pass the mode:
|
||
|
||
```typescript
|
||
async resume(sessionId: string, tht: ThtRunner): Promise<SessionRuntime> {
|
||
const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null;
|
||
return this.spawnFor(sessionId, {
|
||
provider: manifest?.provider,
|
||
model: manifest?.model,
|
||
thinking: manifest?.thinking,
|
||
mode: "resume",
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && npx vitest run test/pi-process-manager.test.ts`
|
||
Expected: PASS (existing tests still pass — they don't assert the prompt).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/pi/pi-process-manager.ts backend/test/pi-process-manager.test.ts
|
||
git commit -m "feat(backend): spawnFor new/resume prompt mode; resume sends /riprendi-sessione"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Skill Resume section + end-to-end verification
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/skills/tht-sessione/SKILL.md`
|
||
- Manual verification (no unit test — requires real Pi + harness)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `tht session show <id>` (returns `phase`), the phase artifacts on disk.
|
||
- Produces: a documented cold-start resume procedure the orchestrator follows when launched via `/riprendi-sessione <id>`.
|
||
|
||
- [ ] **Step 1: Add the Resume section to SKILL.md**
|
||
|
||
In `harness/.pi/skills/tht-sessione/SKILL.md`, insert a new section immediately **before** `## Phase 1 — Clarification`:
|
||
|
||
```markdown
|
||
## Phase 0 — Resume (cold start)
|
||
|
||
When launched with `/riprendi-sessione <id>` you have NO prior conversation — the
|
||
persisted state is your only context. Bootstrap before doing anything else:
|
||
|
||
1. `tht session show <id> --json` → read `phase` (the current phase N), `status`, and
|
||
the manifest (`question`, `database`, `schema`).
|
||
2. Load the artifacts produced so far, as needed for phase N: `question.md` (revised
|
||
question), `schema_linking.json` (F4 output), `ctes/*.sql` + `cte_tests.json` (F6),
|
||
`sql_final.sql` (F7). The decision ledger is summarized by `tht session show`.
|
||
3. **Resume at phase N reviewing the existing artifacts** (same discipline as rollback,
|
||
§Disciplines 11). Do NOT restart from Phase 1, do NOT re-run `tht` commands for
|
||
artifacts that already exist and are valid, and do NOT treat this as a new question.
|
||
4. Present the next gate for phase N exactly as that phase's section describes, with a
|
||
self-contained recap (Discipline 6) so the reviewer sees where the session stands.
|
||
|
||
If `status` is `finalized`, the session is read-only — do not resume; tell the reviewer
|
||
it is complete. (The backend already refuses resume for finalized/archived sessions.)
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the doc change is present**
|
||
|
||
Run: `grep -n "Phase 0 — Resume" harness/.pi/skills/tht-sessione/SKILL.md`
|
||
Expected: one match.
|
||
|
||
- [ ] **Step 3: Manual end-to-end verification**
|
||
|
||
This gate requires real Pi; it cannot be a deterministic unit test. Perform it and record the result:
|
||
|
||
1. Start backend + frontend (`backend`: `npm run dev`; `frontend`: `npm run dev`).
|
||
2. Create a session and drive it to **Phase 4 (schema linking)** — approve through F1–F3 so `question.md` exists and the phase is F4.
|
||
3. Stop the session (the composer "Stop" → `closeSession`) and confirm the Pi process is gone.
|
||
4. Reopen the session via **Riprendi** in the documents panel.
|
||
5. **Assert:** the harness re-enters at **Phase 4** (presents the schema-linking gate / continues schema linking), with the revised question and prior decisions available — NOT a fresh `/nuova-domanda` kickoff and NOT Phase 1.
|
||
|
||
Record PASS/FAIL in the PR description. The **"Riprendi"** action must not be advertised as working until this is PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/skills/tht-sessione/SKILL.md
|
||
git commit -m "docs(harness): add Phase 0 Resume cold-start procedure to the orchestrator skill"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase D — Frontend
|
||
|
||
### Task 8: Types + API client
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api/types.ts`
|
||
- Modify: `frontend/src/api/sessions.ts`
|
||
- Test: `frontend/src/api/sessions.test.ts` (append)
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `SessionSummary` gains `archived: boolean`, `group: string | null`, `name: string | null`.
|
||
- `SessionDocument { phase: string; key: string; title: string; format: "markdown" | "sql" | "schema-linking" | "decisions" | "text"; content: string }`.
|
||
- `renameSession(id, name)`, `setSessionGroup(id, group)`, `archiveSession(id)`, `unarchiveSession(id)`, `deleteSession(id)`, `getSessionDocuments(id): Promise<SessionDocument[]>`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `frontend/src/api/sessions.test.ts`:
|
||
|
||
```typescript
|
||
import {
|
||
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
||
deleteSession, getSessionDocuments,
|
||
} from "./sessions";
|
||
|
||
test("renameSession POSTs {name}", async () => {
|
||
let body: unknown = null;
|
||
server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => {
|
||
body = await request.json();
|
||
return new HttpResponse(null, { status: 204 });
|
||
}));
|
||
await renameSession("s1", "Nome");
|
||
expect(body).toEqual({ name: "Nome" });
|
||
});
|
||
|
||
test("setSessionGroup POSTs {group}", async () => {
|
||
let body: unknown = null;
|
||
server.use(http.post("http://localhost:8787/sessions/s1/group", async ({ request }) => {
|
||
body = await request.json();
|
||
return new HttpResponse(null, { status: 204 });
|
||
}));
|
||
await setSessionGroup("s1", "G");
|
||
expect(body).toEqual({ group: "G" });
|
||
});
|
||
|
||
test("archive / unarchive / delete hit the right verbs+paths", async () => {
|
||
const hits: string[] = [];
|
||
server.use(
|
||
http.post("http://localhost:8787/sessions/s1/archive", () => { hits.push("archive"); return new HttpResponse(null, { status: 204 }); }),
|
||
http.post("http://localhost:8787/sessions/s1/unarchive", () => { hits.push("unarchive"); return new HttpResponse(null, { status: 204 }); }),
|
||
http.delete("http://localhost:8787/sessions/s1", () => { hits.push("delete"); return new HttpResponse(null, { status: 204 }); }),
|
||
);
|
||
await archiveSession("s1");
|
||
await unarchiveSession("s1");
|
||
await deleteSession("s1");
|
||
expect(hits).toEqual(["archive", "unarchive", "delete"]);
|
||
});
|
||
|
||
test("getSessionDocuments GETs the array", async () => {
|
||
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||
HttpResponse.json([{ phase: "—", key: "question", title: "t", format: "text", content: "q" }]),
|
||
));
|
||
const docs = await getSessionDocuments("s1");
|
||
expect(docs[0].key).toBe("question");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/api/sessions.test.ts`
|
||
Expected: FAIL — `renameSession` not exported.
|
||
|
||
- [ ] **Step 3: Extend the types**
|
||
|
||
In `frontend/src/api/types.ts`, extend `SessionSummary` (add the three fields after `author`):
|
||
|
||
```typescript
|
||
export interface SessionSummary {
|
||
id: string;
|
||
status: string;
|
||
question: string;
|
||
summary: string | null;
|
||
created_at: string;
|
||
updated_at: string | null;
|
||
author: string | null;
|
||
name: string | null;
|
||
group: string | null;
|
||
archived: boolean;
|
||
}
|
||
|
||
export interface SessionDocument {
|
||
phase: string;
|
||
key: string;
|
||
title: string;
|
||
format: "markdown" | "sql" | "schema-linking" | "decisions" | "text";
|
||
content: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add the client functions**
|
||
|
||
In `frontend/src/api/sessions.ts`, add (update the `import type` line to include `SessionDocument`):
|
||
|
||
```typescript
|
||
import type { SessionSummary, SessionDocument, UiResponse } from "./types";
|
||
|
||
export const renameSession = (id: string, name: string) =>
|
||
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
|
||
|
||
export const setSessionGroup = (id: string, group: string) =>
|
||
apiFetch<void>(`/sessions/${id}/group`, { method: "POST", body: JSON.stringify({ group }) });
|
||
|
||
export const archiveSession = (id: string) =>
|
||
apiFetch<void>(`/sessions/${id}/archive`, { method: "POST" });
|
||
|
||
export const unarchiveSession = (id: string) =>
|
||
apiFetch<void>(`/sessions/${id}/unarchive`, { method: "POST" });
|
||
|
||
export const deleteSession = (id: string) =>
|
||
apiFetch<void>(`/sessions/${id}`, { method: "DELETE" });
|
||
|
||
export const getSessionDocuments = (id: string) =>
|
||
apiFetch<SessionDocument[]>(`/sessions/${id}/documents`);
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/api/sessions.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/types.ts frontend/src/api/sessions.ts frontend/src/api/sessions.test.ts
|
||
git commit -m "feat(frontend): session mutation + documents API client and types"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Kebab menu component + row opens panel
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/shell/SessionMenu.tsx`
|
||
- Modify: `frontend/src/shell/NavSessions.tsx`
|
||
- Test: `frontend/src/shell/NavSessions.test.tsx` (rewrite data + add menu test)
|
||
|
||
**Interfaces:**
|
||
- Consumes: base-ui `Menu` (`@base-ui/react/menu`), `SessionSummary`.
|
||
- Produces:
|
||
- `SessionMenu` props: `{ session: SessionSummary; groups: string[]; onView(): void; onRename(): void; onMove(group: string): void; onNewGroup(): void; onArchiveToggle(): void; onDelete(): void }`.
|
||
- `NavSessions` props change to `{ sessions: SessionSummary[]; activeSessionId: string | null; onOpenPanel(id: string): void; menuFor(session): React.ReactNode }`. (Grouping/archive added in Task 11; this task wires row-click → `onOpenPanel` and renders a `menuFor` slot.)
|
||
|
||
> Note: from this task NavSessions receives `sessions` as a prop instead of fetching, so AppShell owns the query and the mutations. The fetch moves to AppShell in Task 12; until then the test passes sessions directly.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Replace the body of `frontend/src/shell/NavSessions.test.tsx` with:
|
||
|
||
```typescript
|
||
import { render, screen } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { NavSessions } from "./NavSessions";
|
||
import type { SessionSummary } from "../api/types";
|
||
|
||
const SESSIONS: SessionSummary[] = [
|
||
{ id: "s1", status: "open", question: "Come va?", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false },
|
||
{ id: "s2", status: "closed", question: "Seconda domanda", summary: "r", created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: "Etichetta", group: null, archived: false },
|
||
];
|
||
|
||
test("lists sessions and shows name when present", () => {
|
||
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={vi.fn()} menuFor={() => null} />);
|
||
expect(screen.getByText("Come va?")).toBeInTheDocument();
|
||
expect(screen.getByText("Etichetta")).toBeInTheDocument(); // name overrides question
|
||
});
|
||
|
||
test("clicking a row opens the panel (does not resume)", async () => {
|
||
const onOpenPanel = vi.fn();
|
||
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => null} />);
|
||
await userEvent.click(screen.getByText("Come va?"));
|
||
expect(onOpenPanel).toHaveBeenCalledWith("s1");
|
||
});
|
||
|
||
test("active session is highlighted", () => {
|
||
render(<NavSessions sessions={SESSIONS} activeSessionId="s2" onOpenPanel={vi.fn()} menuFor={() => null} />);
|
||
expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/NavSessions.test.tsx`
|
||
Expected: FAIL — NavSessions still fetches and uses the old props.
|
||
|
||
- [ ] **Step 3: Create the SessionMenu component**
|
||
|
||
Create `frontend/src/shell/SessionMenu.tsx`:
|
||
|
||
```tsx
|
||
import { Menu } from "@base-ui/react/menu";
|
||
import { MoreVertical } from "lucide-react";
|
||
import type { SessionSummary } from "../api/types";
|
||
|
||
interface Props {
|
||
session: SessionSummary;
|
||
groups: string[];
|
||
onView: () => void;
|
||
onRename: () => void;
|
||
onMove: (group: string) => void;
|
||
onNewGroup: () => void;
|
||
onArchiveToggle: () => void;
|
||
onDelete: () => void;
|
||
}
|
||
|
||
const itemCls =
|
||
"flex cursor-default items-center justify-between gap-6 rounded-md px-2.5 py-1.5 text-sm outline-none data-highlighted:bg-accent";
|
||
|
||
export function SessionMenu({ session, groups, onView, onRename, onMove, onNewGroup, onArchiveToggle, onDelete }: Props) {
|
||
return (
|
||
<Menu.Root>
|
||
<Menu.Trigger
|
||
aria-label="Session actions"
|
||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 data-popup-open:opacity-100"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<MoreVertical className="size-4" />
|
||
</Menu.Trigger>
|
||
<Menu.Portal>
|
||
<Menu.Positioner side="bottom" align="end" sideOffset={4}>
|
||
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||
<Menu.Item className={itemCls} onClick={onView}>Vista divisa</Menu.Item>
|
||
<Menu.Item className={itemCls} onClick={onRename}>Rinomina</Menu.Item>
|
||
<Menu.SubmenuRoot>
|
||
<Menu.SubmenuTrigger className={itemCls}>Sposta nel gruppo ›</Menu.SubmenuTrigger>
|
||
<Menu.Portal>
|
||
<Menu.Positioner side="right" align="start">
|
||
<Menu.Popup className="z-50 min-w-44 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||
{groups.filter((g) => g !== session.group).map((g) => (
|
||
<Menu.Item key={g} className={itemCls} onClick={() => onMove(g)}>{g}</Menu.Item>
|
||
))}
|
||
{session.group && (
|
||
<Menu.Item className={itemCls} onClick={() => onMove("")}>Senza gruppo</Menu.Item>
|
||
)}
|
||
<Menu.Item className={itemCls} onClick={onNewGroup}>Nuovo gruppo…</Menu.Item>
|
||
</Menu.Popup>
|
||
</Menu.Positioner>
|
||
</Menu.Portal>
|
||
</Menu.SubmenuRoot>
|
||
<Menu.Item className={itemCls} onClick={onArchiveToggle}>
|
||
{session.archived ? "Ripristina" : "Archivia"}
|
||
</Menu.Item>
|
||
<Menu.Separator className="my-1 h-px bg-border" />
|
||
<Menu.Item className={`${itemCls} text-destructive`} onClick={onDelete}>Elimina</Menu.Item>
|
||
</Menu.Popup>
|
||
</Menu.Positioner>
|
||
</Menu.Portal>
|
||
</Menu.Root>
|
||
);
|
||
}
|
||
```
|
||
|
||
> base-ui exports `Menu.Root/Trigger/Portal/Positioner/Popup/Item/Separator/SubmenuRoot/SubmenuTrigger` (the `menu/submenu-root` and `menu/submenu-trigger` subpaths confirm the submenu parts exist). If a part name or the `data-popup-open` attribute differs in this `@base-ui/react@1.6` build, check `node_modules/@base-ui/react/menu/index.d.ts` and adjust — the menu still functions without the open-state opacity class.
|
||
|
||
- [ ] **Step 4: Rewrite NavSessions to take props and render the row + menu slot**
|
||
|
||
Replace `frontend/src/shell/NavSessions.tsx` with:
|
||
|
||
```tsx
|
||
// frontend/src/shell/NavSessions.tsx
|
||
import type { ReactNode } from "react";
|
||
import type { SessionSummary } from "../api/types";
|
||
|
||
interface Props {
|
||
sessions: SessionSummary[];
|
||
activeSessionId: string | null;
|
||
onOpenPanel: (id: string) => void;
|
||
menuFor: (session: SessionSummary) => ReactNode;
|
||
}
|
||
|
||
export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }: Props) {
|
||
if (sessions.length === 0) {
|
||
return (
|
||
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
||
No sessions yet. Start with <span className="font-medium text-foreground">New session</span>.
|
||
</p>
|
||
);
|
||
}
|
||
return (
|
||
<ul className="flex flex-col gap-0.5">
|
||
{sessions.map((s) => {
|
||
const active = s.id === activeSessionId;
|
||
return (
|
||
<li key={s.id}>
|
||
<div
|
||
data-testid={`session-item-${s.id}`}
|
||
data-active={active ? "true" : "false"}
|
||
onClick={() => onOpenPanel(s.id)}
|
||
className={[
|
||
"group flex w-full cursor-pointer items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||
active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent",
|
||
].join(" ")}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<span className={[
|
||
"block truncate text-[0.8rem] leading-snug",
|
||
active ? "font-bold text-primary" : "text-foreground/90",
|
||
].join(" ")}>
|
||
{s.name || s.question || s.id}
|
||
</span>
|
||
<span className="mt-1 flex items-center gap-1.5">
|
||
<span className={[
|
||
"size-1.5 rounded-full",
|
||
s.status === "open" ? "bg-[oklch(var(--success))]"
|
||
: s.status === "finalized" ? "bg-primary"
|
||
: "bg-muted-foreground/50",
|
||
].join(" ")} />
|
||
<span className="text-[0.65rem] uppercase tracking-wide text-muted-foreground">{s.status}</span>
|
||
</span>
|
||
</div>
|
||
{menuFor(s)}
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Keep AppShell compiling (minimal wiring)**
|
||
|
||
NavSessions' new signature breaks AppShell's old call site, so the build won't compile until AppShell is updated. Do the MINIMAL change here (full grouping/panel/menu arrive in Task 12). In `frontend/src/shell/AppShell.tsx`:
|
||
|
||
(a) Extend the imports: change `import { closeSession } from "../api/sessions";` to
|
||
|
||
```tsx
|
||
import { closeSession, listSessions, resumeSession } from "../api/sessions";
|
||
import type { SessionSummary } from "../api/types";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
```
|
||
|
||
(b) Inside `AppShell()`, after the `const [activeSessionId, setActiveSessionId] = useState<string | null>(null);` line, add:
|
||
|
||
```tsx
|
||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
|
||
});
|
||
```
|
||
|
||
(c) Replace the existing call `<NavSessions activeSessionId={activeSessionId} onSessionSelect={setActiveSessionId} />` with (preserves today's resume-on-click behavior; menu/panel added in Task 12):
|
||
|
||
```tsx
|
||
<NavSessions
|
||
sessions={sessions}
|
||
activeSessionId={activeSessionId}
|
||
onOpenPanel={async (id) => { await resumeSession(id); setActiveSessionId(id); }}
|
||
menuFor={() => null}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/NavSessions.test.tsx src/App.test.tsx`
|
||
Expected: PASS. (`App.test.tsx` still works because click still resumes. If it asserted the old fetch-inside-NavSessions, update it to the prop-driven flow.)
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/SessionMenu.tsx frontend/src/shell/NavSessions.tsx frontend/src/shell/NavSessions.test.tsx frontend/src/shell/AppShell.tsx
|
||
git commit -m "feat(frontend): SessionMenu kebab + NavSessions prop-driven; AppShell owns sessions query"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Rename dialog + delete confirm + actions container
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/shell/RenameDialog.tsx`
|
||
- Create: `frontend/src/shell/DeleteConfirmDialog.tsx`
|
||
- Test: `frontend/src/shell/SessionActions.test.tsx` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Dialog`/`DialogContent`/`DialogHeader`/`DialogTitle`/`DialogFooter` from `../components/ui/dialog`, `Button`.
|
||
- Produces:
|
||
- `RenameDialog` props `{ open: boolean; initial: string; onOpenChange(open): void; onSubmit(name: string): void }`.
|
||
- `DeleteConfirmDialog` props `{ open: boolean; label: string; onOpenChange(open): void; onConfirm(): void }`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `frontend/src/shell/SessionActions.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { RenameDialog } from "./RenameDialog";
|
||
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
||
|
||
test("RenameDialog submits the edited name", async () => {
|
||
const onSubmit = vi.fn();
|
||
render(<RenameDialog open initial="Vecchio" onOpenChange={vi.fn()} onSubmit={onSubmit} />);
|
||
const input = screen.getByLabelText(/name/i);
|
||
await userEvent.clear(input);
|
||
await userEvent.type(input, "Nuovo");
|
||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||
expect(onSubmit).toHaveBeenCalledWith("Nuovo");
|
||
});
|
||
|
||
test("DeleteConfirmDialog confirms only on the destructive button", async () => {
|
||
const onConfirm = vi.fn();
|
||
render(<DeleteConfirmDialog open label="Sessione X" onOpenChange={vi.fn()} onConfirm={onConfirm} />);
|
||
expect(screen.getByText(/Sessione X/)).toBeInTheDocument();
|
||
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||
expect(onConfirm).toHaveBeenCalled();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/SessionActions.test.tsx`
|
||
Expected: FAIL — modules not found.
|
||
|
||
- [ ] **Step 3: Create RenameDialog**
|
||
|
||
Create `frontend/src/shell/RenameDialog.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog";
|
||
import { Button } from "../components/ui/button";
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
initial: string;
|
||
onOpenChange: (open: boolean) => void;
|
||
onSubmit: (name: string) => void;
|
||
}
|
||
|
||
export function RenameDialog({ open, initial, onOpenChange, onSubmit }: Props) {
|
||
const [name, setName] = useState(initial);
|
||
useEffect(() => { if (open) setName(initial); }, [open, initial]);
|
||
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
onSubmit(name.trim());
|
||
onOpenChange(false);
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>Rinomina sessione</DialogTitle></DialogHeader>
|
||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="rename-name">Name</label>
|
||
<input
|
||
id="rename-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||
placeholder="Session name…"
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||
<Button type="submit" size="sm">Save</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create DeleteConfirmDialog**
|
||
|
||
Create `frontend/src/shell/DeleteConfirmDialog.tsx`:
|
||
|
||
```tsx
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
|
||
import { Button } from "../components/ui/button";
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
label: string;
|
||
onOpenChange: (open: boolean) => void;
|
||
onConfirm: () => void;
|
||
}
|
||
|
||
export function DeleteConfirmDialog({ open, label, onOpenChange, onConfirm }: Props) {
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent>
|
||
<DialogHeader><DialogTitle>Elimina definitivamente</DialogTitle></DialogHeader>
|
||
<DialogDescription>
|
||
“{label}” verrà eliminata definitivamente, inclusi tutti i suoi documenti. L'operazione non è reversibile.
|
||
</DialogDescription>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>Delete</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
> If `variant="destructive"` is not defined on `Button`, use `className="bg-destructive text-white hover:bg-destructive/90"` on a default Button instead. Verify against `frontend/src/components/ui/button.tsx` before implementing.
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/SessionActions.test.tsx`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/RenameDialog.tsx frontend/src/shell/DeleteConfirmDialog.tsx frontend/src/shell/SessionActions.test.tsx
|
||
git commit -m "feat(frontend): RenameDialog and DeleteConfirmDialog"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: SessionDocumentsPanel (read-only viewer)
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/shell/SessionDocumentsPanel.tsx`
|
||
- Test: `frontend/src/shell/SessionDocumentsPanel.test.tsx` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSessionDocuments`, `SessionDocument`, `SessionSummary`, viewers `SqlViewer` (`{ blocks }`), `SchemaLinkingViewer` (`{ linking }`), `MarkdownView` (`{ source }`), TanStack Query.
|
||
- Produces: `SessionDocumentsPanel` props `{ session: SessionSummary; onClose(): void; onResume(id: string): void }`. "Riprendi" shown only when `session.status !== "finalized" && !session.archived`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `frontend/src/shell/SessionDocumentsPanel.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen } from "@testing-library/react";
|
||
import { http, HttpResponse } from "msw";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { server } from "../test/msw";
|
||
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||
import type { SessionSummary } from "../api/types";
|
||
|
||
function wrap(ui: React.ReactElement) {
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||
}
|
||
|
||
const base: SessionSummary = {
|
||
id: "s1", status: "open", question: "Quante ablazioni?", summary: null,
|
||
created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null,
|
||
name: null, group: null, archived: false,
|
||
};
|
||
|
||
beforeEach(() => {
|
||
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||
HttpResponse.json([
|
||
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Quante ablazioni?" },
|
||
{ phase: "F7", key: "sql", title: "SQL finale", format: "sql", content: "SELECT 1" },
|
||
]),
|
||
));
|
||
});
|
||
|
||
test("renders the document titles from the API", async () => {
|
||
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
|
||
expect(screen.getByText("SQL finale")).toBeInTheDocument();
|
||
});
|
||
|
||
test("shows Riprendi for resumable, hides it for finalized", async () => {
|
||
const { rerender } = wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||
expect(await screen.findByRole("button", { name: /riprendi/i })).toBeInTheDocument();
|
||
rerender(<SessionDocumentsPanel session={{ ...base, status: "finalized" }} onClose={vi.fn()} onResume={vi.fn()} />);
|
||
expect(screen.queryByRole("button", { name: /riprendi/i })).not.toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/SessionDocumentsPanel.test.tsx`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Create the panel**
|
||
|
||
Create `frontend/src/shell/SessionDocumentsPanel.tsx`:
|
||
|
||
```tsx
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { X } from "lucide-react";
|
||
import { getSessionDocuments } from "../api/sessions";
|
||
import type { SessionDocument, SessionSummary } from "../api/types";
|
||
import { Button } from "../components/ui/button";
|
||
import { SqlViewer } from "../viewers/SqlViewer";
|
||
import { SchemaLinkingViewer } from "../viewers/SchemaLinkingViewer";
|
||
import { MarkdownView } from "../viewers/MarkdownView";
|
||
|
||
interface Props {
|
||
session: SessionSummary;
|
||
onClose: () => void;
|
||
onResume: (id: string) => void;
|
||
}
|
||
|
||
function statusLabel(s: SessionSummary): string {
|
||
if (s.archived) return "Archiviata";
|
||
if (s.status === "finalized") return "Completata";
|
||
return `In corso · ${s.status}`;
|
||
}
|
||
|
||
function DocBody({ doc }: { doc: SessionDocument }) {
|
||
if (doc.format === "sql") return <SqlViewer blocks={[{ name: doc.title, sql: doc.content }]} />;
|
||
if (doc.format === "markdown") return <MarkdownView source={doc.content} />;
|
||
if (doc.format === "schema-linking") {
|
||
try {
|
||
return <SchemaLinkingViewer linking={JSON.parse(doc.content)} />;
|
||
} catch {
|
||
return <pre className="whitespace-pre-wrap text-xs">{doc.content}</pre>;
|
||
}
|
||
}
|
||
if (doc.format === "decisions") {
|
||
const lines = doc.content.split("\n").filter(Boolean);
|
||
return (
|
||
<details>
|
||
<summary className="cursor-pointer text-sm text-muted-foreground">{lines.length} decisioni</summary>
|
||
<ul className="mt-2 flex flex-col gap-1 text-xs">
|
||
{lines.map((line, i) => {
|
||
let d: { type?: string; subject?: string; detail?: string };
|
||
try { d = JSON.parse(line); } catch { d = {}; }
|
||
return <li key={i} className="font-mono">[{d.type}] {d.subject}{d.detail ? ` — ${d.detail}` : ""}</li>;
|
||
})}
|
||
</ul>
|
||
</details>
|
||
);
|
||
}
|
||
return <p className="whitespace-pre-wrap text-sm">{doc.content}</p>;
|
||
}
|
||
|
||
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
||
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
|
||
queryKey: ["session-documents", session.id],
|
||
queryFn: () => getSessionDocuments(session.id),
|
||
});
|
||
const resumable = session.status !== "finalized" && !session.archived;
|
||
|
||
return (
|
||
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
|
||
<div className="flex items-start justify-between gap-2 border-b border-border/70 px-4 py-3">
|
||
<div className="min-w-0">
|
||
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
|
||
{session.name || session.question}
|
||
</h2>
|
||
<p className="mt-0.5 text-[0.7rem] uppercase tracking-wide text-muted-foreground">{statusLabel(session)}</p>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{resumable && (
|
||
<Button size="sm" variant="outline" onClick={() => onResume(session.id)}>Riprendi</Button>
|
||
)}
|
||
<Button size="icon-sm" variant="ghost" aria-label="Close panel" onClick={onClose}>
|
||
<X className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||
{isLoading ? (
|
||
<p className="text-xs text-muted-foreground">Loading…</p>
|
||
) : (
|
||
<div className="flex flex-col gap-5">
|
||
{docs.map((doc) => (
|
||
<section key={doc.key}>
|
||
<h3 className="mb-1.5 text-xs font-bold uppercase tracking-wide text-primary">
|
||
{doc.title}{doc.phase !== "—" ? ` · ${doc.phase}` : ""}
|
||
</h3>
|
||
<DocBody doc={doc} />
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
> Verify `size="icon-sm"` exists on `Button` (used by `dialog.tsx`). If not, use `size="sm"`.
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/SessionDocumentsPanel.test.tsx`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/SessionDocumentsPanel.tsx frontend/src/shell/SessionDocumentsPanel.test.tsx
|
||
git commit -m "feat(frontend): SessionDocumentsPanel read-only viewer with Riprendi"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: AppShell integration — grouping, archive view, panel, mutations
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/shell/AppShell.tsx`
|
||
- Test: `frontend/src/shell/AppShell.session-mgmt.test.tsx` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything above — `NavSessions`, `SessionMenu`, `RenameDialog`, `DeleteConfirmDialog`, `SessionDocumentsPanel`, the API client, `useQuery`/`useQueryClient`/`useMutation`.
|
||
- Produces: AppShell owns the `["sessions"]` query; renders the active list grouped (collapsible group headers + "Senza gruppo") filtered by `!archived`, an "Archivio" toggle showing `archived`, the left documents panel, and wires all mutations with query invalidation. "Riprendi" calls `resumeSession` then sets the active chat session.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `frontend/src/shell/AppShell.session-mgmt.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { http, HttpResponse } from "msw";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { server } from "../test/msw";
|
||
import { AppShell } from "./AppShell";
|
||
|
||
function wrap() {
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
|
||
}
|
||
|
||
const LIST = [
|
||
{ id: "s1", status: "open", question: "Attiva uno", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false },
|
||
{ id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true },
|
||
];
|
||
|
||
beforeEach(() => {
|
||
server.use(
|
||
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
||
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
||
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
|
||
])),
|
||
http.post("http://localhost:8787/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
|
||
);
|
||
});
|
||
|
||
test("active list shows group header and hides archived sessions", async () => {
|
||
wrap();
|
||
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
|
||
expect(screen.getByText("Aritmologia")).toBeInTheDocument(); // group header
|
||
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); // archived hidden
|
||
});
|
||
|
||
test("opening the panel shows the session documents", async () => {
|
||
wrap();
|
||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
|
||
});
|
||
|
||
test("Archivio toggle reveals archived sessions", async () => {
|
||
wrap();
|
||
await userEvent.click(await screen.findByRole("button", { name: /archivio/i }));
|
||
expect(await screen.findByText("Archiviata due")).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx`
|
||
Expected: FAIL — AppShell does not yet fetch sessions / render groups / archive toggle.
|
||
|
||
- [ ] **Step 3: Rewrite the AppShell right rail + add the left panel**
|
||
|
||
In `frontend/src/shell/AppShell.tsx`:
|
||
|
||
(a) Update imports at the top:
|
||
|
||
```tsx
|
||
import { useSessionStream } from "../stream/useSessionStream";
|
||
import { useSessionStore } from "../store/sessionStore";
|
||
import { WidgetHost } from "./WidgetHost";
|
||
import { Transcript } from "./Transcript";
|
||
import { NavSessions } from "./NavSessions";
|
||
import { SessionMenu } from "./SessionMenu";
|
||
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||
import { RenameDialog } from "./RenameDialog";
|
||
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||
import { WorkflowBar } from "./WorkflowBar";
|
||
import { Button } from "../components/ui/button";
|
||
import { Toaster } from "../components/ui/sonner";
|
||
import {
|
||
closeSession, listSessions, resumeSession,
|
||
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession,
|
||
} from "../api/sessions";
|
||
import type { SessionSummary } from "../api/types";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useMemo, useRef, useState } from "react";
|
||
```
|
||
|
||
(b) Inside `AppShell()`, the `sessions` query already exists from Task 9. Add `queryClient`, the remaining state, and the handlers next to it (do NOT re-declare the `sessions` query):
|
||
|
||
```tsx
|
||
const queryClient = useQueryClient();
|
||
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||
const [showArchive, setShowArchive] = useState(false);
|
||
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
||
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(null);
|
||
|
||
const groups = useMemo(
|
||
() => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(),
|
||
[sessions],
|
||
);
|
||
const activeList = sessions.filter((s) => !s.archived);
|
||
const archivedList = sessions.filter((s) => s.archived);
|
||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||
|
||
function openPanel(id: string) {
|
||
const s = sessions.find((x) => x.id === id);
|
||
if (s) setPanelSession(s);
|
||
}
|
||
async function doResume(id: string) {
|
||
await resumeSession(id);
|
||
setPanelSession(null);
|
||
setActiveSessionId(id);
|
||
}
|
||
async function move(s: SessionSummary, group: string) { await setSessionGroup(s.id, group); refresh(); }
|
||
async function newGroup(s: SessionSummary) {
|
||
const name = window.prompt("Nuovo gruppo:");
|
||
if (name && name.trim()) { await setSessionGroup(s.id, name.trim()); refresh(); }
|
||
}
|
||
async function toggleArchive(s: SessionSummary) {
|
||
await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id));
|
||
if (panelSession?.id === s.id) setPanelSession(null);
|
||
refresh();
|
||
}
|
||
|
||
function menuFor(s: SessionSummary) {
|
||
return (
|
||
<SessionMenu
|
||
session={s}
|
||
groups={groups}
|
||
onView={() => setPanelSession(s)}
|
||
onRename={() => setRenameTarget(s)}
|
||
onMove={(g) => move(s, g)}
|
||
onNewGroup={() => newGroup(s)}
|
||
onArchiveToggle={() => toggleArchive(s)}
|
||
onDelete={() => setDeleteTarget(s)}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
(c) Add the left panel as the FIRST child of the top-level flex row (immediately inside `<div className="flex h-screen ...">`, before the conversation column):
|
||
|
||
```tsx
|
||
{panelSession && (
|
||
<SessionDocumentsPanel
|
||
session={panelSession}
|
||
onClose={() => setPanelSession(null)}
|
||
onResume={doResume}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
(d) Replace the right-rail `<NavSessions .../>` block (the one inside the scrolling `<div className="flex-1 overflow-y-auto px-2 pb-4">`) with grouped rendering + the archive toggle. Replace that scrolling container's contents with:
|
||
|
||
```tsx
|
||
{showArchive ? (
|
||
<NavSessions sessions={archivedList} activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} />
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{groups.map((g) => (
|
||
<div key={g}>
|
||
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
|
||
<NavSessions
|
||
sessions={activeList.filter((s) => s.group === g)}
|
||
activeSessionId={activeSessionId}
|
||
onOpenPanel={openPanel}
|
||
menuFor={menuFor}
|
||
/>
|
||
</div>
|
||
))}
|
||
<div>
|
||
{groups.length > 0 && (
|
||
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">Senza gruppo</p>
|
||
)}
|
||
<NavSessions
|
||
sessions={activeList.filter((s) => !s.group)}
|
||
activeSessionId={activeSessionId}
|
||
onOpenPanel={openPanel}
|
||
menuFor={menuFor}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => setShowArchive((v) => !v)}
|
||
className="mt-3 w-full rounded-md px-2 py-1.5 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-accent"
|
||
>
|
||
{showArchive ? "← Sessioni attive" : `Archivio (${archivedList.length})`}
|
||
</button>
|
||
```
|
||
|
||
(e) Before the closing `</div>` of the top-level row (next to `<Toaster />`), add the dialogs:
|
||
|
||
```tsx
|
||
{renameTarget && (
|
||
<RenameDialog
|
||
open
|
||
initial={renameTarget.name ?? ""}
|
||
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
|
||
onSubmit={async (name) => { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); }}
|
||
/>
|
||
)}
|
||
{deleteTarget && (
|
||
<DeleteConfirmDialog
|
||
open
|
||
label={deleteTarget.name || deleteTarget.question}
|
||
onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}
|
||
onConfirm={async () => {
|
||
await deleteSession(deleteTarget.id);
|
||
if (panelSession?.id === deleteTarget.id) setPanelSession(null);
|
||
if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); }
|
||
setDeleteTarget(null); refresh();
|
||
}}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Run the full frontend suite (catch regressions in App.test/NavSessions)**
|
||
|
||
Run: `cd frontend && npx vitest run`
|
||
Expected: PASS. If `App.test.tsx` referenced the old NavSessions fetch behavior, update it to the new prop-driven flow.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx
|
||
git commit -m "feat(frontend): AppShell session management — grouping, archive view, docs panel, mutations"
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification
|
||
|
||
- [ ] **Harness:** `cd harness && .venv/bin/pytest tests/test_session_mutations.py tests/test_session_documents.py -q` → all pass.
|
||
- [ ] **Backend:** `cd backend && npx vitest run` → all pass.
|
||
- [ ] **Frontend:** `cd frontend && npx vitest run` → all pass.
|
||
- [ ] **Resume e2e (Task 7, manual):** PASS recorded — fresh-process resume lands on the correct phase.
|
||
- [ ] **Lint/build:** `cd frontend && npm run build` and `cd backend && npm run build` succeed.
|
||
|
||
---
|
||
|
||
## Spec coverage check
|
||
|
||
- Read-only document panel (status header + docs in phase order + decisions) → Tasks 3, 11.
|
||
- Resume rule (finalized/archived read-only) → Tasks 5 (guard), 11 (Riprendi visibility).
|
||
- Row click opens panel; explicit Riprendi → Tasks 9, 11, 12.
|
||
- Archive = manifest flag + Archivio view → Tasks 1, 2, 12.
|
||
- Delete = hard delete + confirm, teardown live runtime → Tasks 1, 2, 5, 10, 12.
|
||
- Groups = manifest field + submenu + grouped rail → Tasks 1, 2, 9, 12.
|
||
- Rename = manifest name → Tasks 1, 2, 10, 12.
|
||
- Resume correctness prerequisite (prompt mode + skill + e2e) → Tasks 6, 7.
|
||
- Deferred (pgvector/app-DB, keyboard shortcuts, CTE docs) → not implemented, by design.
|