Archive note(2026-06-05,document-system-governance):本计划已移出
active/,归入deferred/——用户明确暂缓,未来可能重启。不是当前任务入口;当前工作入口见 exec-plans README,重启方式见 deferred/README.md。
⏸ 本轮重构暂缓(见 refactor-closeout.md "暂缓清单")— Round 1+2 已完成;Round 3(PermissionPrompt 视觉收编)不主动推进,等收口完成后再评估。
上一阶段(Context chips Phase 1)见 ../completed/context-chips-phase-1.md 设计原则见 insights/chat-composer-redesign.md 的 "Chat 页设计原则" 节 Round 1 实现见 handover/chat-run-checkpoint.md + insights/chat-run-checkpoint.md
用户决定暂缓 Round 3,PermissionPrompt 保持现状不动。
暂缓原因:
- 当前用户习惯:除删除文件等明显危险操作外,大多数权限请求都直接同意——视觉收编对当前主流程没有实际收益
- 没有用户反馈说权限确认信息不够清晰或不够安全
- PermissionPrompt 属于执行中权限链路,改动风险高,容易造成 agent pending、确认失效或 allow_session 行为回归
- Round 1 / Round 2 已经解决了更高频、更影响理解的"发送前状态"问题(pinned-invalid / runtime-fallback / context-cost / permission-elevation),这是更高 ROI 的工作
保持现状的明确边界:
- ❌ 不改
PermissionPrompt代码 - ❌ 不收编 generic permission prompt 视觉
- ❌ 不引入新的"危险工具"判断逻辑
- ❌ 不改变
allow_session/full_access/NEVER_AUTO_APPROVE行为
重新启动条件(命中任一即重新评估 Round 3):
- 用户反馈"看不懂权限请求"或要求"更清楚的工具说明"
- 删除 / 覆盖 / 批量修改类操作造成误操作或用户投诉
- 企业客户要求更明确的审计 / 确认说明
- 我们以后做"工具风险分级"或"多 Agent 协作权限模型"——这两个是结构性引入,会自然带出权限 UI 重设
已写的 Round 3 前置设计保留在文档第 §Round 3 前置设计 节作为参考,未来重启时不必重新调研入口盘点 / 工具语言设计 / 视觉规范——直接以那一节为基线讨论新增/修订即可。
核心改动
src/lib/run-checkpoint.ts:CheckpointReasonId新增'context-cost-change' | 'permission-elevation'CheckpointAction.actionId新增'confirm-context-cost' | 'confirm-permission-elevation'CheckpointReason.requiresConfirm?: boolean标记阻断型 reasonBuildCheckpointsOpts新增pendingContextTokens / usedContextTokens / permissionElevationPendingshouldTriggerContextCost(pending, used)公开纯函数 +CONTEXT_COST_PENDING_HARD = 10_000、CONTEXT_COST_PENDING_RATIO = 0.3常量
src/components/chat/RunCheckpoint.tsx:新增onAction(actionId)prop;当 reason.action.actionId 存在且 onAction 提供时优先调用,否则回退到 href 导航src/components/chat/MessageInput.tsx:- 新增
blockingReasonIds?: ReadonlyArray<string>prop handleSubmit顶部读bypassBlockingRef,未 bypass + 有 blocking reasons 时直接返回;否则消费 bypass 标志继续- 监听
'run-checkpoint-confirm-send'窗口事件:set bypass + 程序化点击button[data-message-input-submit](locale-agnostic data attribute;找不到/disabled 时清回 bypass,避免标志泄漏)
- 新增
src/app/chat/page.tsx+src/components/chat/ChatView.tsx:- 新增
permissionElevationConfirmedFor: 'full_access' | nullstate + reset effect(profile 离开 full_access → 清回 null) - chat/page.tsx 用
usedContextTokens = 0(新会话还没 assistant turn) - ChatView 用
useContextUsage(messages, currentModel).used跟 RunCockpit 同源 buildCheckpoints多传四个新参数;blockingReasonIds = checkpointReasons.filter(r => r.requiresConfirm).map(r => r.id)派给 MessageInputhandleCheckpointAction(actionId):permission-elevation 时 setState;两个 confirm 都 dispatch 事件- 传
onAction={handleCheckpointAction}给 RunCheckpoint
- 新增
src/i18n/{en,zh}.ts:新增 6 个 key(runCheckpoint.contextCost.{title,description,action}+runCheckpoint.permissionElevation.{title,description,action})
测试
unit/run-checkpoint.test.ts:从 10 用例扩展到 23(新增 13)unit/run-checkpoint-blocking.test.ts:9 新用例覆盖 bypass 状态机 + permission session 重置 + context-cost 自然清空npm run test:1354 / 1354 通过
- 2026-04-30 Round 2 设计:Banner 不持久化"已确认"(除了 permission-elevation 的本-session 标志)。context-cost 通过 pending→0 的自然 state 转换让 banner 自己消失;不需要 sessionStorage 或 confirmedFlag。permission-elevation 必须持久化是因为它的触发条件(profile === full_access)即使用户发送也不会变。
- 2026-04-30 Round 2 设计:Bypass 走 ref + 程序化 click,不走 prop。直接给 MessageInput 一个
bypassprop 会让父组件难以保证"只 bypass 一次",而 ref 是 MessageInput 内部状态,自然 single-shot 消费。Click 走原 PromptInput 提交链路,避免重新实现一遍 file/mentions/attachments 序列化。 - 2026-04-30 Round 2 设计:两个入口同样接入。chat/page.tsx 与 ChatView.tsx 各自维护自己的
permissionElevationConfirmedFor——这是 session 级 state,不应通过全局 store 共享。usedContextTokens 也分别计算(新会话恒为 0;已有会话从useContextUsage拿)。 - 2026-04-30 Round 2 决定:MessageInput 内部用
document.querySelector('button[data-message-input-submit]')(locale-agnostic data attribute,由MessageInputParts.tsx的FileAwareSubmitButton设置)。Codex P2 review 期间发现原来用button[aria-label="Submit"]在 zh locale 下匹配不到(aria-label 已 i18n 成"发送消息");改成 data attribute 后跟 e2erun-checkpoint-confirm.spec.ts一起锁定契约。如果将来同页有多个 composer(split-chat 已经是这样但我们今天不支持),需要换成 ref-forwarded approach。
src/lib/run-checkpoint.ts—CheckpointReasonunion +buildCheckpoints()推导(pure,可单测)src/components/chat/RunCheckpoint.tsx— inline banner 组件,tone: error/warning/info×bg-status-*-mutedtoken,与 RateLimitBanner / TerminalReasonChip 共享视觉语言(注意:PermissionPrompt 不在此列——它仍是 border-t + bg-background 独立面板,留给 Round 3 收编)- 接入
src/app/chat/page.tsx:替换原<ErrorBanner>forinvalidDefault,统一进 RunCheckpoint - 接入
src/components/chat/ChatView.tsx:新增同样的 RunCheckpoint,挂在 RateLimitBanner 后、MessageInput 前 - i18n 9 条键(
runCheckpoint.{noProvider,pinnedInvalid,runtimeFallback}.{title,description,action})en + zh 同步 - 单测
src/__tests__/unit/run-checkpoint.test.ts10 用例:precedence、stacking、Round 1 reason id 锁定、单一 action 强约束 - CDP 验证:三类 banner 同时渲染时视觉一致(截图 docs/exec-plans/screenshots/run-checkpoint-round1-banner-preview.png)
- P2.1 — ChatView 不再订阅
overview.defaultInvalid:已建会话只关心自己保存的(provider, model)还能不能跑(用noCompatibleProvider)+ 全局 runtime fallback。全局 pinned-invalid 留给/chat新会话入口 / Overview / Runtime / Health。修复了"看起来阻断但实际没阻断"的语义错位。 - P2.2 — 抽
composeSubmitPayload()接管handleSubmit的 payload 组装;src/__tests__/unit/context-chips-send-clear.test.ts新增 5 条用例覆盖完整 submit 周期;MessageInput 三个 submit 分支(normal / image-agent / badge)全部走 composeSubmitPayload。 - P2.2 round 2 —
src/__tests__/e2e/context-chips-send-clear.spec.ts加real form submit clears chips + posts inode/directory in body @smoke:mock/api/chat/sessions+/api/chat、dispatch 目录 chip、textarea 填字、按 Enter 真实提交,断言(a)POST body 含[Referenced Directories]+inode/directory文件项;(b)chip 从 DOM 消失(验证setDirectoryRefs([])真的跑了);(c)textarea 清空(验证setInputValue('')真的跑了);(d)user bubble 渲染纯文本,不含[Referenced Directories]。playwright.config.ts 加PLAYWRIGHT_BASE_URLenv 支持 worktree 端口。
按用户 2026-04-29 反馈,本计划的 5 类触发不一次性接入。先做最小闭环,证明"同一个等待确认形态"成立,再按风险扩展。
目标:建立统一 trust layer + 验证 inline banner 形态
- ✅ 抽象
RunCheckpoint组件(src/components/chat/RunCheckpoint.tsx) - ✅ 接入 Pinned default 不可执行(chat/page.tsx 替代原 ErrorBanner;ChatView 新增)
- ✅ 接入 Runtime 降级 / 无兼容模型(runtimeFallback 本地推导 +
state.noCompatibleProvider) ⚠️ 视觉统一部分完成:RateLimitBanner/TerminalReasonChip/ 新RunCheckpoint已对齐到bg-status-*-muted同族 token;PermissionPrompt仍是 border-t + bg-background 的独立面板形态——它跟工具执行链耦合,留给 Round 3 危险工具调用收编时一并处理(不在 Round 1 范围)- ✅ 单测覆盖(10 用例):覆盖 precedence、stacking、Round 1 scope guard
不在 Round 1 内:上下文成本变化、权限提升首次发送、危险工具调用拦截。
目标:上下文成本提醒 + 权限提升首次发送提醒,复用 Round 1 组件,引入"阻断 + 确认并发送" 流
- ✅ 接入 上下文成本明显变化(
shouldTriggerContextCost:pending ≥ 10K 或 used > 0 且 pending/used ≥ 30%) - ✅ 接入 权限提升首次发送(permission profile 是
full_access且本会话未确认过 → 触发;用户确认后本会话不再提示;切回 default 自动重置) - ✅ 两类 checkpoint 均设置
requiresConfirm: true,MessageInput.handleSubmit 在blockingReasonIds.length > 0且未 bypass 时静默 early-return - ✅ Banner 的"确认并发送"action:page-level handler 设置确认状态 → dispatch
run-checkpoint-confirm-send窗口事件 → MessageInput 监听后bypassBlockingRef.current = true并程序化点击button[aria-label="Submit"],让 PromptInput 完整 submit pipeline(text + 附件 + mentions)走原通道 - ✅ 两个入口(
chat/page.tsx新会话、ChatView.tsx已有会话)一致接入 - ✅ 新增 13 个 i18n key(
runCheckpoint.{contextCost,permissionElevation}.{title,description,action})en + zh 同步 - ✅ Round 2 单测:
unit/run-checkpoint.test.ts+13 用例:shouldTriggerContextCost阈值(hard cap / 30% 比例 / 边界)、context-cost reason 形状(tone=info、requiresConfirm、descriptionValues 格式化)、permission-elevation reason 形状、Round 1+2 stacking、no-provider precedence 仍生效unit/run-checkpoint-blocking.test.ts9 用例:阻断/bypass 状态机模型(user submit 被阻、confirm-and-send 即使 blockingIds 还在也通过、bypass 一次后自动清、permission 确认状态在 toggle 离开 full_access 时重置、context-cost 在 pending→0 后自动清)
- ✅ Round 1 scope guard 替换为 Round 1+2 scope guard,覆盖全部 5 个 reason id
- ⏸ Codex 已用内置浏览器人工验证;CDP 自动化验证留待 Round 3 一并补
状态:用户 2026-04-30 决定暂缓——见文档顶部 "Round 3 暂缓决定"。下面这份前置设计保留为未来重启时的基线参考,不要按此立即开工。
前提:Round 2 稳定 ✅(用户已确认 Round 2 收住)
把 chat 页所有"等用户确认 / 让用户感知"的入口分成两类:发送前 vs 执行中。Round 3 只动执行中的"工具调用确认"那一条,其它入口边界保持不动。
| 入口 | 何时出现 | 触发 | 视觉 / 状态 | Round 3 是否动 |
|---|---|---|---|---|
| RunCheckpoint banner | 发送前 | 配置异常 / 上下文超阈值 / 权限提升首次发送 | inline banner 在 composer 上方;bg-status-*-muted;requiresConfirm 阻断 send |
❌ 不动 |
| RateLimitBanner | 执行中(SDK 抛 rate-limit 事件后) | streamSnapshot.rateLimitInfo.status !== 'allowed' |
bg-status-error-muted / bg-status-warning-muted 同族 banner |
❌ 不动(已视觉对齐) |
| TerminalReasonChip | 执行后(end-of-turn) | streamSnapshot.terminalReason ∈ prompt_too_long 等 |
同族 token chip + 操作按钮 | ❌ 不动(已视觉对齐) |
| PermissionPrompt — 工具调用分支(generic toolName ≠ AskUserQuestion / ExitPlanMode) | 执行中,SDK 在 stream 里发 permission_request 事件 |
pendingPermission.toolName 非 AskUserQuestion / ExitPlanMode |
border-t border-border bg-background max-h-[50vh] 独立面板;展示 toolName + decisionReason + ToolInputDisplay(折叠 JSON)+ Deny / Allow Once / Allow For Session 三按钮 |
✅ 本期收编 |
| PermissionPrompt — ExitPlanMode 分支 | 执行中 | toolName === 'ExitPlanMode' |
"Plan complete — ready to execute" 大卡片 + View Plan / Approve / Reject + free-form feedback 输入;交互重 | ❌ 不动(独立产品语义,跟"危险确认"不是一回事) |
| PermissionPrompt — AskUserQuestion 分支 | 执行中 | toolName === 'AskUserQuestion' |
多问题选择表单;本质是用户访谈,不是权限确认 | ❌ 不动 |
| MessageInput.disabled gate | 发送前 | noCompatibleProvider 等 |
隐式 disable 按钮 | ❌ 不动(Round 1 已说明) |
关键观察:用户口中的"危险工具调用"对应到代码里只有 PermissionPrompt 的 generic 分支(line 475-524 pendingPermission.toolName !== 'AskUserQuestion' && !== 'ExitPlanMode')。其它两个分支看起来在同一个组件里,但功能完全不同——一个是 Plan 模式收尾的产品步骤,一个是结构化用户问答;都不该被混到"危险确认"的视觉收编里。
只做:generic 分支的视觉收编 + 信息结构改写,不动 state machine。
具体收编动作:
- 把外层 chrome 从
border-t border-border bg-background改成跟RunCheckpoint/RateLimitBanner/TerminalReasonChip同族bg-status-*-muted+rounded-lg border卡片 - 把内容结构改成"操作 + 影响 + 路径 + 决策按钮"四段式(详见下面 §2)
- 把按钮文案 / 图标 / 间距 / icon 跟 RunCheckpoint banner 对齐
- 权限响应相关 props 不变(
pendingPermission,permissionResolved,onPermissionResponse,toolUses,permissionProfile全保留) - 新增
workingDirectory: stringprop(项目路径行的稳定来源,由 ChatView/chat page 显式传入;详见 §2 项目路径行说明) - 保留所有现有行为:
auto-approveinfull_access、NEVER_AUTO_APPROVEset、resolved 状态自动清除、三个决策值 (allow / allow_session / deny + denyMessage)
不做(明确边界):
- ❌ 不改
respondToPermission后端契约 - ❌ 不改
permission_request事件格式 - ❌ 不改
full_access自动批准的判定逻辑(NEVER_AUTO_APPROVE 集合 / autoApprovedRef 去重) - ❌ 不改
allow_session的 suggestions 应用规则 - ❌ 不改
respondToPermission调用方式 + stream-session-manager - ❌ 不动 ExitPlanMode 分支(独立产品概念,不在"危险确认"语义内)
- ❌ 不动 AskUserQuestion 分支(本质是用户访谈而非权限确认)
- ❌ 不引入新的"危险度评分"或"工具白名单/黑名单"——危险与否由 SDK 的
permission_request事件决定,前端只负责 surface 信息 - ❌ 不做"用户记住的危险偏好"持久化(已经有
allow_session覆盖此场景) - ❌ 不做发送前的危险预判 banner(那需要 LLM 输出解析,超出 Round 3 视觉收编范围)
触发条件不变:banner 出现的时机仍然是"SDK 在 stream 里发 permission_request 事件",跟现在一样。Round 3 不引入新的客户端预判触发。
禁止使用的措辞:"危险工具"、"危险操作"、"高风险命令"、"敏感操作"。这类词把客户端往"风险评估"角色推,但客户端没有评估能力——评估在 SDK 端。
统一标题:"需要你确认这次操作"(en: "Confirm this operation")。中性、事实性,不预判风险等级。
信息结构(四段式):
┌─────────────────────────────────────────────┐
│ [icon] 需要你确认这次操作 │
│ │
│ 操作: 运行命令 `rm -rf node_modules` │ ← per-tool 渲染
│ 影响: 会删除当前目录下 node_modules │ ← decisionReason / blockedPath
│ 项目: ~/Documents/my-project │ ← workingDirectory basename + parent
│ │
│ [拒绝] [仅允许这次] [本会话允许] │
└─────────────────────────────────────────────┘
按 toolName 渲染"操作"行:
| toolName | 操作行 | 影响行 |
|---|---|---|
Bash |
运行命令 \{command}`` |
cwd: {cwd or 'project root'} 加 decisionReason 如有 |
Edit / Write |
修改文件 {basename(file_path)} |
{file_path 相对项目的相对路径} |
Read (受限) |
读取文件 {basename(file_path)} |
同上 |
WebFetch |
访问 {hostname} |
{decisionReason} |
| 其它 | 调用工具 {toolName} |
`{decisionReason |
每行都"点名"——不是抽象概念。pendingPermission.blockedPath、description、decisionReason 都用上。
项目路径行:永远显示,从新增 prop workingDirectory 取,渲染成 ~/.../{lastTwoSegments} 风格(防绝对路径泄漏过深的目录结构)。注意:当前 PermissionPrompt props 没有 workingDirectory,Step A 必须新增此 prop 并由两个调用点(ChatView.tsx 从 session.working_directory / chat/page.tsx 从当前 panel context)显式传入。从 toolInput 里临时猜路径不可接受——Bash cwd 可能为空、Edit/Write 的 file_path 可能是绝对路径,都不能稳定推出"项目根"。
按钮文案:
- "拒绝" / Deny
- "仅允许这次" / Allow Once
- "本会话允许" / Allow For Session(仅当
pendingPermission.suggestions非空时显示,跟现状一致)
Step A:纯视觉壳替换(首次提交)
- 拷贝
PermissionPrompt的 generic 分支 JSX 到新的 inner component(暂叫ToolConfirmCard) - 外壳 className 换成
mx-auto w-full max-w-3xl px-4 ...+ 内层rounded-lg border border-status-warning-muted bg-status-warning-muted text-status-warning-foreground p-4 - 标题/描述/按钮区按四段式重排
- 权限响应相关 props 不变(
pendingPermission、permissionResolved、onPermissionResponse、toolUses、permissionProfile全保留,行为零变化) - 新增
workingDirectory: stringprop(必填,由 ChatView/chat page 两个调用点显式传入),项目路径行依赖此值;不可从 toolInput 反推
Step B:信息结构 per-tool 渲染
- 加
formatToolAction(toolName, toolInput)纯函数 - 加
formatToolImpact(pendingPermission, workingDirectory)纯函数 - 加
formatProjectPath(workingDirectory)纯函数 - ToolInputDisplay 的折叠 JSON 降级为"展开看完整参数"次级 reveal(默认收起,避免吓人)
- 单测覆盖
formatToolAction/formatToolImpact/formatProjectPath各 toolName 分支
Step C:i18n 切换
- en/zh 各加
permissionPrompt.toolConfirm.{title, action.{bash,edit,write,read,webfetch,generic}, impact.{withReason, noReason}, projectLabel, allowOnce, allowSession, deny, expandInput}大概 14 条 key - 不删旧 i18n key(避免破坏 ExitPlanMode / AskUserQuestion 分支)
Step D:a11y + e2e
- 新外壳是
role="alert"+aria-live="assertive"(inline card,不是 modal;不抢焦点、不强制弹窗语义)。为什么不用alertdialog:alertdialog 隐含 modal 对话框语义,要求焦点转移 + aria-modal + 焦点陷阱;本期不引入任何这些行为,所以 alert + aria-live 才是诚实的语义匹配。 - 三个按钮加明确 aria-label(含 toolName,比如
aria-label="拒绝运行命令 rm") - 标题 + 操作行使用
<strong>或aria-describedby链接,确保读屏播报顺序合理 - e2e fixture A(无 suggestions):mock
permission_request事件且suggestions: []→ 断言 banner 渲染 + toolName 出现在标题 + 拒绝/仅允许这次两按钮可点击 + 本会话允许按钮不渲染 + 点击 deny 触发onPermissionResponse({ behavior: 'deny', ... }) - e2e fixture B(有 suggestions):mock
permission_request事件且suggestions: [{...}]→ 断言三按钮全部渲染 + 点击"本会话允许"触发onPermissionResponse({ behavior: 'allow_session', updatedInput: ..., updatedPermissions: [...] })
验收标准:
- generic 分支视觉跟 RunCheckpoint / RateLimitBanner 一族
- ExitPlanMode 分支与 AskUserQuestion 分支视觉不变(本期不动)
- 文案不出现"危险"/"敏感"等评判词
- 文案点名: 操作 + 影响 + 项目路径都可见
permissionProfile === 'full_access'下行为完全不变(auto-approve、NEVER_AUTO_APPROVE 仍生效)npm run test通过;新增formatTool*单测覆盖至少 5 类 toolName- e2e 覆盖两个 fixture:(A)无 suggestions → deny + allow-once 两按钮各触发一次;(B)有 suggestions → 三按钮全渲染 + allow-session 触发一次。不要为了凑齐"三按钮各触发一次"在无 suggestions fixture 里强行注入 suggestion——那会污染契约
最大风险:改动跟工具执行链耦合的组件,万一改坏导致"按钮看着点了但 onPermissionResponse 没真触发",agent 会卡在 pending 状态。
缓解:
- Step A 是视觉壳 + 新增
workingDirectory必填 prop;除此之外权限响应相关 props 与行为零变化。两个调用点(ChatView / chat page)的 prop 接入跟 Step A 同 PR,避免半成品状态 - Step B-D 逐步加纯函数 + i18n + e2e,每一步可独立回滚
- e2e 双 fixture 都必须真实断言对应 callback fire(fixture A 断 deny + allow-once;fixture B 断 allow-session),不能只看 UI 渲染
回滚路径:每个 Step 都是单独 commit;如果 Step C 文案改坏可单独 revert,UI 壳不动;Step A 改坏可整个分支 revert,PermissionPrompt 完全恢复 Round 2 状态。
- 把 PermissionPrompt 的 ExitPlanMode 分支也视觉对齐(独立任务,独立产品语义)
- AskUserQuestion 分支的视觉重设(独立任务,跟"权限确认"无关)
- 客户端解析 LLM 输出做"危险预判 banner"(超出本计划范围;如果做也不该叫 Run Checkpoint)
- 把
allow_session升级成跨 session 持久化(产品决策,超出范围) - 给用户加"全局自动允许"开关(明确禁止——违背设计原则 §B "保护用户的硬性规则不能被关掉")
Round 1 选 Pinned invalid 和 Runtime 降级有三个理由:
- 数据信号已存在(不需要新逻辑),代码风险低
- 用户场景频度高(每次刷新切到不可用模型都会触发),覆盖面广
- 已经有零散的 UI 提示,能直接对比"统一前 vs 统一后"的效果
危险工具调用放最后是因为它触及工具执行链的状态机——一旦做错可能让 Agent 行为不一致(确认通过了但仍被拦/确认失败但仍执行)。等前两轮证明了组件抽象 + state gate 模型稳定,再动这块。
Chat 页现在能解释「这次会用什么 Runtime / Model / 权限 / 上下文」(Run 状态面板 + Context chips),但还没解决"用户发送瞬间到 Agent 实际开始执行之间的信任空隙"。
具体的信任问题:
- 用户加了一堆文件后突然发送 → Agent 默默多花 100K tokens,用户不知道
- 用户切到「完全访问」权限 → Agent 可以无确认动文件,用户没看到提醒
- 用户发了一个会触发外部命令的 prompt → Agent 跑了
rm才反应过来 - 系统 Runtime 自动降级 → 用户以为还在 Claude Code 跑
这些都是该提示但没提示的瞬间。Run Checkpoint 是 inline 出现在输入框和发送之间的轻量信任层,只在以下场景出现,否则完全不打扰。
呼应 insights 里 Chat 页的 5 条原则,本 Phase 增加 3 条具体边界:
正常发送(普通文本 / 已确认过的 chip / 已稳定的运行环境)→ Run Checkpoint 不渲染任何 UI。用户体验跟现在没区别。
不要做"发送前必须点 N 次确认"的 wizard / stepper。Checkpoint 是单点警告 + 单击确认:要么用户当下知情后继续发送,要么用户取消回到输入框。永远不引入"下一步"按钮。
Checkpoint 不做"启用 MCP"、"启用 Agent" 之类常驻开关。只在系统准备调用时才展示——比如 LLM 在响应中决定要调 MCP 工具,那时候 Checkpoint 才出来问"允许调用 X 工具吗"。这跟现在的 PermissionPrompt 是同一个机制,统一进 Checkpoint 框架。
| 触发 | 信号 | UI 形态 |
|---|---|---|
| 上下文成本明显变化 | pending tokens 占当前已用的 ≥30%,或绝对值 ≥10K | inline banner 在输入框上方,"本次会增加 ~XK 上下文,确认发送?" + 取消 + 发送 |
| 权限提升 | 当前 session 是「完全访问」 | 发送前 inline banner(首次或久未送过)"完全访问已开启,Agent 可无确认动文件" + 关闭 + 发送 |
| 危险命令意图识别 | LLM 响应里 tool_use 包含 rm / sudo / 写系统目录 / 网络外发 |
复用 PermissionPrompt 的 in-context 卡片通道(Round 3 收编时再视觉对齐) |
| Runtime 降级 | runtimeFallback === true 第一次发送时 |
inline banner "你设置的 Claude Code SDK 不可用,已降级到 Native,确认这样继续?" + 跳设置 + 发送 |
| Pinned default 不可执行 | state.defaultInvalid === true 但用户绕过了 banner 试图发送 |
阻断发送 + chip 形式提示(已有 PermissionPrompt + chat banner) |
注意(已被 Round 1 实现修订):原计划设想"不新建组件,把零散提示视觉收敛",但实施时发现发送前 inline banner 跟工具执行中的 in-context 确认是两个使用场景,没办法塞进同一个组件。Round 1 实际做法:新建统一组件 RunCheckpoint 承接发送前 checkpoint(pinned-invalid / runtime-fallback / no-provider),并把 RateLimitBanner / TerminalReasonChip 视觉对齐到同一族 bg-status-*-muted token;PermissionPrompt(执行中工具确认)保留原 in-context 卡片形态,留给 Round 3 危险工具调用收编时一并处理。用户依然能从视觉一致性(status token 同族)认出"这是 Agent 在等我确认",只是不强求在同一个 React 组件里。
- ❌ 步骤流 / wizard:每条 prompt 都不应该"过 5 步审批"
- ❌ 常驻 toggle:不加任何"启用风险检查 / 跳过 Checkpoint" 的 settings 项(用户改不动它的核心规则)
- ❌ 历史 / 分析 / 报表:Run Checkpoint 是当下的信任层,不做日志展示(→ 留给 Run Cockpit 实时态升级)
- ❌ 自动审批 / 批量确认:永远不让用户在 modal 里勾"以后不再问"——危险操作的提示就是设计意图
- ❌ 文案/视觉的过度差异化:5 类 Checkpoint 都用同一个 inline banner 形态,靠图标 + 文字色区分严重度
- 新建
src/lib/run-checkpoint.ts:定义CheckpointReasonunion +buildCheckpoints(state)推导 - 新建
src/components/chat/RunCheckpoint.tsx:单一组件,接收 reason[] 数组渲染 inline banners - 把
RateLimitBanner/TerminalReasonChip/ chat-page invalid-default banner 统一进这个组件 OR 让它们共用同一个 reason → banner 映射
- 上下文成本变化:MessageInput 在 onSubmit 前检查
pendingContextTokens / used >= 0.3,若是触发 Checkpoint 阻断 - 权限提升:每个 session 第一次发送时(或权限切换后第一次)触发;用
sessionStorage记"已确认过" - Runtime 降级:复用 RunCockpit 的
runtimeFallback信号 - Pinned invalid:复用现有 banner 但视觉统一进 RunCheckpoint
- 危险工具调用:等 PermissionPrompt 触发时复用同视觉
- 严重度三色对应 design.md
status-pair:warning(黄)/ error(红)/ info(蓝) - 每个 banner 只有两个动作:取消(次级)+ 继续发送 / 修复(主操作)
- 图标统一:⚠ Warning / 🔒 Lock / ⚡ Lightning / 💸 Coins (新增) / 🛑 Stop
- handover/chat-run-checkpoint.md:技术契约(trust layer API、5 类触发的 signal/condition、状态机)
- insights/chat-run-checkpoint.md:为什么是 inline banner 而不是 modal、为什么不做步骤流、跟 Permission v2 的关系
- 新建 memory feedback:trust 不靠 toggle 靠场景
- 5 类触发场景各做一组 fixture 测试:构造对应 state → checkpoint 应该出现 → 取消 → 不发送 → 确认 → 发送
- 反向:正常发送(无任何 checkpoint)应该不出现任何 UI
- Checkpoint 信号是 pull 不是 push:MessageInput 在 onSubmit 前主动调用
buildCheckpoints(state)拿到当前应该出现的 reasons。不要做成"事件驱动"否则会跟 streaming 状态打架 - Checkpoint 是同步阻断:用户没确认前发送链路不进入 doStartStream。这跟现在的 invalid-default 阻断是同一种 gate,应该共用
- 状态分离:Checkpoint 自己不持久化"已确认"——危险操作每次都问,权限/Runtime 之类的"已知悉"放 sessionStorage(关闭 chat 就重置)
Modal 打断流。用户输完正想 Enter 发送,结果一个 modal 弹出来要点确认,再点取消还得回到输入框继续编辑——这是糟糕的中断模式。inline banner 在输入框上方,用户可以继续看着自己的输入做决定,按 Enter 等于"继续发送",按 Esc 等于"取消"。
这是个原则问题:Checkpoint 是对用户的保护,不是"功能"。用户不应该有"关掉它"的入口——否则它就退化成 toggle,违背 Chat 页设计原则 #3(AI 能调度的东西不要常驻按钮化,反过来:保护用户的硬性规则不要被用户关掉)。如果将来真的有用户场景需要绕过,应该提升优化触发条件而不是给个"关闭"按钮。
视觉一致 = 用户认知一致。如果 Runtime fallback 长一个样、权限提升长另一个样、上下文成本提升长第三个样——用户每次都要重新理解。
但"视觉一致"不等于"同一个 React 组件"。Round 1 落地时把发送前 inline banner 抽成 RunCheckpoint(pinned-invalid / runtime-fallback / no-provider),并让 RateLimitBanner / TerminalReasonChip 对齐到同一族 bg-status-*-muted token;执行中的工具确认(PermissionPrompt)保留 in-context 卡片形态,留给 Round 3 危险工具调用一起收编。这样:
- 同一族 status token 让用户一眼认出"这是信任层在等我确认一件事"
- 不同的 React 组件分别承接发送前(banner 不打断输入流)和执行中(卡片嵌在消息流里跟工具调用同位)两种使用场景
- 上一阶段:../completed/context-chips-phase-1.md(Phase 完成后归档)
- 设计原则:../../insights/chat-composer-redesign.md 的 "Chat 页设计原则" 节
- Trust 层组件现状(Round 1 已落地):
src/components/chat/RunCheckpoint.tsx— Round 1 新建的发送前 inline banner(pinned-invalid / runtime-fallback / no-provider)src/components/chat/RateLimitBanner.tsx— 视觉已对齐bg-status-*-mutedtoken,独立组件保留(订阅 SDK rate-limit 事件,跟 RunCheckpoint 不同生命周期)src/components/chat/TerminalReasonChip.tsx— 视觉已对齐,独立组件保留(end-of-turn 状态,依赖 streamSnapshot.terminalReason)src/components/chat/PermissionPrompt.tsx— 未对齐,仍是 border-t + bg-background 独立面板;执行中工具确认场景,留给 Round 3 危险工具调用收编
- 相关 memory:
feedback_pinned_default_hard_promise.md(Pinned invalid 必须阻断)、feedback_no_silent_auto_irreversible.md(无 silent auto 操作)