diff --git a/src/claire/db.py b/src/claire/db.py index fc47627..d42b55b 100644 --- a/src/claire/db.py +++ b/src/claire/db.py @@ -313,6 +313,13 @@ _MIGRATIONS: list[tuple[str, str]] = [ ON events(event_type) WHERE event_type = 'host_telemetry_reported'; """, ), + ( + "0012_decision_pinned", + # Sentinel — apply via _apply_0012_decision_pinned. ALTER ADD COLUMN + # can't be IF-NOT-EXISTS in SQLite; add `pinned` to the existing + # decisions table so a decision can be featured at the top of the log. + "", + ), ] @@ -393,6 +400,15 @@ def _apply_0003_pm_alter(conn: sqlite3.Connection) -> None: conn.execute("CREATE INDEX IF NOT EXISTS tasks_type ON tasks(task_type)") +def _apply_0012_decision_pinned(conn: sqlite3.Connection) -> None: + """Add `pinned` to `decisions` — feature a decision at the top of the log.""" + existing = {row[1] for row in conn.execute("PRAGMA table_info(decisions)")} + if "pinned" not in existing: + conn.execute( + "ALTER TABLE decisions ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0" + ) + + def default_db_path() -> Path: """`~/.local/share/claire/claire.db` — XDG-compliant.""" xdg = os.environ.get("XDG_DATA_HOME") @@ -445,6 +461,7 @@ def migrate(conn: sqlite3.Connection) -> list[str]: "": _apply_0006_project_org, "": _apply_0008_task_blocked_by, "": _apply_0010_role_clare_to_claire, + "": _apply_0012_decision_pinned, } for mid, sql in _MIGRATIONS: if mid in already: diff --git a/src/claire/domain.py b/src/claire/domain.py index 126485e..1223146 100644 --- a/src/claire/domain.py +++ b/src/claire/domain.py @@ -265,6 +265,7 @@ class Decision(_Strict): project_id: UUID | None = None task_id: UUID | None = None created_hlc: str + pinned: bool = False # surfaced in a "Pinned" section at the top of the web log class ChatMessage(_Strict): diff --git a/src/claire/events.py b/src/claire/events.py index be1f048..6a7132a 100644 --- a/src/claire/events.py +++ b/src/claire/events.py @@ -367,6 +367,14 @@ class DecisionRecorded(_Payload): task_id: UUID | None = None +class DecisionPinned(_Payload): + """Feature/unfeature a decision at the top of the web log (migration 0012).""" + + kind: Literal["decision_pinned"] = "decision_pinned" + decision_id: UUID + pinned: bool + + # --------------------------------------------------------------------------- # Host telemetry (migration 0011_host_telemetry). The Linux `claire agent` # samples local CPU/mem/load/disk per interval and emits one of these; it @@ -424,6 +432,7 @@ EventPayload: TypeAlias = Annotated[ | AgentStatusReported | UsageRecorded | DecisionRecorded + | DecisionPinned | HostTelemetryReported, Field(discriminator="kind"), ] @@ -1099,6 +1108,11 @@ def _apply_payload(conn: sqlite3.Connection, hlc: HLC, payload: EventPayload) -> h, ), ) + case DecisionPinned(): + conn.execute( + "UPDATE decisions SET pinned = ? WHERE id = ?", + (1 if payload.pinned else 0, str(payload.decision_id)), + ) case AgentStatusReported(): # Upsert: each session has at most one current-status row. # `source` distinguishes push vs triage-derived; the hybrid diff --git a/src/claire/read.py b/src/claire/read.py index dc5badb..b39514c 100644 --- a/src/claire/read.py +++ b/src/claire/read.py @@ -689,4 +689,5 @@ def _decision_from_row(row: sqlite3.Row) -> Decision: project_id=UUID(row["project_id"]) if row["project_id"] else None, task_id=UUID(row["task_id"]) if row["task_id"] else None, created_hlc=row["created_hlc"], + pinned=bool(row["pinned"]), ) diff --git a/src/claire/web/api.py b/src/claire/web/api.py index 415e16c..be93192 100644 --- a/src/claire/web/api.py +++ b/src/claire/web/api.py @@ -175,6 +175,10 @@ class DecisionCreateReq(_Strict): task_ref: str | None = None +class DecisionPinReq(_Strict): + pinned: bool + + # --------------------------------------------------------------------------- # Error translation # --------------------------------------------------------------------------- @@ -642,6 +646,17 @@ def decisions_create(req: DecisionCreateReq, cg: Dep) -> dict[str, Any]: return decision.model_dump(mode="json") +@router.post("/decisions/{decision_id}/pin") +def decisions_pin(decision_id: str, req: DecisionPinReq, cg: Dep) -> dict[str, Any]: + """Pin/unpin a decision (featured at the top of the log).""" + conn, gen = cg + try: + decision = service.pin_decision(conn, gen, decision_id=decision_id, pinned=req.pinned) + except (service.NotFound, service.InvalidInput) as exc: + raise _to_http(exc) from exc + return decision.model_dump(mode="json") + + # --------------------------------------------------------------------------- # Sync # --------------------------------------------------------------------------- diff --git a/src/claire/web/app/src/decisions/DecisionsPage.tsx b/src/claire/web/app/src/decisions/DecisionsPage.tsx index 75e52bd..56eb544 100644 --- a/src/claire/web/app/src/decisions/DecisionsPage.tsx +++ b/src/claire/web/app/src/decisions/DecisionsPage.tsx @@ -1,15 +1,16 @@ /** - * Decisions log — newest-first list of non-trivial decisions recorded - * during the work, with a badge for WHO made each decision (Claire vs User). + * Decisions log — non-trivial decisions recorded during the work, with a badge + * for WHO made each (Claire vs User). Pinned decisions are featured in a + * highlighted section at the top; each row has a pin/unpin toggle. * - * Backing: GET /api/v1/decisions; rows are appended via the orchestrator - * `record_decision` MCP tool (or POST /api/v1/decisions). + * Backing: GET /api/v1/decisions; rows appended via `record_decision` / + * POST /api/v1/decisions; pinned via POST /api/v1/decisions/{id}/pin. */ import type { ReactElement } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import styled from "styled-components"; -import { ApiError, fetchDecisions } from "../lib/api"; +import { ApiError, fetchDecisions, pinDecision } from "../lib/api"; import type { Decision, DecisionMaker } from "../lib/types"; const Wrap = styled.div` @@ -30,6 +31,15 @@ const Sub = styled.p` font-size: 0.85rem; `; +const SectionLabel = styled.h3` + margin: 0 0 0.5rem 0; + font-weight: normal; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: ${({ theme }): string => theme.colors.accent}; +`; + const Dim = styled.span` color: ${({ theme }): string => theme.colors.text.muted}; `; @@ -40,19 +50,22 @@ const ErrorMsg = styled.span` const List = styled.ul` list-style: none; - margin: 0; + margin: 0 0 1.5rem 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; `; -const Row = styled.li` +const Row = styled.li<{ $pinned: boolean }>` display: grid; grid-template-columns: auto 1fr auto; gap: 0.75rem; padding: 0.75rem 1rem; border: 1px solid ${({ theme }): string => theme.colors.border}; + /* Pinned rows get an accent left-edge so the section reads as "featured". */ + border-left: ${({ theme, $pinned }): string => + $pinned ? `3px solid ${theme.colors.accent}` : `1px solid ${theme.colors.border}`}; background: ${({ theme }): string => theme.colors.background.secondary}; align-items: start; `; @@ -61,11 +74,6 @@ interface BadgeProps { $maker: DecisionMaker; } -/** - * Maker badge — Claire decisions ride the existing accent color (cyan-ish - * in cyberpunk); User decisions get success (green-ish). Both colors come - * from @lilith/ui-theme so we don't introduce a flat-theme regression. - */ const Badge = styled.span` font-size: 0.7rem; text-transform: uppercase; @@ -114,11 +122,35 @@ const Tag = styled.span` font-family: ui-monospace, "SF Mono", Menlo, monospace; `; +const RightCol = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.4rem; +`; + const When = styled.div` color: ${({ theme }): string => theme.colors.text.muted}; font-size: 0.72rem; white-space: nowrap; - align-self: start; +`; + +const PinBtn = styled.button<{ $pinned: boolean }>` + cursor: pointer; + background: transparent; + border: none; + padding: 0; + font-size: 0.95rem; + line-height: 1; + opacity: ${({ $pinned }): string => ($pinned ? "1" : "0.35")}; + filter: ${({ $pinned }): string => ($pinned ? "none" : "grayscale(1)")}; + transition: opacity 0.12s ease; + &:hover { + opacity: 1; + } + &:disabled { + cursor: default; + } `; const MAKER_LABEL: Record = { @@ -128,8 +160,7 @@ const MAKER_LABEL: Record = { /** * HLC encodes as `..`. The leading millis is - * UTC epoch ms; show "Ns/m/h/d ago" using that. Matches SessionsPage's - * `relativeTime` shape — kept inline because it takes a string, not iso. + * UTC epoch ms; show "Ns/m/h/d ago" using that. */ function relativeFromHlc(hlc: string): string { const head = hlc.split(".", 1)[0] ?? ""; @@ -152,19 +183,66 @@ function shortId(id: string): string { } export function DecisionsPage(): ReactElement { + const queryClient = useQueryClient(); const decisionsQ = useQuery({ queryKey: ["decisions"], queryFn: ({ signal }): Promise => fetchDecisions({}, signal), }); + const pinMut = useMutation({ + mutationFn: ({ id, pinned }): Promise => pinDecision(id, pinned), + onSuccess: (): void => { + void queryClient.invalidateQueries({ queryKey: ["decisions"] }); + }, + }); + const decisions = decisionsQ.data ?? []; + const pinned = decisions.filter((d): boolean => d.pinned); + const rest = decisions.filter((d): boolean => !d.pinned); + + const renderRow = (d: Decision): ReactElement => ( + + {MAKER_LABEL[d.made_by]} + + {d.text} + {d.rationale ? {d.rationale} : null} + {d.project_id || d.task_id ? ( + + {d.project_id ? ( + + project {shortId(d.project_id)} + + ) : null} + {d.task_id ? ( + + task {shortId(d.task_id)} + + ) : null} + + ) : null} + + + pinMut.mutate({ id: d.id, pinned: !d.pinned })} + > + 📌 + + {relativeFromHlc(d.created_hlc)} + + + ); return (

decisions

- Non-trivial decisions recorded during the work — what was decided, - by whom, and why. + Non-trivial decisions recorded during the work — what was decided, by + whom, and why. Pin the important ones to feature them at the top. {decisionsQ.error ? ( @@ -178,32 +256,20 @@ export function DecisionsPage(): ReactElement { ) : decisions.length === 0 ? ( (no decisions recorded yet) ) : ( - - {decisions.map((d): ReactElement => ( - - {MAKER_LABEL[d.made_by]} - - {d.text} - {d.rationale ? {d.rationale} : null} - {(d.project_id || d.task_id) ? ( - - {d.project_id ? ( - - project {shortId(d.project_id)} - - ) : null} - {d.task_id ? ( - - task {shortId(d.task_id)} - - ) : null} - - ) : null} - - {relativeFromHlc(d.created_hlc)} - - ))} - + <> + {pinned.length > 0 ? ( + <> + 📌 pinned + {pinned.map(renderRow)} + + ) : null} + {rest.length > 0 ? ( + <> + {pinned.length > 0 ? all decisions : null} + {rest.map(renderRow)} + + ) : null} + )}
); diff --git a/src/claire/web/app/src/lib/api.ts b/src/claire/web/app/src/lib/api.ts index d51aa29..33d3aab 100644 --- a/src/claire/web/app/src/lib/api.ts +++ b/src/claire/web/app/src/lib/api.ts @@ -275,3 +275,14 @@ export async function recordDecision( ): Promise { return request("POST", "/api/v1/decisions", { body, signal }); } + +export async function pinDecision( + id: string, + pinned: boolean, + signal?: AbortSignal, +): Promise { + return request("POST", `/api/v1/decisions/${id}/pin`, { + body: { pinned }, + signal, + }); +} diff --git a/src/claire/web/app/src/lib/types.ts b/src/claire/web/app/src/lib/types.ts index 120cc66..2379e23 100644 --- a/src/claire/web/app/src/lib/types.ts +++ b/src/claire/web/app/src/lib/types.ts @@ -237,6 +237,7 @@ export interface Decision { project_id: string | null; task_id: string | null; created_hlc: string; + pinned: boolean; } export interface DecisionsResponse { diff --git a/src/claire/web/service.py b/src/claire/web/service.py index 2e4386b..242edfa 100644 --- a/src/claire/web/service.py +++ b/src/claire/web/service.py @@ -1591,6 +1591,27 @@ def record_decision( return decision +def pin_decision( + conn: sqlite3.Connection, + gen: HLCGenerator, + *, + decision_id: str, + pinned: bool, +) -> Decision: + """Feature/unfeature a decision at the top of the log. Raises NotFound if + the decision doesn't exist.""" + try: + did = UUID(decision_id) + except ValueError as exc: + raise InvalidInput(f"invalid decision id: {decision_id}") from exc + if read.get_decision(conn, did) is None: + raise NotFound(f"no such decision: {decision_id}") + ev.append(conn, gen, ev.DecisionPinned(decision_id=did, pinned=pinned)) + decision = read.get_decision(conn, did) + assert decision is not None + return decision + + def list_decisions( conn: sqlite3.Connection, *, diff --git a/tests/test_db.py b/tests/test_db.py index cb1065a..2d4bdf4 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -12,6 +12,7 @@ def test_migrate_is_idempotent() -> None: "0003_pm_alter", "0005_session_liveness", "0006_project_org", "0007_usage", "0008_task_blocked_by", "0009_decisions", "0010_role_clare_to_claire", "0011_host_telemetry", + "0012_decision_pinned", ] assert second == [] # already applied diff --git a/tests/test_decision_pin.py b/tests/test_decision_pin.py new file mode 100644 index 0000000..b5ad650 --- /dev/null +++ b/tests/test_decision_pin.py @@ -0,0 +1,46 @@ +"""Decision pinning — DecisionPinned event + projection + service (migration 0012).""" + +from __future__ import annotations + +from claire import events as ev +from claire.db import migrate, open_db +from claire.domain import DecisionMaker +from claire.hlc import HLCGenerator +from claire.web import service + + +def _conn(): + conn = open_db(":memory:") + migrate(conn) + return conn + + +def test_decision_defaults_unpinned_then_toggles(): + conn = _conn() + gen = HLCGenerator("m") + d = service.record_decision(conn, gen, made_by=DecisionMaker.USER, text="ship it") + assert d.pinned is False + + pinned = service.pin_decision(conn, gen, decision_id=str(d.id), pinned=True) + assert pinned.pinned is True + + unpinned = service.pin_decision(conn, gen, decision_id=str(d.id), pinned=False) + assert unpinned.pinned is False + + +def test_pin_unknown_decision_raises(): + conn = _conn() + gen = HLCGenerator("m") + import pytest + + with pytest.raises(service.NotFound): + service.pin_decision(conn, gen, decision_id="00000000-0000-0000-0000-000000000000", pinned=True) + + +def test_pinned_survives_replay(): + conn = _conn() + gen = HLCGenerator("m") + d = service.record_decision(conn, gen, made_by=DecisionMaker.CLAIRE, text="x") + service.pin_decision(conn, gen, decision_id=str(d.id), pinned=True) + ev.replay(conn) + assert service.read.get_decision(conn, d.id).pinned is True