Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions apps/presentation/dashboard/smoke/attention-details-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import { AttentionDetailCard } from "../src/features/personal-workspace/attention-detail-card";
import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n";
import { todoItemSchema } from "../src/data/status";
import { attentionDetails, attentionDetailsFromSnapshot, attentionSuccessor, canReviewAttention, refreshAttention, sourceAttention } from "../src/features/personal-workspace/attention-details";
import { attentionDetails, attentionDetailsFromSnapshot, attentionSuccessor, canDecideAttention, canHandleUserAction, canReviewAttention, nextMorningResumeWhen, refreshAttention, sourceAttention } from "../src/features/personal-workspace/attention-details";
import { parseTodoResumeCondition } from "../src/features/personal-workspace/todo-resume-condition";
import { normalizePersonalHomeModel, type WorkspaceAttention } from "../src/features/personal-workspace/personal-workspace-model";

function assert(condition: unknown, message: string): asserts condition {
Expand All @@ -27,8 +28,31 @@ assert(model.attentionHistory?.[0].details?.decisionScope?.scopeKey === "route-o
assert(canReviewAttention(row), "open gate retains governed preview");
for (const task_class of ["user_action", undefined, "unknown_future_class"]) {
const item = { ...row, details: attentionDetails({ ...source, task_class, text: "Please authorize production", note: "read approval required" }) };
assert(item.details.interaction === "unknown", "prose never classifies interaction");
// Only the typed task_class classifies; approval wording never makes a decision.
assert(item.details.interaction === (task_class === "user_action" ? "user_action" : "unknown"), "prose never classifies interaction");
assert(!canDecideAttention(item), "approval wording never grants approve/reject");
assert(canReviewAttention(item), "existing ordinary or legacy preview preserved without granting authority");
assert(canHandleUserAction(item) === (task_class === "user_action"), "only a typed User action is handled as done/defer/cancel");
}
// A User action is handled on its own Todo; it needs the stable id the owner validates.
const action = { ...row, details: attentionDetails({ ...source, task_class: "user_action" }) };
assert(!canHandleUserAction({ ...row, details: attentionDetails(source) }), "a User gate is decided, not marked done");
const { todo_id: _omitted, ...idless } = source;
assert(!canHandleUserAction({ ...action, details: attentionDetails({ ...idless, task_class: "user_action" }) }), "missing todo_id cannot be written");
assert(!canHandleUserAction({ ...action, decisionSource: "run_operator_gate" }), "run operator gate is never a User action");
for (const inactive of [{ status: "deferred" }, { status: "done", done: true }, { superseded_by: "todo_next" }]) {
assert(!canHandleUserAction({ ...action, details: attentionDetails({ ...source, task_class: "user_action", ...inactive }) }), "inactive User action is not writable");
}
assert(!canHandleUserAction(refreshAttention(action, [])), "unobservable User action is not writable");
// The defer preset is tomorrow 09:00 local, in a form the resume-condition owner accepts.
for (const [now, day] of [["2026-10-04T19:13:00", "2026-10-05"], ["2026-10-04T00:30:00", "2026-10-05"],
["2026-01-31T23:59:00", "2026-02-01"], ["2026-12-31T10:00:00", "2027-01-01"]] as const) {
const local = new Date(now);
const condition = nextMorningResumeWhen(local);
assert(condition.startsWith(`resume_at:${day}T09:00:00`), `defer preset is the next calendar day at 09:00: ${condition}`);
assert(parseTodoResumeCondition(condition) === condition.toLowerCase(), "defer preset is a supported resume condition");
const target = new Date(Number(day.slice(0, 4)), Number(day.slice(5, 7)) - 1, Number(day.slice(8, 10)), 9);
assert(Date.parse(condition.slice("resume_at:".length)) === target.getTime(), "offset names the target instant");
}
assert(attentionDetails({ ...source, done: true, status: "deferred" }).lifecycle === "deferred", "explicit deferral overrides checked legacy marker");
for (const status of ["done", "deferred", "closed", "completed", "archived"]) {
Expand Down Expand Up @@ -79,7 +103,7 @@ const canonical = todoItemSchema.parse({...source, role: "user", task_class: "us
const compact = todoItemSchema.parse({...canonical, text: `${longRequest.slice(0, 217)}...`, note: null, evidence: null});
const joined = attentionDetailsFromSnapshot(compact, [canonical], row.goalId);
assert(joined.requestText === longRequest && joined.reason === canonical.note && joined.evidence === canonical.evidence, "same revision restores full display content");
assert(joined.interaction === "unknown" && joined.lifecycle === "open", "enrichment cannot reclassify a user action as a gate");
assert(joined.interaction === "user_action" && joined.lifecycle === "open", "enrichment cannot reclassify a user action as a gate");
const joinedMarkup = renderToStaticMarkup(createElement(WorkspaceI18nProvider, null,
createElement(AttentionDetailCard, {item: {...row, text: compact.text, details: joined}})));
assert(joinedMarkup.includes("Publish version 2.0 to stable only after acceptance.")
Expand All @@ -91,7 +115,7 @@ for (const candidates of [[], [canonical, canonical], [{...canonical, goal_id: "
[{...canonical, text: "A different request with the same title"}]]) {
const rejected = attentionDetailsFromSnapshot(compact, candidates, row.goalId);
assert(rejected.requestText === null && rejected.reason === null, "missing/conflicting details remain summary-only");
assert(rejected.lifecycle === "open" && rejected.interaction === "unknown", "failed enrichment preserves selected lifecycle and authority");
assert(rejected.lifecycle === "open" && rejected.interaction === "user_action", "failed enrichment preserves selected lifecycle and authority");
}
const authoritySource = {...canonical, blocks_agent: "different-agent", decision_scope: {kind: "trade", granularity: "goal", scope_key: "different"}};
const displayOnly = attentionDetailsFromSnapshot(compact, [authoritySource], row.goalId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { CalendarClock, Check, MessageCircleQuestion, MessageSquareReply, MoreHorizontal, Square, X } from "lucide-react";
import type { DecisionOutcome } from "../../../../../../loopx/control_plane/todos/user_completion_types.js";
import { canDecideAttention, canHandleUserAction, canReviewAttention, nextMorningResumeWhen } from "./attention-details";
import { useWorkspaceI18n } from "./i18n";
import type { PersonalWorkspaceCallbacks, WorkspaceActionPreviewRequest, WorkspaceAttention } from "./personal-workspace-model";

type PreviewState = { status: "idle" } | { status: "pending" } | { status: "error"; message: string };

/** Owner affordances for one "needs you" item. Every write is a typed preview
* the owner confirms once; the Todo owner validates it and decides effects. */
export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }: {
item: WorkspaceAttention;
readOnly: boolean;
callbacks: PersonalWorkspaceCallbacks;
fallbackAgentId: string;
}) {
const { t } = useWorkspaceI18n();
const [preview, setPreview] = useState<PreviewState>({ status: "idle" });
useEffect(() => setPreview({ status: "idle" }), [item.goalId, item.todoId]);
// Hard-lease Goals attribute the write to the Agent the request unblocks.
const agentId = item.details?.blocksAgent ?? fallbackAgentId;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] user_action binds its responsibility through canonical bound_agent, but attentionDetails only retains blocksAgent. This line therefore chooses the first available/default actor. In the compiled UI with a request bound to codex-delivery, it posts agent_id=codex; the real hard-lease File owner refuses completion and cancellation, with no change to the dependency. A registered wrong peer also fails; the bound peer succeeds and reads back only its scoped effects. Carry bound_agent into the projection and prefer it for user_action while retaining blocks_agent for user_gate and all native binding checks; add a packaged UI + real backend case with a non-default bound role.

const context = { goal_id: item.goalId, kind: "todo", todo_id: item.todoId };
const key = (suffix: string) => `workspace-attention-${item.todoId}-${suffix}-${Date.now().toString(36)}`;

async function requestPreview(request: WorkspaceActionPreviewRequest) {
if (preview.status === "pending" || !callbacks.onPreviewAction) return;
setPreview({ status: "pending" });
try {
await callbacks.onPreviewAction(request);
setPreview({ status: "idle" });
} catch (error) {
setPreview({ status: "error", message: error instanceof Error ? error.message : String(error) });
}
}

function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) {
if (readOnly || !canDecideAttention(attention)) return;
void requestPreview({
actionKind: "gate.resolve", context, idempotencyKey: key(`decision-${decision}`),
normalizedParameters: { agent_id: agentId, goal_id: attention.goalId, decision, todo_id: attention.todoId },
summary: attention.text,
});
}

function previewUserAction(attention: WorkspaceAttention, operation: "complete" | "defer" | "cancel") {
if (readOnly || !canHandleUserAction(attention)) return;
if (operation === "cancel") {
// Cancelling a user_action is the one decision outcome its owner admits;
// it closes only this reminder and never resumes or approves other work.
void requestPreview({
actionKind: "gate.resolve", context, idempotencyKey: key("cancel"),
normalizedParameters: { agent_id: agentId, goal_id: attention.goalId, decision: "cancel",
note: t("drawer.userActionCancelNote"), todo_id: attention.todoId },
summary: t("drawer.userActionCancelSummary", { task: attention.text }),
});
return;
}
void requestPreview({
actionKind: "todo.update", context, idempotencyKey: key(operation),
normalizedParameters: { agent_id: agentId, goal_id: attention.goalId, operation, todo_id: attention.todoId,
...(operation === "defer" ? { resume_when: nextMorningResumeWhen(new Date()) } : {}) },
summary: t(operation === "defer" ? "drawer.userActionDeferSummary" : "drawer.userActionCompleteSummary", { task: attention.text }),
});
}

const pending = preview.status === "pending";
const feedback = preview.status === "error"
? <p className="personal-proposal-state is-error" role="alert"><X size={16} /><span>{t("drawer.attentionPreviewFailed", { error: preview.message })}</span></p>
: pending ? <p className="personal-proposal-state" role="status">{t("drawer.attentionPreparing")}</p> : null;
const reply = callbacks.onReplyToAttention
? <button className="personal-secondary-action" onClick={() => callbacks.onReplyToAttention?.(item, "reply")} type="button"><MessageSquareReply size={16} />{t("drawer.attentionReply")}</button>
: null;
const explain = (label: string) => callbacks.onExplainDecision
? <button onClick={() => void callbacks.onExplainDecision?.(item)} type="button"><MessageCircleQuestion size={16} />{label}</button>
: null;

if (readOnly) {
return canReviewAttention(item) ? <p className="personal-proposal-explainer" role="status">{t("drawer.attentionReadOnly")}</p> : null;
}
if (canDecideAttention(item)) {
return <>
<div className="personal-decision-bar" role="group" aria-label={t("drawer.decisionGroup")}>
<button className="personal-primary-action" disabled={pending} onClick={() => previewDecision(item, "approve")} type="button"><Check size={17} />{t("drawer.decisionApprove")}</button>
<button className="personal-secondary-action" disabled={pending} onClick={() => previewDecision(item, "reject")} type="button"><X size={17} />{t("drawer.decisionReject")}</button>
</div>
{feedback}
<details className="personal-compact-menu">
<summary><MoreHorizontal size={17} />{t("drawer.decisionMore")}</summary>
<div>
{explain(t("drawer.explainDecision"))}
<button disabled={pending} onClick={() => previewDecision(item, "cancel")} type="button"><Square size={16} />{t("drawer.decisionCancel")}</button>
</div>
</details>
</>;
}
if (canHandleUserAction(item)) {
return <>
<p className="personal-proposal-explainer">{t(item.details?.unblocksTodoId ? "drawer.userActionUnblocks" : "drawer.userActionReminder")}</p>
<div className="personal-decision-bar" role="group" aria-label={t("drawer.userActionGroup")}>
<button className="personal-primary-action" disabled={pending} onClick={() => previewUserAction(item, "complete")} type="button"><Check size={17} />{t("drawer.userActionComplete")}</button>
{reply}
</div>
{feedback}
<details className="personal-compact-menu">
<summary><MoreHorizontal size={17} />{t("drawer.userActionMore")}</summary>
<div>
<button disabled={pending} onClick={() => previewUserAction(item, "defer")} type="button"><CalendarClock size={16} />{t("drawer.userActionDefer")}</button>
<button disabled={pending} onClick={() => previewUserAction(item, "cancel")} type="button"><Square size={16} />{t("drawer.userActionCancel")}</button>
{explain(t("drawer.askAgentExplain"))}
</div>
</details>
</>;
}
if (!canReviewAttention(item)) return null;
const note = item.decisionSource === "run_operator_gate" ? "drawer.decisionRunGate"
: item.details?.interaction === "user_action" ? "drawer.userActionMissingTodo" : "drawer.decisionNotGate";
return <>
<p className="personal-proposal-explainer">{t(note)}</p>
<div className="personal-decision-bar" role="group" aria-label={t("drawer.userActionGroup")}>
{reply}
{callbacks.onExplainDecision ? <button className="personal-secondary-action" onClick={() => void callbacks.onExplainDecision?.(item)} type="button"><MessageCircleQuestion size={16} />{t("drawer.askAgentExplain")}</button> : null}
</div>
</>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { todoRequestContent } from "../../../../../../loopx/control_plane/presen

/** A display of existing Todo facts, never a gate or dependency evaluator. */
export type AttentionDetails = {
interaction: "decision" | "unknown";
/** Mirrors the User Todo task_class; anything else stays "unknown". */
interaction: "decision" | "user_action" | "unknown";
lifecycle: "open" | "closed" | "deferred" | "superseded" | "unknown" | "unavailable";
/** False when the row was projected without a stable todo_id to write against. */
todoIdentified: boolean;
requestText?: string | null;
reason: string | null;
evidence: string | null;
Expand All @@ -25,11 +28,13 @@ export function attentionDetails(todo: Record<string, unknown>): AttentionDetail
const supersededBy = text(todo.superseded_by);
// Do not infer a request for authorization from wording, blocking, or scope kind.
return {
interaction: todo.task_class === "user_gate" ? "decision" : "unknown",
interaction: todo.task_class === "user_gate" ? "decision"
: todo.task_class === "user_action" ? "user_action" : "unknown",
lifecycle: supersededBy ? "superseded"
: todo.status === "deferred" ? "deferred"
: todo.done === true || ["done", "completed", "closed", "archived"].includes(String(todo.status)) ? "closed"
: todo.status === "open" || todo.status === "blocked" ? "open" : "unknown",
todoIdentified: text(todo.todo_id) !== null,
requestText: text(todo.text),
reason: text(todo.note),
evidence: text(todo.evidence),
Expand Down Expand Up @@ -84,3 +89,19 @@ export function canDecideAttention(item: WorkspaceAttention): boolean {
return canReviewAttention(item) && item.details?.interaction === "decision"
&& item.decisionSource !== "run_operator_gate";
}

/** Complete, defer, or cancel the User action Todo itself; never an approval.
* Requires the stable todo_id the typed Todo owner writes against. */
export function canHandleUserAction(item: WorkspaceAttention): boolean {
return canReviewAttention(item) && item.details?.interaction === "user_action"
&& item.details.todoIdentified && item.decisionSource !== "run_operator_gate";
}

/** Tomorrow's local 09:00 as a timezone-aware resume_at condition. */
export function nextMorningResumeWhen(now: Date): string {
const target = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9, 0, 0, 0);
const pad = (value: number) => String(Math.abs(value)).padStart(2, "0");
const offset = -target.getTimezoneOffset();
const zone = `${offset >= 0 ? "+" : "-"}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`;
return `resume_at:${target.getFullYear()}-${pad(target.getMonth() + 1)}-${pad(target.getDate())}T09:00:00${zone}`;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card";
import { AttentionActions } from "./attention-actions";
import { AttentionDetailCard } from "./attention-detail-card";
import type {DecisionOutcome} from "../../../../../../loopx/control_plane/todos/user_completion_types.js";
import { attentionSuccessor, canDecideAttention, canReviewAttention } from "./attention-details";
import { attentionSuccessor } from "./attention-details";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ArrowLeft,
Expand All @@ -14,7 +14,6 @@ import {
Download,
ExternalLink,
GitBranch,
MessageCircleQuestion,
Maximize2,
Minimize2,
MoreHorizontal,
Expand Down Expand Up @@ -397,23 +396,6 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
});
}

async function previewDecision(attention: WorkspaceAttention, decision: DecisionOutcome) {
if (readOnly || !canDecideAttention(attention)) return;
await callbacks.onPreviewAction?.({
actionKind: "gate.resolve",
context: { goal_id: attention.goalId, kind: "todo", todo_id: attention.todoId },
idempotencyKey: `workspace-decision-${attention.todoId}-${decision}-${Date.now().toString(36)}`,
normalizedParameters: {
// Hard-lease Goals attribute the decision to the Agent it unblocks.
agent_id: attention.details?.blocksAgent ?? todoAgentId,
goal_id: attention.goalId,
decision,
todo_id: attention.todoId,
},
summary: attention.text,
});
}

const currentSubagentConfiguration: WorkspaceGoalSubagentConfiguration = selection.kind === "goal"
? verifiedSubagentConfiguration
?? selection.item.subagentExecution
Expand Down Expand Up @@ -606,22 +588,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.kind === "attention" ? (
<>
<AttentionDetailCard item={selection.item} onSelect={onSelectAttention} successor={attentionSuccessor(selection.item, attentionHistory)} />
{!readOnly && canDecideAttention(selection.item) ? <>
<div className="personal-decision-bar" role="group" aria-label={t("drawer.decisionGroup")}>
<button className="personal-primary-action" onClick={() => void previewDecision(selection.item, "approve")} type="button"><Check size={17} />{t("drawer.decisionApprove")}</button>
<button className="personal-secondary-action" onClick={() => void previewDecision(selection.item, "reject")} type="button"><X size={17} />{t("drawer.decisionReject")}</button>
</div>
<details className="personal-compact-menu">
<summary><MoreHorizontal size={17} />{t("drawer.decisionMore")}</summary>
<div>
<button onClick={() => void callbacks.onExplainDecision?.(selection.item)} type="button"><MessageCircleQuestion size={16} />{t("drawer.explainDecision")}</button>
<button onClick={() => void previewDecision(selection.item, "cancel")} type="button"><Square size={16} />{t("drawer.decisionCancel")}</button>
</div>
</details>
</> : !readOnly && canReviewAttention(selection.item) ? <>
<p className="personal-proposal-explainer">{t(selection.item.decisionSource === "run_operator_gate" ? "drawer.decisionRunGate" : "drawer.decisionNotGate")}</p>
<button className="personal-secondary-action" onClick={() => void callbacks.onExplainDecision?.(selection.item)} type="button"><MessageCircleQuestion size={16} />{t("drawer.explainDecision")}</button>
</> : null}
<AttentionActions callbacks={callbacks} fallbackAgentId={todoAgentId} item={selection.item} readOnly={readOnly} />
</>
) : null}

Expand Down
Loading
Loading