diff --git a/src/claire/web/app/src/App.tsx b/src/claire/web/app/src/App.tsx index 8589dba..672f434 100644 --- a/src/claire/web/app/src/App.tsx +++ b/src/claire/web/app/src/App.tsx @@ -28,6 +28,7 @@ import { ProjectDetailPage } from "./projects/ProjectDetailPage"; import { SessionsPage } from "./sessions/SessionsPage"; import { BroadcastPage } from "./broadcast/BroadcastPage"; import { DashboardPage } from "./dashboard/DashboardPage"; +import { DecisionsPage } from "./decisions/DecisionsPage"; const queryClient = new QueryClient({ defaultOptions: { @@ -56,6 +57,7 @@ export function App(): ReactElement { } /> } /> } /> + } /> } /> } /> diff --git a/src/claire/web/app/src/AppShell.tsx b/src/claire/web/app/src/AppShell.tsx index ffa9f4d..bb65bc2 100644 --- a/src/claire/web/app/src/AppShell.tsx +++ b/src/claire/web/app/src/AppShell.tsx @@ -60,6 +60,7 @@ export function AppShell(): ReactElement { chat projects sessions + decisions broadcast
diff --git a/src/claire/web/app/src/dashboard/ConsideredWork.tsx b/src/claire/web/app/src/dashboard/ConsideredWork.tsx index 27b0316..c860e8f 100644 --- a/src/claire/web/app/src/dashboard/ConsideredWork.tsx +++ b/src/claire/web/app/src/dashboard/ConsideredWork.tsx @@ -29,7 +29,7 @@ const Pairing = styled.div` `; const TaskTitle = styled.span` - color: ${({ theme }): string => theme.colors.fg}; + color: ${({ theme }): string => theme.colors.text.primary}; flex: 1 1 8rem; min-width: 0; overflow: hidden; @@ -38,12 +38,12 @@ const TaskTitle = styled.span` `; const Target = styled.span` - color: ${({ theme }): string => theme.colors.dim}; + color: ${({ theme }): string => theme.colors.text.muted}; font-size: 0.78rem; `; const BindButton = styled.button` - background: ${({ theme }): string => theme.colors.bg}; + background: ${({ theme }): string => theme.colors.background.primary}; border: 1px solid ${({ theme }): string => theme.colors.accent}; color: ${({ theme }): string => theme.colors.accent}; padding: 0.15rem 0.6rem; @@ -51,7 +51,7 @@ const BindButton = styled.button` font-size: 0.78rem; cursor: pointer; &:hover:not(:disabled) { - background: ${({ theme }): string => theme.colors.bg}; + background: ${({ theme }): string => theme.colors.background.primary}; } &:disabled { opacity: 0.4; @@ -61,7 +61,7 @@ const BindButton = styled.button` const Footer = styled.div` font-size: 0.78rem; - color: ${({ theme }): string => theme.colors.dim}; + color: ${({ theme }): string => theme.colors.text.muted}; border-top: 1px solid ${({ theme }): string => theme.colors.border}; padding-top: 0.4rem; `; diff --git a/src/claire/web/app/src/decisions/DecisionsPage.tsx b/src/claire/web/app/src/decisions/DecisionsPage.tsx new file mode 100644 index 0000000..75e52bd --- /dev/null +++ b/src/claire/web/app/src/decisions/DecisionsPage.tsx @@ -0,0 +1,210 @@ +/** + * Decisions log — newest-first list of non-trivial decisions recorded + * during the work, with a badge for WHO made each decision (Claire vs User). + * + * Backing: GET /api/v1/decisions; rows are appended via the orchestrator + * `record_decision` MCP tool (or POST /api/v1/decisions). + */ +import type { ReactElement } from "react"; +import { useQuery } from "@tanstack/react-query"; +import styled from "styled-components"; + +import { ApiError, fetchDecisions } from "../lib/api"; +import type { Decision, DecisionMaker } from "../lib/types"; + +const Wrap = styled.div` + padding: 1.5rem; + width: 100%; + max-width: 60rem; +`; + +const H2 = styled.h2` + margin: 0 0 0.25rem 0; + font-weight: normal; + color: ${({ theme }): string => theme.colors.text.primary}; +`; + +const Sub = styled.p` + margin: 0 0 1.25rem 0; + color: ${({ theme }): string => theme.colors.text.muted}; + font-size: 0.85rem; +`; + +const Dim = styled.span` + color: ${({ theme }): string => theme.colors.text.muted}; +`; + +const ErrorMsg = styled.span` + color: ${({ theme }): string => theme.colors.error}; +`; + +const List = styled.ul` + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +const Row = styled.li` + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid ${({ theme }): string => theme.colors.border}; + background: ${({ theme }): string => theme.colors.background.secondary}; + align-items: start; +`; + +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; + letter-spacing: 0.06em; + padding: 0.18rem 0.55rem; + border: 1px solid + ${({ theme, $maker }): string => + $maker === "claire" ? theme.colors.accent : theme.colors.success}; + color: ${({ theme, $maker }): string => + $maker === "claire" ? theme.colors.accent : theme.colors.success}; + background: transparent; + white-space: nowrap; + align-self: start; +`; + +const Body = styled.div` + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +`; + +const Text = styled.div` + color: ${({ theme }): string => theme.colors.text.primary}; + line-height: 1.4; + word-wrap: break-word; +`; + +const Rationale = styled.div` + color: ${({ theme }): string => theme.colors.text.muted}; + font-size: 0.82rem; + line-height: 1.45; + white-space: pre-wrap; + word-wrap: break-word; +`; + +const Links = styled.div` + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.72rem; + color: ${({ theme }): string => theme.colors.text.muted}; +`; + +const Tag = styled.span` + font-family: ui-monospace, "SF Mono", Menlo, monospace; +`; + +const When = styled.div` + color: ${({ theme }): string => theme.colors.text.muted}; + font-size: 0.72rem; + white-space: nowrap; + align-self: start; +`; + +const MAKER_LABEL: Record = { + claire: "Claire", + user: "User", +}; + +/** + * 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. + */ +function relativeFromHlc(hlc: string): string { + const head = hlc.split(".", 1)[0] ?? ""; + const ms = Number.parseInt(head, 10); + if (!Number.isFinite(ms)) return hlc; + const diffMs = Date.now() - ms; + if (diffMs < 0) return "just now"; + const sec = Math.round(diffMs / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + const days = Math.round(hr / 24); + return `${days}d ago`; +} + +function shortId(id: string): string { + return id.slice(0, 8); +} + +export function DecisionsPage(): ReactElement { + const decisionsQ = useQuery({ + queryKey: ["decisions"], + queryFn: ({ signal }): Promise => fetchDecisions({}, signal), + }); + + const decisions = decisionsQ.data ?? []; + + return ( + +

decisions

+ + Non-trivial decisions recorded during the work — what was decided, + by whom, and why. + + + {decisionsQ.error ? ( +
+ {decisionsQ.error.detail} +
+ ) : null} + + {decisionsQ.isLoading ? ( + loading… + ) : 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)} + + ))} + + )} +
+ ); +} diff --git a/src/claire/web/app/src/lib/api.ts b/src/claire/web/app/src/lib/api.ts index e01feb2..d51aa29 100644 --- a/src/claire/web/app/src/lib/api.ts +++ b/src/claire/web/app/src/lib/api.ts @@ -17,6 +17,8 @@ import type { ChatPostResponse, ChatScope, ConsideredWork, + Decision, + DecisionMaker, FleetAgent, FleetLoad, OrgsResponse, @@ -239,3 +241,37 @@ export async function autocomplete( const r = await request<{ hits: AutocompleteHit[] }>("GET", "/api/v1/autocomplete", { params, signal }); return r.hits; } + +// --------------------------------------------------------------------------- +// Decisions log +// --------------------------------------------------------------------------- + +export async function fetchDecisions( + filters: { + project?: string; + task?: string; + made_by?: DecisionMaker; + limit?: number; + } = {}, + signal?: AbortSignal, +): Promise { + const r = await request<{ decisions: Decision[] }>( + "GET", + "/api/v1/decisions", + { params: filters, signal }, + ); + return r.decisions; +} + +export async function recordDecision( + body: { + made_by: DecisionMaker; + text: string; + rationale?: string | null; + project?: string | null; + task_ref?: string | null; + }, + signal?: AbortSignal, +): Promise { + return request("POST", "/api/v1/decisions", { body, signal }); +} diff --git a/src/claire/web/app/src/lib/types.ts b/src/claire/web/app/src/lib/types.ts index 23b8219..ee9d7a8 100644 --- a/src/claire/web/app/src/lib/types.ts +++ b/src/claire/web/app/src/lib/types.ts @@ -218,3 +218,23 @@ export interface ConsideredWork { remaining_sessions: RemainingSession[]; capped_out: CappedHost[]; } + +// --------------------------------------------------------------------------- +// Decisions log +// --------------------------------------------------------------------------- + +export type DecisionMaker = "claire" | "user"; + +export interface Decision { + id: string; + made_by: DecisionMaker; + text: string; + rationale: string | null; + project_id: string | null; + task_id: string | null; + created_hlc: string; +} + +export interface DecisionsResponse { + decisions: Decision[]; +} diff --git a/tests/test_db.py b/tests/test_db.py index 70c613b..6acdd36 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -10,7 +10,7 @@ def test_migrate_is_idempotent() -> None: assert first == [ "0001_initial", "0002_chat", "0003_pm", "0004_fleet", "0003_pm_alter", "0005_session_liveness", "0006_project_org", - "0007_usage", "0008_task_blocked_by", + "0007_usage", "0008_task_blocked_by", "0009_decisions", ] assert second == [] # already applied diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..3cc1338 --- /dev/null +++ b/tests/test_decisions.py @@ -0,0 +1,144 @@ +"""Service-layer + replay tests for the Decisions log (migration 0009).""" + +from __future__ import annotations + +import pytest + +from claire.db import migrate, open_db +from claire.domain import DecisionMaker +from claire.events import replay +from claire.hlc import HLCGenerator +from claire.web import service + + +def _setup() -> tuple: + conn = open_db(":memory:") + migrate(conn) + gen = HLCGenerator("test-machine") + return conn, gen + + +def test_record_decision_claire_minimal() -> None: + conn, gen = _setup() + d = service.record_decision( + conn, gen, + made_by=DecisionMaker.CLAIRE, + text="Use a 2-gate review workflow.", + ) + assert d.made_by is DecisionMaker.CLAIRE + assert d.text == "Use a 2-gate review workflow." + assert d.rationale is None + assert d.project_id is None + assert d.task_id is None + assert d.created_hlc + + +def test_record_decision_user_with_rationale_and_links() -> None: + conn, gen = _setup() + proj = service.create_project(conn, gen, name="p") + task = service.add_task(conn, gen, project="p", title="t") + d = service.record_decision( + conn, gen, + made_by=DecisionMaker.USER, + text="Defer mobile to phase 2.", + rationale="Web app is unblocking more users right now.", + project="p", + task_ref=str(task.id), + ) + assert d.made_by is DecisionMaker.USER + assert d.rationale == "Web app is unblocking more users right now." + assert d.project_id == proj.id + assert d.task_id == task.id + + +def test_record_decision_strips_and_rejects_empty_text() -> None: + conn, gen = _setup() + with pytest.raises(service.InvalidInput): + service.record_decision( + conn, gen, made_by=DecisionMaker.CLAIRE, text=" ", + ) + with pytest.raises(service.InvalidInput): + service.record_decision( + conn, gen, made_by=DecisionMaker.CLAIRE, text="", + ) + + +def test_record_decision_unknown_project_raises() -> None: + conn, gen = _setup() + with pytest.raises(service.NotFound): + service.record_decision( + conn, gen, + made_by=DecisionMaker.CLAIRE, text="x", + project="no-such-project", + ) + + +def test_record_decision_unknown_task_raises() -> None: + conn, gen = _setup() + with pytest.raises(service.NotFound): + service.record_decision( + conn, gen, + made_by=DecisionMaker.CLAIRE, text="x", + task_ref="00000000-0000-0000-0000-000000000000", + ) + + +def test_list_decisions_filters() -> None: + conn, gen = _setup() + service.create_project(conn, gen, name="pa") + service.create_project(conn, gen, name="pb") + task_a = service.add_task(conn, gen, project="pa", title="ta") + task_b = service.add_task(conn, gen, project="pb", title="tb") + + d1 = service.record_decision( + conn, gen, made_by=DecisionMaker.CLAIRE, + text="claire on pa", project="pa", + ) + d2 = service.record_decision( + conn, gen, made_by=DecisionMaker.USER, + text="user on pa task", project="pa", task_ref=str(task_a.id), + ) + d3 = service.record_decision( + conn, gen, made_by=DecisionMaker.CLAIRE, + text="claire on pb task", project="pb", task_ref=str(task_b.id), + ) + + # All — newest first. + all_ = service.list_decisions(conn) + assert [d.id for d in all_] == [d3.id, d2.id, d1.id] + + # Filter by project name → id resolution. + proj_a = service.read.get_project(conn, "pa") + assert proj_a is not None + by_a = service.list_decisions(conn, project_id=proj_a.id) + assert {d.id for d in by_a} == {d1.id, d2.id} + + # Filter by task. + by_task = service.list_decisions(conn, task_id=task_a.id) + assert [d.id for d in by_task] == [d2.id] + + # Filter by made_by. + by_user = service.list_decisions(conn, made_by=DecisionMaker.USER) + assert [d.id for d in by_user] == [d2.id] + by_claire = service.list_decisions(conn, made_by=DecisionMaker.CLAIRE) + assert [d.id for d in by_claire] == [d3.id, d1.id] + + +def test_decision_survives_replay() -> None: + conn, gen = _setup() + service.create_project(conn, gen, name="p") + task = service.add_task(conn, gen, project="p", title="t") + d = service.record_decision( + conn, gen, made_by=DecisionMaker.CLAIRE, + text="design call", rationale="why", + project="p", task_ref=str(task.id), + ) + # Wipe + replay event log; the decision row must come back identical. + replay(conn) + fresh = service.read.get_decision(conn, d.id) + assert fresh is not None + assert fresh.id == d.id + assert fresh.made_by is DecisionMaker.CLAIRE + assert fresh.text == "design call" + assert fresh.rationale == "why" + assert fresh.task_id == task.id diff --git a/uv.lock b/uv.lock index bef9884..a3aa701 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "claire" -version = "0.1.4" +version = "0.1.6" source = { editable = "." } dependencies = [ { name = "anthropic" },