Add delete endpoints for spaces, checkpoints, and sessions

Two rounds of agent-handoff dogfood testing surfaced that Smriti had no
way to delete spaces, sessions, or checkpoints via any surface. This
adds DELETE endpoints to the V2/V4 API, new CLI commands, and UI
affordances on the workspace overview and chat history panel so the
daily cleanup path does not require opening a Python shell.

Checkpoint delete refuses with 409 Conflict when child commits or
forked sessions reference the target, because silently orphaning them
would cause walk_ancestors to collapse lineage and forked sessions to
lose isolation. The refusal is escaped via ?cascade=true on the API,
--cascade on the CLI, and a two-step confirm with a dependents list
plus checkbox in the UI modal.

Space delete relies on the existing DB-level cascade chain from the
earlier commit/session/turn migrations — no new Alembic migration is
needed. Session delete cascades turn events but preserves commits
authored by the session, since commits are space-owned artifacts.

14 integration tests cover cascade correctness, 409 refusal, the
cascade escape hatch, cross-user 404s, subtree ordering, and
idempotency. Existing tests pass unchanged (143/143).
This commit is contained in:
Himanshu Dongre 2026-04-11 17:25:36 +05:30
parent 36591d9375
commit 73c71b4c9d
11 changed files with 1122 additions and 48 deletions

1
.gitignore vendored
View file

@ -19,6 +19,7 @@ npm-debug.log*
# IDE
.vscode/
.idea/
.claude/
*.swp
*.swo
*~

View file

