Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js",
"smoke:task-board-scroll": "node smoke/task-board-scroll-smoke.mjs",
"smoke:usage-progress": "rm -rf /tmp/loopx-usage-progress-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --skipLibCheck --strict --outDir /tmp/loopx-usage-progress-smoke smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && node /tmp/loopx-usage-progress-smoke/apps/presentation/dashboard/smoke/usage-progress-smoke.js"
"smoke:usage-progress": "rm -rf /tmp/loopx-usage-progress-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --skipLibCheck --strict --outDir /tmp/loopx-usage-progress-smoke smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && node /tmp/loopx-usage-progress-smoke/apps/presentation/dashboard/smoke/usage-progress-smoke.js",
"smoke:attention-details": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --skipLibCheck --strict --outDir /tmp/loopx-attention-details-smoke smoke/attention-details-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-attention-details-smoke/apps/presentation/dashboard/smoke/attention-details-smoke.js"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
Expand Down
57 changes: 57 additions & 0 deletions apps/presentation/dashboard/smoke/attention-details-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { todoItemSchema } from "../src/data/status";
import { attentionDetails, attentionSuccessor, canReviewAttention, refreshAttention, sourceAttention } from "../src/features/personal-workspace/attention-details";
import { normalizePersonalHomeModel, type WorkspaceAttention } from "../src/features/personal-workspace/personal-workspace-model";

function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const source = {
index: 1, todo_id: "todo_gate_one", done: false, status: "open", text: "Review change",
task_class: "user_gate", note: "The chosen direction needs review", evidence: "review:bounded-check",
blocks_agent: "worker-one", unblocks_todo_id: "todo_target",
decision_scope: { schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "route-one" },
};
// Production Zod parse must preserve the public projection facts before mapping.
const details = attentionDetails(todoItemSchema.parse(source));
assert(details.reason === source.note && details.evidence === source.evidence, "reason/evidence survived schema");
assert(details.blocksAgent === "worker-one" && details.unblocksTodoId === "todo_target", "exact relationships survive schema");
assert(details.decisionScope?.scopeKey === "route-one", "scope survives schema");
assert(details.interaction === "decision", "typed user gate is decision");
const row: WorkspaceAttention = { blocking: true, goalId: "goal-one", todoId: "todo_gate_one", sourceId: "source-a", text: source.text, details };
const model = normalizePersonalHomeModel({ blockingTodoCount: 1, goals: [], openUserTodoCount: 1, userTodos: [row], attentionHistory: [row] });
assert(model.attentionHistory?.[0].details?.decisionScope?.scopeKey === "route-one", "workspace normalization retains details/history");
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");
assert(canReviewAttention(item), "existing ordinary or legacy preview preserved without granting authority");
}
assert(attentionDetails({ ...source, done: true, status: "deferred" }).lifecycle === "deferred", "explicit deferral overrides checked legacy marker");
for (const status of ["done", "deferred", "closed", "completed", "archived"]) {
const item = { ...row, details: attentionDetails({ ...source, status }) };
assert(!canReviewAttention(item), "inactive row cannot preview");
}
const replacement = { ...row, todoId: "todo_gate_two", text: "Review change" };
const old = { ...row, details: attentionDetails(todoItemSchema.parse({ ...source, superseded_by: "todo_gate_two", done: true })) };
assert(old.details.lifecycle === "superseded", "supersession takes precedence over completion");
assert(!canReviewAttention(old), "superseded cannot preview");
assert(attentionSuccessor(old, [{ ...replacement, goalId: "other" }]) === undefined, "same title and id in other Goal not a successor");
assert(attentionSuccessor(old, [{ ...replacement, sourceId: "source-b" }]) === undefined, "cross-source link rejected");
assert(attentionSuccessor(old, [replacement]) === replacement, "exact source and Goal successor linked");
assert(attentionSuccessor({ ...old, details: { ...old.details, supersededBy: "todo_gate_one" } }, [row]) === undefined, "self-cycle not linked");
assert(refreshAttention(row, [old]).details?.lifecycle === "superseded", "selection refreshes terminal facts");
const missing = refreshAttention(row, []);
assert(missing.details?.lifecycle === "unavailable" && !canReviewAttention(missing), "missing is unknown availability, not completion");
assert(refreshAttention(row, [{ ...row, sourceId: "source-b" }]).details?.lifecycle === "unavailable", "source switch fences old selection");
assert(attentionDetails({ ...source, decision_scope: { kind: "direction" } }).decisionScope === null, "partial scope is unknown");
assert(attentionDetails({}).lifecycle === "unknown", "missing state never means open or completed");
assert(source.status === "open" && !source.done, "projection has no mutation side effects");
console.log("attention-details-smoke: ok");

const failedSource = sourceAttention(row, "source-a", false, "Current Goal title");
assert(failedSource.goalTitle === "Current Goal title", "current source preserves display title");
assert(failedSource.details?.lifecycle === "unavailable" && !canReviewAttention(failedSource), "retained row from failed source cannot preview");
const healthySource = sourceAttention(row, "source-b", true, "Healthy Goal");
assert(canReviewAttention(refreshAttention(healthySource, [failedSource, healthySource])), "another source failure cannot fence healthy source");
const healthyGoal = sourceAttention({ ...row, goalId: "healthy-goal" }, "source-a", true);
assert(canReviewAttention(refreshAttention(healthyGoal, [failedSource, healthyGoal])), "another Goal read failure cannot fence healthy Goal");
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useWorkspaceI18n } from "./i18n";
import type { WorkspaceAttention } from "./personal-workspace-model";

export function AttentionDetailCard({ item, successor, onSelect }: {
item: WorkspaceAttention;
successor?: WorkspaceAttention;
onSelect?: (item: WorkspaceAttention) => void;
}) {
const { t } = useWorkspaceI18n();
const detail = item.details;
return <section className="personal-detail-card" aria-label={t("attentionDetail.title")}>
<h3>{t("attentionDetail.title")}</h3>
<dl>
<div><dt>{t("attentionDetail.request")}</dt><dd>{t(detail?.interaction === "decision" ? "attentionDetail.decision" : "attentionDetail.unknownRequest")}</dd></div>
<div><dt>{t("common.status")}</dt><dd>{t(`attentionDetail.${detail?.lifecycle ?? "unknown"}`)}</dd></div>
<div><dt>{t("drawer.reason")}</dt><dd>{detail?.reason ?? item.explanation ?? t("attentionDetail.unknownReason")}</dd></div>
<div><dt>Todo</dt><dd>{item.todoId}</dd></div>
<div><dt>{t("attentionDetail.targetTodo")}</dt><dd>{detail?.unblocksTodoId ?? t("attentionDetail.notProvided")}</dd></div>
<div><dt>{t("attentionDetail.targetAgent")}</dt><dd>{detail?.blocksAgent ?? t("attentionDetail.notProvided")}</dd></div>
<div><dt>{t("attentionDetail.scope")}</dt><dd>{detail?.decisionScope
? `${detail.decisionScope.kind} · ${detail.decisionScope.granularity} · ${detail.decisionScope.scopeKey}`
: t("attentionDetail.notProvided")}</dd></div>
<div><dt>{t("drawer.evidence")}</dt><dd>{detail?.evidence ?? item.evidence ?? t("drawer.decisionDefaultEvidence")}</dd></div>
</dl>
{detail?.supersededBy ? <p>{t("attentionDetail.replacement")}: {detail.supersededBy}</p> : null}
{successor && onSelect ? <button className="personal-secondary-action" onClick={() => onSelect(successor)} type="button">{t("attentionDetail.openReplacement")}</button> : null}
<p>{t("attentionDetail.boundary")}</p>
</section>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { WorkspaceAttention } from "./personal-workspace-model";

/** A display of existing Todo facts, never a gate or dependency evaluator. */
export type AttentionDetails = {
interaction: "decision" | "unknown";
lifecycle: "open" | "closed" | "deferred" | "superseded" | "unknown" | "unavailable";
reason: string | null;
evidence: string | null;
blocksAgent: string | null;
unblocksTodoId: string | null;
decisionScope: { kind: string; granularity: string; scopeKey: string } | null;
supersededBy: string | null;
};

const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value.trim() : null;

export function attentionDetails(todo: Record<string, unknown>): AttentionDetails {
const scope = todo.decision_scope && typeof todo.decision_scope === "object" && !Array.isArray(todo.decision_scope)
? todo.decision_scope as Record<string, unknown> : {};
const kind = text(scope.kind);
const granularity = text(scope.granularity);
const scopeKey = text(scope.scope_key);
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",
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",
reason: text(todo.note),
evidence: text(todo.evidence),
blocksAgent: text(todo.blocks_agent),
unblocksTodoId: text(todo.unblocks_todo_id),
decisionScope: kind && granularity && scopeKey ? { kind, granularity, scopeKey } : null,
supersededBy,
};
}

