fix: close alignment review races and preserve local conversation state

This commit is contained in:
Codex committed 2026-10-21 04:55:11 +09:00
1 parent c7231be2c5
commit 37ddb2ce24
14 files changed
+200 -42

No files matched your search

+16 -12
View File
@@ -1,6 +1,6 @@
# YCode
借助 REA,按 WorkBuddy 的视觉、交互与功能进行 **1:1 复刻**(Windows 优先)。Electron + React;当前优先使用 OMP 内核,保留自建 OpenAI 兼容循环作为回退。
借助 REA,按 WorkBuddy 的视觉、交互与功能进行 **1:1 复刻**(Windows 优先)。Electron + React;当前聊天执行路径使用 Claude Agent SDK;历史 OMP 文档不代表当前入口。
> 所有查看 WorkBuddy 实现的工作统一通过 REA。当前实现包含 mock 与未完成项,尚未达到完整 1:1;现行约定见 `AGENTS.md`,最新对话状态见 [八类对齐记录](docs/对话区域-八类对齐-2026-10-15.md),基础证据见 [统一字体与间距](docs/统一字体与间距-2026-10-15.md)、[图表、图片与文件](docs/对话区域-图表图片文件-2026-10-15.md) 和 [对话渲染源码还原](docs/源码还原-对话渲染-2026-10-15.md)。
@@ -11,7 +11,7 @@
| 桌面壳 | Electron 37(`contextIsolation` + `sandbox`,无 `nodeIntegration`) |
| 前端 | React 19 + Vite 8 + TypeScript 7 + Zustand 5 |
| 构建 | electron-vite 5 |
| 模型接入 | OMP RPC sidecar(优先,独立 profile);自建 `/chat/completions` + SSE 循环(回退) |
| 模型接入 | Claude Agent SDK(`src/main/agent/claude`),通过当前会话执行服务接入 |
| 存储 | MVP 为 JSON(`userData/`),后续替换为 SQLite |
## 目录
@@ -38,10 +38,11 @@ docs/ 方案与验收记录
## 开发
```bash
npm install
npm ci --legacy-peer-deps
npm run dev # 启动桌面窗口(含热更新)
npm run build # 类型检查 + 打包产物到 out/
npm start # 预览构建产物
npm run test:alignment # 十项对齐的离线状态/宿主回归,不启动模型
npm run typecheck # 仅类型检查
npm run test:markdown # 正文、表格、代码块与源文本规则
npm run test:recovered # 还原的解析器、源码处理、缓存、代码组件与数学
@@ -50,33 +51,36 @@ npm run test:resources # 本地文件、图片、下载与实际 IPC
npm run test:font-size # 六档字号、CSS 覆盖、固定区域与动态样式
npm run test:chat # 消息顺序、发送状态与工具输出适配
npm run test:conversation # 专用工具、问答/审批、消息操作、输入与真实文件执行
npm run test:conversation-ipc # 分支、恢复、并行会话、附件和 localhost 转写
npm run test:persistence # 实际 IPC → 会话 JSON 的顺序与终态
npm run test:sessions # 并发读写、原子替换与冷加载
npm run test:omp # OMP 回合终态、取消、错误与监听清理
npm run test:omp-session # OMP 持久会话参数与进程释放(假子进程)
npm run test:omp:bundled # 内置 OMP 资产、摘要与 --version 启动(--packaged 检查打包产物)
```
## Windows 打包
完整发布流程见 [Windows 分发包 SOP](docs/打包流程SOP-2026-10-18.md)。
使用 Windows x64、Node.js 24.x 和 npm。当前源码构建与本次验收边界见 [十项对齐实现与验收](docs/对话输入统一对齐实现与验收-2026-10-21.md)。[旧打包 SOP](docs/打包流程SOP-2026-10-18.md) 中 OMP 与旧版本信息仅作历史参照。
~~~powershell
npm run dist:installer
~~~
该命令会先校验并内置 OMP,再构建主进程/预加载/渲染进程并生成 NSIS 安装程序。当前 Windows x64 测试包未签名。
该命令构建主进程/预加载/渲染器,再生成 NSIS 安装程序到 `release/`。当前配置打包 Windows Claude SDK 可执行文件;不要从 Linux 复制 `node_modules` 到 Windows,也不要省略 optional dependencies。本次只验证 Linux 源码构建,Windows 安装包和实际启动仍需本机验收,未宣称已签名或可直接覆盖生产安装。
## 首次运行
1. OMP 按 `YCODE_OMP_PATH` → 随包 `resources/omp/omp.exe` → 本机 Ygent 安装的顺序探测;随包版本由 `resources/omp/runtime.lock.json` 锁定,`npm run omp:vendor` 负责拉取与校验。使用 `ycode` profile。回退循环的上游地址、模型和 Key 在应用设置中配置;设置尚未同步 OMP profile。
2. 选择**工作目录**;默认模式限制路径、写入需确认,命令执行需要明确开启完全访问。
先在独立数据目录测试,避免使用生产配置:
```powershell
$env:YCODE_USER_DATA_DIR = Join-Path $env:TEMP 'ycode-alignment-review'
npm run dev
```
1. 在应用设置配置真实模型和凭据;不要把凭据放入源码或 Git。
2. 选择测试工作目录,按实际工具请求审阅授权;plan/ask 限制和默认拒绝仍有效。
3. 回到对话区提问,例如「列出工作目录里有哪些文件」
## 安全边界
- 渲染进程没有 Node / fs / 网络能力,只能通过 preload 暴露的受限 API
- API Key 用 Electron `safeStorage`(Windows DPAPI)加密;系统不支持时降级明文并在 UI 明确告警
- 默认权限下工具路径做工作区逃逸检查,写入需审批;完全访问允许更广的路径与命令操作。该策略不等同于原版操作系统沙箱。
- 当前授权由宿主与 Claude SDK 协作处理;完全访问影响工具权限。工作目录不是操作系统安全隔离,勿把未验证的 SDK 规则或旧沙箱说明当成保证。
- 外链一律交由系统浏览器打开
@@ -2,7 +2,7 @@
基线:`771ca540f71b387d11865bc5c07f7d4f4d8b6ecf`。工作分支:`workbuddy/alignment-2026-10-21`。
本次为十项方案的本地代码实现及可执行回归,**不是 WorkBuddy 当前版本完整 1:1 / Windows / 像素验收**。未推送、发布、安装覆盖用户应用,也未使用 Codex 云任务或 CLI。
本次为十项方案的本地代码实现及可执行回归,**不是 WorkBuddy 当前版本完整 1:1 / Windows / 像素验收**。未发布、安装覆盖用户应用,也未使用 Codex 云任务或 CLI。推送仅尝试用户指定 Gitea 的独立审核分支;实际结果以交付回执为准,不修改 master。
## 十项交付矩阵
@@ -14,10 +14,10 @@
| 模式输入栏 | plan/ask Chip、清除回 craft、插槽顺序、权限禁用且关闭浮层;plan 有效显示默认权限,保留原偏好,ask 禁改 | 状态/SSR 回归。Chip SVG 为本地几何,不谎称原版 SVG 已恢复;同版本原版图标、字体和宿主差异待 REA |
| 局部过程分组 | 至少两项连续过程各自成组,后续正文/终态触发局部折叠;尾段独立;保留特殊节点 | 同轨迹、多组、正文锚点及展开顺序回归 |
| 底部运行状态 | 共享 progress 事件、真实准备/思考/工具/等待问答/等待授权/收尾;恢复快照、序号及终态清理;reasoning-only、tips 延迟/轮换/独立悬停与焦点暂停/关闭 | 纯事件与 DOM 行为测试;无云启动/网络恢复/credit 数据源,未伪造这些分支或静默超时;原版文案优先级全状态仍待 REA |
| 提问与已答记录 | 分页、键盘/焦点、补充、多选、跳过、重复提交、失败重试、IME、旧计时器隔离;工具关联投影去重;缺失位置的历史明确标识 | 18 项 jsdom 行为及投影/SSR;真实浏览器布局/焦点待验 |
| 提问与已答记录 | 分页、键盘/焦点、补充、多选、跳过、重复提交、失败重试、IME、旧计时器隔离;工具关联投影去重;缺失位置的历史明确标识 | 24 项 jsdom 行为与历史投影及投影/SSR;真实浏览器布局/焦点待验 |
| 完成折叠 | 默认完成/取消耗时块;外层/局部/工具/思考独立状态;展开原始记录顺序;真实终态时间,不冒用创建时间 | 完成展开/局部切换、消息 footer 回归;历史无终态时间则不伪造 |
| 工具授权及会话允许 | 输入区宽卡、真实工具/命令/目标/原因/范围、编号/快捷键、默认拒绝、失败重试;仅完整可安全重放的 SDK 建议组提供会话允许;按 YCode session 隔离、非永久设置 | 14 项授权卡 DOM + broker/宿主策略/SDK 接口 mock。**实际 SDK 应用、后续范围匹配、跨回合/重建等真实运行未验收**,不能凭 mock 或按钮成功宣称已修好所有 SDK 语义 |
| 归档/删除确认 | 240 宽任务行右侧 Popconfirm、箭头/焦点/外点吞掉/Escape、锚点消失、重复提交/失败重试;其他 Popover 默认兼容,保留全权限模态 | 26 项 jsdom 行为。原归档文案仅有“系统设置-数据管理”目的地记录,现明确说明 YCode 无此入口且可从已归档查看;全文/边缘避让/像素待验 |
| 归档/删除确认 | 240 宽任务行右侧 Popconfirm、箭头/焦点/外点吞掉/Escape、锚点消失、重复提交/失败重试;其他 Popover 默认兼容,保留全权限模态 | 28 项 jsdom 行为。原归档文案仅有“系统设置-数据管理”目的地记录,现明确说明 YCode 无此入口且可从已归档查看;全文/边缘避让/像素待验 |
## 证据范围
@@ -55,10 +55,25 @@ YCODE_TEST_DOM_MODULE=/tmp/ycode-popconfirm-test/node_modules/jsdom npm run test
4. REA 补取当前版本 ModeChip SVG、模式宿主行为、授权生命周期与未覆盖的运行状态证据;未完成前不能标记完整 1:1。
5. 浏览器大历史实际帧耗时/滚动阻塞、浮层边缘避让及屏幕阅读器/平台焦点实测。
## 最终验证记录(2026-10-20 UTC)
## 首轮验证记录(2026-10-20 UTC)
以下 15 条命令退出码均为 0:`test:alignment`(含 58 项 jsdom 交互检查及纯状态/宿主/SDK接口 mock)、`typecheck`、`build`、`test:chat`、`test:conversation`、`test:composer-capabilities`、`test:persistence`、`test:sessions`、`test:assistant-footer`、`test:markdown`、`test:tool-approvals`、`test:references`、`test:recovered`、`test:model-metadata`、`test:font-size`(均使用 `npm run`)。完整命令、耗时、退出码及日志见交付包 `validation-results.json` 与 `logs/`。
`check:tokens` **退出码 1**:另在原始 `771ca54` 只读导出上运行,确认同样 6 个既存问题(recommended-flow、skill-detail-shell、skills-host、capability-picker、composer、prompt-queue);本次新增样式没有新增门禁违规,没有刷新/放宽基线。故不宣称 token 门禁全绿。相关旧测试 fixture 的修正:无模型时代显式提供测试模型;Markdown 引用动作测试提供 sourceId,不放宽生产 guard。
新增授权链路在 IPC 成功前等待 `applyFlagSettings` 的接口确认;失败保留同一待审批交互并显示错误,重试可恢复;取消/切换后不接受滞后结果。会话规则以 session ID + 工作目录隔离,防止相对规则随目录变化扩展。以上仅由宿主/SDK 接口 mock 验证,真正 SDK 匹配与回调内应用行为仍未验收。
## 独立复审与增量修复(2026-10-20 UTC)
在 `c7231be` 上另做状态/首发、运行时/授权、UI/集成三条审查,修复六处实际缺陷,而非只重复旧测试:
1. **待保存模型被默认值覆盖**:旧/失效模型会话选择新模型后立即发送,先前发送路径会排队写回默认模型。现先等待该会话设置收敛再解析回退,修复仍绑定原会话;新增缺失/已删除模型 × 直接发送/队列及等待中切换会话四组回归。
2. **提问跳页竞态**:单选后的 120ms 自动翻页可能在手动“下一页/跳过”后再次翻页。显式导航取消计时器,回调绑定请求代次与原页;新增两组 DOM 回归。
3. **历史问答伪造顺序/重复**:没有记录 `parts` 的旧工具回退序列不再当成真实提问位置。交互或 metadata-only 答案走明确的未知位置兼容区,抑制重复工具条目;四项投影/SSR 回归。
4. **完成展开丢失局部分组**:展开外层完成块仍保留局部摘要控件与分类上下文,而不是扁平化所有过程;继续按记录顺序展示,新增分组与顺序断言。
5. **外部点击误选任务**:指针按下在任务标题子节点、抬起在任务行时 click 可能重新定向到祖先。确认浮层现在同样吞掉这次关闭点击,但后续普通点击不受影响;新增两项 DOM 断言。
6. **拒绝/跳过后仍显示等待**:审批拒绝、问题跳过和正常回答在有效回合内回到真实思考阶段;取消后的滞后回调不发新阶段;宿主 mock 回归覆盖这些路径。
同时校正 README 中过期 OMP 启动说明和不存在的脚本入口。Windows 重编译使用 Node.js 24.x、`npm ci --legacy-peer-deps`、`npm run build`;启动前设置独立 `YCODE_USER_DATA_DIR`,勿用生产数据目录试验。`npm run dist:installer` 需在 Windows x64 环境及对应 optional SDK 依赖下另行验收,本次不以 Linux build 成功代替 Windows 安装包验证。
复审最终 15 条测试/typecheck/build 命令均退出 0;命令、退出码与日志由源码包中的 `review-validation/validation-results.json` 和 `review-validation/logs/` 提供。`test:alignment` 使用独立 jsdom 安装,包含 **66 项 DOM/投影检查**(28 任务确认、24 问答、14 授权),另含状态、分组及宿主/SDK接口 mock;它不是实际 SDK/窗口/像素验收。`check:tokens` 在重新导出的原始 `771ca54` 与复审代码上各运行一次,均为相同六个既存失败,未修改 token baseline。
@@ -37,3 +37,10 @@ console.log('alignment-conversation: local groups, original expanded order, ques
assert.equal(isLoadingTipPaused({hovered:false,focused:true}),true)
assert.equal(isLoadingTipPaused({hovered:true,focused:false}),true)
assert.equal(isLoadingTipPaused({hovered:false,focused:false}),false)
const expandedGrouped=renderToStaticMarkup(<CompletedFold parts={message.parts!} turnFinished turnDurationMs={1000} expandFinishedEvents
renderGroup={(group,children)=><section data-local-group={group.startIndex}>{children}</section>}
renderUnit={(p,i)=><i data-index={i}>{p.type}</i>}/> )
assert.deepEqual([...expandedGrouped.matchAll(/data-index="(\d+)"/g)].map(m=>Number(m[1])),[0,1,2,3,4,5])
assert.deepEqual([...expandedGrouped.matchAll(/data-local-group="(\d+)"/g)].map(m=>Number(m[1])),[1,4],
'opening completion must preserve independently controlled local summaries')
+15 -2
View File
@@ -14,9 +14,10 @@ let calls=0
const setup = (kernel:ClaudeKernel, choose='allow-always') => {
const controller = new AbortController()
const raw=kernel as any
const events:any[]=[]
raw.instance={applyFlagSettings:async()=>{},close:()=>{}}
raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:()=>{},interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}}
return { raw, controller, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) }
raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:(event:any)=>events.push(event),interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}}
return { raw, controller, events, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) }
}
try {
const a=setup(create('a')); const result=await a.invoke()
@@ -25,11 +26,23 @@ try {
assert.equal(sessionGrantRules('a',temp).length,2);assert.equal(sessionGrantRules('b',temp).length,0)
const once=setup(create('once'),'allow-once'); assert.equal((await once.invoke()).updatedPermissions,undefined);assert.equal(sessionGrantRules('once',temp).length,0)
const denied=setup(create('denied'),'reject-once');assert.equal((await denied.invoke()).behavior,'deny')
assert.deepEqual(denied.events.map(event=>event.progress.phase),['waiting-approval','thinking'],'rejected approval retires waiting status before next model event')
for(const mode of ['ask','plan'] as const) {const old=calls;assert.equal((await setup(create(mode,mode,'full-access')).invoke()).behavior,'deny');assert.equal(calls,old)}
const full=setup(create('full','craft','full-access'));const old=calls;assert.equal((await full.invoke()).behavior,'allow');assert.equal(calls,old)
await full.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash',ruleContent:'echo safe:*'}});assert.equal(calls,old+1)
const failed=setup(create('failed'));failed.raw.instance.applyFlagSettings=async()=>{throw new Error('apply failed')};assert.equal((await failed.invoke()).behavior,'deny');assert.equal(sessionGrantRules('failed',temp).length,0)
const stale=setup(create('stale'));stale.raw.active.request.interactions.approval=async()=>{stale.controller.abort();return {kind:'approval',optionId:'allow-always'}}
assert.equal((await stale.invoke()).behavior,'deny');assert.equal(sessionGrantRules('stale',temp).length,0)
const skipped=setup(create('skipped-question'))
skipped.raw.active.request.interactions.question=async()=>({kind:'question',skipped:true,answer:{questions:[]}})
const askOptions={signal:skipped.controller.signal,toolUseID:'question-tool'}
const questionInput={questions:[{question:'Choose?',options:[{label:'Yes',description:'Answer yes'}],multiSelect:false}]}
assert.equal((await skipped.raw.canUseTool('AskUserQuestion',questionInput,askOptions)).behavior,'deny')
assert.deepEqual(skipped.events.map(event=>event.progress.phase),['waiting-answer','thinking'],'skipped question retires waiting status')
const cancelledQuestion=setup(create('cancelled-question'))
cancelledQuestion.raw.active.request.interactions.question=async()=>{cancelledQuestion.controller.abort();return {kind:'question',skipped:true,answer:{questions:[]}}}
assert.equal((await cancelledQuestion.raw.canUseTool('AskUserQuestion',questionInput,{...askOptions,signal:cancelledQuestion.controller.signal})).behavior,'deny')
assert.deepEqual(cancelledQuestion.events.map(event=>event.progress.phase),['waiting-answer'],'cancelled request cannot emit stale thinking phase')
assert.deepEqual(stale.events.map(event=>event.progress.phase),['waiting-approval'],'cancelled approval cannot emit stale tool/thinking phase')
console.log('Claude host approval policy unit checks passed (no SDK process/application/matching verification)')
} finally {clearSessionGrants();await rm(temp,{recursive:true,force:true})}
@@ -10,6 +10,9 @@ const React=require('react') as typeof import('react')
const {act}=React
const {createRoot}=require('react-dom/client') as typeof import('react-dom/client')
const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating')
const {projectQuestionAnswers}=require('../src/renderer/src/workbuddy/conversation/question-projection') as typeof import('../src/renderer/src/workbuddy/conversation/question-projection')
const {renderToStaticMarkup}=require('react-dom/server') as typeof import('react-dom/server')
const MessageBubble=require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
const {CompletedFold,LiveEventDisclosure}=require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
const root=createRoot(document.getElementById('root')!)
const click=(el:Element)=>el.dispatchEvent(new MouseEvent('click',{bubbles:true}))
@@ -54,6 +57,26 @@ async function main(){
await act(async()=>root.render(<QuestionFloating key="replace" config={{...config,toolCallId:'new'}}/>))
await act(async()=>new Promise(r=>setTimeout(r,140)))
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='First','request replacement cancels old delayed advance')
// Manual pagination must retire a previously selected option's delayed navigation.
const three={toolCallId:'three',questions:[{id:'a',question:'Page A',options:['A']},{id:'b',question:'Page B',options:['B']},{id:'c',question:'Page C',options:['C']}]}
await act(async()=>root.render(<QuestionFloating key="manual-page" config={three}/>))
await act(async()=>click(document.querySelector('.question-floating__option')!))
await act(async()=>click(document.querySelectorAll('.question-floating__page-button')[1]))
await act(async()=>new Promise(r=>setTimeout(r,140)))
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','manual next retires delayed advance instead of skipping a page')
await act(async()=>root.render(<QuestionFloating key="manual-skip" config={three}/>))
await act(async()=>click(document.querySelector('.question-floating__option')!))
await act(async()=>click(document.querySelector('.question-floating__skip')!))
await act(async()=>new Promise(r=>setTimeout(r,140)))
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','skip retires delayed advance instead of skipping two pages')
const legacyAnswer={questions:[{id:'legacy-question',question:'Legacy unique question',answers:['yes']}]}
const legacy: import('../src/shared/types').ChatMessage={id:'legacy',role:'assistant',createdAt:1,content:'Final prose',status:'done',tools:[{id:'legacy-tool',name:'AskUserQuestion',args:{},argsRaw:'{}',status:'ok',metadata:{questionAnswer:legacyAnswer}}],interactions:[{id:'legacy-interaction',runId:'old',kind:'question',toolCallId:'legacy-tool',title:'Question',questions:[],status:'answered',answer:legacyAnswer}]}
const projected=projectQuestionAnswers(legacy)
check(projected.positionedAnswers.size===0 && projected.unpositioned.length===1,'fallback tool order is not treated as recorded question position')
check(projected.unpositionedToolIds.has('legacy-tool'),'legacy duplicate tool rendering is suppressed')
const legacyMarkup=renderToStaticMarkup(<MessageBubble message={legacy}/>)
check(legacyMarkup.includes('历史问答(原始位置不可用)') && (legacyMarkup.match(/Legacy unique question/g)||[]).length===1,'legacy answer displayed exactly once with unknown-position label')
check(projectQuestionAnswers({...legacy,interactions:[]}).unpositioned.length===1,'metadata-only legacy answer preserves unknown-position compatibility path')
const parts=[{type:'reasoning',id:'r',text:'thought'},{type:'tool',toolId:'t'},{type:'text',text:'A'},{type:'tool',toolId:'later'}] as const
await act(async()=>root.render(<CompletedFold parts={parts} turnFinished turnDurationMs={1000} renderUnit={(_p,i)=><i data-order={i}/>}/>))
check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','completion initially collapsed')
@@ -141,7 +141,7 @@ async function main() {
// Automatic invalid-model repair is unrelated to a failed permission choice.
useChatStore.setState(state => ({ sessions: state.sessions.map(item => item.id === 'a' ? { ...item, modelId:'deleted' } : item) }))
await assert.rejects(useChatStore.getState().send('automatic model fallback must not clear permission failure'), /cannot save downgrade/)
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, model.id)
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, 'deleted', 'failed settings block automatic repair as well as submission')
await useChatStore.getState().selectAgentMode('plan')
await assert.rejects(useChatStore.getState().send('mode save must not clear permission failure'), /cannot save downgrade/)
await useChatStore.getState().selectAgentMode('craft')
@@ -184,6 +184,35 @@ async function main() {
assert.equal(useChatStore.getState().currentSessionId, null)
assert.equal(useChatStore.getState().draftContext.permissionMode, 'default-sandbox', 'pending creation cannot overwrite newer draft preference')
for (const initialModel of [undefined, 'deleted']) {
for (const queued of [false, true]) {
setup()
const owner = `pending-model-${initialModel}-${queued}`
const chosen = { ...model, id: 'chosen', name: 'Chosen' }
useSettingsStore.setState({ models: [model, chosen], selectedModelId: model.id })
useChatStore.setState({ currentSessionId: owner, sessions: [{ ...session(owner), modelId: initialModel }],
...(queued ? { runs: { existing: { runId: 'existing', assistantId: 'assistant', sessionId: owner } } } : {}) })
const choice = deferred<SessionMeta>()
const writes: Array<{ id: string; modelId?: string }> = []
window.ycode.session.updateContext = async (id, patch) => {
writes.push({ id, modelId: patch.modelId })
if (writes.length === 1) return choice.promise
return { ...session(id), ...patch }
}
if (queued) window.ycode.chat.queue = { enqueue: async (payload: SendPayload) => { sent = payload; return {} } } as any
const choosing = useChatStore.getState().selectModel(chosen.id)
const nextSend = useChatStore.getState().send('explicit pending choice wins')
assert.equal(sent, undefined)
// Switching views while the save is pending must not repair the new owner.
useChatStore.setState({ currentSessionId: 'b' })
choice.resolve({ ...session(owner), modelId: chosen.id })
await choosing; await nextSend
assert.deepEqual(writes, [{ id: owner, modelId: chosen.id }], 'fallback cannot overwrite a pending explicit choice')
assert.equal(sent?.sessionId, owner)
assert.equal(useChatStore.getState().sessions.find(item => item.id === owner)?.modelId, chosen.id)
}
}
assert.equal(renderToStaticMarkup(<ModeChip mode="craft" onClear={() => {}} />), '')
assert.match(renderToStaticMarkup(<ModeChip mode="plan" onClear={() => {}} />), /退出计划模式/)
assert.doesNotMatch(renderToStaticMarkup(<ModeChip mode="ask" disabled onClear={() => {}} />), /<button/)
+12
View File
@@ -67,6 +67,18 @@ async function main() {
await act(async () => click(other))
check(underlyingClicks === 1, 'only the dismissal click is consumed')
await render()
const otherChild = document.createElement('span')
otherChild.textContent = 'nested task title'
other.append(otherChild)
await act(async () => {
otherChild.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }))
// Pointer-up on the row produces a click on the common ancestor, not the title.
click(other)
})
check(underlyingClicks === 1, 'retargeted outside click cannot select underlying task')
check(!document.querySelector('.task-popconfirm'), 'retargeted dismissal still closes')
otherChild.remove()
await render()
anchor.focus()
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })))
check(document.activeElement === anchor, 'Escape restores anchor focus')
+4 -3
View File
@@ -573,15 +573,17 @@ export class ClaudeKernel {
return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' }
}
if (toolName === 'AskUserQuestion') {
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } })
const questions = questionsFromInput(input)
if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' }
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } })
const response = await active.request.interactions.question(
options.toolUseID,
options.title ?? 'Claude 想确认一些信息',
questions,
options.signal
)
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
if (response.kind !== 'question') return { behavior: 'deny', message: '问答响应无效。' }
if (response.skipped) return { behavior: 'deny', message: '用户关闭了问题,未提供回答。' }
const questionText = new Map(questions.map((question) => [question.id, question.question]))
@@ -589,8 +591,6 @@ export class ClaudeKernel {
for (const answer of response.answer.questions) {
answers[questionText.get(answer.id) ?? answer.id] = answer.answers.join(', ')
}
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
return { behavior: 'allow', updatedInput: { ...input, answers } }
}
if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) {
@@ -641,6 +641,7 @@ export class ClaudeKernel {
)
if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
if (response.kind !== 'approval' || response.optionId === 'reject-once') {
active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } })
return { behavior: 'deny', message: '用户拒绝了此操作。' }
}
if (response.optionId === 'allow-always' && !grant) return { behavior: 'deny', message: '会话授权不可用。' }
@@ -77,8 +77,8 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
}
const streaming = message.status === 'streaming'
const parts = getMessageParts(message)
const questionProjection = projectQuestionAnswers(message)
const parts = getMessageParts(message).filter(part => part.type !== 'tool' || !questionProjection.unpositionedToolIds.has(part.toolId))
const tools = questionProjection.tools.map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool)
: tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool)
const activeApprovals = new Map(tools.flatMap(tool => {
+18 -6
View File
@@ -180,8 +180,7 @@ function sortSessions(sessions: SessionMeta[]): SessionMeta[] {
}
export const useChatStore = create<ChatState>((set, get) => {
const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel' | 'permissionMode'>): Promise<void> => {
const owner = get().currentSessionId
const saveRequestSettings = async (patch: Pick<SessionContext, 'modelId' | 'thinkingLevel' | 'permissionMode'>, owner = get().currentSessionId): Promise<void> => {
if (!owner) { set(state => ({ draftContext: { ...state.draftContext, ...patch } })); return }
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(owner, patch))
trackRequestSettings(owner, write, Object.keys(patch) as (keyof SessionContext)[])
@@ -190,6 +189,19 @@ export const useChatStore = create<ChatState>((set, get) => {
...contextUsageState(state.sessionUsage, state.sessionContextWindow, owner,
lastContextUsage(state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [])) }))
}
const settleRequestModel = async (owner: string): Promise<void> => {
// A pending explicit choice must win over automatic invalid-model repair.
// Keep the original owner even if the user switches sessions while waiting.
await awaitRequestSettings(owner)
const settings = useSettingsStore.getState()
const context = get().sessions.find(item => item.id === owner)
const model = resolveSelectedModel(settings.models, context?.modelId, settings.selectedModelId)
if (!model) throw new Error('请先配置自定义模型')
if (context?.modelId !== model.id) {
await saveRequestSettings({ modelId: model.id }, owner)
await awaitRequestSettings(owner)
}
}
const streamCommitter = new ChatStreamCommitter((key, events) => {
const first = events[0]
if (!first) return
@@ -504,7 +516,7 @@ export const useChatStore = create<ChatState>((set, get) => {
const context = get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext
const model = resolveSelectedModel(settings.models, context?.modelId, settings.selectedModelId)
if (!model) throw new Error('请先配置自定义模型')
const modelWrite = context?.modelId !== model.id ? get().selectModel(model.id) : null
const modelWrite = !get().currentSessionId && context?.modelId !== model.id ? get().selectModel(model.id) : null
// Observe rejection immediately; the guarded submission below surfaces it.
void modelWrite?.catch(() => {})
// Read the saved choice at submission time; a React effect is not authoritative.
@@ -537,7 +549,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (sessionId && currentRun(get().runs, sessionId)) {
if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文')
if (modelWrite) await modelWrite
await awaitRequestSettings(sessionId)
await settleRequestModel(sessionId)
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
return
}
@@ -546,7 +558,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (queue && (queue.items.length || queue.editingDraft && (options.command || queue.editingDraft.id !== options.queueEditId))) {
if (options.action || options.command) throw new Error('请先处理待发送队列')
if (modelWrite) await modelWrite
await awaitRequestSettings(sessionId)
await settleRequestModel(sessionId)
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
return
}
@@ -556,7 +568,7 @@ export const useChatStore = create<ChatState>((set, get) => {
...(state.currentSessionId === owner ? { sending: true, error: null } : {}) }))
try {
if (modelWrite) await modelWrite
await awaitRequestSettings(owner)
await settleRequestModel(owner)
const result = await window.ycode.chat.send({ ...options, permissionMode: get().sessions.find(item => item.id === owner)?.permissionMode ?? savedPermission, sessionId: owner, text: trimmed })
const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant',
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
+3 -1
View File
@@ -79,7 +79,9 @@ function PopoverLayer({ anchor, close, id, exiting, placement, portalRoot, class
// Keep this one-shot listener alive through the closing/unmounting of the layer.
const outsideTarget = event.target
const swallow = (click: MouseEvent): void => {
if (click.target === outsideTarget) { click.preventDefault(); click.stopImmediatePropagation() }
if (click.target instanceof Node && (click.target === outsideTarget || click.target.contains(outsideTarget))) {
click.preventDefault(); click.stopImmediatePropagation()
}
cleanup()
}
const cleanup = (): void => { document.removeEventListener('click', swallow, true); clearTimeout(timer) }
@@ -185,11 +185,22 @@ export function CompletedFold({
if (completedExpanded && model.segments.some(segment => segment.kind === 'completed')) {
return <CollapseSection id={`completed-${foldKey}`} variant="completed" title={model.title}
controlledExpanded onExpandedChange={setCompletedExpanded}>
{parts.map((part, index) => {
{(() => {
// Keep local summary controls independent of the outer completion control.
// Suppressing only the outer fold preserves chronological presentation and
// classification context (including linked widgets) without flattening groups.
const expandedModel = buildCompletedFoldModel(parts, {
turnFinished, turnDurationMs, turnCancelled, suppressCompletedFold: true,
labels, classifyToolCall
})
return expandedModel ? expandedModel.segments.map(segment =>
segment.kind === 'unit' ? renderPresentationUnit(segment.unit, render, renderGroup) : null
) : parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
return <Fragment key={`${part.type}:${index}`}>{render(part, index, { kind: 'part', classification })}</Fragment>
})}
})
})()}
</CollapseSection>
}
@@ -186,8 +186,18 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
rootRef.current?.querySelector<HTMLElement>('[role="button"][tabindex="0"], input:not(:disabled)')?.focus()
}, [index, questionKey])
const cancelAdvance = () => {
if (advanceTimer.current) clearTimeout(advanceTimer.current)
advanceTimer.current = undefined
}
const navigate = (delta: number) => {
cancelAdvance()
setIndex(value => Math.min(Math.max(value + delta, 0), questions.length - 1))
}
const commit = (action: () => void | Promise<void>) => {
if (submitting.current || responding) return
cancelAdvance()
submitting.current = true
setSubmitted(true)
setLocalError(null)
@@ -251,8 +261,15 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
if (isLast) {
submit(nextSelected, nextCustom)
} else {
if (advanceTimer.current) clearTimeout(advanceTimer.current)
advanceTimer.current = setTimeout(() => setIndex(value => Math.min(value + 1, questions.length - 1)), 120)
cancelAdvance()
const owner = generation.current
const page = currentIndex
advanceTimer.current = setTimeout(() => {
advanceTimer.current = undefined
if (owner === generation.current && !submitting.current) {
setIndex(value => value === page ? Math.min(page + 1, questions.length - 1) : value)
}
}, 120)
}
}
@@ -260,12 +277,12 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
if (responding || submitted) return
if (current.multiSelect) {
if (isLast) submit()
else if (multiSelectedCount > 0) setIndex(value => Math.min(value + 1, questions.length - 1))
else if (multiSelectedCount > 0) navigate(1)
return
}
if (!singleHasAnswer) return
if (isLast) submit()
else setIndex(value => Math.min(value + 1, questions.length - 1))
else navigate(1)
}
const skipCurrent = () => {
@@ -286,7 +303,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
}
commit(() => onSelect?.(answerText(payload, labels), payload))
} else {
setIndex(value => Math.min(value + 1, questions.length - 1))
navigate(1)
}
}
@@ -331,7 +348,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
className="question-floating__page-button"
aria-label={labels.previousQuestion}
disabled={currentIndex === 0 || responding || submitted}
onClick={() => setIndex(value => Math.max(value - 1, 0))}
onClick={() => navigate(-1)}
>
<QuestionChevronLeftIcon />
</button>
@@ -341,7 +358,7 @@ export const QuestionFloating = forwardRef<QuestionFloatingHandle, QuestionFloat
className="question-floating__page-button"
aria-label={labels.nextQuestion}
disabled={isLast || responding || submitted}
onClick={() => setIndex(value => Math.min(value + 1, questions.length - 1))}
onClick={() => navigate(1)}
>
<QuestionChevronRightIcon />
</button>
@@ -1,15 +1,16 @@
import type { ChatMessage, ToolCallRecord } from '@shared/types'
import type { QuestionAnswer } from '@shared/conversation'
import { getMessageParts } from '@shared/message-parts'
/** Use the recorded tool position; never guess a timestamp/order for old unlinked answers. */
export function projectQuestionAnswers(message: ChatMessage): {
tools: ToolCallRecord[]
unpositionedToolIds: Set<string>
positionedAnswers: Map<string, QuestionAnswer>
unpositioned: Array<{ id: string; answer: QuestionAnswer }>
} {
const tools = (message.tools ?? []).map(tool => ({ ...tool }))
const positions = new Set(getMessageParts(message).flatMap(part => part.type === 'tool' ? [part.toolId] : []))
const positions = new Set((message.parts ?? []).flatMap(part => part.type === 'tool' ? [part.toolId] : []))
const unpositionedToolIds = new Set<string>()
const positionedAnswers = new Map<string, QuestionAnswer>()
const unpositioned: Array<{ id: string; answer: QuestionAnswer }> = []
const seen = new Set<string>()
@@ -24,7 +25,18 @@ export function projectQuestionAnswers(message: ChatMessage): {
if (tool) tool.metadata = { ...tool.metadata, questionAnswer: interaction.answer, questionSkipped: interaction.skipped ?? false }
} else {
unpositioned.unshift({ id: interaction.id, answer: interaction.answer })
if (tool) unpositionedToolIds.add(tool.id)
}
}
return { tools, positionedAnswers, unpositioned }
// Metadata-only legacy answers also have no chronological position without parts.
for (const tool of tools) {
if (positions.has(tool.id) || seen.has(tool.id)) continue
const answer = tool.metadata?.questionAnswer as QuestionAnswer | undefined
if (!answer || !Array.isArray(answer.questions) || !answer.questions.every(question =>
question != null && typeof question === 'object' && typeof question.id === 'string' && typeof question.question === 'string' &&
Array.isArray(question.answers) && question.answers.every(value => typeof value === 'string'))) continue
unpositioned.push({ id: `tool:${tool.id}`, answer })
unpositionedToolIds.add(tool.id)
}
return { tools, positionedAnswers, unpositioned, unpositionedToolIds }
}