@ -25,7 +25,7 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -407,6 +407,22 @@ def list_turns_generic(session_id: uuid.UUID, db: Session = Depends(get_db)):
return db.scalars(stmt).all()
@router.delete("/sessions/{session_id}", status_code=204)
def delete_session_generic(session_id: uuid.UUID, db: Session = Depends(get_db)) -> Response:
"""Delete a chat session and cascade to its turn events. Commits authored
by this session are preserved (they are owned by the space, not the session)."""
session = db.get(ChatSession, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.repo_id is not None:
repo = db.get(RepoModel, session.repo_id)
if not repo or repo.user_id != DEMO_USER_ID:
raise HTTPException(status_code=404, detail="Session not found")
db.delete(session)
db.commit()
return Response(status_code=204)
class AttachSessionRequest(BaseModel):
repo_id: str

View file

@ -3,16 +3,31 @@ import json
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.db.models import RepoModel, CommitModel
from app.db.models import RepoModel, CommitModel, ChatSession
from app.api.routes.repos import CommitResponse, DEMO_USER_ID
router = APIRouter(prefix="/commits", tags=["commits"])
class CheckpointDependent(BaseModel):
kind: str # "child_commit" | "forked_session" | "seeded_session"
id: uuid.UUID
label: str
class CheckpointDependentsResponse(BaseModel):
checkpoint_id: uuid.UUID
child_commits: list[CheckpointDependent]
forked_sessions: list[CheckpointDependent]
seeded_sessions: list[CheckpointDependent]
blocking_count: int
class CommitCreate(BaseModel):
repo_id: str
parent_commit_id: str | None = None
@ -88,9 +103,135 @@ def get_commit(commit_id: uuid.UUID, db: Session = Depends(get_db)):
commit = db.get(CommitModel, commit_id)
if not commit:
raise HTTPException(status_code=404, detail="Commit not found")
repo = db.get(RepoModel, commit.repo_id)
if not repo or repo.user_id != DEMO_USER_ID:
raise HTTPException(status_code=404, detail="Commit/Repo not found")
return commit
def _dependents_payload(
commit_id: uuid.UUID,
child_commits: list[CommitModel],
forks: list[ChatSession],
seeds: list[ChatSession],
) -> CheckpointDependentsResponse:
return CheckpointDependentsResponse(
checkpoint_id=commit_id,
child_commits=[
CheckpointDependent(
kind="child_commit",
id=c.id,
label=f"{c.commit_hash[:7]}{(c.message or '')[:60]}",
)
for c in child_commits
],
forked_sessions=[
CheckpointDependent(
kind="forked_session",
id=s.id,
label=f"{s.branch_name}{(s.title or 'Untitled')[:60]}",
)
for s in forks
],
seeded_sessions=[
CheckpointDependent(
kind="seeded_session",
id=s.id,
label=(s.title or "Untitled")[:60],
)
for s in seeds
],
blocking_count=len(child_commits) + len(forks),
)
def _collect_descendant_subtree(root_id: uuid.UUID, db: Session) -> list[CommitModel]:
"""BFS down the parent_commit_id DAG; return deepest-first so SET NULL
never fires on a live row during cascade delete."""
frontier = [root_id]
visited: set[uuid.UUID] = set()
ordered: list[CommitModel] = []
while frontier:
next_frontier = []
for pid in frontier:
children = db.scalars(
select(CommitModel).where(CommitModel.parent_commit_id == pid)
).all()
for child in children:
if child.id in visited:
continue
visited.add(child.id)
ordered.append(child)
next_frontier.append(child.id)
frontier = next_frontier
return list(reversed(ordered)) # deepest first
@router.delete("/{commit_id}", status_code=204)
def delete_commit(
commit_id: uuid.UUID,
cascade: bool = Query(
False,
description="Also delete descendant commits and forked sessions",
),
db: Session = Depends(get_db),
) -> Response:
"""Delete a checkpoint. Refuses if it has child commits or forked sessions
unless cascade=true is passed."""
commit = db.get(CommitModel, commit_id)
if not commit:
raise HTTPException(status_code=404, detail="Checkpoint not found")
repo = db.get(RepoModel, commit.repo_id)
if not repo or repo.user_id != DEMO_USER_ID:
raise HTTPException(status_code=404, detail="Checkpoint not found")
child_commits = db.scalars(
select(CommitModel).where(CommitModel.parent_commit_id == commit_id)
).all()
forked_sessions = db.scalars(
select(ChatSession).where(ChatSession.forked_from_checkpoint_id == commit_id)
).all()
seeded_sessions = db.scalars(
select(ChatSession).where(
ChatSession.seeded_commit_id == commit_id,
ChatSession.forked_from_checkpoint_id != commit_id,
)
).all()
blocking_count = len(child_commits) + len(forked_sessions)
if blocking_count > 0 and not cascade:
raise HTTPException(
status_code=409,
detail={
"message": (
f"Cannot delete checkpoint {commit.commit_hash[:7]}: "
f"it has {len(child_commits)} child commit(s) and "
f"{len(forked_sessions)} forked session(s). "
f"Re-send with ?cascade=true to delete the subtree."
),
"dependents": _dependents_payload(
commit_id, list(child_commits), list(forked_sessions), list(seeded_sessions)
).model_dump(mode="json"),
},
)
if cascade:
descendants = _collect_descendant_subtree(commit_id, db) # deepest first
ids_in_subtree = {c.id for c in descendants} | {commit_id}
dep_forks = db.scalars(
select(ChatSession).where(
ChatSession.forked_from_checkpoint_id.in_(ids_in_subtree)
)
).all()
for sess in dep_forks:
db.delete(sess) # cascades to TurnEvent
for descendant in descendants:
db.delete(descendant)
db.delete(commit)
db.commit()
return Response(status_code=204)

View file

@ -4,7 +4,7 @@ from typing import Annotated
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -127,3 +127,13 @@ def get_latest_commit(repo_id: uuid.UUID, branch: str = "main", db: Session = De
if not commit:
raise HTTPException(status_code=404, detail="No commits found for this repo/branch")
return commit
@router.delete("/{repo_id}", status_code=204)
def delete_repo(repo_id: uuid.UUID, db: Session = Depends(get_db)) -> Response:
"""Delete a space and cascade to all its commits, sessions, and turns."""
repo = db.get(RepoModel, repo_id)
if not repo or repo.user_id != DEMO_USER_ID:
raise HTTPException(status_code=404, detail="Repo not found")
db.delete(repo)
db.commit()
return Response(status_code=204)

View file

@ -0,0 +1,339 @@
"""
Integration tests for DELETE endpoints across V2 (repos, commits) and V4 (sessions).
Covers cascade correctness, child-commit / forked-session refusal, the cascade
escape hatch, and idempotency. All tests use the SQLite-backed in-memory client
fixture from conftest.py which enables FK enforcement via PRAGMA foreign_keys=ON.
"""
import uuid
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _create_repo(client, name="Delete Test Repo"):
r = client.post("/api/v2/repos", json={"name": name})
assert r.status_code == 201, r.text
return r.json()["id"]
def _create_session(client, repo_id, title="test session"):
r = client.post(f"/api/v4/chat/spaces/{repo_id}/sessions", json={
"title": title, "provider": "openrouter", "model": "mock",
})
assert r.status_code == 201, r.text
return r.json()["id"]
def _send(client, session_id, message="hello"):
r = client.post("/api/v4/chat/send", json={
"session_id": session_id,
"provider": "openrouter",
"model": "mock",
"message": message,
"use_mock": True,
})
assert r.status_code == 200, r.text
return r.json()
def _commit(client, repo_id, session_id, message="checkpoint", **kwargs):
payload = {
"repo_id": repo_id,
"session_id": session_id,
"message": message,
**kwargs,
}
r = client.post("/api/v4/chat/commit", json=payload)
assert r.status_code == 201, r.text
return r.json()
def _fork(client, space_id, checkpoint_id, branch_name=""):
r = client.post("/api/v5/lineage/sessions/fork", json={
"space_id": space_id,
"checkpoint_id": checkpoint_id,
"branch_name": branch_name,
})
assert r.status_code == 201, r.text
return r.json()
# ── 1. Space delete cascades commits ─────────────────────────────────────────
def test_delete_space_cascades_commits(client):
repo_id = _create_repo(client, "Cascade-1")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "first")
c2 = _commit(client, repo_id, session_id, "second")
r = client.delete(f"/api/v2/repos/{repo_id}")
assert r.status_code == 204, r.text
assert client.get(f"/api/v2/repos/{repo_id}").status_code == 404
assert client.get(f"/api/v2/commits/{c1['id']}").status_code == 404
assert client.get(f"/api/v2/commits/{c2['id']}").status_code == 404
# ── 2. Space delete cascades sessions and turns ──────────────────────────────
def test_delete_space_cascades_sessions_and_turns(client):
repo_id = _create_repo(client, "Cascade-2")
session_id = _create_session(client, repo_id)
_send(client, session_id, "hello")
_send(client, session_id, "world")
r = client.delete(f"/api/v2/repos/{repo_id}")
assert r.status_code == 204, r.text
# The session's turns endpoint should fail because the session itself is gone.
turns = client.get(f"/api/v4/chat/sessions/{session_id}/turns")
assert turns.status_code == 404
# ── 3. Space delete rejects cross-user ───────────────────────────────────────
def test_delete_space_cross_user_returns_404(client, db_session):
from app.db.models import RepoModel
other_user = uuid.UUID("00000000-0000-0000-0000-0000000000ff")
repo = RepoModel(user_id=other_user, name="Not mine", description="", metadata_={})
db_session.add(repo)
db_session.commit()
db_session.refresh(repo)
other_id = str(repo.id)
r = client.delete(f"/api/v2/repos/{other_id}")
assert r.status_code == 404
# ── 4. Checkpoint leaf delete succeeds ───────────────────────────────────────
def test_delete_commit_leaf_succeeds(client):
repo_id = _create_repo(client, "Leaf")
session_id = _create_session(client, repo_id)
commit = _commit(client, repo_id, session_id, "solo")
r = client.delete(f"/api/v2/commits/{commit['id']}")
assert r.status_code == 204, r.text
assert client.get(f"/api/v2/commits/{commit['id']}").status_code == 404
# ── 5. Checkpoint delete refuses when children exist (no cascade) ────────────
def test_delete_commit_with_child_refuses_without_cascade(client):
repo_id = _create_repo(client, "With-Child")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "parent")
_send(client, session_id, "next turn")
c2 = _commit(client, repo_id, session_id, "child")
r = client.delete(f"/api/v2/commits/{c1['id']}")
assert r.status_code == 409, r.text
detail = r.json()["detail"]
assert "child commit" in detail["message"].lower()
assert detail["dependents"]["blocking_count"] >= 1
assert len(detail["dependents"]["child_commits"]) == 1
assert detail["dependents"]["child_commits"][0]["id"] == c2["id"]
# C1 still exists
assert client.get(f"/api/v2/commits/{c1['id']}").status_code == 200
# ── 6. Checkpoint cascade deletes entire subtree ─────────────────────────────
def test_delete_commit_with_cascade_deletes_subtree(client):
repo_id = _create_repo(client, "Subtree")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "root")
_send(client, session_id, "turn 1")
c2 = _commit(client, repo_id, session_id, "mid")
_send(client, session_id, "turn 2")
c3 = _commit(client, repo_id, session_id, "leaf")
r = client.delete(f"/api/v2/commits/{c1['id']}?cascade=true")
assert r.status_code == 204, r.text
assert client.get(f"/api/v2/commits/{c1['id']}").status_code == 404
assert client.get(f"/api/v2/commits/{c2['id']}").status_code == 404
assert client.get(f"/api/v2/commits/{c3['id']}").status_code == 404
# ── 7. Checkpoint with fork refuses without cascade ──────────────────────────
def test_delete_commit_with_fork_refuses_without_cascade(client):
repo_id = _create_repo(client, "Fork-Refuse")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "base")
fork = _fork(client, repo_id, c1["id"], branch_name="experiment")
fork_session_id = fork["session_id"]
r = client.delete(f"/api/v2/commits/{c1['id']}")
assert r.status_code == 409, r.text
detail = r.json()["detail"]
assert detail["dependents"]["blocking_count"] >= 1
assert len(detail["dependents"]["forked_sessions"]) == 1
assert detail["dependents"]["forked_sessions"][0]["id"] == fork_session_id
# ── 8. Checkpoint cascade also deletes forked sessions and turns ─────────────
def test_delete_commit_with_cascade_deletes_forked_sessions_and_turns(client):
repo_id = _create_repo(client, "Fork-Cascade")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "base")
fork = _fork(client, repo_id, c1["id"], branch_name="experiment")
fork_session_id = fork["session_id"]
_send(client, fork_session_id, "fork message 1")
_send(client, fork_session_id, "fork message 2")
r = client.delete(f"/api/v2/commits/{c1['id']}?cascade=true")
assert r.status_code == 204, r.text
assert client.get(f"/api/v2/commits/{c1['id']}").status_code == 404
assert client.get(f"/api/v4/chat/sessions/{fork_session_id}").status_code == 404
# ── 9. Seeded-only session does NOT block checkpoint delete ─────────────────
def test_delete_commit_with_seeded_only_session_succeeds(client):
repo_id = _create_repo(client, "Seeded-Only")
session_a_id = _create_session(client, repo_id, title="author session")
c1 = _commit(client, repo_id, session_a_id, "first")
# New session — auto-seeds from head (which is C1). It is NOT a fork.
session_b_id = _create_session(client, repo_id, title="reader session")
session_b = client.get(f"/api/v4/chat/sessions/{session_b_id}").json()
assert session_b["seeded_commit_id"] == c1["id"]
assert session_b["forked_from_checkpoint_id"] is None # not a fork
# Delete C1: informational seed should not block.
r = client.delete(f"/api/v2/commits/{c1['id']}")
assert r.status_code == 204, r.text
# session_b still exists; seeded_commit_id is now null.
r2 = client.get(f"/api/v4/chat/sessions/{session_b_id}")
assert r2.status_code == 200
assert r2.json()["seeded_commit_id"] is None
# ── 10. Session delete cascades turn events ──────────────────────────────────
def test_delete_chat_session_cascades_turns(client):
repo_id = _create_repo(client, "Session-Cascade")
session_id = _create_session(client, repo_id)
_send(client, session_id, "hello")
_send(client, session_id, "world")
turns_before = client.get(f"/api/v4/chat/sessions/{session_id}/turns").json()
assert len(turns_before) >= 2
r = client.delete(f"/api/v4/chat/sessions/{session_id}")
assert r.status_code == 204, r.text
assert client.get(f"/api/v4/chat/sessions/{session_id}").status_code == 404
assert client.get(f"/api/v4/chat/sessions/{session_id}/turns").status_code == 404
# ── 11. Session delete preserves commits it authored ─────────────────────────
def test_delete_chat_session_leaves_commits_authored_by_it(client):
repo_id = _create_repo(client, "Preserve-Commits")
session_id = _create_session(client, repo_id)
commit = _commit(client, repo_id, session_id, "survives")
r = client.delete(f"/api/v4/chat/sessions/{session_id}")
assert r.status_code == 204, r.text
# Commit authored by the deleted session still exists.
r2 = client.get(f"/api/v2/commits/{commit['id']}")
assert r2.status_code == 200
# ── 12. Checkpoint delete rejects cross-user ────────────────────────────────
def test_delete_commit_cross_user_returns_404(client, db_session):
from app.db.models import RepoModel, CommitModel
other_user = uuid.UUID("00000000-0000-0000-0000-0000000000ff")
repo = RepoModel(user_id=other_user, name="Not mine", description="", metadata_={})
db_session.add(repo)
db_session.commit()
db_session.refresh(repo)
commit = CommitModel(
repo_id=repo.id,
commit_hash="deadbeef" * 8,
branch_name="main",
author_type="user",
message="foreign",
metadata_={},
)
db_session.add(commit)
db_session.commit()
db_session.refresh(commit)
r = client.delete(f"/api/v2/commits/{commit.id}")
assert r.status_code == 404
# ── 13. Subtree cascade ordering is safe ─────────────────────────────────────
def test_delete_commit_subtree_ordering_is_safe(client, db_session):
from app.db.models import CommitModel
repo_id = _create_repo(client, "Ordering")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "depth-1")
_send(client, session_id, "t1")
c2 = _commit(client, repo_id, session_id, "depth-2")
_send(client, session_id, "t2")
c3 = _commit(client, repo_id, session_id, "depth-3")
r = client.delete(f"/api/v2/commits/{c1['id']}?cascade=true")
assert r.status_code == 204, r.text
# Ensure nothing left with parent_commit_id pointing at a deleted commit.
remaining = db_session.query(CommitModel).all()
deleted_ids = {uuid.UUID(c1["id"]), uuid.UUID(c2["id"]), uuid.UUID(c3["id"])}
for c in remaining:
assert c.id not in deleted_ids, f"{c.id} should have been deleted"
assert c.parent_commit_id not in deleted_ids, (
f"{c.id} still points at deleted parent {c.parent_commit_id}"
)
# ── 14. Idempotency: double-delete is not a 500 ──────────────────────────────
def test_delete_idempotency_double_call(client):
repo_id = _create_repo(client, "Idempotent")
session_id = _create_session(client, repo_id)
commit = _commit(client, repo_id, session_id, "solo")
r1 = client.delete(f"/api/v2/commits/{commit['id']}")
assert r1.status_code == 204
r2 = client.delete(f"/api/v2/commits/{commit['id']}")
assert r2.status_code == 404
# And the same for space delete
r3 = client.delete(f"/api/v2/repos/{repo_id}")
assert r3.status_code == 204
r4 = client.delete(f"/api/v2/repos/{repo_id}")
assert r4.status_code == 404

