Skip to content

Commit 7121ea6

Browse files
feat(app): decide User gates in the drawer with one confirmation
The request drawer offers Approve / Reject (Withdraw in the menu) for typed User gates, previews one decision, confirms once, and reads back the recorded outcome and its effect on waiting work. Removes the CLI copy hint and the proposal-level reject/defer controls for decisions; run operator gates and non-gate requests stay explanation-only. Also repairs browser smokes that drifted on main: the attention fixture now carries updated_at for the request-content join, and the operation fixture uses a relative expiry, a complete Lark delivery receipt, and the visible-timeline placement of awaiting operations. Signed-off-by: huangruiteng <huangrt01@163.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ffeed67 commit 7121ea6

11 files changed

Lines changed: 158 additions & 77 deletions

File tree

‎apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,9 @@ export function canReviewAttention(item: WorkspaceAttention): boolean {
7878
// not an authorization grant; only known inactive or missing rows are fenced.
7979
return !["closed", "deferred", "superseded", "unavailable"].includes(item.details?.lifecycle ?? "unknown");
8080
}
81+
82+
/** Approve/reject/withdraw exist only for a User gate Todo; the owner still validates each preview. */
83+
export function canDecideAttention(item: WorkspaceAttention): boolean {
84+
return canReviewAttention(item) && item.details?.interaction === "decision"
85+
&& item.decisionSource !== "run_operator_gate";
86+
}

‎apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx‎

