From ef8faf6e4d2551c103ecfa1e71fa3e877ba6f296 Mon Sep 17 00:00:00 2001 From: Himanshu Dongre Date: Sat, 16 May 2026 20:01:16 +0530 Subject: [PATCH] Add Project Current State endpoint and UI panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/v5/current/spaces/{id} — a packaged, computed-on-demand snapshot of where a space is right now: current direction, counts, attention signals (open questions, divergence, active work), active work claims, open tasks grouped by intent, recent milestones, and recent activity. Shared payload contract with the smriti current CLI surface built in parallel. Render it as the ProjectCurrentState panel at the top of LineagePage, replacing the hand-rolled current-state summary. Extract a shared _get_active_claims helper so the state and current endpoints report active work identically. No schema changes. --- backend/app/api/routes/chat.py | 103 +++--- backend/app/api/routes/current.py | 350 ++++++++++++++++++ backend/app/main.py | 2 + .../tests/integration/test_current_state.py | 302 +++++++++++++++ frontend/src/api/client.ts | 9 + .../src/components/ProjectCurrentState.tsx | 281 ++++++++++++++ frontend/src/pages/LineagePage.tsx | 102 +---- frontend/src/types/index.ts | 72 ++++ 8 files changed, 1078 insertions(+), 143 deletions(-) create mode 100644 backend/app/api/routes/current.py create mode 100644 backend/tests/integration/test_current_state.py create mode 100644 frontend/src/components/ProjectCurrentState.tsx diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index f7eb9a2..e29d58f 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -936,6 +936,63 @@ def _compute_space_divergence( return DivergenceSummary(pairs=pairs) if pairs else None +def _get_active_claims( + repo_id: uuid.UUID, db: Session, limit: int = 10 +) -> list[ActiveClaimSummary]: + """Active, non-expired work claims for a space, most-recently-claimed first. + + Claims bound to an active worktree are enriched with cached git drift + (path, branch, dirty file count, ahead/behind vs origin/main, last + commit). Shared by `GET /spaces/{id}/state` and + `GET /api/v5/current/spaces/{id}` so both surfaces report active work + identically. + """ + now = _utcnow() + claims_stmt = ( + select(WorkClaim) + .where( + WorkClaim.repo_id == repo_id, + WorkClaim.status == "active", + WorkClaim.expires_at > now, + ) + .order_by(WorkClaim.claimed_at.desc()) + .limit(limit) + ) + active_claims: list[ActiveClaimSummary] = [] + for wc in db.scalars(claims_stmt): + base_hash = None + if wc.base_commit_id: + base_commit = db.get(CommitModel, wc.base_commit_id) + base_hash = base_commit.commit_hash[:7] if base_commit else None + worktree_summary = None + if wc.worktree_id: + worktree = db.get(WorkTree, wc.worktree_id) + if worktree and worktree.status == "active": + probed = _probe_worktree( + str(worktree.id), + worktree.path, + worktree.branch_name, + ) + if probed: + worktree_summary = ActiveWorktreeSummary(**probed) + active_claims.append( + ActiveClaimSummary( + id=wc.id, + agent=wc.agent, + branch_name=wc.branch_name, + scope=wc.scope, + task_id=wc.task_id, + worktree_id=wc.worktree_id, + worktree=worktree_summary, + intent_type=wc.intent_type, + claimed_at=wc.claimed_at, + expires_at=wc.expires_at, + base_commit_hash=base_hash, + ) + ) + return active_claims + + FRESHNESS_NEW_CHECKPOINTS_CAP = 5 @@ -1016,50 +1073,8 @@ def get_space_state( if main_head_commit and active_branch_commits: divergence = _compute_space_divergence(main_head_commit, active_branch_commits) - # Active work claims — query-time expiration filter. - now = _utcnow() - claims_stmt = ( - select(WorkClaim) - .where( - WorkClaim.repo_id == repo_id, - WorkClaim.status == "active", - WorkClaim.expires_at > now, - ) - .order_by(WorkClaim.claimed_at.desc()) - .limit(10) - ) - active_claims = [] - for wc in db.scalars(claims_stmt): - base_hash = None - if wc.base_commit_id: - base_commit = db.get(CommitModel, wc.base_commit_id) - base_hash = base_commit.commit_hash[:7] if base_commit else None - worktree_summary = None - if wc.worktree_id: - worktree = db.get(WorkTree, wc.worktree_id) - if worktree and worktree.status == "active": - probed = _probe_worktree( - str(worktree.id), - worktree.path, - worktree.branch_name, - ) - if probed: - worktree_summary = ActiveWorktreeSummary(**probed) - active_claims.append( - ActiveClaimSummary( - id=wc.id, - agent=wc.agent, - branch_name=wc.branch_name, - scope=wc.scope, - task_id=wc.task_id, - worktree_id=wc.worktree_id, - worktree=worktree_summary, - intent_type=wc.intent_type, - claimed_at=wc.claimed_at, - expires_at=wc.expires_at, - base_commit_hash=base_hash, - ) - ) + # Active work claims — extracted helper, shared with GET /api/v5/current. + active_claims = _get_active_claims(repo_id, db) # Freshness check: if since_commit_id is provided, determine whether # HEAD has moved and list new checkpoints since the caller's base. diff --git a/backend/app/api/routes/current.py b/backend/app/api/routes/current.py new file mode 100644 index 0000000..11d42ae --- /dev/null +++ b/backend/app/api/routes/current.py @@ -0,0 +1,350 @@ +""" +V5 Project Current State API — a compact, legible snapshot of where a +space is *right now*. + +This is the founder-facing / agent-facing "what is happening, what changed, +what needs my attention" surface. It is computed on demand from existing +tables (commits, work_claims, chat_sessions) — no new schema, no events, +no background jobs. The CLI renders the same payload via `smriti current`. + +Endpoint: + GET /api/v5/current/spaces/{space_id} – packaged current-state snapshot +""" +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.routes.chat import ( + ActiveClaimSummary, + _compute_space_divergence, + _get_active_branch_heads, + _get_active_claims, + _get_latest_commit, + _get_repo, +) +from app.db.database import get_db +from app.db.models import CommitModel + +router = APIRouter(prefix="/current", tags=["current-v5"]) + + +# ── Caps & vocabulary ──────────────────────────────────────────────────────── +# +# Constants on purpose — this surface is "digestible by default." A caller +# who needs unbounded history hits the lineage / metrics endpoints instead. + +INTENT_ORDER = ("implement", "review", "test", "docs", "investigate", "other") +VALID_INTENTS = {"implement", "review", "test", "docs", "investigate"} + +RECENT_MILESTONES_CAP = 5 +RECENT_ACTIVITY_CAP = 8 +OPEN_QUESTIONS_CAP = 6 +TASKS_PER_INTENT_CAP = 8 +SUMMARY_PREVIEW_CHARS = 280 + + +# ── Response schemas ───────────────────────────────────────────────────────── + + +class CurrentDirection(BaseModel): + """Where the project is headed right now — drawn from the latest + main-branch checkpoint. All fields are None for a space with no + checkpoints yet.""" + objective: Optional[str] = None + headline: Optional[str] = None # latest main checkpoint message/title + summary: Optional[str] = None # truncated to SUMMARY_PREVIEW_CHARS + checkpoint_id: Optional[uuid.UUID] = None + checkpoint_hash: Optional[str] = None + author_agent: Optional[str] = None + updated_at: Optional[datetime] = None + + +class CurrentCounts(BaseModel): + """At-a-glance project size signals.""" + checkpoints: int = 0 + agents: int = 0 + active_claims: int = 0 + active_branches: int = 0 + open_tasks: int = 0 # open structured tasks on the latest main checkpoint + milestones: int = 0 # total milestone notes across all checkpoints + + +class AttentionSignal(BaseModel): + """One "needs my attention right now" item. + + kind is one of: + - "open_question" — an unresolved question on the latest checkpoint + - "divergence" — an active branch disagrees with main on decisions + - "active_work" — an agent currently holds a claim + severity is "info" or "warn". + """ + kind: str + severity: str + message: str + + +class MilestoneEntry(BaseModel): + """A milestone note and the checkpoint it annotates.""" + checkpoint_id: uuid.UUID + checkpoint_hash: str + checkpoint_message: str + note: str + author_agent: Optional[str] = None + created_at: datetime + + +class CurrentTask(BaseModel): + """A structured task, normalized from the latest checkpoint's task list.""" + text: str + id: Optional[str] = None + intent_hint: Optional[str] = None + blocked_by: Optional[str] = None + status: str = "open" + + +class RecentActivityEntry(BaseModel): + """One recent checkpoint, newest-first. Spans all branches so the + surface honestly reflects 'what has been happening'.""" + checkpoint_id: uuid.UUID + checkpoint_hash: str + message: str + author_agent: Optional[str] = None + branch_name: str + created_at: datetime + has_milestone: bool = False + + +class CurrentStateResponse(BaseModel): + """Composite, packaged snapshot for `GET /current/spaces/{id}`. + + One round trip. Shared payload contract between this endpoint, the + `smriti current` CLI command, and the Project Current State UI panel. + """ + space_id: uuid.UUID + name: str + description: Optional[str] = None + current_direction: CurrentDirection + counts: CurrentCounts + attention: list[AttentionSignal] = Field(default_factory=list) + active_work: list[ActiveClaimSummary] = Field(default_factory=list) + recent_milestones: list[MilestoneEntry] = Field(default_factory=list) + open_tasks_by_intent: dict[str, list[CurrentTask]] = Field(default_factory=dict) + recent_activity: list[RecentActivityEntry] = Field(default_factory=list) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _normalize_task(raw: object) -> Optional[CurrentTask]: + """Coerce a raw task entry into a CurrentTask. + + Tasks in `CommitModel.tasks` (JSONB) are either legacy strings or + structured dicts. Returns None for empty / unusable entries. + """ + if isinstance(raw, str): + text = raw.strip() + return CurrentTask(text=text) if text else None + if isinstance(raw, dict): + text = str(raw.get("text") or "").strip() + if not text: + return None + status = raw.get("status") or "open" + intent = raw.get("intent_hint") + return CurrentTask( + text=text, + id=(raw.get("id") or None), + intent_hint=(intent or None), + blocked_by=(raw.get("blocked_by") or None), + status=status if status in ("open", "done") else "open", + ) + return None + + +# ── Endpoint ───────────────────────────────────────────────────────────────── + + +@router.get("/spaces/{space_id}", response_model=CurrentStateResponse) +def get_current_state(space_id: uuid.UUID, db: Session = Depends(get_db)): + """Return the packaged current-state snapshot for a space. + + Contains: + - space header (id, name, description) + - current_direction — the latest main-branch checkpoint + - counts — checkpoints, agents, active claims, active branches, + open tasks, milestones + - attention — open questions, branch divergence, and active claims, + as a flat list of typed signals + - active_work — active work claims (with worktree drift when bound) + - recent_milestones — milestone notes, newest-first, capped + - open_tasks_by_intent — open structured tasks from the latest main + checkpoint, grouped by intent + - recent_activity — recent checkpoints across all branches, capped + + All data is derived from existing tables. No schema, no events. + """ + repo = _get_repo(space_id, db) + + # One ordered fetch of every checkpoint, newest-first. The metrics + # endpoint uses the same all-in-Python pattern — fast at smriti scale. + all_commits = list( + db.scalars( + select(CommitModel) + .where(CommitModel.repo_id == space_id) + .order_by(CommitModel.created_at.desc()) + ) + ) + + main_head = _get_latest_commit(space_id, db) + + # ── current_direction ──────────────────────────────────────────── + if main_head: + direction = CurrentDirection( + objective=(main_head.objective or None), + headline=(main_head.message or None), + summary=((main_head.summary or "")[:SUMMARY_PREVIEW_CHARS] or None), + checkpoint_id=main_head.id, + checkpoint_hash=main_head.commit_hash, + author_agent=main_head.author_agent, + updated_at=main_head.created_at, + ) + else: + direction = CurrentDirection() + + # ── single pass: recent activity + milestone scan ──────────────── + recent_activity: list[RecentActivityEntry] = [] + recent_milestones: list[MilestoneEntry] = [] + milestone_total = 0 + for c in all_commits: + notes = (c.metadata_ or {}).get("notes") or [] + milestone_notes = [ + n for n in notes + if isinstance(n, dict) and n.get("kind") == "milestone" + ] + milestone_total += len(milestone_notes) + if len(recent_activity) < RECENT_ACTIVITY_CAP: + recent_activity.append( + RecentActivityEntry( + checkpoint_id=c.id, + checkpoint_hash=c.commit_hash, + message=c.message or "", + author_agent=c.author_agent, + branch_name=c.branch_name, + created_at=c.created_at, + has_milestone=bool(milestone_notes), + ) + ) + for n in milestone_notes: + if len(recent_milestones) >= RECENT_MILESTONES_CAP: + break + note_text = str(n.get("text") or "").strip() + if not note_text: + continue + recent_milestones.append( + MilestoneEntry( + checkpoint_id=c.id, + checkpoint_hash=c.commit_hash, + checkpoint_message=c.message or "", + note=note_text, + author_agent=(n.get("author") or c.author_agent), + created_at=(n.get("created_at") or c.created_at), + ) + ) + + # ── counts inputs ──────────────────────────────────────────────── + agents = {c.author_agent for c in all_commits if c.author_agent} + active_branch_commits = _get_active_branch_heads(space_id, db) + active_claims = _get_active_claims(space_id, db) + + # ── open tasks grouped by intent (latest main checkpoint) ──────── + open_tasks_by_intent: dict[str, list[CurrentTask]] = {} + open_task_total = 0 + if main_head: + grouped: dict[str, list[CurrentTask]] = {k: [] for k in INTENT_ORDER} + for raw in (main_head.tasks or []): + task = _normalize_task(raw) + if task is None or task.status == "done": + continue + intent = task.intent_hint if task.intent_hint in VALID_INTENTS else "other" + grouped[intent].append(task) + open_task_total += 1 + # Fixed intent order; only non-empty groups appear in the dict. + for intent in INTENT_ORDER: + capped = grouped[intent][:TASKS_PER_INTENT_CAP] + if capped: + open_tasks_by_intent[intent] = capped + + counts = CurrentCounts( + checkpoints=len(all_commits), + agents=len(agents), + active_claims=len(active_claims), + active_branches=len(active_branch_commits), + open_tasks=open_task_total, + milestones=milestone_total, + ) + + # ── attention signals ──────────────────────────────────────────── + attention: list[AttentionSignal] = [] + + # Open questions on the latest main checkpoint. + if main_head: + open_qs = [ + str(q).strip() + for q in (main_head.open_questions or []) + if str(q).strip() + ] + for text in open_qs[:OPEN_QUESTIONS_CAP]: + attention.append( + AttentionSignal(kind="open_question", severity="info", message=text) + ) + + # Branch divergence — an active branch disagrees with main on decisions. + if main_head and active_branch_commits: + divergence = _compute_space_divergence(main_head, active_branch_commits) + if divergence and divergence.pairs: + for pair in divergence.pairs: + attention.append( + AttentionSignal( + kind="divergence", + severity="warn", + message=( + f"Branch '{pair.branch_name}' " + f"({pair.branch_commit_hash[:7]}) diverges from main " + f"on decisions — run compare to reconcile." + ), + ) + ) + + # Active work — an agent currently holds a claim. Part of the + # founder-facing "check before starting overlapping work" story. + for claim in active_claims: + attention.append( + AttentionSignal( + kind="active_work", + severity="info", + message=( + f"{claim.agent} is working on \"{claim.scope}\" " + f"[{claim.intent_type}] — check before starting " + f"overlapping work." + ), + ) + ) + + return CurrentStateResponse( + space_id=repo.id, + name=repo.name, + description=repo.description, + current_direction=direction, + counts=counts, + attention=attention, + active_work=active_claims, + recent_milestones=recent_milestones, + open_tasks_by_intent=open_tasks_by_intent, + recent_activity=recent_activity, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 620bc32..9a574f5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -59,6 +59,7 @@ def create_app() -> FastAPI: claims, commits, context_git, + current, lineage, metrics, repos, @@ -78,6 +79,7 @@ def create_app() -> FastAPI: app.include_router(lineage.router, prefix="/api/v5", tags=["lineage-v5"]) app.include_router(claims.router, prefix="/api/v5", tags=["claims-v5"]) app.include_router(metrics.router, prefix="/api/v5", tags=["metrics-v5"]) + app.include_router(current.router, prefix="/api/v5", tags=["current-v5"]) app.include_router(worktrees.router, prefix="/api/v5", tags=["worktrees-v5"]) # ── Capabilities manifest ──────────────────────────────────────── diff --git a/backend/tests/integration/test_current_state.py b/backend/tests/integration/test_current_state.py new file mode 100644 index 0000000..bff62a4 --- /dev/null +++ b/backend/tests/integration/test_current_state.py @@ -0,0 +1,302 @@ +"""Integration tests for the Project Current State endpoint. + +Covers `GET /api/v5/current/spaces/{space_id}` end-to-end through the +FastAPI app with an in-memory SQLite session. No mocking. + +- Empty space returns a usable, fully-empty shape +- current_direction is drawn from the latest main checkpoint +- counts aggregate checkpoints / agents / claims / branches / tasks / milestones +- open_tasks_by_intent groups open structured tasks, excludes done tasks +- recent_milestones surfaces milestone notes, newest-first, capped at 5 +- recent_activity surfaces recent checkpoints, capped at 8 +- attention carries open_question, divergence, and active_work signals +- unknown space returns 404 +""" +from __future__ import annotations + +import uuid + +# ── Helpers (match the shape used in test_multi_branch_state.py) ───────────── + + +def _create_repo(client, name: str = "Current State Test") -> str: + 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: str, title: str = "test") -> str: + 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 _commit(client, repo_id: str, session_id: str, message: str = "checkpoint", **kwargs) -> dict: + 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: str, checkpoint_id: str, branch_name: str = "") -> dict: + r = client.post( + "/api/v5/lineage/sessions/fork", + json={ + "space_id": space_id, + "checkpoint_id": checkpoint_id, + "branch_name": branch_name, + "provider": "openrouter", + "model": "mock", + }, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _add_note( + client, checkpoint_id: str, text: str, kind: str = "note", author: str = "founder" +) -> dict: + r = client.post( + f"/api/v5/checkpoint/{checkpoint_id}/notes", + json={"text": text, "kind": kind, "author": author}, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _create_claim( + client, space_id: str, agent: str, scope: str, intent_type: str = "implement" +) -> dict: + r = client.post( + "/api/v5/claims", + json={"space_id": space_id, "agent": agent, "scope": scope, "intent_type": intent_type}, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _get_current(client, space_id: str) -> dict: + r = client.get(f"/api/v5/current/spaces/{space_id}") + assert r.status_code == 200, r.text + return r.json() + + +# ── Tests ──────────────────────────────────────────────────────────────────── + + +def test_current_empty_space(client): + """A space with no checkpoints returns a usable, fully-empty payload.""" + repo_id = _create_repo(client, "Empty Current") + + cur = _get_current(client, repo_id) + + assert cur["space_id"] == repo_id + assert cur["name"] == "Empty Current" + assert cur["current_direction"]["objective"] is None + assert cur["current_direction"]["checkpoint_id"] is None + assert cur["counts"] == { + "checkpoints": 0, + "agents": 0, + "active_claims": 0, + "active_branches": 0, + "open_tasks": 0, + "milestones": 0, + } + assert cur["attention"] == [] + assert cur["active_work"] == [] + assert cur["recent_milestones"] == [] + assert cur["open_tasks_by_intent"] == {} + assert cur["recent_activity"] == [] + + +def test_current_direction_from_latest_main_checkpoint(client): + """current_direction mirrors the latest main checkpoint.""" + repo_id = _create_repo(client, "Direction") + session_id = _create_session(client, repo_id) + commit = _commit( + client, + repo_id, + session_id, + message="Build the current-state layer", + objective="Ship Project Current State", + summary="Backend endpoint plus a UI panel.", + author_agent="claude-code", + ) + + cur = _get_current(client, repo_id) + + direction = cur["current_direction"] + assert direction["headline"] == "Build the current-state layer" + assert direction["objective"] == "Ship Project Current State" + assert direction["summary"] == "Backend endpoint plus a UI panel." + assert direction["checkpoint_id"] == commit["id"] + assert direction["checkpoint_hash"] == commit["commit_hash"] + assert direction["author_agent"] == "claude-code" + assert cur["counts"]["checkpoints"] == 1 + assert cur["counts"]["agents"] == 1 + assert len(cur["recent_activity"]) == 1 + assert cur["recent_activity"][0]["message"] == "Build the current-state layer" + assert cur["recent_activity"][0]["has_milestone"] is False + + +def test_current_open_tasks_grouped_by_intent(client): + """open_tasks_by_intent groups open tasks; done tasks are excluded.""" + repo_id = _create_repo(client, "Tasks") + session_id = _create_session(client, repo_id) + _commit( + client, + repo_id, + session_id, + message="Has tasks", + tasks=[ + { + "text": "Implement endpoint", + "intent_hint": "implement", + "id": "impl-1", + "status": "open", + }, + {"text": "Write tests", "intent_hint": "test", "status": "open"}, + {"text": "Update docs", "intent_hint": "docs", "status": "done"}, + {"text": "Unlabelled work", "status": "open"}, + ], + ) + + cur = _get_current(client, repo_id) + + grouped = cur["open_tasks_by_intent"] + assert set(grouped.keys()) == {"implement", "test", "other"} + assert "docs" not in grouped # its only task is done + assert len(grouped["implement"]) == 1 + assert grouped["implement"][0]["text"] == "Implement endpoint" + assert grouped["implement"][0]["id"] == "impl-1" + assert grouped["other"][0]["text"] == "Unlabelled work" + assert cur["counts"]["open_tasks"] == 3 # done task excluded + + +def test_current_recent_milestones(client): + """Milestone notes surface in recent_milestones; plain notes do not.""" + repo_id = _create_repo(client, "Milestones") + session_id = _create_session(client, repo_id) + commit = _commit(client, repo_id, session_id, message="Proof checkpoint") + _add_note(client, commit["id"], "First clean autonomous proof", kind="milestone") + _add_note(client, commit["id"], "just a plain note", kind="note") + + cur = _get_current(client, repo_id) + + assert len(cur["recent_milestones"]) == 1 + ms = cur["recent_milestones"][0] + assert ms["note"] == "First clean autonomous proof" + assert ms["checkpoint_hash"] == commit["commit_hash"] + assert ms["checkpoint_message"] == "Proof checkpoint" + assert cur["counts"]["milestones"] == 1 # plain note not counted + assert cur["recent_activity"][0]["has_milestone"] is True + + +def test_current_attention_open_questions(client): + """Open questions on the latest checkpoint become open_question signals.""" + repo_id = _create_repo(client, "Questions") + session_id = _create_session(client, repo_id) + _commit( + client, + repo_id, + session_id, + message="Has questions", + open_questions=["Should state be cached?", "How do we page large spaces?"], + ) + + cur = _get_current(client, repo_id) + + oq = [a for a in cur["attention"] if a["kind"] == "open_question"] + assert len(oq) == 2 + assert {a["message"] for a in oq} == { + "Should state be cached?", + "How do we page large spaces?", + } + assert all(a["severity"] == "info" for a in oq) + + +def test_current_attention_active_work(client): + """An active claim appears in active_work and as an active_work signal.""" + repo_id = _create_repo(client, "Active Work") + session_id = _create_session(client, repo_id) + _commit(client, repo_id, session_id, message="Base") + _create_claim(client, repo_id, agent="codex-local", scope="Wire the CLI surface") + + cur = _get_current(client, repo_id) + + assert cur["counts"]["active_claims"] == 1 + assert len(cur["active_work"]) == 1 + assert cur["active_work"][0]["agent"] == "codex-local" + + aw = [a for a in cur["attention"] if a["kind"] == "active_work"] + assert len(aw) == 1 + assert "codex-local" in aw[0]["message"] + assert "Wire the CLI surface" in aw[0]["message"] + + +def test_current_attention_divergence(client): + """A divergent active branch produces a divergence attention signal.""" + repo_id = _create_repo(client, "Divergence") + session_id = _create_session(client, repo_id) + base = _commit( + client, + repo_id, + session_id, + message="Base", + decisions=["Use Postgres for storage"], + ) + + fork = _fork(client, repo_id, base["id"], branch_name="sqlite-route") + _commit( + client, + repo_id, + fork["session_id"], + message="Try SQLite", + decisions=["Use embedded SQLite only", "Drop the Postgres dependency"], + ) + + cur = _get_current(client, repo_id) + + assert cur["counts"]["active_branches"] == 1 + div = [a for a in cur["attention"] if a["kind"] == "divergence"] + assert len(div) == 1 + assert div[0]["severity"] == "warn" + assert "sqlite-route" in div[0]["message"] + + +def test_current_recent_activity_capped_at_8(client): + """recent_activity is capped at 8 and ordered newest-first.""" + repo_id = _create_repo(client, "Many Checkpoints") + session_id = _create_session(client, repo_id) + for i in range(10): + _commit(client, repo_id, session_id, message=f"checkpoint-{i:02d}") + + cur = _get_current(client, repo_id) + + assert cur["counts"]["checkpoints"] == 10 + assert len(cur["recent_activity"]) == 8 + timestamps = [e["created_at"] for e in cur["recent_activity"]] + assert timestamps == sorted(timestamps, reverse=True) # newest-first + + +def test_current_recent_milestones_capped_at_5(client): + """recent_milestones caps at 5 even though counts.milestones is the true total.""" + repo_id = _create_repo(client, "Many Milestones") + session_id = _create_session(client, repo_id) + for i in range(6): + commit = _commit(client, repo_id, session_id, message=f"milestone-cp-{i}") + _add_note(client, commit["id"], f"Milestone {i}", kind="milestone") + + cur = _get_current(client, repo_id) + + assert len(cur["recent_milestones"]) == 5 + assert cur["counts"]["milestones"] == 6 + + +def test_current_unknown_space_returns_404(client): + """An unknown space id returns 404.""" + r = client.get(f"/api/v5/current/spaces/{uuid.uuid4()}") + assert r.status_code == 404 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ae40e40..bbbc35d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -361,6 +361,15 @@ export async function getSpaceState(spaceId: string): Promise(`/chat/spaces/${spaceId}/state`); } +/** + * Packaged "Project Current State" snapshot for a space — current direction, + * counts, attention signals, active work, open tasks by intent, recent + * milestones, and recent activity. One round trip. + */ +export async function getCurrentState(spaceId: string): Promise { + return requestV5(`/current/spaces/${spaceId}`); +} + export async function compareCheckpoints( aId: string, bId: string, diff --git a/frontend/src/components/ProjectCurrentState.tsx b/frontend/src/components/ProjectCurrentState.tsx new file mode 100644 index 0000000..6465bcd --- /dev/null +++ b/frontend/src/components/ProjectCurrentState.tsx @@ -0,0 +1,281 @@ +/** + * ProjectCurrentState — the packaged "where is this project right now" panel. + * + * Renders GET /api/v5/current/spaces/{id}: current direction, counts, + * attention signals, active work, open tasks grouped by intent, recent + * milestones, and recent activity. Compact and scannable — the + * founder-facing current-state surface. Empty sub-sections are elided. + */ + +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { getCurrentState } from '../api/client'; +import type { CurrentState } from '../types'; +import { Activity, AlertTriangle, Compass, ListChecks, Loader2, Star, Zap } from 'lucide-react'; + +const PANEL_STYLE = { + borderColor: 'var(--color-border, #27272a)', + background: 'var(--color-surface, #18181b)', +} as const; + +const INTENT_LABELS: Record = { + implement: 'Implement', + review: 'Review', + test: 'Test', + docs: 'Docs', + investigate: 'Investigate', + other: 'Other', +}; + +function fmt(d: string) { + return new Date(d).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function Section({ + icon: Icon, + title, + iconClass, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + iconClass?: string; + children: ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} + +function Stat({ n, label, tone }: { n: number; label: string; tone?: string }) { + return ( + + {n} {label} + {n === 1 ? '' : 's'} + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function ProjectCurrentState({ spaceId }: { spaceId: string }) { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(''); + + useEffect(() => { + if (!spaceId) return; + setLoading(true); + setErr(''); + getCurrentState(spaceId) + .then(setState) + .catch((e: unknown) => setErr(e instanceof Error ? e.message : 'Failed to load current state')) + .finally(() => setLoading(false)); + }, [spaceId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (err || !state) { + return ( +
+

{err || 'No current state available.'}

+
+ ); + } + + const dir = state.current_direction; + const hasDirection = Boolean(dir.checkpoint_id); + const counts = state.counts; + const hasWarn = state.attention.some(a => a.severity === 'warn'); + const intentKeys = Object.keys(state.open_tasks_by_intent); + + return ( +
+ {/* Header */} +
+

{state.name}

+ {state.description &&

{state.description}

} +
+ + {/* Current direction */} + {hasDirection ? ( +
+
+ + Current direction +
+

{dir.headline}

+ {dir.objective &&

{dir.objective}

} +
+ {dir.author_agent && ( + + {dir.author_agent} + + )} + {dir.checkpoint_hash && {dir.checkpoint_hash.slice(0, 7)}} + {dir.updated_at && {fmt(dir.updated_at)}} +
+
+ ) : ( +

No checkpoints yet — this space has no recorded direction.

+ )} + + {/* Counts strip */} +
+ + + + + + +
+ + {/* Attention */} + {state.attention.length > 0 && ( +
+
+ + Needs attention +
+
    + {state.attention.map((a, i) => ( +
  • + • {a.message} +
  • + ))} +
+
+ )} + + {/* Active work */} + {state.active_work.length > 0 && ( +
+
+ {state.active_work.map(claim => ( +
+
+ + {claim.agent} + + + {claim.intent_type} + + + on {claim.branch_name} + + {claim.task_id && ( + + task: {claim.task_id} + + )} +
+

{claim.scope}

+
+ ))} +
+
+ )} + + {/* Open tasks by intent */} + {intentKeys.length > 0 && ( +
+
+ {intentKeys.map(intent => ( +
+

+ {INTENT_LABELS[intent] ?? intent} +

+
    + {state.open_tasks_by_intent[intent].map((t, i) => ( +
  • + + + {t.text} + {t.blocked_by && ( + — blocked by {t.blocked_by} + )} + +
  • + ))} +
+
+ ))} +
+
+ )} + + {/* Recent milestones */} + {state.recent_milestones.length > 0 && ( +
+
    + {state.recent_milestones.map((m, i) => ( +
  • +
    + {m.checkpoint_hash.slice(0, 7)} + {m.author_agent && {m.author_agent}} + {fmt(m.created_at)} +
    +

    {m.note}

    +
  • + ))} +
+
+ )} + + {/* Recent activity */} + {state.recent_activity.length > 0 && ( +
+
    + {state.recent_activity.map(a => ( +
  • + {a.checkpoint_hash.slice(0, 7)} + {a.has_milestone && } + {a.message} + {a.branch_name !== 'main' && ( + + {a.branch_name} + + )} + {a.author_agent && ( + {a.author_agent} + )} + {fmt(a.created_at)} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/LineagePage.tsx b/frontend/src/pages/LineagePage.tsx index 8756f94..de87354 100644 --- a/frontend/src/pages/LineagePage.tsx +++ b/frontend/src/pages/LineagePage.tsx @@ -18,15 +18,14 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { getLineage, getSpaceState, compareCheckpoints, forkSession } from '../api/client'; +import { ProjectCurrentState } from '../components/ProjectCurrentState'; import type { ActiveClaimSummary, CheckpointNode, SessionNode, LineageResponse, CompareResponse, - StructuredTask, } from '../types'; -import { normalizeTask } from '../types'; import { GitBranch, GitCommit, Loader2, X, ArrowLeft, Zap } from 'lucide-react'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -624,7 +623,6 @@ export function LineagePage() { const [lineage, setLineage] = useState(null); const [activeClaims, setActiveClaims] = useState([]); - const [spaceState, setSpaceState] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(''); @@ -646,7 +644,6 @@ export function LineagePage() { ]) .then(([lin, state]) => { setLineage(lin); - setSpaceState(state); setActiveClaims(state?.active_claims ?? []); }) .catch(e => setErr(e.message || 'Failed to load lineage')) @@ -752,101 +749,8 @@ export function LineagePage() { {lineage && !loading && (
- {/* Current-state summary panel */} - {spaceState && ( -
- {/* Project header */} -
-

- {spaceState.space.name} -

- {spaceState.space.description && ( -

{spaceState.space.description}

- )} -
- - {/* Current direction */} - {spaceState.commit && ( -
-
- Current direction -
-

{spaceState.commit.message}

- {spaceState.commit.objective && ( -

{spaceState.commit.objective}

- )} -
- {spaceState.commit.author_agent && ( - - {spaceState.commit.author_agent} - - )} - {fmt(spaceState.commit.created_at)} - {spaceState.commit.commit_hash?.slice(0, 7)} -
-
- )} - - {/* Status bar */} -
- - {lineage.checkpoints.length} checkpoint{lineage.checkpoints.length !== 1 ? 's' : ''} - - {(spaceState.active_branches?.length ?? 0) > 0 && ( - - {spaceState.active_branches.length} active branch{spaceState.active_branches.length !== 1 ? 'es' : ''} - - )} - {activeClaims.length > 0 && ( - - {activeClaims.length} active claim{activeClaims.length !== 1 ? 's' : ''} - - )} - {spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0 && ( - - ⚠ Divergence detected - - )} - {(spaceState.commit?.tasks?.length ?? 0) > 0 && (() => { - const tasks = (spaceState.commit.tasks ?? []).map(normalizeTask); - const openCount = tasks.filter((t: StructuredTask) => !t.status || t.status === 'open').length; - const doneCount = tasks.filter((t: StructuredTask) => t.status === 'done').length; - return ( - - {openCount} open task{openCount !== 1 ? 's' : ''} - {doneCount > 0 && ( - <>, {doneCount} done - )} - - ); - })()} -
- - {/* Needs attention signal */} - {(() => { - const reasons: string[] = []; - if (spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0) - reasons.push('Branch divergence needs resolution'); - if (activeClaims.length > 0) - reasons.push(`${activeClaims.length} agent${activeClaims.length !== 1 ? 's' : ''} working — check before starting new work`); - if ((spaceState.commit?.open_questions?.length ?? 0) > 0) - reasons.push(`${spaceState.commit.open_questions.length} open question${spaceState.commit.open_questions.length !== 1 ? 's' : ''} from latest checkpoint`); - if (reasons.length === 0) return null; - return ( -
-
- ⚡ Needs attention -
-
    - {reasons.map((r, i) => ( -
  • • {r}
  • - ))} -
-
- ); - })()} -
- )} + {/* Project Current State panel */} + {spaceId && } {/* Active work claims */} {activeClaims.length > 0 && ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e1d1d41..d8a6962 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -237,6 +237,7 @@ export interface ActiveClaimSummary { branch_name: string; scope: string; intent_type: string; + task_id?: string | null; claimed_at: string; expires_at: string; base_commit_hash: string | null; @@ -319,3 +320,74 @@ export interface CheckpointReviewResponse { issues: ReviewIssue[]; suggestions: string[]; } + +// ── Project Current State (V5) ──────────────────────────────────────────────── +// +// Shape of GET /api/v5/current/spaces/{id} — the packaged "where is this +// project right now" payload. Shared contract with the `smriti current` +// CLI command and the ProjectCurrentState UI panel. + +export interface CurrentDirection { + objective: string | null; + headline: string | null; + summary: string | null; + checkpoint_id: string | null; + checkpoint_hash: string | null; + author_agent: string | null; + updated_at: string | null; +} + +export interface CurrentCounts { + checkpoints: number; + agents: number; + active_claims: number; + active_branches: number; + open_tasks: number; + milestones: number; +} + +export interface AttentionSignal { + kind: 'open_question' | 'divergence' | 'active_work'; + severity: 'info' | 'warn'; + message: string; +} + +export interface MilestoneEntry { + checkpoint_id: string; + checkpoint_hash: string; + checkpoint_message: string; + note: string; + author_agent: string | null; + created_at: string; +} + +export interface CurrentTask { + text: string; + id: string | null; + intent_hint: string | null; + blocked_by: string | null; + status: string; +} + +export interface RecentActivityEntry { + checkpoint_id: string; + checkpoint_hash: string; + message: string; + author_agent: string | null; + branch_name: string; + created_at: string; + has_milestone: boolean; +} + +export interface CurrentState { + space_id: string; + name: string; + description: string | null; + current_direction: CurrentDirection; + counts: CurrentCounts; + attention: AttentionSignal[]; + active_work: ActiveClaimSummary[]; + recent_milestones: MilestoneEntry[]; + open_tasks_by_intent: Record; + recent_activity: RecentActivityEntry[]; +}