View file

@ -21,9 +21,10 @@ DEFAULT_API_URL = "http://localhost:8000"
class SmritiError(Exception):
"""Raised for any API error or unreachable backend."""
def __init__(self, message: str, status: int | None = None):
def __init__(self, message: str, status: int | None = None, detail: Any = None):
super().__init__(message)
self.status = status
self.detail = detail
class SmritiClient:
@ -53,14 +54,24 @@ class SmritiClient:
raise SmritiError(f"Request to {url} timed out after {self.timeout}s")
if not resp.ok:
detail = None
detail_obj: Any = None
message: str
try:
detail = resp.json().get("detail")
detail_obj = resp.json().get("detail")
except Exception:
detail = resp.text[:200]
detail_obj = None
message = resp.text[:200]
else:
if isinstance(detail_obj, str):
message = detail_obj
elif isinstance(detail_obj, dict) and "message" in detail_obj:
message = str(detail_obj.get("message"))
else:
message = str(detail_obj) if detail_obj is not None else ""
raise SmritiError(
f"{method} {path} failed: HTTP {resp.status_code}{detail}",
f"{method} {path} failed: HTTP {resp.status_code}{message}",
status=resp.status_code,
detail=detail_obj,
)
if resp.status_code == 204 or not resp.content:
@ -82,6 +93,9 @@ class SmritiClient:
json={"name": name, "description": description},
)
def delete_space(self, space_id: str) -> None:
self._request("DELETE", f"/api/v2/repos/{space_id}")
def resolve_space(self, name_or_id: str) -> dict:
"""Look up a space by UUID or by name. Returns the full space dict.
@ -141,6 +155,13 @@ class SmritiClient:
def review_checkpoint(self, commit_id: str) -> dict:
return self._request("POST", f"/api/v5/checkpoint/{commit_id}/review")
def delete_commit(self, commit_id: str, cascade: bool = False) -> None:
params = {"cascade": "true"} if cascade else None
self._request("DELETE", f"/api/v2/commits/{commit_id}", params=params)
def delete_session(self, session_id: str) -> None:
self._request("DELETE", f"/api/v4/chat/sessions/{session_id}")
# ── sessions (used internally by CLI) ──────────────────────────────────
def create_session(self, repo_id: str, title: str = "", provider: str = "anthropic", model: str = "claude-sonnet-4-6") -> dict:

View file

@ -1,14 +1,16 @@
"""Smriti CLI entry point.
Seven commands for agent and programmatic use:
Commands for agent and programmatic use:
smriti space list
smriti space create <name> [--description]
smriti space delete <space> [-y]
smriti state <space>
smriti checkpoint create <space> # reads JSON from stdin
smriti checkpoint show <checkpoint-id>
smriti checkpoint list <space>
smriti checkpoint review <checkpoint-id>
smriti checkpoint delete <checkpoint-id> [--cascade] [-y]
Every command supports --json for structured output.
Default output is a readable markdown brief.
@ -42,6 +44,29 @@ def _fail(message: str, code: int = 1) -> None:
sys.exit(code)
def _confirm(preview: str, yes_flag: bool) -> bool:
"""Interactive 'Type yes' if stdin is a TTY, otherwise require --yes.
Destructive commands must be approved explicitly. When stdin is piped
(agent / script use) we refuse without --yes; when interactive we
require the full word 'yes' typed back.
"""
print(preview, file=sys.stderr)
if yes_flag:
return True
if not sys.stdin.isatty():
print(
"error: refusing to proceed without --yes in non-interactive mode.",
file=sys.stderr,
)
return False
try:
resp = input("Type 'yes' to confirm: ").strip().lower()
except EOFError:
return False
return resp == "yes"
_USAGE_HINT = (
"No checkpoint JSON provided. Pipe JSON on stdin, or use --from-json <path>.\n"
"Example:\n"
@ -97,6 +122,26 @@ def cmd_space_create(client: SmritiClient, args: argparse.Namespace) -> None:
print(f"Created space: {space['name']} `{space['id']}`")
def cmd_space_delete(client: SmritiClient, args: argparse.Namespace) -> None:
space = client.resolve_space(args.space)
commits = client.list_commits(space["id"])
commit_count = len(commits)
preview = (
f"Delete space '{space['name']}' (`{space['id']}`)?\n"
f" This will permanently delete {commit_count} checkpoint(s) "
f"and all sessions/turns under this space."
)
if not _confirm(preview, args.yes):
_fail("Cancelled.", code=0)
client.delete_space(space["id"])
if args.json:
_print_json(
{"deleted": True, "space_id": space["id"], "commits_deleted": commit_count}
)
else:
print(f"Deleted space '{space['name']}' and its {commit_count} checkpoint(s).")
def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None:
space = client.resolve_space(args.space)
head = client.get_head(space["id"])
@ -189,6 +234,42 @@ def cmd_checkpoint_review(client: SmritiClient, args: argparse.Namespace) -> Non
print(format_review(result), end="")
def cmd_checkpoint_delete(client: SmritiClient, args: argparse.Namespace) -> None:
commit = client.get_commit(args.checkpoint_id)
preview = (
f"Delete checkpoint '{commit.get('message', '')}' "
f"(`{commit['commit_hash'][:7]}`)?"
)
if args.cascade:
preview += "\n --cascade set: descendant commits and forked sessions will also be deleted."
if not _confirm(preview, args.yes):
_fail("Cancelled.", code=0)
try:
client.delete_commit(args.checkpoint_id, cascade=args.cascade)
except SmritiError as e:
if e.status == 409 and isinstance(e.detail, dict):
deps = e.detail.get("dependents", {}) or {}
lines = [f"Refusing to delete: {e.detail.get('message', str(e))}"]
for c in deps.get("child_commits", []):
lines.append(f" - child commit: {c['label']} ({c['id']})")
for s in deps.get("forked_sessions", []):
lines.append(f" - forked session: {s['label']} ({s['id']})")
lines.append(" Re-run with --cascade to delete the subtree.")
_fail("\n".join(lines))
raise
if args.json:
_print_json(
{
"deleted": True,
"checkpoint_id": args.checkpoint_id,
"cascade": args.cascade,
}
)
else:
note = " (cascade)" if args.cascade else ""
print(f"Deleted checkpoint `{commit['commit_hash'][:7]}`{note}.")
# ── argparse wiring ──────────────────────────────────────────────────────
@ -218,6 +299,17 @@ def _build_parser() -> argparse.ArgumentParser:
sp_create.add_argument("--json", action="store_true", help="Output structured JSON")
sp_create.set_defaults(func=cmd_space_create)
sp_delete = space_sub.add_parser(
"delete",
help="Delete a space and all its checkpoints, sessions, and turns",
)
sp_delete.add_argument("space", help="Space name or UUID")
sp_delete.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation prompt"
)
sp_delete.add_argument("--json", action="store_true", help="Output structured JSON")
sp_delete.set_defaults(func=cmd_space_delete)
# state
state_parser = subparsers.add_parser(
"state",
@ -269,6 +361,22 @@ def _build_parser() -> argparse.ArgumentParser:
cp_review.add_argument("--json", action="store_true", help="Output structured JSON")
cp_review.set_defaults(func=cmd_checkpoint_review)
cp_delete = cp_sub.add_parser(
"delete",
help="Delete a checkpoint. Refuses if it has children; pass --cascade to force.",
)
cp_delete.add_argument("checkpoint_id", help="Checkpoint UUID")
cp_delete.add_argument(
"--cascade",
action="store_true",
help="Also delete descendant commits and forked sessions",
)
cp_delete.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation prompt"
)
cp_delete.add_argument("--json", action="store_true", help="Output structured JSON")
cp_delete.set_defaults(func=cmd_checkpoint_delete)
return parser

