mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Add Project Current State endpoint and UI panel
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.
This commit is contained in:
parent
c470947477
commit
ef8faf6e4d
8 changed files with 1078 additions and 143 deletions
|
|
@ -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.
|
||||
|
|
|
|||
350
backend/app/api/routes/current.py
Normal file
350
backend/app/api/routes/current.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 ────────────────────────────────────────
|
||||
|
|
|
|||
302
backend/tests/integration/test_current_state.py
Normal file
302
backend/tests/integration/test_current_state.py
Normal file
|
|
@ -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
|
||||
|
|
@ -361,6 +361,15 @@ export async function getSpaceState(spaceId: string): Promise<import('../types')
|
|||
return requestV4<import('../types').SpaceStateResponse>(`/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<import('../types').CurrentState> {
|
||||
return requestV5<import('../types').CurrentState>(`/current/spaces/${spaceId}`);
|
||||
}
|
||||
|
||||
export async function compareCheckpoints(
|
||||
aId: string,
|
||||
bId: string,
|
||||
|
|
|
|||
281
frontend/src/components/ProjectCurrentState.tsx
Normal file
281
frontend/src/components/ProjectCurrentState.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Icon className={`w-3.5 h-3.5 ${iconClass ?? 'text-gray-500'}`} />
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ n, label, tone }: { n: number; label: string; tone?: string }) {
|
||||
return (
|
||||
<span className="text-gray-500">
|
||||
<span className={`font-medium ${tone ?? 'text-white'}`}>{n}</span> {label}
|
||||
{n === 1 ? '' : 's'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProjectCurrentState({ spaceId }: { spaceId: string }) {
|
||||
const [state, setState] = useState<CurrentState | null>(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 (
|
||||
<section className="rounded-xl border p-5 flex justify-center" style={PANEL_STYLE}>
|
||||
<Loader2 className="w-5 h-5 animate-spin text-gray-500" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (err || !state) {
|
||||
return (
|
||||
<section className="rounded-xl border p-5" style={PANEL_STYLE}>
|
||||
<p className="text-xs text-red-400">{err || 'No current state available.'}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="rounded-xl border p-5 space-y-4" style={PANEL_STYLE}>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{state.name}</h2>
|
||||
{state.description && <p className="text-xs text-gray-500 mt-0.5">{state.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Current direction */}
|
||||
{hasDirection ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-gray-600 uppercase tracking-wider">
|
||||
<Compass className="w-3 h-3" />
|
||||
Current direction
|
||||
</div>
|
||||
<p className="text-sm text-gray-200">{dir.headline}</p>
|
||||
{dir.objective && <p className="text-xs text-gray-500">{dir.objective}</p>}
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-600 flex-wrap">
|
||||
{dir.author_agent && (
|
||||
<span className="font-mono border border-gray-700 px-1.5 py-px rounded text-gray-500">
|
||||
{dir.author_agent}
|
||||
</span>
|
||||
)}
|
||||
{dir.checkpoint_hash && <code className="text-blue-400">{dir.checkpoint_hash.slice(0, 7)}</code>}
|
||||
{dir.updated_at && <span>{fmt(dir.updated_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600">No checkpoints yet — this space has no recorded direction.</p>
|
||||
)}
|
||||
|
||||
{/* Counts strip */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] border-t border-b py-2"
|
||||
style={{ borderColor: 'var(--color-border, #27272a)' }}>
|
||||
<Stat n={counts.checkpoints} label="checkpoint" />
|
||||
<Stat n={counts.agents} label="agent" />
|
||||
<Stat n={counts.active_claims} label="active claim" tone="text-amber-400" />
|
||||
<Stat n={counts.active_branches} label="active branch" tone="text-purple-400" />
|
||||
<Stat n={counts.open_tasks} label="open task" tone="text-green-400" />
|
||||
<Stat n={counts.milestones} label="milestone" tone="text-amber-400" />
|
||||
</div>
|
||||
|
||||
{/* Attention */}
|
||||
{state.attention.length > 0 && (
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 ${
|
||||
hasWarn ? 'border-red-500/30 bg-red-900/10' : 'border-amber-500/30 bg-amber-900/10'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[11px] font-medium mb-1 ${
|
||||
hasWarn ? 'text-red-400' : 'text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
Needs attention
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-[11px]">
|
||||
{state.attention.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={a.severity === 'warn' ? 'text-red-300/90' : 'text-amber-300/80'}
|
||||
>
|
||||
• {a.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active work */}
|
||||
{state.active_work.length > 0 && (
|
||||
<Section icon={Zap} title="Active work" iconClass="text-amber-400">
|
||||
<div className="space-y-1.5">
|
||||
{state.active_work.map(claim => (
|
||||
<div key={claim.id} className="rounded-lg border border-amber-500/20 bg-amber-900/5 px-3 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<span className="text-[10px] text-amber-400 font-mono border border-amber-500/30 px-1.5 py-px rounded">
|
||||
{claim.agent}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 border border-gray-700 px-1.5 py-px rounded">
|
||||
{claim.intent_type}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
on <code className="text-gray-500">{claim.branch_name}</code>
|
||||
</span>
|
||||
{claim.task_id && (
|
||||
<span className="text-[10px] text-gray-600">
|
||||
task: <code className="text-gray-500">{claim.task_id}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-300">{claim.scope}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Open tasks by intent */}
|
||||
{intentKeys.length > 0 && (
|
||||
<Section icon={ListChecks} title="Open tasks">
|
||||
<div className="space-y-2">
|
||||
{intentKeys.map(intent => (
|
||||
<div key={intent}>
|
||||
<p className="text-[9px] uppercase tracking-wider text-gray-600 mb-1">
|
||||
{INTENT_LABELS[intent] ?? intent}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{state.open_tasks_by_intent[intent].map((t, i) => (
|
||||
<li key={t.id ?? `${intent}-${i}`} className="text-[11px] text-gray-400 flex items-start gap-1.5">
|
||||
<span className="text-gray-700 mt-px">•</span>
|
||||
<span>
|
||||
{t.text}
|
||||
{t.blocked_by && (
|
||||
<span className="text-amber-500/70"> — blocked by {t.blocked_by}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Recent milestones */}
|
||||
{state.recent_milestones.length > 0 && (
|
||||
<Section icon={Star} title="Recent milestones" iconClass="text-amber-400">
|
||||
<ul className="space-y-1.5">
|
||||
{state.recent_milestones.map((m, i) => (
|
||||
<li key={`${m.checkpoint_id}-${i}`} className="text-[11px]">
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-600">
|
||||
<code className="text-blue-400">{m.checkpoint_hash.slice(0, 7)}</code>
|
||||
{m.author_agent && <span className="font-mono">{m.author_agent}</span>}
|
||||
<span>{fmt(m.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-0.5">{m.note}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
{state.recent_activity.length > 0 && (
|
||||
<Section icon={Activity} title="Recent activity">
|
||||
<ul className="space-y-1">
|
||||
{state.recent_activity.map(a => (
|
||||
<li key={a.checkpoint_id} className="flex items-center gap-2 text-[11px]">
|
||||
<code className="text-blue-400 flex-shrink-0">{a.checkpoint_hash.slice(0, 7)}</code>
|
||||
{a.has_milestone && <Star className="w-3 h-3 text-amber-400 flex-shrink-0" />}
|
||||
<span className="text-gray-400 truncate flex-1">{a.message}</span>
|
||||
{a.branch_name !== 'main' && (
|
||||
<span className="text-[9px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-px rounded flex-shrink-0">
|
||||
{a.branch_name}
|
||||
</span>
|
||||
)}
|
||||
{a.author_agent && (
|
||||
<span className="text-[9px] text-gray-600 font-mono flex-shrink-0">{a.author_agent}</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-600 flex-shrink-0">{fmt(a.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<LineageResponse | null>(null);
|
||||
const [activeClaims, setActiveClaims] = useState<ActiveClaimSummary[]>([]);
|
||||
const [spaceState, setSpaceState] = useState<import('../types').SpaceStateResponse | null>(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 && (
|
||||
<div className="space-y-10">
|
||||
{/* Current-state summary panel */}
|
||||
{spaceState && (
|
||||
<section className="rounded-xl border p-5 space-y-4" style={{ borderColor: 'var(--color-border, #27272a)', background: 'var(--color-surface, #18181b)' }}>
|
||||
{/* Project header */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
{spaceState.space.name}
|
||||
</h2>
|
||||
{spaceState.space.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{spaceState.space.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current direction */}
|
||||
{spaceState.commit && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">Current direction</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-200">{spaceState.commit.message}</p>
|
||||
{spaceState.commit.objective && (
|
||||
<p className="text-xs text-gray-500">{spaceState.commit.objective}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-[10px] text-gray-600">
|
||||
{spaceState.commit.author_agent && (
|
||||
<span className="font-mono border border-gray-700 px-1.5 py-px rounded text-gray-500">
|
||||
{spaceState.commit.author_agent}
|
||||
</span>
|
||||
)}
|
||||
<span>{fmt(spaceState.commit.created_at)}</span>
|
||||
<code className="text-blue-400">{spaceState.commit.commit_hash?.slice(0, 7)}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center gap-4 flex-wrap text-[11px]">
|
||||
<span className="text-gray-500">
|
||||
<span className="text-white font-medium">{lineage.checkpoints.length}</span> checkpoint{lineage.checkpoints.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{(spaceState.active_branches?.length ?? 0) > 0 && (
|
||||
<span className="text-gray-500">
|
||||
<span className="text-purple-400 font-medium">{spaceState.active_branches.length}</span> active branch{spaceState.active_branches.length !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
)}
|
||||
{activeClaims.length > 0 && (
|
||||
<span className="text-gray-500">
|
||||
<span className="text-amber-400 font-medium">{activeClaims.length}</span> active claim{activeClaims.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0 && (
|
||||
<span className="text-red-400 font-medium flex items-center gap-1">
|
||||
⚠ Divergence detected
|
||||
</span>
|
||||
)}
|
||||
{(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 (
|
||||
<span className="text-gray-500">
|
||||
<span className="text-green-400 font-medium">{openCount}</span> open task{openCount !== 1 ? 's' : ''}
|
||||
{doneCount > 0 && (
|
||||
<>, <span className="text-gray-600">{doneCount} done</span></>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-900/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-[11px] text-amber-400 font-medium mb-1">
|
||||
<span>⚡ Needs attention</span>
|
||||
</div>
|
||||
<ul className="text-[11px] text-amber-300/80 space-y-0.5">
|
||||
{reasons.map((r, i) => (
|
||||
<li key={i}>• {r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
)}
|
||||
{/* Project Current State panel */}
|
||||
{spaceId && <ProjectCurrentState spaceId={spaceId} />}
|
||||
|
||||
{/* Active work claims */}
|
||||
{activeClaims.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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<string, CurrentTask[]>;
|
||||
recent_activity: RecentActivityEntry[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue