feat(@projects/@claire): add pinned decision feature

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-01 02:36:07 -06:00
parent c22cae1b74
commit c6bd77cc5b
11 changed files with 238 additions and 44 deletions

View file

@ -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.
"<sentinel:apply_0012_decision_pinned>",
),
]
@ -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]:
"<sentinel:apply_0006_project_org>": _apply_0006_project_org,
"<sentinel:apply_0008_task_blocked_by>": _apply_0008_task_blocked_by,
"<sentinel:apply_0010_role_clare_to_claire>": _apply_0010_role_clare_to_claire,
"<sentinel:apply_0012_decision_pinned>": _apply_0012_decision_pinned,
}
for mid, sql in _MIGRATIONS:
if mid in already:

View file

@ -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):

View file

@ -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

View file

@ -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"]),
)

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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<BadgeProps>`
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<DecisionMaker, string> = {
@ -128,8 +160,7 @@ const MAKER_LABEL: Record<DecisionMaker, string> = {
/**
* HLC encodes as `<millis>.<counter>.<machine>`. 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<Decision[], ApiError>({
queryKey: ["decisions"],
queryFn: ({ signal }): Promise<Decision[]> => fetchDecisions({}, signal),
});
const pinMut = useMutation<Decision, ApiError, { id: string; pinned: boolean }>({
mutationFn: ({ id, pinned }): Promise<Decision> => 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 => (
<Row key={d.id} $pinned={d.pinned}>
<Badge $maker={d.made_by}>{MAKER_LABEL[d.made_by]}</Badge>
<Body>
<Text>{d.text}</Text>
{d.rationale ? <Rationale>{d.rationale}</Rationale> : null}
{d.project_id || d.task_id ? (
<Links>
{d.project_id ? (
<span>
project <Tag>{shortId(d.project_id)}</Tag>
</span>
) : null}
{d.task_id ? (
<span>
task <Tag>{shortId(d.task_id)}</Tag>
</span>
) : null}
</Links>
) : null}
</Body>
<RightCol>
<PinBtn
type="button"
$pinned={d.pinned}
disabled={pinMut.isPending}
title={d.pinned ? "Unpin" : "Pin to top"}
aria-label={d.pinned ? "Unpin decision" : "Pin decision"}
onClick={(): void => pinMut.mutate({ id: d.id, pinned: !d.pinned })}
>
📌
</PinBtn>
<When title={d.created_hlc}>{relativeFromHlc(d.created_hlc)}</When>
</RightCol>
</Row>
);
return (
<Wrap>
<H2>decisions</H2>
<Sub>
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.
</Sub>
{decisionsQ.error ? (
@ -178,32 +256,20 @@ export function DecisionsPage(): ReactElement {
) : decisions.length === 0 ? (
<Dim>(no decisions recorded yet)</Dim>
) : (
<List>
{decisions.map((d): ReactElement => (
<Row key={d.id}>
<Badge $maker={d.made_by}>{MAKER_LABEL[d.made_by]}</Badge>
<Body>
<Text>{d.text}</Text>
{d.rationale ? <Rationale>{d.rationale}</Rationale> : null}
{(d.project_id || d.task_id) ? (
<Links>
{d.project_id ? (
<span>
project <Tag>{shortId(d.project_id)}</Tag>
</span>
) : null}
{d.task_id ? (
<span>
task <Tag>{shortId(d.task_id)}</Tag>
</span>
) : null}
</Links>
) : null}
</Body>
<When title={d.created_hlc}>{relativeFromHlc(d.created_hlc)}</When>
</Row>
))}
</List>
<>
{pinned.length > 0 ? (
<>
<SectionLabel>📌 pinned</SectionLabel>
<List>{pinned.map(renderRow)}</List>
</>
) : null}
{rest.length > 0 ? (
<>
{pinned.length > 0 ? <SectionLabel>all decisions</SectionLabel> : null}
<List>{rest.map(renderRow)}</List>
</>
) : null}
</>
)}
</Wrap>
);

View file

@ -275,3 +275,14 @@ export async function recordDecision(
): Promise<Decision> {
return request<Decision>("POST", "/api/v1/decisions", { body, signal });
}
export async function pinDecision(
id: string,
pinned: boolean,
signal?: AbortSignal,
): Promise<Decision> {
return request<Decision>("POST", `/api/v1/decisions/${id}/pin`, {
body: { pinned },
signal,
});
}

View file

@ -237,6 +237,7 @@ export interface Decision {
project_id: string | null;
task_id: string | null;
created_hlc: string;
pinned: boolean;
}
export interface DecisionsResponse {

View file

@ -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,
*,

View file

@ -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

View file

@ -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