View file

@ -2,6 +2,34 @@
import type { Artifacts, ContextPack, Session, TargetTool } from '../types';
/**
* Error thrown from any API helper on non-2xx responses.
*
* `detail` is the parsed `detail` field from the backend's error body when
* it is present. Some endpoints (notably `DELETE /api/v2/commits/{id}`) return
* structured details like `{ message, dependents }` so the UI can render a
* second-step cascade confirmation.
*/
export class ApiError extends Error {
status: number;
detail: unknown;
constructor(message: string, status: number, detail: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
}
}
function _errorMessageFromDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') return detail;
if (detail && typeof detail === 'object' && 'message' in detail) {
const msg = (detail as { message: unknown }).message;
if (typeof msg === 'string') return msg;
}
return fallback;
}
const BASE_URL = '/api/v1';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -10,9 +38,15 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || `HTTP ${res.status}`);
const body = await res.json().catch(() => ({ detail: res.statusText }));
const detail = body?.detail;
throw new ApiError(
_errorMessageFromDetail(detail, `HTTP ${res.status}`),
res.status,
detail,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
@ -59,9 +93,15 @@ async function requestV2<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || `HTTP ${res.status}`);
const body = await res.json().catch(() => ({ detail: res.statusText }));
const detail = body?.detail;
throw new ApiError(
_errorMessageFromDetail(detail, `HTTP ${res.status}`),
res.status,
detail,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
@ -167,9 +207,15 @@ async function requestV4<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || `HTTP ${res.status}`);
const body = await res.json().catch(() => ({ detail: res.statusText }));
const detail = body?.detail;
throw new ApiError(
_errorMessageFromDetail(detail, `HTTP ${res.status}`),
res.status,
detail,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
@ -280,9 +326,15 @@ async function requestV5<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || `HTTP ${res.status}`);
const body = await res.json().catch(() => ({ detail: res.statusText }));
const detail = body?.detail;
throw new ApiError(
_errorMessageFromDetail(detail, `HTTP ${res.status}`),
res.status,
detail,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
@ -364,3 +416,30 @@ export async function reviewCheckpoint(checkpointId: string): Promise<import('..
});
}
// ── Delete endpoints ─────────────────────────────────────────────────────────
/** Delete a space and cascade to all its checkpoints, sessions, and turns. */
export async function deleteRepo(repoId: string): Promise<void> {
return requestV2<void>(`/repos/${repoId}`, { method: 'DELETE' });
}
/**
* Delete a checkpoint. Without cascade the backend refuses with 409 if the
* checkpoint has child commits or forked sessions, and the ApiError.detail
* payload will contain { message, dependents } that the UI can use to render
* a second-step cascade confirmation.
*/
export async function deleteCommit(
commitId: string,
opts?: { cascade?: boolean },
): Promise<void> {
const qs = opts?.cascade ? '?cascade=true' : '';
return requestV2<void>(`/commits/${commitId}${qs}`, { method: 'DELETE' });
}
/** Delete a chat session. Cascades to turn events; commits authored by the
* session remain (they are owned by the space). */
export async function deleteChatSession(sessionId: string): Promise<void> {
return requestV4<void>(`/chat/sessions/${sessionId}`, { method: 'DELETE' });
}

