feat(@projects/@claire): ✨ add pinned decision feature
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c22cae1b74
commit
c6bd77cc5b
11 changed files with 238 additions and 44 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export interface Decision {
|
|||
project_id: string | null;
|
||||
task_id: string | null;
|
||||
created_hlc: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface DecisionsResponse {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
46
tests/test_decision_pin.py
Normal file
46
tests/test_decision_pin.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue