From 4243cb6275a268f8b8546dfb1a91b90869a08180 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 1 Oct 2026 13:26:19 +0800 Subject: [PATCH 1/3] fix(manager): refresh confirmation cards and contain Goal footer text Signed-off-by: huangruiteng --- apps/presentation/dashboard/src/data/chat.ts | 4 +- .../src/data/use-typed-action-readback.ts | 18 +++ .../personal-workspace/channel-timeline.tsx | 4 +- .../personal-workspace/context-drawer.tsx | 20 ++- .../src/features/personal-workspace/i18n.tsx | 28 ++++- .../personal-workspace/manager-brief.tsx | 38 ++++-- .../personal-workspace-page.tsx | 118 ++++++++++++------ .../personal-workspace/personal-workspace.css | 9 +- .../confirmed-operation-fixtures.py | 2 + .../confirmed-operations.mjs | 61 ++++++++- .../presentation/action_review_plan.ts | 38 +++++- .../action_review_plan.test.ts | 50 ++++++++ 12 files changed, 323 insertions(+), 67 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/use-typed-action-readback.ts diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index c7b57a1519..277776b4fa 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -498,13 +498,13 @@ const typedActionListEnvelopeSchema = z.object({ proposals: z.array(typedActionProposalSchema), }); -export async function listTypedActions(filters: { contextKind?: string; goalId?: string } = {}) { +export async function listTypedActions(filters: { contextKind?: string; goalId?: string } = {}, signal?: AbortSignal) { const query = new URLSearchParams(); if (filters.contextKind) query.set("context_kind", filters.contextKind); if (filters.goalId) query.set("goal_id", filters.goalId); const suffix = query.size > 0 ? `?${query.toString()}` : ""; return typedActionListEnvelopeSchema.parse( - await requestJson(`/api/actions${suffix}`), + await requestJson(`/api/actions${suffix}`, { signal }), ).proposals; } diff --git a/apps/presentation/dashboard/src/data/use-typed-action-readback.ts b/apps/presentation/dashboard/src/data/use-typed-action-readback.ts new file mode 100644 index 0000000000..443251b808 --- /dev/null +++ b/apps/presentation/dashboard/src/data/use-typed-action-readback.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { listTypedActions } from "./chat"; + +/** Visible workspace readback only: no confirmation, dispatch or effect owner. + * Query keys fence scope changes; React Query serializes same-key requests, + * cancels superseded reads and suspends background interval polling. */ +export function useTypedActionReadback(readOnly: boolean, goalId: string | null | undefined) { + return useQuery({ + queryKey: ["typed-action-readback", goalId ?? "manager"], + queryFn: ({ signal }) => listTypedActions(goalId ? { goalId } : {}, + AbortSignal.any([signal, AbortSignal.timeout(10_000)])), + enabled: !readOnly, + refetchInterval: 5_000, + refetchIntervalInBackground: false, + refetchOnWindowFocus: "always", + retry: false, + }); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 19a9a59bf1..ef1c89ad83 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -74,7 +74,7 @@ export function ChannelTimeline({ : ""; const gatedItems = items.filter((item): item is Extract => - item.kind === "proposal" && item.proposal.status === "gated"); + item.kind === "proposal" && item.proposal.status === "gated" && item.proposal.actionKind !== "operation.execute"); // Only routine execution is folded. Waiting, interruption, and failures stay // visible; no prose-based inference that a waiting run is safe to ignore. const routineRuns = items.filter((item): item is Extract => @@ -95,7 +95,7 @@ export function ChannelTimeline({ ? [workingCount && `${workingCount} 个执行中`, queuedCount && `${queuedCount} 个排队中`, completedCount && `${completedCount} 次执行已结束`, progressCount && `${progressCount} 项进展更新`] : [workingCount && `${workingCount} running`, queuedCount && `${queuedCount} queued`, completedCount && `${completedCount} runs finished`, progressCount && `${progressCount} progress updates`]).filter(Boolean).join(" · "); const activeProposalItems = items.filter((item): item is Extract => - item.kind === "proposal" && item.proposal.status !== "gated"); + item.kind === "proposal" && (item.proposal.status !== "gated" || item.proposal.actionKind === "operation.execute")); // Only drafts awaiting the owner fold behind the newest one; applying, applied and failed results stay visible. // "Newest" is read from the stored proposal, not from the position in this // list: a restore arrives newest first and a draft created in this session is diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index a0bfc08c54..097dfbb81e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -119,7 +119,7 @@ const RUN_ACTION_LABEL_KEYS = { retry: "drawer.recoveryRetry", } as const; -export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, 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, proposalReadbackUnavailable = false, onRetryProposalReadback, proposalReadbackFetching = false, runs = [], selection }: { agents: WorkspaceAgentOption[]; attentionHistory?: WorkspaceAttention[]; onSelectAttention?: (item: WorkspaceAttention) => void; @@ -131,6 +131,9 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention onClose: () => void; onToggleInspectorSize?: () => void; readOnly?: boolean; + proposalReadbackUnavailable?: boolean; + onRetryProposalReadback?: () => void; + proposalReadbackFetching?: boolean; runs?: WorkspaceRun[]; selection: ContextDrawerSelection; }) { @@ -292,8 +295,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : selection.kind === "run" ? t("drawer.runDetails") : selection.kind === "output" ? t("drawer.titleOutput") : selection.kind === "proposal" && selection.item.actionKind === "team.plan" && selection.item.status === "applied" ? t("proposal.teamPlan.resultTitle") - : selection.kind === "proposal" && selection.item.actionKind === "operation.execute" - && selection.item.reviewPlan?.operationFrame?.kind !== "confirmation" ? t("drawer.operationReadOnly") + : selection.kind === "proposal" && selection.item.actionKind === "operation.execute" ? t("drawer.operationReadOnly") : selection.kind === "proposal" ? t(selection.item.reviewPlan?.retryOriginal ? "drawer.recoverEditResult" : selection.item.status === "applied" ? "drawer.titleProposalApplied" : "drawer.titleProposalConfirm") : selection.kind === "schedule" ? (selection.item.scheduleKind === "heartbeat" ? "Heartbeat" : t("drawer.titleSchedule")) : t("drawer.goalDetails"); @@ -1101,14 +1103,21 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.kind === "proposal" ? ( <> + {proposalReadbackUnavailable && selection.item.actionKind === "operation.execute" + ?

{t("proposal.readbackUnavailable")}

+ {onRetryProposalReadback ? : null}
: null} {selection.item.actionKind === "team.plan" && selection.item.status === "applied" ? :
{selection.item.actionKind} · {selection.item.status}

{selection.item.title}

{selection.item.impact ?

{selection.item.impact}

: null} {selection.item.reviewPlan && !selection.item.reviewPlan.retryOriginal && selection.item.actionKind !== "team.plan" ?

{operationUnknown ? t("actionReview.operation_reconcile_original") + : selection.item.reviewPlan.operationFrame?.kind === "inactive" + ? t(`actionReview.${selection.item.reviewPlan.operationFrame.reason}`) : selection.item.actionKind === "operation.execute" && selection.item.status === "gated" - ? t("actionReview.operation_group_confirmation") + ? t(selection.item.reviewPlan.operationFrame?.kind === "confirmation" + && selection.item.reviewPlan.operationFrame.confirmationDeliveryVerified + ? "actionReview.operation_group_confirmation" : "proposal.impact.operationDeliveryPending") : selection.item.actionKind === "operation.execute" && selection.item.reviewPlan.reason === "readback_unverified" ? t("actionReview.operation_result_delivery_pending") : t(`actionReview.${selection.item.reviewPlan.reason}`)}

: null} @@ -1120,6 +1129,9 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.status === "stale" ?

{t("drawer.proposalStale")}

: null} {selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ?
{operationUnknown ? t("proposal.operationState.submission_unknown") : selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}{selection.item.errorMessage ? {selection.item.errorMessage} : null}{t(operationUnknown ? "actionReview.operation_reconcile_original" : selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryHint" : "drawer.proposalApplyFailedHint")}
: null} {selection.item.status === "rejected" ?

{t("drawer.proposalRejected")}

: null} + {selection.item.reviewPlan?.operationFrame?.kind === "result" + && selection.item.reviewPlan.operationFrame.resultKind === "cancelled" + ?

{t("proposal.primary.operationCancelled")}

: null} {selection.item.status === "deferred" ?

{t("drawer.proposalDeferred")}

: null} {selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ? (() => { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 7af8eb78bb..b6327f7cbe 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -382,7 +382,7 @@ const en = { "drawer.titleOutput": "Output details", "drawer.titleProposalApplied": "Execution result", "drawer.titleProposalConfirm": "Confirm execution", - "drawer.operationReadOnly": "Original operation status", + "drawer.operationReadOnly": "Operation details", "drawer.operationCanonicalStatus": "Read-only canonical operation; no new execution", "drawer.titleSchedule": "Scheduled check", "drawer.unconfigured": "Not configured", @@ -739,6 +739,16 @@ const en = { "proposal.primary.lifecycleStop": "Stop Goal", "proposal.primary.todoStart": "Create task and start execution", "proposal.primary.operationGroup": "Confirm in Feishu group", + "proposal.primary.operationDeliveryPending": "Prepared; confirmation card not delivered yet", + "actionReview.operation_confirmation_expired": "Confirmation request expired; ask the Agent for a fresh request", + "actionReview.operation_expiry_unknown": "Confirmation expiry cannot be verified; ask the Agent to check the original request", + "proposal.primary.operationCancelled": "Request cancelled before confirmation", + "proposal.impact.operationDeliveryPending": "The immutable request was prepared, but no confirmation-card delivery is recorded. No confirmation or execution is established; the bound transport must deliver its card first.", + "proposal.impact.operationCancelled": "This request was cancelled before confirmation. It grants no execution authority and is not a domain execution result.", + "proposal.impact.operationResult": "Read the original outcome and its return receipt. Recorded evidence is not permission to repeat the operation.", + "proposal.readbackUnavailable": "Proposal state could not be refreshed. Cached cards may be stale; do not infer current approval or execution from them.", + "proposal.readbackRetry": "Retry state readback", + "home.operationSummary": "Also {count} operation requests awaiting delivery or confirmation.", "proposal.primary.operationResultPending": "Result card delivery pending", "proposal.primary.operationResultVerified": "Verified result", "proposal.resultDelivery.verified": "Verified in the original group card", @@ -1137,6 +1147,8 @@ const en = { "brief.title": "Today's brief", "brief.needs": "Needs you", "brief.needsEmpty": "Nothing is waiting on you", + "brief.viewAll": "View all {count} items", + "brief.operationDeliveryPending": "Agent: confirmation delivery pending", "brief.running": "Running now", "brief.runningEmpty": "No Agent is running right now", "brief.runningEmptyQueued": "No Agent is running · {count} queued", @@ -1580,7 +1592,7 @@ const zhCN: Record = { "drawer.titleOutput": "产出详情", "drawer.titleProposalApplied": "执行结果", "drawer.titleProposalConfirm": "确认执行", - "drawer.operationReadOnly": "原操作状态", + "drawer.operationReadOnly": "操作详情", "drawer.operationCanonicalStatus": "只读规范操作,不产生新执行", "drawer.titleSchedule": "定时检查", "drawer.unconfigured": "未配置", @@ -1937,6 +1949,16 @@ const zhCN: Record = { "proposal.primary.lifecycleStop": "停止 Goal", "proposal.primary.todoStart": "创建任务并开始执行", "proposal.primary.operationGroup": "前往飞书群确认", + "proposal.primary.operationDeliveryPending": "请求已准备,确认卡尚未投递", + "actionReview.operation_confirmation_expired": "确认请求已过期,请 Agent 重新准备请求", + "actionReview.operation_expiry_unknown": "无法核验确认有效期,请 Agent 核对原请求", + "proposal.primary.operationCancelled": "请求已在确认前取消", + "proposal.impact.operationDeliveryPending": "不可变请求已准备,但尚无确认卡投递回执;不代表已确认或已执行,须先由绑定通道投递原卡。", + "proposal.impact.operationCancelled": "该请求已在确认前取消,不再授予执行权限,也不代表领域执行结果。", + "proposal.impact.operationResult": "核对原始结果及回传回执;记录的证据不授予重复执行许可。", + "proposal.readbackUnavailable": "提案状态刷新失败,缓存卡片可能过期;不能据此认定当前已批准或已执行。", + "proposal.readbackRetry": "重试状态读取", + "home.operationSummary": "另有 {count} 个操作请求等待投递或确认。", "proposal.primary.operationResultPending": "结果卡回传待恢复", "proposal.primary.operationResultVerified": "结果已核验", "proposal.resultDelivery.verified": "已在原群卡片完成回读核验", @@ -2335,6 +2357,8 @@ const zhCN: Record = { "brief.title": "今日简报", "brief.needs": "等你处理", "brief.needsEmpty": "没有等你处理的事", + "brief.viewAll": "查看全部 {count} 项", + "brief.operationDeliveryPending": "Agent 待投递确认卡", "brief.running": "正在执行", "brief.runningEmpty": "现在没有 Agent 在执行", "brief.runningEmptyQueued": "现在没有 Agent 在执行 · {count} 个已安排", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx index 719fcc6e2e..c36c55d76c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { ArrowRight, Check } from "lucide-react"; import { useWorkspaceI18n } from "./i18n"; -import type { WorkspaceGoal } from "./personal-workspace-model"; +import type { WorkspaceActionPreview, WorkspaceGoal } from "./personal-workspace-model"; import { workspaceHomeLaneForGoal } from "./personal-workspace-model"; import { goalWorkKind, presentGoalActivity } from "./goal-activity"; import { GoalIdentityMark, useExecutionDetail } from "./goal-activity-view"; @@ -10,28 +10,32 @@ import { recentCompletions } from "./recent-completions"; const briefRowLimit = 3; -type BriefRow = { goal: WorkspaceGoal; key: string; meta?: ReactNode; text: string }; +type BriefRow = { goal: WorkspaceGoal; key: string; meta?: ReactNode; text: string; proposal?: WorkspaceActionPreview }; function RunningMeta({ goal }: { goal: WorkspaceGoal }) { return <>{useExecutionDetail(goal.execution).join(" · ") || goal.title}; } -function BriefTile({ count, empty, kind, live = false, onSelectGoal, rows, title, total }: { +function BriefTile({ count, empty, kind, live = false, onSelectGoal, onSelectOperation, onViewAll, rows, title, total }: { count: number; empty: string; - kind: "needs" | "running" | "claimed" | "completed"; + kind: "needs" | "operations" | "running" | "claimed" | "completed"; live?: boolean; onSelectGoal: (goalId: string) => void; + onSelectOperation?: (proposal: WorkspaceActionPreview) => void; + onViewAll?: () => void; rows: BriefRow[]; title: string; total?: string | null; }) { + const { t } = useWorkspaceI18n(); return (
{title}{count}
{rows.length ?
{rows.slice(0, briefRowLimit).map((row) => ( -
:

{empty}

} {total ?
{total}
: null} + {onViewAll && rows.length > briefRowLimit ?
: null}
); } -export function ManagerBrief({ goals, onSelectGoal }: { goals: WorkspaceGoal[]; onSelectGoal: (goalId: string) => void }) { +export function ManagerBrief({ goals, onSelectGoal, operations = [], onSelectOperation, onViewAllOperations }: { + goals: WorkspaceGoal[]; onSelectGoal: (goalId: string) => void; + operations?: WorkspaceActionPreview[]; onSelectOperation?: (proposal: WorkspaceActionPreview) => void; + onViewAllOperations?: () => void; +}) { const { t } = useWorkspaceI18n(); const active = goals.filter((goal) => goal.activationState === "active" && !goal.loadState); const needs = active.filter((goal) => workspaceHomeLaneForGoal(goal) === "needs_you"); + const operationRows: BriefRow[] = operations.flatMap(proposal => { + const goal = goals.find(goal => goal.goalId === proposal.goalId && !goal.loadState); + return goal ? [{goal, key: proposal.previewId, proposal, text: proposal.title, + meta: `${goal.title} · ${proposal.primaryLabel}`}]: []; + }); + const deliveredOperations = operationRows.filter(row => row.proposal?.reviewPlan?.operationFrame?.kind === "confirmation" + && row.proposal.reviewPlan.operationFrame.confirmationDeliveryVerified); + const awaitingDelivery = operationRows.filter(row => !deliveredOperations.includes(row)); const running = active.filter((goal) => goalWorkKind(goal) === "executing"); const claimed = active.filter((goal) => goalWorkKind(goal) === "claimed"); const queued = active.filter((goal) => goal.state === "已安排" && goalWorkKind(goal) === "none").length; @@ -59,9 +76,14 @@ export function ManagerBrief({ goals, onSelectGoal }: { goals: WorkspaceGoal[]; : executionPending ? t("brief.runningReading") : t("activity.executionUnknown"); return (
- ({ goal, key: goal.goalId, meta: goal.title, text: goal.needsYou ?? goal.nextSentence }))} + briefRowLimit ? onViewAllOperations : undefined} + rows={[...deliveredOperations, ...needs.map((goal) => ({ goal, key: goal.goalId, meta: goal.title, text: goal.needsYou ?? goal.nextSentence }))]} title={t("brief.needs")} /> + {awaitingDelivery.length ? : null} presentGoalActivity(goal).live)} onSelectGoal={onSelectGoal} rows={running.map((goal) => ({ goal, key: goal.goalId, meta: , text: goal.title }))} title={t("brief.running")} total={running.length && queued ? t("brief.alsoQueued", { count: queued }) : null} /> diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index ae53146737..8e2a631332 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -22,7 +22,6 @@ import { fetchGoalChannelTargets, fetchLarkConnections, fetchLoopXMode, - listTypedActions, previewTypedAction, setupGoalChannel, stewardPrompts, @@ -33,6 +32,7 @@ import { type ManagerRuntimeSessionReadback, type TypedActionProposal, } from "../../data/chat"; +import { useTypedActionReadback } from "../../data/use-typed-action-readback"; import { ChannelHeader } from "./channel-header"; import { GoalLoopXMode } from "./goal-loopx-mode"; @@ -82,7 +82,8 @@ function dedupeProposals(proposals: WorkspaceActionPreview[]): WorkspaceActionPr const latest = new Map(); proposals.forEach((proposal) => { const subject = proposal.fields.find((field) => field.key === "todo_id")?.value ?? ""; - const key = [proposal.actionKind, proposal.goalId ?? "", subject, proposal.title].join(":"); + const key = proposal.actionKind === "operation.execute" ? proposal.previewId + : [proposal.actionKind, proposal.goalId ?? "", subject, proposal.title].join(":"); // Two records can describe the same draft; keep the newest by its stored // time rather than whichever one this list happened to end with, since a // restored list and a session-created draft arrive in opposite orders. @@ -107,11 +108,17 @@ function ManagerHomeBoard({ onSelectGoal, onRetry, systemHealth, + operations, + onSelectOperation, + onViewAllOperations, }: { goals: WorkspaceGoal[]; onSelectGoal: (goalId: string) => void; onRetry?: () => void; systemHealth?: WorkspaceSystemHealth; + operations: WorkspaceActionPreview[]; + onSelectOperation: (proposal: WorkspaceActionPreview) => void; + onViewAllOperations: () => void; }) { const { locale, t } = useWorkspaceI18n(); const currentGoals = goals.filter((goal) => goal.activationState === "active"); @@ -164,7 +171,8 @@ function ManagerHomeBoard({ : null} {currentGoals.filter((goal) => goal.loadState).map(goalCard)}
: null} - {currentGoals.some((goal) => !goal.loadState) ? : null} + {currentGoals.some((goal) => !goal.loadState) ? : null}

{t("home.allGoals")}

{activeHomeLanes.filter((lane) => active[lane.key].length > 0).map((lane) => ( @@ -558,8 +566,13 @@ function operationProposalFields( label: t("proposal.field.operationState"), value: frame?.kind === "pending" && frame.executionState ? t(`proposal.operationState.${frame.executionState}`) + : frame?.kind === "inactive" ? t(`actionReview.${frame.reason}`) : frame?.kind === "result" && frame.resultKind === "unknown" ? t("proposal.operationState.submission_unknown") + : frame?.kind === "result" && frame.resultKind === "cancelled" + ? t("proposal.primary.operationCancelled") + : frame?.kind === "confirmation" && !frame.confirmationDeliveryVerified + ? t("proposal.primary.operationDeliveryPending") : frame?.lifecycleState ?? proposal.status, }, ...(frame?.kind === "result" ? [{ @@ -613,7 +626,7 @@ function workspaceCandidatesFromGate(gate: Record | null | unde function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate): WorkspaceActionPreview { const lifecycleOperation = lifecycleOperationFor(proposal); - const reviewPlan = compileActionReviewPlan(proposal); + const reviewPlan = compileActionReviewPlan(proposal, Date.now()); const title = typeof proposal.normalized_parameters.title === "string" ? proposal.normalized_parameters.title : typeof proposal.normalized_parameters.goal_id === "string" @@ -657,12 +670,17 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) : proposalFields(proposal.normalized_parameters, t), goalId: typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : undefined, impact: reviewPlan.retryOriginal ? t(`actionReview.${reviewPlan.reason}`) : proposal.action_kind === "operation.execute" - ? operationFrame?.kind === "pending" && operationFrame.executionState + ? operationFrame?.kind === "inactive" ? t(`actionReview.${operationFrame.reason}`) + : operationFrame?.kind === "pending" && operationFrame.executionState ? t(operationFrame.executionState === "consumed_outcome_pending" ? "proposal.impact.operationConsumed" : operationFrame.executionState === "managed_turn_pending" ? "proposal.impact.operationManagedPending" : "proposal.impact.operationAuthorized") : operationFrame?.kind === "result" && operationFrame.resultKind === "unknown" - ? t("proposal.impact.operationUnknown") : t("proposal.impact.operation") + ? t("proposal.impact.operationUnknown") + : operationFrame?.kind === "result" + ? t(operationFrame.resultKind === "cancelled" ? "proposal.impact.operationCancelled" : "proposal.impact.operationResult") + : operationFrame?.kind === "confirmation" && !operationFrame.confirmationDeliveryVerified + ? t("proposal.impact.operationDeliveryPending") : t("proposal.impact.operation") : proposal.action_kind === "team.plan" ? proposal.status === "applied" ? t("proposal.teamPlan.assignedHint") : t("proposal.impact.teamPlan") : proposal.action_kind === "goal.create" @@ -692,15 +710,18 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) } : undefined, workspaceCandidates, primaryLabel: reviewPlan.retryOriginal ? t("drawer.retryOriginal") : proposal.action_kind === "operation.execute" - ? operationFrame?.kind === "pending" && operationFrame.executionState + ? operationFrame?.kind === "inactive" ? t(`actionReview.${operationFrame.reason}`) + : operationFrame?.kind === "pending" && operationFrame.executionState ? t(`proposal.operationState.${operationFrame.executionState}`) : operationFrame?.kind === "result" && operationFrame.resultKind === "unknown" ? t("proposal.operationState.submission_unknown") : operationFrame?.kind === "result" - ? operationFrame.resultDeliveryVerified + ? operationFrame.resultKind === "cancelled" + ? t("proposal.primary.operationCancelled") : operationFrame.resultDeliveryVerified ? t("proposal.primary.operationResultVerified") : t("proposal.primary.operationResultPending") - : t("proposal.primary.operationGroup") + : operationFrame?.kind === "confirmation" && operationFrame.confirmationDeliveryVerified + ? t("proposal.primary.operationGroup") : t("proposal.primary.operationDeliveryPending") : proposal.action_kind === "team.plan" ? t(proposal.status === "applied" ? "proposal.teamPlan.viewResult" : "proposal.primary.teamPlan") : proposal.action_kind === "goal.create" ? t("proposal.primary.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" @@ -826,6 +847,7 @@ export function PersonalWorkspacePage({ const [historyRefreshRevision, setHistoryRefreshRevision] = useState(0); const [sessionProposalIds, setSessionProposalIds] = useState([]); const [managerChannelProposalIds, setManagerChannelProposalIds] = useState([]); + const restoredProposalIdsRef = useRef(new Set()); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [theme, setTheme] = useState(readWorkspaceTheme); const [goalContexts, setGoalContexts] = useState>({}); @@ -842,6 +864,7 @@ export function PersonalWorkspacePage({ const quickCompletingTodoIdsRef = useRef(new Set()); const [digest, setDigest] = useState<{ done: number; failed: number } | null>(null); const selectedGoalId = controlledGoalId === undefined ? localGoalId : controlledGoalId; + const actionReadback = useTypedActionReadback(readOnly, selectedGoalId); const selectedAgentId = controlledAgentId ?? localAgentId; const composerDraftKey = `${selectedGoalId ?? "manager"}:${selectedAgentId}`; const composer = drafts[composerDraftKey] ?? ""; @@ -1085,6 +1108,10 @@ export function PersonalWorkspacePage({ latestMessageTextLength, latestMessage?.pending, latestMessage?.activity?.length]); const drawerSelection = useMemo | null>(() => { if (selection?.kind === "settings") return null; + if (selection?.kind === "proposal") { + const current = proposals[selection.item.previewId]; + return current ? { kind: "proposal", item: current } : selection; + } if (selection?.kind === "attention") return { kind: "attention", item: refreshAttention(selection.item, model.attentionHistory ?? model.userTodos) }; if (selection?.kind === "goal") { const currentGoal = workspaceGoals.find((goal) => goal.goalId === selection.item.goalId); @@ -1095,7 +1122,7 @@ export function PersonalWorkspacePage({ item.kind === "run" && item.run.runId === selection.item.runId ); return currentRun ? { item: currentRun.run, kind: "run" } : selection; - }, [items, selection, workspaceGoals, model.attentionHistory, model.userTodos]); + }, [items, selection, proposals, workspaceGoals, model.attentionHistory, model.userTodos]); useEffect(() => { if (readOnly) { @@ -1159,34 +1186,38 @@ export function PersonalWorkspacePage({ useEffect(() => { if (readOnly) { setProposals({}); + restoredProposalIdsRef.current.clear(); + setManagerChannelProposalIds([]); return; } - let cancelled = false; - void listTypedActions(selectedGoalId ? { goalId: selectedGoalId } : { contextKind: "manager" }) - .then((stored) => { - if (cancelled) return; - const restoreable = stored - .filter((proposal) => ["preview_ready", "gated", "deferred", "applying"].includes(proposal.status) - || compileActionReviewPlan(proposal).retryOriginal === true - || (proposal.action_kind === "team.plan" && proposal.status === "applied") - || (proposal.action_kind === "operation.execute" && proposal.status === "applied")) - .map((proposal) => workspaceProposal(proposal, t)); - const restored = Object.fromEntries(restoreable.map((proposal) => [proposal.previewId, proposal])); - setProposals((current) => ({ ...current, ...restored })); - // The manager conversation shows the cards this channel offered: a team - // plan the steward proposed from here is confirmed here, instead of the - // owner hunting for the Goal whose workspace happens to hold the card. - // A Goal-scoped fetch belongs to that Goal's workspace, not to this - // conversation, so it is left alone. - if (!selectedGoalId) { - setManagerChannelProposalIds(restoreable.map((proposal) => proposal.previewId)); - } - }) - .catch(() => { - // The workspace remains usable when the optional local proposal store is unavailable. - }); - return () => { cancelled = true; }; - }, [readOnly, selectedGoalId, t]); + if (!actionReadback.data) return; + const knownGoals = new Set(workspaceGoals.map(goal => goal.goalId)); + const stored = actionReadback.data.filter(proposal => selectedGoalId || proposal.context.kind === "manager" + || (proposal.action_kind === "operation.execute" && knownGoals.has(String(proposal.normalized_parameters.goal_id)))); + const restoreable = stored + .filter((proposal) => ["preview_ready", "gated", "deferred", "applying"].includes(proposal.status) + || compileActionReviewPlan(proposal).retryOriginal === true + || (proposal.action_kind === "team.plan" && proposal.status === "applied") + // Terminal operations replace cached gated cards, including in an + // already-open drawer. They never authorize local execution. + || proposal.action_kind === "operation.execute") + .map((proposal) => workspaceProposal(proposal, t)); + const restored = Object.fromEntries(restoreable.map((proposal) => [proposal.previewId, proposal])); + const previousIds = restoredProposalIdsRef.current; + restoredProposalIdsRef.current = new Set(Object.keys(restored)); + setProposals((current) => ({...Object.fromEntries(Object.entries(current).filter(([id]) => !previousIds.has(id))), ...restored})); + // The manager conversation shows the cards this channel offered: a team + // plan the steward proposed from here is confirmed here, instead of the + // owner hunting for the Goal whose workspace happens to hold the card. + // A Goal-scoped fetch belongs to that Goal's workspace, not to this + // conversation, so it is left alone. + if (!selectedGoalId) setManagerChannelProposalIds(stored.filter(proposal => proposal.context.kind === "manager" + || proposal.action_kind === "operation.execute") + .map(proposal => proposal.proposal_id)); + }, [readOnly, selectedGoalId, actionReadback.data, actionReadback.dataUpdatedAt, t, workspaceGoals]); + + const homeOperations = Object.values(proposals).filter(proposal => proposal.actionKind === "operation.execute" + && proposal.reviewPlan?.operationFrame?.kind === "confirmation" && proposal.status === "gated"); async function createPreview( request: WorkspaceActionPreviewRequest, @@ -1803,7 +1834,10 @@ export function PersonalWorkspacePage({