/** Stamp only the source/Goal being observed; unrelated failed reads cannot fence it. */
export function sourceAttention(item: WorkspaceAttention, sourceId: string, sourceReady: boolean, goalTitle?: string): WorkspaceAttention {
return {
...item, sourceId, goalTitle: goalTitle ?? item.goalTitle,
details: sourceReady ? item.details : { ...(item.details ?? attentionDetails({})), lifecycle: "unavailable" },
};
}

export function refreshAttention(selected: WorkspaceAttention, current: WorkspaceAttention[]): WorkspaceAttention {
const match = current.find((item) => item.sourceId === selected.sourceId
&& item.goalId === selected.goalId && item.todoId === selected.todoId);
if (match) return match;
// Absence can mean a truncated/failed projection or a source switch, not completion.
return { ...selected, details: { ...(selected.details ?? attentionDetails({})), lifecycle: "unavailable" } };
}

export function attentionSuccessor(item: WorkspaceAttention, current: WorkspaceAttention[]): WorkspaceAttention | undefined {
const successorId = item.details?.supersededBy;
if (!successorId || successorId === item.todoId || item.details?.lifecycle === "unavailable") return undefined;
return current.find((candidate) => candidate.sourceId === item.sourceId
&& candidate.goalId === item.goalId && candidate.todoId === successorId);
}