View file

@ -0,0 +1,180 @@
import { useState } from 'react';
import { Trash2, Loader2, GitCommit, GitBranch, X } from 'lucide-react';
/**
* A dependent is something that references the object being deleted.
* The backend returns three kinds for checkpoint delete; space delete
* does not populate this at all.
*/
export type Dependent = {
kind: 'child_commit' | 'forked_session' | 'seeded_session';
id: string;
label: string;
};
export type Dependents = {
checkpoint_id?: string;
child_commits: Dependent[];
forked_sessions: Dependent[];
seeded_sessions: Dependent[];
blocking_count: number;
};
type Props = {
/** Title shown in the modal header, e.g. "Delete space 'foo'?". */
title: string;
/** Body text explaining the consequences. May contain newlines. */
body: string;
/** Optional override for the primary action label. Defaults to "Delete". */
confirmLabel?: string;
/**
* When null/undefined, the modal is in its first-pass state: plain body,
* plain Delete button.
*
* When populated with dependents the modal is in its cascade-confirm state:
* it renders the blocking list and a "I understand..." checkbox that must
* be ticked before the Delete button re-enables. The label switches to
* "Delete subtree".
*
* The modal itself is stateless about which pass it's in the parent
* catches the 409 ApiError, reads `error.detail.dependents`, and re-passes
* the modal with the new dependents prop to move to the second pass.
*/
dependents?: Dependents | null;
onClose: () => void;
/** Returns a promise so the modal can show a loading spinner while the
* caller hits the backend. Parent decides whether to close on success. */
onConfirm: (cascade: boolean) => Promise<void>;
};
export function ConfirmDeleteModal({
title,
body,
confirmLabel,
dependents,
onClose,
onConfirm,
}: Props) {
const [cascadeConfirmed, setCascadeConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const blocking = dependents?.blocking_count ?? 0;
const inCascadeMode = blocking > 0;
const disabled = loading || (inCascadeMode && !cascadeConfirmed);
const handleConfirm = async () => {
setErr(null);
setLoading(true);
try {
await onConfirm(inCascadeMode);
// Parent is responsible for calling onClose() in the success path
// because it may also want to refresh state, navigate, etc. We stay
// mounted in case the parent chooses to leave us open (e.g. to
// transition us into cascade-confirm mode on a 409).
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4 overflow-y-auto"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="w-full max-w-md bg-zinc-950 border border-gray-800 rounded-xl shadow-2xl overflow-hidden my-auto">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
<h2 className="font-semibold text-white flex items-center gap-2">
<Trash2 className="w-4 h-4 text-red-400" /> {title}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-white transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">{body}</p>
{/* Cascade callout — second pass only */}
{inCascadeMode && (
<div className="border border-amber-500/30 bg-amber-500/5 rounded-lg p-3 space-y-2">
<p className="text-xs uppercase tracking-wider text-amber-400">
This will also delete:
</p>
<ul className="text-xs text-gray-400 space-y-0.5">
{dependents!.child_commits.map(d => (
<li key={d.id} className="flex items-center gap-1.5">
<GitCommit className="w-3 h-3 text-blue-400/70 flex-shrink-0" />
<span className="truncate">{d.label}</span>
</li>
))}
{dependents!.forked_sessions.map(d => (
<li key={d.id} className="flex items-center gap-1.5">
<GitBranch className="w-3 h-3 text-purple-400/70 flex-shrink-0" />
<span className="truncate">{d.label}</span>
</li>
))}
</ul>
<label className="flex items-start gap-2 text-xs text-amber-200 cursor-pointer pt-2">
<input
type="checkbox"
checked={cascadeConfirmed}
onChange={e => setCascadeConfirmed(e.target.checked)}
className="mt-0.5"
/>
<span>
I understand that {blocking} item{blocking === 1 ? '' : 's'} will be
deleted along with this checkpoint
</span>
</label>
</div>
)}
{/* Informational seeded_sessions — never blocks */}
{dependents && dependents.seeded_sessions.length > 0 && (
<p className="text-[11px] text-gray-500">
{dependents.seeded_sessions.length} session
{dependents.seeded_sessions.length === 1 ? ' was' : 's were'} opened
from this checkpoint; they will keep their materialised turns but lose
the seed pointer.
</p>
)}
{err && <p className="text-red-400 text-sm">{err}</p>}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-gray-800 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-sm text-gray-400 hover:text-white px-3 py-1.5 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={disabled}
className="bg-red-600 hover:bg-red-500 text-white text-sm font-medium px-4 py-1.5 rounded-md flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
{inCascadeMode ? 'Delete subtree' : confirmLabel ?? 'Delete'}
</button>
</div>
</div>
</div>
);
}
export default ConfirmDeleteModal;

View file

@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ApiError,
createChatCommit,
createChatSessionGeneric,
deleteCommit,
getChatSessionGeneric,
getRepo,
getRepos,
@ -39,10 +41,12 @@ import {
FolderInput,
RotateCcw,
Paperclip,
Trash2,
X,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { ConfirmDeleteModal, type Dependents as DependentsPayload } from '../components/ConfirmDeleteModal';
// ── Provider / model config ───────────────────────────────────────────────────
@ -831,6 +835,7 @@ function MemorySpacePanel({
providerStatus,
onMount,
onFork,
onDeleteCheckpoint,
onClose,
}: {
repoId: string;
@ -843,6 +848,7 @@ function MemorySpacePanel({
providerStatus: Record<string, ProviderStatus> | null;
onMount: (info: { id: string; message: string } | null) => void;
onFork: (checkpoint: Commit) => void;
onDeleteCheckpoint: (checkpoint: Commit) => void;
onClose: () => void;
}) {
const navigate = useNavigate();
@ -1051,6 +1057,14 @@ function MemorySpacePanel({
<GitBranch className="w-3 h-3" />
Fork
</button>
<button
onClick={() => onDeleteCheckpoint(c)}
title="Delete this checkpoint"
className="text-xs px-3 py-1.5 rounded-md border border-gray-800 text-gray-500 hover:text-red-300 hover:border-red-500/40 hover:bg-red-900/20 transition-colors flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
Delete
</button>
</div>
</div>
@ -1316,6 +1330,11 @@ export function ChatWorkspacePage() {
const [mountedAtSeq, setMountedAtSeq] = useState<number | null>(null);
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
const [forkTarget, setForkTarget] = useState<Commit | null>(null);
// Delete-checkpoint flow state. `deleteCheckpointTarget` opens the modal;
// `deleteCheckpointDependents` is populated after a 409 response to show
// the cascade-confirm second step.
const [deleteCheckpointTarget, setDeleteCheckpointTarget] = useState<Commit | null>(null);
const [deleteCheckpointDependents, setDeleteCheckpointDependents] = useState<DependentsPayload | null>(null);
// Artifacts captured from messages, passed to CommitModal when opening
const [pendingArtifacts, setPendingArtifacts] = useState<Artifact[]>([]);
@ -2051,6 +2070,10 @@ export function ChatWorkspacePage() {
}
}}
onFork={checkpoint => setForkTarget(checkpoint)}
onDeleteCheckpoint={checkpoint => {
setDeleteCheckpointTarget(checkpoint);
setDeleteCheckpointDependents(null);
}}
onClose={() => setShowHistoryPanel(false)}
/>
)}
@ -2067,6 +2090,48 @@ export function ChatWorkspacePage() {
}}
/>
)}
{/* ── Delete Checkpoint Modal ───────────────────────────────── */}
{deleteCheckpointTarget && (
<ConfirmDeleteModal
title={`Delete checkpoint '${deleteCheckpointTarget.commit_hash.slice(0, 7)}'?`}
body={`"${deleteCheckpointTarget.message}"\n\nDescendant commits and forked sessions will block this delete unless you confirm cascade.`}
dependents={deleteCheckpointDependents}
onClose={() => {
setDeleteCheckpointTarget(null);
setDeleteCheckpointDependents(null);
}}
onConfirm={async (cascade) => {
try {
await deleteCommit(deleteCheckpointTarget.id, { cascade });
if (mountedCheckpointId === deleteCheckpointTarget.id) {
setMountedCheckpointId(null);
setMountedCheckpointLabel(null);
setMountedAtSeq(null);
}
const wasForkSource = forkSourceCommit?.id === deleteCheckpointTarget.id;
setDeleteCheckpointTarget(null);
setDeleteCheckpointDependents(null);
setCheckpointRefreshKey(k => k + 1);
if (wasForkSource) {
navigate('/');
}
} catch (e) {
if (
e instanceof ApiError &&
e.status === 409 &&
e.detail &&
typeof e.detail === 'object' &&
'dependents' in e.detail
) {
setDeleteCheckpointDependents((e.detail as { dependents: DependentsPayload }).dependents);
} else {
throw e;
}
}
}}
/>
)}
</div>
</div>

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowRight,
@ -7,16 +7,20 @@ import {
GitCommit,
Loader2,
MessageSquare,
MoreVertical,
Plus,
RotateCcw,
Settings2,
Trash2,
} from 'lucide-react';
import {
deleteRepo,
getRecentSessionsGeneric,
getRepos,
getSpaceHead,
} from '../api/client';
import type { ChatSession, HeadState, Repo } from '../types';
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal';
/**
* Landing page focused on resume.
@ -35,6 +39,43 @@ export function WorkspaceOverviewPage() {
const [headBySpace, setHeadBySpace] = useState<Record<string, HeadState>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Repo | null>(null);
const menuContainerRef = useRef<HTMLDivElement | null>(null);
// Close the kebab menu when clicking anywhere outside of it.
useEffect(() => {
if (!openMenuId) return;
const handler = (e: MouseEvent) => {
if (!menuContainerRef.current) return;
if (!menuContainerRef.current.contains(e.target as Node)) {
setOpenMenuId(null);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [openMenuId]);
const refreshWorkspace = async () => {
const [s, r] = await Promise.all([getRecentSessionsGeneric(), getRepos()]);
setSessions(s);
setRepos(r);
const repoIds = Array.from(
new Set(s.map(sess => sess.repo_id).filter((id): id is string => !!id)),
).slice(0, 8);
const heads = await Promise.all(
repoIds.map(id =>
getSpaceHead(id)
.then(h => [id, h] as const)
.catch(() => null),
),
);
const headMap: Record<string, HeadState> = {};
for (const entry of heads) {
if (entry) headMap[entry[0]] = entry[1];
}
setHeadBySpace(headMap);
};
useEffect(() => {
(async () => {
@ -169,6 +210,10 @@ export function WorkspaceOverviewPage() {
<p className="text-[10px] uppercase tracking-widest text-gray-600 mb-3">
Pick up where you left off
</p>
<div
className="relative"
ref={openMenuId === resumeRepo.id ? menuContainerRef : undefined}
>
<button
onClick={() => handleOpenSession(resumeSession.id)}
className="w-full text-left border border-purple-500/30 bg-gradient-to-br from-purple-900/10 to-transparent rounded-xl p-6 hover:border-purple-500/50 hover:from-purple-900/20 transition-colors group"
@ -226,6 +271,32 @@ export function WorkspaceOverviewPage() {
)}
</div>
</button>
<button
onClick={e => {
e.stopPropagation();
setOpenMenuId(openMenuId === resumeRepo.id ? null : resumeRepo.id);
}}
className="absolute top-4 right-4 p-1.5 rounded hover:bg-zinc-800 text-gray-600 hover:text-gray-300 transition-colors"
title="Space actions"
aria-label="Space actions"
>
<MoreVertical className="w-4 h-4" />
</button>
{openMenuId === resumeRepo.id && (
<div className="absolute top-12 right-3 z-20 bg-zinc-950 border border-gray-800 rounded-lg shadow-xl py-1 min-w-[140px]">
<button
onClick={e => {
e.stopPropagation();
setOpenMenuId(null);
setDeleteTarget(resumeRepo);
}}
className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors"
>
<Trash2 className="w-3 h-3" /> Delete space
</button>
</div>
)}
</div>
</section>
) : sessions.length > 0 ? (
// Fallback: no checkpointed session to resume, but sessions exist
@ -265,36 +336,66 @@ export function WorkspaceOverviewPage() {
const head = headBySpace[id];
const latestSession = sessions.find(s => s.repo_id === id);
return (
<button
<div
key={id}
onClick={() =>
latestSession
? handleOpenSession(latestSession.id)
: handleNewSession()
}
className="text-left border border-gray-800 bg-zinc-900/30 rounded-lg p-4 hover:bg-zinc-900/50 hover:border-gray-700 transition-colors"
className="relative"
ref={openMenuId === id ? menuContainerRef : undefined}
>
<div className="flex items-center gap-2 mb-1">
<FolderOpen className="w-3 h-3 text-gray-600 flex-shrink-0" />
<span className="text-sm text-gray-200 truncate flex-1">
{repo.name}
</span>
</div>
{head?.summary ? (
<p className="text-[11px] text-gray-500 leading-relaxed line-clamp-2 mt-1">
{head.summary}
</p>
) : (
<p className="text-[11px] text-gray-700 italic mt-1">
No checkpoints yet
</p>
<button
onClick={() =>
latestSession
? handleOpenSession(latestSession.id)
: handleNewSession()
}
className="w-full text-left border border-gray-800 bg-zinc-900/30 rounded-lg p-4 hover:bg-zinc-900/50 hover:border-gray-700 transition-colors"
>
<div className="flex items-center gap-2 mb-1 pr-5">
<FolderOpen className="w-3 h-3 text-gray-600 flex-shrink-0" />
<span className="text-sm text-gray-200 truncate flex-1">
{repo.name}
</span>
</div>
{head?.summary ? (
<p className="text-[11px] text-gray-500 leading-relaxed line-clamp-2 mt-1">
{head.summary}
</p>
) : (
<p className="text-[11px] text-gray-700 italic mt-1">
No checkpoints yet
</p>
)}
{latestSession && (
<p className="text-[10px] text-gray-700 mt-2">
{formatAgo(latestSession.updated_at)}
</p>
)}
</button>
<button
onClick={e => {
e.stopPropagation();
setOpenMenuId(openMenuId === id ? null : id);
}}
className="absolute top-3 right-3 p-1 rounded hover:bg-zinc-800 text-gray-600 hover:text-gray-300 transition-colors"
title="Space actions"
aria-label="Space actions"
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
{openMenuId === id && (
<div className="absolute top-10 right-2 z-20 bg-zinc-950 border border-gray-800 rounded-lg shadow-xl py-1 min-w-[140px]">
<button
onClick={e => {
e.stopPropagation();
setOpenMenuId(null);
setDeleteTarget(repo);
}}
className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors"
>
<Trash2 className="w-3 h-3" /> Delete space
</button>
</div>
)}
{latestSession && (
<p className="text-[10px] text-gray-700 mt-2">
{formatAgo(latestSession.updated_at)}
</p>
)}
</button>
</div>
);
})}
</div>
@ -322,6 +423,19 @@ export function WorkspaceOverviewPage() {
)}
</div>
</main>
{deleteTarget && (
<ConfirmDeleteModal
title={`Delete space '${deleteTarget.name}'?`}
body="This will permanently delete the space, all of its checkpoints, sessions, and turns. This cannot be undone."
onClose={() => setDeleteTarget(null)}
onConfirm={async () => {
await deleteRepo(deleteTarget.id);
setDeleteTarget(null);
await refreshWorkspace();
}}
/>
)}
</div>
);
}