Lines changed: 28 additions & 34 deletions
Large diffs are not rendered by default.

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,18 @@ const en = {
177177
"attentionDetail.scope": "Declared decision scope",
178178
"attentionDetail.replacement": "Replacement Todo",
179179
"attentionDetail.openReplacement": "Open replacement",
180-
"attentionDetail.boundary": "Reading this detail does not resolve a gate or grant authority. Available decisions require a fresh preview.",
181-
"drawer.decisionDefaultEvidence": "No additional public-safe evidence is attached. The next step will still show a Preview first.",
180+
"attentionDetail.boundary": "Reading records no decision and grants nothing. Every decision asks you to confirm once before it is saved.",
181+
"drawer.decisionDefaultEvidence": "No evidence is attached to this request.",
182182
"drawer.decisionDefaultReason": "This decision affects the next step of the current Todo.",
183-
"drawer.decisionDefer": "Decide later",
184183
"drawer.decisionMore": "More decisions",
185184
"drawer.decisionReject": "Reject",
186-
"drawer.decisionReview": "Review impact and decide",
185+
"drawer.decisionApprove": "Approve",
186+
"drawer.decisionCancel": "Withdraw this request",
187+
"drawer.decisionGroup": "Your decision",
188+
"drawer.decisionNotGate": "This request asks for a reply or an action, not an approval. Ask the responsible Agent in the conversation.",
189+
"drawer.decisionRunGate": "This run is paused for an operator decision outside a Todo. Ask the responsible Agent in the conversation.",
190+
"drawer.gateDecisionRecheckTitle": "This preview is out of date",
191+
"drawer.gateDecisionRecheck": "It was prepared before decisions could be recorded here. Recheck it, then decide directly in this panel.",
187192
"drawer.dependencies": "Dependencies",
188193
"drawer.detailsAndActions": "Details & actions",
189194
"drawer.duration": "Duration 24h / 7d",
@@ -192,8 +197,6 @@ const en = {
192197
"drawer.executionRecord": "Run record",
193198
"drawer.executionRecordAndResult": "Execution & result",
194199
"drawer.explainDecision": "Explain this decision",
195-
"drawer.gateApproveHint": "Run one command in the terminal to complete approval:",
196-
"drawer.gateRejectHint": "Replace approve with reject at the end to decline. This notice disappears after the command runs.",
197200
"drawer.gateRequiresHost": "Host confirmation required",
198201
"drawer.gateRequiresHostDescription": "This page cannot approve this protected permission change. Nothing was written by your click.",
199202
"drawer.goalAutoRun": "Automatic runs for this Goal",
@@ -735,6 +738,23 @@ const en = {
735738
"proposal.impact.operationConsumed": "The authorization has been consumed. Wait for original external evidence; a retry or lost response must not grant another submission.",
736739
"proposal.impact.operationUnknown": "Submission may have had an external effect. Reconcile the original operation using its evidence; do not resubmit or treat card delivery as execution completion.",
737740
"proposal.primary.apply": "Confirm and apply",
741+
"proposal.decision.approve": "Approve",
742+
"proposal.decision.reject": "Reject",
743+
"proposal.decision.cancel": "Withdraw",
744+
"proposal.impact.gate.approve": "LoopX records your approval. Work waiting on this request continues unless other confirmations are still open.",
745+
"proposal.impact.gate.reject": "LoopX records your rejection. Work waiting on this request stays paused, and the responsible Agent sees the decision.",
746+
"proposal.impact.gate.cancel": "The request closes without approving or rejecting it. Work waiting on it stays paused.",
747+
"proposal.primary.gate.approve": "Confirm approval",
748+
"proposal.primary.gate.reject": "Confirm rejection",
749+
"proposal.primary.gate.cancel": "Confirm withdrawal",
750+
"proposal.applied.gate.approve": "Approved.",
751+
"proposal.applied.gate.reject": "Rejected.",
752+
"proposal.applied.gate.cancel": "Withdrawn.",
753+
"proposal.dependentEffect.resumed": "The work waiting on it has resumed.",
754+
"proposal.dependentEffect.still_waiting": "The work is still waiting on other confirmations.",
755+
"proposal.dependentEffect.paused": "The work waiting on it stays paused.",
756+
"proposal.dependentEffect.no_waiting_work": "No paused work was waiting on this request.",
757+
"proposal.dependentEffect.unknown": "The decision is recorded; refresh to see its effect on the waiting work.",
738758
"proposal.primary.goalCreate": "Create Goal and start first run",
739759
"proposal.primary.lifecycleDelete": "Delete Goal",
740760
"proposal.primary.lifecycleResume": "Resume Goal",
@@ -1389,13 +1409,18 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
13891409
"attentionDetail.scope": "声明的决策范围",
13901410
"attentionDetail.replacement": "替代 Todo",
13911411
"attentionDetail.openReplacement": "打开替代事项",
1392-
"attentionDetail.boundary": "阅读详情不会关闭 gate 或授予权限;作出决定前仍需新的操作预览。",
1393-
"drawer.decisionDefaultEvidence": "当前状态没有附加公开安全证据;下一步仍会先展示 Preview。",
1412+
"attentionDetail.boundary": "阅读不会做出决定,也不授予任何权限;每个决定保存前都会再请你确认一次。",
1413+
"drawer.decisionDefaultEvidence": "这项请求没有附带证据。",
13941414
"drawer.decisionDefaultReason": "该决定会影响当前 Todo 的下一步执行。",
1395-
"drawer.decisionDefer": "稍后决定",
13961415
"drawer.decisionMore": "更多决定",
13971416
"drawer.decisionReject": "拒绝",
1398-
"drawer.decisionReview": "查看影响并决定",
1417+
"drawer.decisionApprove": "批准",
1418+
"drawer.decisionCancel": "撤回这项请求",
1419+
"drawer.decisionGroup": "你的决定",
1420+
"drawer.decisionNotGate": "这项事项需要你回复或处理,而不是批准。可以在对话里问负责的 Agent。",
1421+
"drawer.decisionRunGate": "这次运行在等一个不属于任何 Todo 的操作员决定。可以在对话里问负责的 Agent。",
1422+
"drawer.gateDecisionRecheckTitle": "这份预览已过时",
1423+
"drawer.gateDecisionRecheck": "它生成于还不能在这里记录决定的时候。重新检查后,就能直接在这个面板里决定。",
13991424
"drawer.dependencies": "依赖",
14001425
"drawer.detailsAndActions": "详情与操作",
14011426
"drawer.duration": "运行时长 24h / 7d",
@@ -1404,8 +1429,6 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
14041429
"drawer.executionRecord": "运行记录",
14051430
"drawer.executionRecordAndResult": "执行过程与结果",
14061431
"drawer.explainDecision": "解释此决定",
1407-
"drawer.gateApproveHint": "在终端执行一条命令即可完成审批:",
1408-
"drawer.gateRejectHint": "不同意就把末尾的 approve 换成 reject。执行后这条提醒会自动消失。",
14091432
"drawer.gateRequiresHost": "需要宿主确认",
14101433
"drawer.gateRequiresHostDescription": "页面无权直接批准这类权限变更,你的点击没有写入任何内容。",
14111434
"drawer.goalAutoRun": "当前 Goal 的自动运行",
@@ -1947,6 +1970,23 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
19471970
"proposal.impact.operationConsumed": "授权已消费。等待原始外部证据;重试或响应丢失均不得重新授予提交许可。",
19481971
"proposal.impact.operationUnknown": "提交可能已产生外部副作用。须以原始证据核对原操作,不可重提,也不能把卡片投递当作执行完成。",
19491972
"proposal.primary.apply": "确认并应用",
1973+
"proposal.decision.approve": "批准",
1974+
"proposal.decision.reject": "拒绝",
1975+
"proposal.decision.cancel": "撤回",
1976+
"proposal.impact.gate.approve": "LoopX 会记录你的批准。等待这项请求的工作,如果没有其他待确认项,会直接继续。",
1977+
"proposal.impact.gate.reject": "LoopX 会记录你的拒绝。等待这项请求的工作保持暂停,负责的 Agent 会看到这个决定。",
1978+
"proposal.impact.gate.cancel": "这项请求会关闭,不算批准也不算拒绝。等待它的工作保持暂停。",
1979+
"proposal.primary.gate.approve": "确认批准",
1980+
"proposal.primary.gate.reject": "确认拒绝",
1981+
"proposal.primary.gate.cancel": "确认撤回",
1982+
"proposal.applied.gate.approve": "已批准。",
1983+
"proposal.applied.gate.reject": "已拒绝。",
1984+
"proposal.applied.gate.cancel": "已撤回。",
1985+
"proposal.dependentEffect.resumed": "等待它的工作已恢复。",
1986+
"proposal.dependentEffect.still_waiting": "这项工作还在等其他确认。",
1987+
"proposal.dependentEffect.paused": "等待它的工作保持暂停。",
1988+
"proposal.dependentEffect.no_waiting_work": "没有暂停中的工作在等这项请求。",
1989+
"proposal.dependentEffect.unknown": "决定已记录;刷新后可查看它对等待中工作的影响。",
19501990
"proposal.primary.goalCreate": "创建 Goal 并开始首轮",
19511991
"proposal.primary.lifecycleDelete": "删除 Goal",
19521992
"proposal.primary.lifecycleResume": "恢复 Goal",

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ assert.match(drawer, /actionKind: "todo\.create"/, "Todo successor uses the cano
5252
for (const field of ["evidence", "explanation"]) {
5353
assert.match(model, new RegExp(`${field}\\??:`), `Decision exposes ${field}`);
5454
}
55-
for (const decision of ["reject", "defer"]) {
56-
assert.match(drawer, new RegExp(`resolution:\\s*"${decision}"`), `Decision previews ${decision}`);
55+
for (const decision of ["approve", "reject", "cancel"]) {
56+
assert.match(drawer, new RegExp(`previewDecision\\(selection\\.item, "${decision}"`), `Decision ${decision} uses a typed preview`);
5757
}
58-
assert.match(drawer, /previewDecision\(selection\.item, "approve"/, "Decision approval uses a typed preview");
58+
assert.doesNotMatch(drawer, /previewDecision\([^)]*"defer"/, "Deferring records no decision, so the drawer does not offer it as one");
59+
assert.match(drawer, /canDecideAttention\(attention\)/, "Only a typed User gate can be decided from the drawer");
5960

6061
for (const callback of ["onRetryResumeRun", "onStartNewRunSession", "onCloseRunSession"]) {
6162
assert.match(model, new RegExp(`${callback}\\??:`), `Run exposes ${callback}`);
@@ -189,7 +190,7 @@ assert.match(tasks, /disabled=\{quickCompletingTodoIds\?\.has\(todo\.todoId\)\}/
189190
assert.match(page, /callbacks\.onGoalActivationStateChange\?\.\(lifecycleChange\.goalId, lifecycleChange\.next\)/, "Goal lifecycle apply projects the requested state before the server responds");
190191
assert.match(page, /model\.goals\.find\(\(goal\) => goal\.goalId === proposal\.goalId\)\?\.activationState/, "Goal lifecycle rollback captures the rendered state instead of assuming the operation inverse");
191192
assert.match(page, /callbacks\.onGoalActivationStateChange\?\.\(lifecycleChange\.goalId, lifecycleChange\.previous\)/, "Rejected Goal lifecycle apply rolls back the optimistic projection");
192-
assert.match(page, /if \(applied\.actionKind === "goal\.lifecycle"\) \{\s*void reconcileStatus\(applied\.goalId \? \[applied\.goalId\] : undefined\)/, "Successful Goal lifecycle apply reconciles the affected Goal without blocking the sidebar");
193+
assert.match(page, /if \(applied\.actionKind === "goal\.lifecycle" \|\| applied\.actionKind === "gate\.resolve"\) \{\s*void reconcileStatus\(applied\.goalId \? \[applied\.goalId\] : undefined\)/, "Successful Goal lifecycle or decision apply reconciles the affected Goal without blocking the sidebar");
193194
assert.match(dashboard, /onReconcileStatus=\{\(options\) => loadFromUrl\([\s\S]*\{ background: true, invalidateGoalIds: options\?\.invalidateGoalIds, readScope: "missing" \}/, "Lifecycle reconciliation uses the non-fatal background status path");
194195
assert.match(dashboard, /statusRequestCanCommit\(statusRequestFenceRef\.current, request\)/, "A stale background response cannot overwrite a newer optimistic transition");
195196
assert.match(sidebar, /Trash2/, "Stopped Goals expose a delete icon");
@@ -286,7 +287,7 @@ assert.doesNotMatch(header, /切换到野兽主题|切换到默认主题/, "Work
286287
assert.match(workspaceTheme, /workspaceThemeStorageKey = "loopx-pw-theme"/, "Theme preference persists across reloads");
287288
assert.doesNotMatch(dashboard, /isManagerProjectionQuestion/, "Ordinary manager questions do not silently bypass the selected model by matching phrases");
288289
assert.match(dashboard, /if \(selectedRoute\.agentId === "status-only" \|\| \(!targetGoal && targetContextId !== "manager"\)\)/, "Projection answers require the explicit status-only route or a missing Goal fallback");
289-
assert.match(drawer, /t\("drawer\.decisionReview"\)/, "Blocked items preview their decision boundary before any write");
290+
assert.match(drawer, /role="group" aria-label=\{t\("drawer\.decisionGroup"\)\}/, "Blocked items expose their decisions as one labelled group that previews before any write");
290291
assert.match(drawer, /const hasProjectedRunActivity = selection\.kind === "run"[\s\S]*selection\.item\.completedSteps > 0/, "Session empty-state copy distinguishes projected progress from a truly idle run");
291292
assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started");
292293
assert.match(drawer, /t\("drawer\.runRecordEmpty"\)/, "A truly empty Session still explains why there is no timeline yet");

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export type WorkspaceGoal = {
135135

136136
export type WorkspaceAttention = {
137137
details?: AttentionDetails;
138+
/** A run-level operator gate has no User Todo to record a decision on. */
139+
decisionSource?: "todo" | "run_operator_gate";
138140
sourceId?: string;
139141
blocking: boolean;
140142
evidence?: string | null;

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
637637
? proposal.normalized_parameters.target
638638
: "";
639639
const operationFrame = reviewPlan.operationFrame;
640+
const decision = reviewPlan.decisionFrame?.decision;
640641
const operationTitle = operationFrame?.content.title ?? proposal.summary;
641642
const localizedSummary = proposal.action_kind === "operation.execute"
642643
? operationTitle
@@ -667,6 +668,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
667668
? operationProposalFields(proposal, reviewPlan, t)
668669
: proposal.action_kind === "team.plan"
669670
? teamPlanFields(proposal.normalized_parameters, t)
671+
: decision ? []
670672
: proposalFields(proposal.normalized_parameters, t),
671673
goalId: typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : undefined,
672674
impact: reviewPlan.retryOriginal ? t(`actionReview.${reviewPlan.reason}`) : proposal.action_kind === "operation.execute"
@@ -684,6 +686,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
684686
? t("proposal.impact.operationDeliveryPending") : t("proposal.impact.operation")
685687
: proposal.action_kind === "team.plan"
686688
? proposal.status === "applied" ? t("proposal.teamPlan.assignedHint") : t("proposal.impact.teamPlan")
689+
: decision ? proposal.status === "applied" ? "" : t(`proposal.impact.gate.${decision}`)
687690
: proposal.action_kind === "goal.create"
688691
? t("proposal.impact.goalCreate")
689692
: proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop"
@@ -724,6 +727,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate)
724727
: operationFrame?.kind === "confirmation" && operationFrame.confirmationDeliveryVerified
725728
? t("proposal.primary.operationGroup") : t("proposal.primary.operationDeliveryPending")
726729
: proposal.action_kind === "team.plan" ? t(proposal.status === "applied" ? "proposal.teamPlan.viewResult" : "proposal.primary.teamPlan")
730+
: decision ? t(`proposal.primary.gate.${decision}`)
727731
: proposal.action_kind === "goal.create" ? t("proposal.primary.goalCreate")
728732
: proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop"
729733
? t("proposal.primary.lifecycleStop")
@@ -1489,7 +1493,7 @@ export function PersonalWorkspacePage({
14891493
if (applied.actionKind === "goal.lifecycle" && applied.lifecycleOperation === "delete" && applied.goalId) {
14901494
callbacks.onGoalDeleted?.(applied.goalId);
14911495
}
1492-
if (applied.actionKind === "goal.lifecycle") {
1496+
if (applied.actionKind === "goal.lifecycle" || applied.actionKind === "gate.resolve") {
14931497
void reconcileStatus(applied.goalId ? [applied.goalId] : undefined);
14941498
}
14951499
} catch (error) {

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,12 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
12331233
.personal-danger-action:hover { background: var(--pw-red-bg); }
12341234
.personal-primary-action:disabled, .personal-secondary-action:disabled, .personal-danger-action:disabled { opacity: .46; cursor: default; box-shadow: none; }
12351235
.personal-drawer-action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
1236+
.personal-decision-bar { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 8px; }
1237+
.personal-decision-bar > button { margin: 0; }
1238+
.personal-decision-chip { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--pw-line-strong); font-size: 11.5px; font-weight: 650; }
1239+
.personal-decision-chip.is-approve { border-color: transparent; background: var(--pw-green-bg); color: var(--pw-green); }
1240+
.personal-decision-chip.is-reject { border-color: transparent; background: var(--pw-red-bg); color: var(--pw-red); }
1241+
.personal-proposal-state.is-applied > span { display: grid; gap: 2px; }
12361242
.personal-correction-panel { margin-top: 12px; }
12371243
.personal-correction-panel header { display: flex; justify-content: space-between; align-items: center; }
12381244
.personal-correction-panel header span { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; }
@@ -1813,9 +1819,6 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
18131819
.personal-proposal-state.is-error:has(small) { display: grid; gap: 4px; align-items: start; }
18141820
.personal-proposal-state.is-error small { line-height: 1.5; word-break: break-all; }
18151821

1816-
.personal-gate-cli-hint { display: grid; gap: 6px; margin-top: 10px; }
1817-
.personal-gate-cli-hint code { display: block; padding: 8px 10px; border: 1px solid var(--pw-line); border-radius: 7px; background: var(--pw-bg, #f7f6f2); font-size: 11.5px; line-height: 1.5; user-select: all; word-break: break-all; }
1818-
.personal-gate-cli-hint small { color: var(--pw-muted); line-height: 1.5; }
18191822

18201823
.personal-notification-list { display: grid; gap: 0; margin: 12px 0 0; padding: 0; list-style: none; }
18211824
.personal-notification-row { display: grid; gap: 8px; padding: 12px 0; border-top: 1px solid var(--pw-line); }

‎apps/presentation/dashboard/src/views/dashboard-page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ type PersonalGoalItem = {
492492

493493
type PersonalNeedsYouItem = {
494494
details?: AttentionDetails;
495+
decisionSource?: "todo" | "run_operator_gate";
495496
actionKind?: string | null;
496497
blocking: boolean;
497498
goalId: string;
@@ -1066,6 +1067,7 @@ function buildPersonalHomeModel(
10661067
details: attentionDetails({ task_class: "user_gate", status: "open", note: row.latestRun?.operator_gate?.reason_summary }),
10671068
actionKind: "gate.resolve",
10681069
blocking: true,
1070+
decisionSource: "run_operator_gate",
10691071
goalId: row.goal.id,
10701072
sourceOrder: payload.attention_queue.items.length + rowOrder,
10711073
taskClass: "user_gate",

0 commit comments

Comments
 (0)