export function canReviewAttention(item: WorkspaceAttention): boolean {
// Preview remains available for existing user actions and legacy rows. It is
// not an authorization grant; only known inactive or missing rows are fenced.
return !["closed", "deferred", "superseded", "unavailable"].includes(item.details?.lifecycle ?? "unknown");
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AttentionDetailCard } from "./attention-detail-card";
import { attentionSuccessor, canReviewAttention } from "./attention-details";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ArrowLeft,
Expand Down Expand Up @@ -99,8 +101,10 @@ function subagentConfigurationsMatch(

type ContextDrawerSelection = Exclude<WorkspaceDrawerSelection, { kind: "settings" }>;

export function ContextDrawer({ agents, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, runs = [], selection }: {
export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, runs = [], selection }: {
agents: WorkspaceAgentOption[];
attentionHistory?: WorkspaceAttention[];
onSelectAttention?: (item: WorkspaceAttention) => void;
callbacks: PersonalWorkspaceCallbacks;
goalNotifications?: WorkspaceGoalNotification[];
goals?: WorkspaceGoal[];
Expand Down Expand Up @@ -318,6 +322,7 @@ export function ContextDrawer({ agents, callbacks, goalNotifications = [], goals
}

async function previewDecision(attention: WorkspaceAttention, decision: "approve" | typeof decisionTransitions[number]["resolution"], label: string) {
if (readOnly || !canReviewAttention(attention)) return;
await callbacks.onPreviewAction?.({
actionKind: "gate.resolve",
context: { goal_id: attention.goalId, kind: "todo", todo_id: attention.todoId },
Expand Down Expand Up @@ -513,11 +518,11 @@ export function ContextDrawer({ agents, callbacks, goalNotifications = [], goals
<div><dt>Goal</dt><dd>{selection.item.goalTitle ?? selection.item.goalId}</dd></div>
<div><dt>{t("drawer.priority")}</dt><dd>{selection.item.priority ?? "medium"}</dd></div>
{attentionAge ? <div><dt>{t("common.waiting")}</dt><dd>{t("tasks.waitingAge", { age: attentionAge })}</dd></div> : null}
<div><dt>{t("drawer.reason")}</dt><dd>{selection.item.explanation ?? t("drawer.decisionDefaultReason")}</dd></div>
<div><dt>{t("drawer.evidence")}</dt><dd>{selection.item.evidence ?? t("drawer.decisionDefaultEvidence")}</dd></div>

</dl>
</section>
{!readOnly ? <>
<AttentionDetailCard item={selection.item} onSelect={onSelectAttention} successor={attentionSuccessor(selection.item, attentionHistory)} />
{!readOnly && canReviewAttention(selection.item) ? <>
<button className="personal-primary-action" onClick={() => void previewDecision(selection.item, "approve", t("common.confirm"))} type="button"><Check size={17} />{t("drawer.decisionReview")}</button>
<details className="personal-compact-menu">
<summary><MoreHorizontal size={17} />{t("drawer.decisionMore")}</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ const en = {
"drawer.tokensShort": "tokens",
"drawer.usageNotMeasured": "Not measured",
"drawer.currentGoal": "Current Goal",
"attentionDetail.title": "Request details",
"attentionDetail.request": "What is requested",
"attentionDetail.decision": "Decision requested",
"attentionDetail.unknownRequest": "Not specified; review the source before deciding",
"attentionDetail.open": "Open in current projection",
"attentionDetail.closed": "Closed",
"attentionDetail.deferred": "Deferred",
"attentionDetail.superseded": "Replaced",
"attentionDetail.unknown": "Status not provided",
"attentionDetail.unavailable": "The source has not confirmed this item; refresh before acting",
"attentionDetail.unknownReason": "The source has not provided a reason.",
"attentionDetail.targetTodo": "Linked Todo to unblock",
"attentionDetail.targetAgent": "Agent named by the request",
"attentionDetail.scope": "Declared decision scope",
"attentionDetail.notProvided": "Not provided",
"attentionDetail.replacement": "Replacement Todo",
"attentionDetail.openReplacement": "Open replacement",
"attentionDetail.boundary": "Reading this detail does not resolve a gate or grant authority. Available decisions require a fresh preview.",
"drawer.decisionDefaultEvidence": "No additional public-safe evidence is attached. The next step will still show a Preview first.",
"drawer.decisionDefaultReason": "This decision affects the next step of the current Todo.",
"drawer.decisionDefer": "Decide later",
Expand Down Expand Up @@ -1040,6 +1058,24 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"drawer.tokensShort": "Token",
"drawer.usageNotMeasured": "未采集",
"drawer.currentGoal": "当前 Goal",
"attentionDetail.title": "事项说明",
"attentionDetail.request": "需要你做什么",
"attentionDetail.decision": "需要作出决定",
"attentionDetail.unknownRequest": "来源未明确,请先查看来源再判断",
"attentionDetail.open": "当前投影中待处理",
"attentionDetail.closed": "已关闭",
"attentionDetail.deferred": "已推迟",
"attentionDetail.superseded": "已被替代",
"attentionDetail.unknown": "来源未提供状态",
"attentionDetail.unavailable": "来源尚未确认当前事项,请刷新后再操作",
"attentionDetail.unknownReason": "来源尚未提供原因。",
"attentionDetail.targetTodo": "关联的待解锁 Todo",
"attentionDetail.targetAgent": "事项指定的 Agent",
"attentionDetail.scope": "声明的决策范围",
"attentionDetail.notProvided": "来源未提供",
"attentionDetail.replacement": "替代 Todo",
"attentionDetail.openReplacement": "打开替代事项",
"attentionDetail.boundary": "阅读详情不会关闭 gate 或授予权限;作出决定前仍需新的操作预览。",
"drawer.decisionDefaultEvidence": "当前状态没有附加公开安全证据;下一步仍会先展示 Preview。",
"drawer.decisionDefaultReason": "该决定会影响当前 Todo 的下一步执行。",
"drawer.decisionDefer": "稍后决定",
Expand Down
Loading