From d51ed2830a735ba0e0ab93a592ba0e5afed9b626 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sun, 18 Oct 2026 19:43:12 +0800 Subject: [PATCH] Improve responsive layouts, session switching and provider requests --- docs/daemon-api.md | 8 + docs/desktop-agent-office.md | 1 + .../.openspec.yaml | 2 + .../design.md | 31 ++ .../proposal.md | 25 ++ .../specs/responsive-workbench-layout/spec.md | 65 ++++ .../specs/webui-side-panel/spec.md | 25 ++ .../tasks.md | 40 ++ .../.openspec.yaml | 2 + .../fix-acemodel-prompt-cache-key/design.md | 25 ++ .../fix-acemodel-prompt-cache-key/proposal.md | 26 ++ .../specs/provider-prompt-cache/spec.md | 32 ++ .../fix-acemodel-prompt-cache-key/tasks.md | 19 + src/adapters/provider/openai_prompt_cache.cpp | 23 +- src/adapters/provider/openai_provider.hpp | 3 +- src/adapters/provider/openai_responses.cpp | 129 +++++-- src/adapters/provider/openai_responses.hpp | 5 + .../provider/openai_responses_provider.cpp | 1 + src/apps/desktop/web_host.cpp | 2 +- src/apps/desktop/web_host.hpp | 3 +- src/domain/session/global_session_catalog.cpp | 7 +- src/domain/session/global_session_catalog.hpp | 6 +- src/domain/session/global_session_search.cpp | 112 ++++-- tests/provider/acemodel_prompt_cache_test.cpp | 145 ++++++++ .../openai_responses_provider_test.cpp | 119 ++++++ tests/provider/openai_responses_test.cpp | 196 ++++++++++ .../session/session_recent_activity_test.cpp | 75 ++++ web/scripts/test-composer-model-label.mjs | 122 ++++++ web/scripts/test-desktop-office.mjs | 17 + web/scripts/test-responsive-sidebars.mjs | 348 ++++++++++++++++++ web/src/App.jsx | 70 +++- web/src/components/AttachmentStrip.jsx | 4 +- web/src/components/ChatView.jsx | 99 +++-- .../components/ComposerSessionControls.jsx | 32 +- web/src/components/Message.jsx | 11 +- web/src/components/ResponsiveSidebar.jsx | 92 +++++ web/src/components/RichComposer.jsx | 7 +- web/src/components/TopBar.jsx | 3 + web/src/lib/agentBrowserArchitecture.test.js | 2 +- web/src/lib/api.js | 4 +- web/src/lib/composerMessageRendering.test.js | 5 +- ...omposerSessionControlsArchitecture.test.js | 12 +- web/src/lib/desktopOffice.test.js | 58 ++- web/src/lib/desktopOfficeController.js | 47 ++- web/src/lib/responsiveSidebars.js | 75 ++++ web/src/lib/responsiveSidebars.test.js | 134 +++++++ web/src/lib/runTests.js | 1 + web/src/lib/singleLayout.js | 9 +- web/src/lib/singleLayout.test.js | 11 + web/src/lib/useResponsiveSidebars.js | 89 +++++ web/src/styles/globals.css | 29 +- web/src/styles/responsive-sidebars.css | 57 +++ 52 files changed, 2300 insertions(+), 165 deletions(-) create mode 100644 openspec/changes/adapt-sidebars-to-transcript-width/.openspec.yaml create mode 100644 openspec/changes/adapt-sidebars-to-transcript-width/design.md create mode 100644 openspec/changes/adapt-sidebars-to-transcript-width/proposal.md create mode 100644 openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md create mode 100644 openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md create mode 100644 openspec/changes/adapt-sidebars-to-transcript-width/tasks.md create mode 100644 openspec/changes/fix-acemodel-prompt-cache-key/.openspec.yaml create mode 100644 openspec/changes/fix-acemodel-prompt-cache-key/design.md create mode 100644 openspec/changes/fix-acemodel-prompt-cache-key/proposal.md create mode 100644 openspec/changes/fix-acemodel-prompt-cache-key/specs/provider-prompt-cache/spec.md create mode 100644 openspec/changes/fix-acemodel-prompt-cache-key/tasks.md create mode 100644 tests/provider/acemodel_prompt_cache_test.cpp create mode 100644 web/scripts/test-composer-model-label.mjs create mode 100644 web/scripts/test-responsive-sidebars.mjs create mode 100644 web/src/components/ResponsiveSidebar.jsx create mode 100644 web/src/lib/responsiveSidebars.js create mode 100644 web/src/lib/responsiveSidebars.test.js create mode 100644 web/src/lib/useResponsiveSidebars.js create mode 100644 web/src/styles/responsive-sidebars.css diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 3cfba4900..e76a43936 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -3109,6 +3109,14 @@ SGLang 等推理服务的扩展字段,官方 OpenAI 接口会拒绝);Respo 该 Provider 的默认协议(ACEModel 为 Responses);切换到其他 Provider 会清除该字段,其他 Provider 显式传入 则返回 `UNSUPPORTED_MODEL_OPTION`,未知协议值返回 `INVALID_API_PROTOCOL`。 +Responses 的自动工具选择使用 `tool_choice.type:"allowed_tools"` 和 `mode:"auto"`, +允许列表仅包含本次提供的 ACECode 函数工具;模型仍可直接回答,不会被强制调用工具。 +没有工具的请求显式发送 `tool_choice:"none"`,压缩请求已有的禁用工具策略保持不变。 +兼容网关须支持该标准字段;拒绝时不会退回不受限制的 `auto`。 +网关额外注入的托管生图、搜索等工具不属于 ACECode 本地执行链路;若仍返回未支持的 +输出类型(如 `image_generation_call`),会明确报错,不再丢弃结果后显示文字成功。 +生图应调用已配置的 `image_generate`,才能走本地成本确认、附件保存和展示流程。 + 返回包含 `api_key` 原值的模型配置。校验错误使用 `BAD_JSON`、`BAD_REQUEST` 或 `SavedModelEditError` 字符串;持久化失败会回滚内存并返回 `500 PERSIST_FAILED`。 diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index d68d0fe2d..137b8e8da 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -32,6 +32,7 @@ Windows 与 macOS Desktop 可开启透明桌面宠物,默认关闭。设置 - 顶部显示跨工作区最近发过消息的五个主会话。顺序只看用户最后发送消息的时间,打开、切换和 agent 输出不会把会话顶到最前面。 - “跟随”默认开启,办公室随主窗口当前会话切换。点击门牌会关闭跟随并固定办公室;再次打开跟随后恢复跟随。查看最近五个之外的当前会话不改变门牌排序。 - 切换会话等待新快照时保留当前办公室的布局、角色和标题,新快照就绪后一起切换,避免中途闪现默认布局。快速连续切换只应用最后一次选择;真实空会话或会话不存在时仍显示空态。 +- 最近五个会话从共享索引的只读分片中筛选,只复制返回结果;旧会话缺失用户发言时间时按批次轮转补读,文件签名检查也计入补读预算,避免每次切换遍历磁盘。模型上下文大小在办公室显示后异步补全,不阻塞布局、角色和标题切换;切换与关闭会取消过期补全请求。 - 主 agent 成功完成后留在座位显示 `zzz`,下一轮自动醒来;成功完成的子 agent 立即离座,重新工作时以同样的外观返回。 - 忙碌的 agent 头顶常驻状态气泡:「图标 动作 · 细节」。正在输出时显示「撰写回复」和最新的几个字(打字动画 + 光标),推理时显示思考点和推理尾巴,调用工具时显示动作(读取、运行命令、修改代码…)和调用预览(路径、命令)。默认「用于编程」模式下服务端不发正文进度,桌宠由 token 事件推断。气泡互相避让:先左右错开,再抬高并用引线连回头顶,放不下时只留动作。 - 子 agent 出现时门先打开,人从门槛淡入走进来,先到派活的人桌前领「任务」信封再回工位,坐下后跳一跳、冒星星、说「报到!」;完成后走到派活的人桌前递「回报」信封(派活的人显示「收到…的结果」),说「再见」后快步出门、在门槛淡出;失败或停止的成员直接道别出门。网状协作中 agent 间消息以带「消息 / 新任务 / 回报」标签的信封在工位间飞过。系统开启「减少动态效果」时不走路,交接信封、「报到!」和原地「再见」仍然显示。 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/.openspec.yaml b/openspec/changes/adapt-sidebars-to-transcript-width/.openspec.yaml new file mode 100644 index 000000000..b699b88bf --- /dev/null +++ b/openspec/changes/adapt-sidebars-to-transcript-width/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/design.md b/openspec/changes/adapt-sidebars-to-transcript-width/design.md new file mode 100644 index 000000000..f351317fc --- /dev/null +++ b/openspec/changes/adapt-sidebars-to-transcript-width/design.md @@ -0,0 +1,31 @@ +## Context + +App 持有全局左栏开关、按工作台保存的右栏开关和全局栏宽;ChatView 持有预览页签、预览隐藏与子对话状态。现有布局求解器允许 transcript 缩到 350 px。动机见 proposal.md。 + +## Goals / Non-Goals + +- 以 transcript 为优先级最高的常规阅读区域,计算窗口、预览、导航与子对话共同产生的占宽。 +- 不迁移偏好存储作用域,不改后端,不修改用户已有的手动最大化/演示模式语义。 +- 自动隐藏保留组件、页签和草稿;不把临时显示状态写进工作台或全局偏好。 + +## Decisions + +1. 按用户调低折叠阈值的要求,所有字号的阅读下限统一为 350 CSS px。恢复门槛比折叠门槛高 64 px;使用壳体实际宽度、保存栏宽和预览意图预测展开后的 transcript 宽度,避免用收起后变宽的 transcript 直接触发反向展开。 +2. 独立纯函数决定左右自动折叠与可停靠能力;React hook 只负责测量、临时状态与外部点击。左栏优先保留,右侧预览和列表作为一组让出空间。子对话打开时,父 transcript 的预算额外考虑子对话宽度。 +3. 只由正常布局下的手动开关修改现有偏好。popup 互斥、切工作台清空,窄窗口刷新重新按偏好计算。宽度恢复后,原先开启的栏停靠,原先关闭的栏保持关闭。 +4. 左右 popup 保留原组件实例,不用改变 portal 目标的方式搬运内容。通过 React 事件边界识别栏内 portal 菜单,防止操作栏内菜单被误认作外部点击。Escape 尊重内层菜单/对话框的处理。 +5. 窄右侧 popup 同时有详情与列表时,以详情为主,列表可临时覆盖详情供选择;不能因两个最小宽度相加而把浮层撑出窗口。临时列表开关也不修改持久化偏好。 +6. 现有显式预览最大化/演示模式继续生效;自动折叠只保护常规对话布局,不能把用户主动最大化误当成挤压。 +7. Desktop 的原生最小窗口宽度由旧的 1170 降为 480 逻辑像素,低于左栏自动折叠门槛。Windows 继续按 DPI 缩放并受工作区上限约束;macOS 拖动和程序化设置宽度共用此下限。初始窗口大小保持现有设置。原生下限与 transcript 的阅读下限分开,用户主动缩到更窄时由前端使用全部可用空间。 +8. 浮层也提供靠 transcript 一侧的拖宽手柄:左浮层拖右边,右浮层拖左边。使用实际渲染宽度作为拖动起点,范围由当前容器和 24 px 外部点击区域计算。浮层手动宽度保存在同一个全局栏宽偏好中的可选 `sidebarPopup` / `rightPanelPopup` 字段,不改正常占位宽度和开关;拖窄不会因此退出浮动或压缩 transcript。默认仍使用现有栏宽,窄视口临时夹取不能覆盖保存值。Pointer capture 保持跨边界拖动,取消、失焦、关闭或卸载均清理监听与拖动光标;键盘方向键沿用原手柄步长。 + +## Risks / Trade-offs + +- 收起后测量反馈环 → 根据未受临时隐藏影响的意图计算,并保留恢复余量。 +- popup 关闭导致编辑草稿丢失 → 自动隐藏保留挂载;对既有草稿存储做回归验证。 +- 老 WebView 不支持新 CSS → 使用普通绝对定位、现有颜色变量与 rgba 阴影。 +- Desktop 运行包可能仍使用旧前端 → 本次验证源码、构建和 Chromium;不把这些证据宣称为已安装 Desktop 已更新。 + +## Migration Plan + +无需存储迁移;已有偏好即为恢复依据。撤回前端变更即可回滚,临时状态不会污染保存数据。 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md b/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md new file mode 100644 index 000000000..a29edb847 --- /dev/null +++ b/openspec/changes/adapt-sidebars-to-transcript-width/proposal.md @@ -0,0 +1,25 @@ +## Why + +窗口缩小时,左右边栏与预览会挤压 transcript,导致最重要的对话内容不可读。需要优先为 transcript 让出空间,并在窄窗口里保留通过 popup 使用边栏的能力。 + +## What Changes + +- 以 transcript 的可用宽度为依据,先自动收起整个右侧工作区,再收起左栏。 +- 所有字号统一保留 350 CSS px 阅读宽度,恢复时增加 64 px 余量,避免临界抖动。 +- 窄窗口 expand 打开对应侧 popup,栏内正常操作,点击外部、再次点击按钮或 Escape 收起;两侧 popup 互斥。 +- 自动折叠与 popup 不修改持久化开关和栏宽;仅恢复用户原先开启的栏。 +- 保留预览页签、编辑草稿和子对话状态;窗口极窄时优先让 transcript 使用可用空间。 + +## Capabilities + +### New Capabilities +- `responsive-workbench-layout`: transcript 优先的空间分配、自动折叠、popup 与偏好恢复。 + +### Modified Capabilities +- `webui-side-panel`: 单会话侧面板不再要求所有窗口宽度下固定占位,支持响应式隐藏与浮层。 + +## Impact + +- Web/Desktop 共用的 App、ChatView、TopBar、布局求解器及独立响应式样式。 +- 复用现有全局左栏偏好、工作台右栏偏好与全局栏宽,不改变存储作用域或后端协议。 +- 增加纯逻辑与 Chromium 真实组件交互验证;保留当前工作区其他未提交修改。 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md b/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md new file mode 100644 index 000000000..f23916d43 --- /dev/null +++ b/openspec/changes/adapt-sidebars-to-transcript-width/specs/responsive-workbench-layout/spec.md @@ -0,0 +1,65 @@ +## Purpose + +保证 ACECode 单会话工作台在窗口缩放时优先显示可阅读的 transcript,同时保留左右辅助栏的临时访问能力、用户保存的开关选择和栏宽,避免窗口缩放意外改变用户长期布局。 + +## ADDED Requirements + +### Requirement: Transcript-first responsive collapse +系统 SHALL 根据 transcript 可用宽度决定辅助栏是否占位。所有字号阅读下限 SHALL 统一为 350 CSS px;恢复展开 SHALL 额外保留 64 px。右侧导航与预览及打开的子对话占宽 MUST 参与判断。空间不足 SHALL 先收右侧工作区,再收左栏;整个窗口不足阅读下限时 transcript SHALL 使用可用空间而不是撑出视口。显式最大化预览和演示模式 SHALL 保留原行为。 + +#### Scenario: 连续缩小再拉大 +- **WHEN** 用户从足够宽的窗口逐渐缩小并重新拉宽 +- **THEN** 右栏先收起、左栏后收起,恢复按左栏优先且 transcript 仍可读的条件进行,临界宽度的小幅抖动不反复切换 + +#### Scenario: 打开预览造成拥挤 +- **WHEN** 窗口宽度不变但预览或子对话占用更多空间 +- **THEN** 系统重新判断 transcript 的空间并按相同顺序释放辅助栏占宽 + +#### Scenario: 统一 350 px 折叠边界 +- **WHEN** 不同字号下,正常占位的辅助栏使 transcript 可用宽度逐渐缩至 350 CSS px,再继续变窄 +- **THEN** 恰好 350 CSS px 时保持占位,低于该值时按右栏优先的顺序折叠;已折叠栏需满足额外 64 px 余量才能恢复 + +#### Scenario: 原生窗口允许拖动到单列布局 +- **WHEN** 用户拖动 Desktop 窗口边缘缩小宽度 +- **THEN** 原生窗口 SHALL 允许缩到 480 逻辑像素,能够触发左右栏自动折叠及单列 transcript,不能以旧多栏布局的最小宽度阻止缩小 + +### Requirement: Preserve persistent layout intent +系统 SHALL 独立保存用户左右栏开关及栏宽;自动折叠、浮层开关和恢复 SHALL NOT 修改这些偏好。宽度恢复 SHALL 仅恢复之前开启的栏。刷新、重启和切工作台 SHALL 使用既有持久化作用域中的偏好,不能继承其他工作台的临时 popup。 + +#### Scenario: 原本关闭的栏临时查看 +- **WHEN** 原本关闭的栏在窄窗口中通过 popup 打开,随后窗口拉宽或刷新 +- **THEN** 该栏仍然关闭,已保存开关与栏宽不变 + +#### Scenario: 原本开启的栏自动恢复 +- **WHEN** 原本开启的栏因空间不足自动收起,随后恢复门槛满足 +- **THEN** 该栏恢复正常占位,预览页签与草稿仍然保留 + +### Requirement: Temporary sidebar popup +在空间不足以正常展开时,expand SHALL 打开对应侧 popup,不改变 transcript 的布局宽度。popup SHALL 限于可用视口,左右互斥;栏内操作和其菜单 SHALL 保持可用;点击栏外、再次点击对应按钮或 Escape SHALL 收起。内层菜单或对话框处理 Escape 时 SHALL 优先关闭内层。 + +#### Scenario: 点击内部与外部 +- **WHEN** 用户在窄窗口打开一个边栏并点击其中的菜单、滚动或选择内容 +- **THEN** 栏正常响应且不会因栏内点击被收起;点击栏外时 popup 收起,持久化偏好不变 + +#### Scenario: 切换两侧与键盘关闭 +- **WHEN** 一个 popup 打开时用户打开另一侧或按 Escape +- **THEN** 打开另一侧时关闭当前侧;Escape 关闭当前 popup 并将键盘焦点还给相应展开按钮 + +#### Scenario: 窄右栏中的详情和文件列表 +- **WHEN** 右侧 popup 不能并排容纳详情和导航 +- **THEN** 用户仍可在浮层内查看详情和打开导航选择文件,浮层不超出窗口 + +### Requirement: Resizable sidebar popups +左右 popup SHALL 支持拖动靠 transcript 一侧的边缘调整宽度,并支持手柄上的键盘方向键。调整 SHALL 保持浮动,不改变 transcript 几何和正常占位栏宽或开关;手动浮层宽度 SHALL 独立持久化。浮层 SHALL 保留可点击的外部区域并遵守当前视口边界;窗口临时变窄 SHALL NOT 覆盖保存的浮层宽度。拖动取消、关闭、切会话或失焦后 SHALL 清理拖动状态。 + +#### Scenario: 拖宽详情浮层 +- **WHEN** 用户向左拖动右侧详情浮层的左边缘 +- **THEN** 浮层变宽,右边缘保持贴边,transcript 宽度和草稿不变;向右拖动则变窄,过程中不会自动收起或恢复占位 + +#### Scenario: 保存浮层宽度并适配视口 +- **WHEN** 用户调整左栏或右栏浮层宽度后关闭、重开或刷新,再缩小和恢复窗口 +- **THEN** 浮层恢复用户选择的宽度,空间不足时仅临时夹取到视口范围;正常栏宽与开关不变 + +#### Scenario: 跨边界拖动和取消 +- **WHEN** 拖动指针移出浮层,随后松开、取消或关闭浮层 +- **THEN** 拖动持续到终止事件,终止后不残留拖动光标、监听或意外的宽度更新 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md b/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md new file mode 100644 index 000000000..092da987f --- /dev/null +++ b/openspec/changes/adapt-sidebars-to-transcript-width/specs/webui-side-panel/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Side panel layout in single-session view + +Web UI 单会话视图 (`view === 'single'`) SHALL 提供右侧导航面板及详情预览,默认导航宽度 280 CSS px,允许使用用户保存的栏宽。是否正常占位 SHALL 服从 transcript 优先的响应式布局与用户保存的开关;窄窗口展开 SHALL 使用 popup。Grid4 / Grid9 视图下 SHALL NOT 显示该面板。 + +#### Scenario: 单会话视图渲染右侧面板 +- **WHEN** 用户进入单会话视图且已开启右侧面板、空间充足 +- **THEN** ChatView 右侧按保存宽度显示导航和已打开的详情 + +#### Scenario: 切到 4 宫格隐藏面板 +- **WHEN** 用户从单会话视图切到 4 宫格视图 +- **THEN** SidePanel 从单会话布局移除,grid 视图主区按既定布局填满 + +#### Scenario: tab 切换零延迟 +- **WHEN** 用户点击变更导航 +- **THEN** 面板立即展示已加载消息聚合的变更数据 + +#### Scenario: 切 session 时面板状态保留 +- **WHEN** 用户选择同一工作区内另一个 session +- **THEN** 既有工作台状态隔离规则继续保留各自文件树缓存、展开目录和页签,不因响应式隐藏而清空 + +#### Scenario: cwd 真变时整面板 reset +- **WHEN** 用户切换到另一个 cwd +- **THEN** 系统按对应工作台加载导航和预览,不能显示其他 cwd 的残留 diff --git a/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md b/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md new file mode 100644 index 000000000..4f61bd8b6 --- /dev/null +++ b/openspec/changes/adapt-sidebars-to-transcript-width/tasks.md @@ -0,0 +1,40 @@ +## 1. 响应式决策 + +- [x] 1.1 实现 transcript 优先的纯布局决策,单测覆盖先右后左、恢复余量、手动关闭、预览和子对话占宽。 +- [x] 1.2 接入 App 与 ChatView 的实际宽度和显示意图,验证 resize 不写入持久化开关与栏宽。 + +## 2. Popup 交互 + +- [x] 2.1 实现左右互斥 popup、外部点击、按钮切换、Escape 与焦点恢复,浏览器验证栏内 portal 菜单不会误关闭。 +- [x] 2.2 处理窄右侧详情与导航、页签草稿保留、极窄布局,浏览器验证实际 transcript 几何和刷新后的偏好恢复。 + +## 3. 集成验证 + +- [x] 3.1 执行完整 pnpm test、pnpm build、严格 OpenSpec 校验和 diff 检查,记录实际结果。 +- [x] 3.2 Chromium 覆盖 1920×1080、较窄及极窄窗口、深浅主题和开关组合,保存截图并人工检查;注明 Desktop 包是否重建。 + +## 4. 原生窗口下限补齐 + +- [x] 4.1 将 Desktop 最小宽度降到 480 逻辑像素,使正常拖动能进入单列 transcript;核对 Windows/macOS 限制入口,验证原生下限与前端折叠阈值的跨层约束。 + +## 5. 浮层调宽 + +- [x] 5.1 增加左右浮层拖动与键盘手柄,按视口约束宽度并独立保存;单测验证范围、偏好兼容及不改变停靠判定。 +- [x] 5.2 真实 App 验证详情、仅导航、左栏浮层的跨边界拖动、重新打开/刷新、视口夹取、取消及草稿保留;完成 Web 测试、构建与严格校验。 + +## 6. 降低折叠阈值 + +- [x] 6.1 将所有字号的 transcript 折叠阈值统一降至 350 CSS px,保留 64 px 恢复缓冲;更新边界与真实页面验证,完成 Web 测试、构建及严格校验。 + +## 验证记录 + +- 最终 `pnpm test`、`pnpm build` 均退出 0;构建扫描 4541 个正则,无旧 WebView 不兼容的 lookbehind。`openspec validate adapt-sidebars-to-transcript-width --strict` 与 `git diff --check` 通过。 +- `node src/lib/responsiveSidebars.test.js`:通过。覆盖逐像素缩小、自定义左栏宽度、恢复余量、四种开关组合及预览/子对话占宽。 +- `node scripts/test-responsive-sidebars.mjs`:通过。使用真实 App、TopBar、Sidebar、ChatView 和存储模块,仅隔离 API 与 WebSocket;覆盖外部点击、按钮切换、Escape、portal 菜单、列表自身收起、四组合刷新恢复、编辑器实例与未保存草稿、显式最大化、子对话占宽、切工作台清理 popup。 +- Chromium 截图:1920×1080、1400×1080、1100×1080、800×1080、320×1080;深浅主题,已目视检查。产物目录由脚本输出,文件名包含 `1920-wide.png`、`1400-preview-popup.png`、`light-1920.png`、`dark-800.png` 等。 +- Impeccable 对本次修改的 UI 文件检测返回空问题列表。 +- 未重建或重启已安装 Desktop。Chromium 中通过 Desktop 编辑模式验证共用前端,不代表运行中的 Desktop 已加载更新。 +- 原生下限补验:旧值 1170 在新增跨层用例中无法触发左栏折叠;改为 480 后,左栏宽度 160/270/360 的组合均能进入单列。`responsiveSidebars.test.js`、`singleLayout.test.js`、`framelessResize.test.js` 均通过。Windows 按 DPI 使用共享下限;macOS 拖动和程序化尺寸也使用该值。本轮未编译/启动原生 Desktop,需重建并重新启动后才改变运行中窗口的限制。 +- 详情最大化拼接修正:去掉相邻文件栏圆角规则对最大化状态的排除。真实 App 的深浅主题验证均为圆角 0、相邻顶边与分隔位置一致,恢复普通布局后仍保持直角拼接。完整 `pnpm test`、`pnpm build` 通过;目视截图确认缺角消失,未重建 Desktop。 +- 浮层调宽补验:纯逻辑覆盖浮层范围、视口临时夹取、旧偏好兼容与停靠判断隔离。`test-responsive-sidebars.mjs --popup-resize-only` 和完整脚本均通过,覆盖左右真实指针拖动、方向键、左栏缩到 160 px 仍保持浮动、从被夹取的实际边缘起拖、仅导航内容跟随宽度、详情编辑器实例与草稿、Escape/pointercancel 清理、关闭重开和刷新记忆。完整 `pnpm test` 与 `pnpm build` 退出 0;样式检测无新问题;已检查 `left-popup-resized.png` 与 `detail-popup-resized.png`。未重建已安装 Desktop。 +- 350 px 阈值补验(2026-10-18):纯逻辑验证各字号统一边界;真实 App 在默认栏宽下确认窗口 906/905 px 时右栏保持/折叠、626/625 px 时左栏保持/折叠,保持时 transcript 实测为 350 px;恢复分别在 970/690 px,保留 64 px 缓冲。完整浏览器脚本通过,包含浮层调宽、刷新记忆与草稿保留;`pnpm test`、`pnpm build`、严格 OpenSpec 校验与 diff 检查通过。截图输出目录为 `ace-responsive-sidebars-KoXviV`(系统临时目录)。未重建 Desktop。 diff --git a/openspec/changes/fix-acemodel-prompt-cache-key/.openspec.yaml b/openspec/changes/fix-acemodel-prompt-cache-key/.openspec.yaml new file mode 100644 index 000000000..5c881ba7f --- /dev/null +++ b/openspec/changes/fix-acemodel-prompt-cache-key/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-18 diff --git a/openspec/changes/fix-acemodel-prompt-cache-key/design.md b/openspec/changes/fix-acemodel-prompt-cache-key/design.md new file mode 100644 index 000000000..6856f72f3 --- /dev/null +++ b/openspec/changes/fix-acemodel-prompt-cache-key/design.md @@ -0,0 +1,25 @@ +# Design + +## Context + +见 proposal.md。请求构建器已经按当前 session id 设置 `ChatRequestOptions::prompt_cache_key`,stream collector 也已传递该选项。遗漏发生在两种 OpenAI provider 的端点能力白名单。线上中转镜像对应提交 `9d8a67e1`,无显式身份的 HTTP 请求使用默认 isolated 模式,每请求产生新的上游缓存键。 + +## Goals / Non-Goals + +目标是打通已有会话身份到已验证中转端点的传递,保持其他供应商的默认兼容行为。此次不改服务器隔离模式、不新增全局共享身份、不修改用户模型配置或其他未提交工作。 + +## Decisions + +1. 在 OpenAI provider 的缓存策略实现中增加受保护的 ACEModel 请求端点判断,复用内置 catalog 地址;Chat 与 Responses 分别校验自身后缀,支持 HTTPS 默认 443、完整 URL 查询参数。 +2. 继续使用现有按调用快照传入的缓存键,不增加 provider 级可变会话状态;空键不发送,主/子会话键独立。 +3. 复用现有显式拒绝回退与端点/模型作用域。Chat 的压缩 tool_choice 能力白名单不随缓存能力扩展。 +4. 新增独立回归测试文件,避免干扰用户正在修改的 Responses 测试和序列化文件。验证实际请求 JSON、协议转换、交错会话和拒绝回退。 + +## Risks / Trade-offs + +- 缓存是否命中仍由上游决定 → 用字段与身份稳定性作为确定性验收,用真实请求记录命中表现,不承诺固定命中百分比。 +- Windows 运行中程序可能锁住输出 → 先确认构建环境和占用情况,保留旧产物与现有会话,区分已构建与运行中实例已加载。 + +## Migration Plan + +在当前 checkout 增量构建并运行定向测试,使用修复产物进行隔离的真实 ACEModel 连续请求验证。运行中的旧进程需要在安全的会话边界重启后加载修复;不改变服务器配置。 diff --git a/openspec/changes/fix-acemodel-prompt-cache-key/proposal.md b/openspec/changes/fix-acemodel-prompt-cache-key/proposal.md new file mode 100644 index 000000000..81eff2605 --- /dev/null +++ b/openspec/changes/fix-acemodel-prompt-cache-key/proposal.md @@ -0,0 +1,26 @@ +# Proposal + +## Why + +会话 `20261018_065107_2e37` 的 32 次请求保持完整且稳定的消息前缀,但累计缓存命中率仅 20.43%。ACECode 只向官方域名发送缓存键,ACEModel 中转在缺少显式会话标识时按请求生成新的上游缓存身份,破坏了会话内的缓存连续性。 + +## What Changes + +- 向已验证的 ACEModel Responses 和 Chat Completions 接口传递现有的会话级缓存键。 +- 主会话、子会话分别使用自身的稳定标识,沿用显式参数拒绝后的按端点和模型回退。 +- 保持其他自定义地址的兼容策略以及服务端隔离配置不变。 +- 补充端点边界、会话隔离和协议转换测试,完成 Windows 构建及真实中转验证。 + +## Capabilities + +### New Capabilities + +- `provider-prompt-cache`: 已验证的模型接口使用稳定、按会话隔离的提示词缓存标识。 + +### Modified Capabilities + +无。 + +## Impact + +影响 OpenAI 兼容请求及 Responses 适配器的缓存能力判断,不新增配置或依赖,不改变 daemon API。需要使用包含修复的可执行文件;已有运行进程不会自动加载新代码。 diff --git a/openspec/changes/fix-acemodel-prompt-cache-key/specs/provider-prompt-cache/spec.md b/openspec/changes/fix-acemodel-prompt-cache-key/specs/provider-prompt-cache/spec.md new file mode 100644 index 000000000..779b5a4c0 --- /dev/null +++ b/openspec/changes/fix-acemodel-prompt-cache-key/specs/provider-prompt-cache/spec.md @@ -0,0 +1,32 @@ +# Spec Delta + +## Purpose + +让支持提示词缓存标识的模型接口识别同一会话的连续请求,保留主会话和子会话各自的缓存身份,并在兼容网关拒绝扩展参数时维持正常调用能力,避免稳定提示词因缺少会话身份而重复计算。 + +## ADDED Requirements + +### Requirement: ACEModel 请求携带会话缓存标识 +系统 SHALL 对内置 ACEModel HTTPS 地址的 Responses 和 Chat Completions 请求发送当前会话的 `prompt_cache_key`,支持普通请求、流式请求以及完整 URL 配置。 + +#### Scenario: 同一会话连续请求 +- **WHEN** 同一会话连续调用 ACEModel,包括工具结果返回后的请求 +- **THEN** 请求携带相同且非空的缓存标识 + +#### Scenario: 子会话隔离 +- **WHEN** 主会话与子会话交错使用同一个 provider +- **THEN** 每次调用携带所属会话的缓存标识,互不覆盖 + +### Requirement: 已验证端点边界 +系统 SHALL 仅向已验证支持缓存键的端点启用该扩展;不得因模型名称、相似域名或包含 ACEModel 地址的路径而向其他地址启用扩展。 + +#### Scenario: 非标准端点 +- **WHEN** 使用 HTTP、其他域名、非默认端口或不匹配的 API 路径 +- **THEN** ACEModel 缓存扩展不启用,其他已有供应商策略保持原状 + +### Requirement: 显式拒绝后的兼容回退 +系统 SHALL 在服务端明确拒绝 `prompt_cache_key` 时删除该字段后重试,并只对当前端点和模型记忆该回退;其他错误不得触发缓存参数降级。 + +#### Scenario: 当前模型不接受参数 +- **WHEN** 端点明确返回缓存键不受支持 +- **THEN** 后续请求对该端点和模型省略缓存键,切换模型或端点后独立判断 diff --git a/openspec/changes/fix-acemodel-prompt-cache-key/tasks.md b/openspec/changes/fix-acemodel-prompt-cache-key/tasks.md new file mode 100644 index 000000000..dbf421357 --- /dev/null +++ b/openspec/changes/fix-acemodel-prompt-cache-key/tasks.md @@ -0,0 +1,19 @@ +# Tasks + +## 1. 缓存策略修复 + +- [x] 1.1 扩展已验证 ACEModel 两种接口的缓存键传递,完成端点、主/子会话、协议转换及兼容回退测试,并同步设计说明。 + +## 2. 集成验证 + +- [x] 2.1 Windows 增量构建并运行 provider 与前缀稳定性相关测试,执行 OpenSpec 严格校验和差异检查。 +- [x] 2.2 构建可运行产物,完成真实 ACEModel 连续请求验证,记录缓存用量及运行实例加载状态。 + +## 验证记录 + +- Windows Release 构建成功;`AceModelPromptCache.*:OpenAiPromptCache.*:OpenAiResponsesProviderTest.*:OpenAiResponsesTest.*:AgentLoopTermination.RequestPrefix*` 共 71 项测试通过。 +- OpenSpec 严格校验及本次代码差异的 `git diff --check` 通过。 +- 真实 Responses 验证会话 `cache_key_smoke_20261018_183726`:三次请求输入分别为 12,537 / 12,567 / 12,597 token,缓存读取为 0 / 12,416 / 12,416;后两次命中率为 98.80% / 98.56%。服务端账本核对一致。 +- 2026-10-18 18:39(Asia/Taipei)确认 Desktop 会话空闲后正常退出并重启,当前 Desktop PID 54204、daemon PID 30508,健康检查成功,daemon 使用本次构建的 `build/Release/acecode.exe`。 +- 修复产物 SHA-256:`aa1106339c24c0d41948a047f0aa7341b0ba34960eab15fc28aac4fcaf6f0627`。旧产物保留为 `build/Release/acecode.before-cache-key-20261018.exe`;其他原有 TUI/独立 Web 进程未重启。 +- 原有四个 Responses 脏文件的 SHA-256 与实施前一致,未覆盖其中的用户修改;服务器配置未改变。 diff --git a/src/adapters/provider/openai_prompt_cache.cpp b/src/adapters/provider/openai_prompt_cache.cpp index 42bd7fb74..fc63e824f 100644 --- a/src/adapters/provider/openai_prompt_cache.cpp +++ b/src/adapters/provider/openai_prompt_cache.cpp @@ -1,5 +1,6 @@ #include "openai_provider.hpp" #include "utils/logger.hpp" +#include "config/builtin_model_catalog.hpp" #include "config/request_headers.hpp" #include @@ -118,7 +119,27 @@ void OpenAiCompatProvider::set_prompt_cache_key(const std::string& key) { } bool OpenAiCompatProvider::supports_prompt_cache_key() const { - return name() == "openai" && known_cache_key_endpoint(request_url()); + return name() == "openai" && (known_cache_key_endpoint(request_url()) || + is_acemodel_request_endpoint("/chat/completions")); +} + +bool OpenAiCompatProvider::is_acemodel_request_endpoint( + const std::string& endpoint_suffix) const { + const auto url = request_url(); + if (ascii_lower(url.substr(0, 8)) != "https://") return false; + const auto authority_end = url.find('/', 8); + if (authority_end == std::string::npos) return false; + auto authority = ascii_lower(url.substr(8, authority_end - 8)); + if (authority.size() > 4 && authority.compare(authority.size() - 4, 4, ":443") == 0) { + authority.resize(authority.size() - 4); + } + const auto path_end = url.find_first_of("?#", authority_end); + auto path = url.substr(authority_end, path_end - authority_end); + if (path.size() < endpoint_suffix.size() || + path.compare(path.size() - endpoint_suffix.size(), endpoint_suffix.size(), + endpoint_suffix) != 0) return false; + path.resize(path.size() - endpoint_suffix.size()); + return is_acemodel_base_url("https://" + authority + path); } bool OpenAiCompatProvider::supports_compaction_tool_choice_none() const { diff --git a/src/adapters/provider/openai_provider.hpp b/src/adapters/provider/openai_provider.hpp index 7cae8cbaf..4aa3a54e0 100644 --- a/src/adapters/provider/openai_provider.hpp +++ b/src/adapters/provider/openai_provider.hpp @@ -143,8 +143,9 @@ class OpenAiCompatProvider : public LlmProvider { ) const; // Capability seams also let protocol adapters use an explicit policy; - // the default implementation recognizes verified official endpoints only. + // the default implementation recognizes verified endpoints only. virtual bool supports_prompt_cache_key() const; + bool is_acemodel_request_endpoint(const std::string& endpoint_suffix) const; virtual bool supports_compaction_tool_choice_none() const; bool remove_rejected_prompt_cache_key( nlohmann::json& body, const std::string& url, diff --git a/src/adapters/provider/openai_responses.cpp b/src/adapters/provider/openai_responses.cpp index 24f7c71af..6a6ae9a25 100644 --- a/src/adapters/provider/openai_responses.cpp +++ b/src/adapters/provider/openai_responses.cpp @@ -138,7 +138,8 @@ bool read_message_text(const Json& item, std::string& text) { return !item.contains("phase") || item["phase"].is_null() || item["phase"].is_string(); } -bool read_reasoning_text(const Json& item, std::string& text) { +bool read_reasoning_text(const Json& item, std::string& text, + std::optional prefer_raw = std::nullopt) { if (!has_array(item, "summary")) return false; if (item.contains("encrypted_content") && !item["encrypted_content"].is_null() && !item["encrypted_content"].is_string()) return false; @@ -155,7 +156,7 @@ bool read_reasoning_text(const Json& item, std::string& text) { content += text_field(part, "text"); } } - text += content.empty() ? summary : content; + text += prefer_raw ? (*prefer_raw ? content : summary) : (content.empty() ? summary : content); return true; } @@ -224,8 +225,6 @@ Json native_group(const Json& normalized, const ChatMessage* original) { const auto& item = part["item"]; const auto type = text_field(item, "type"); if (type != "reasoning" && type != "message" && type != "function_call") return {}; - // Storage is disabled, so a bare reasoning ID cannot be resolved later. - if (type == "reasoning" && text_field(item, "encrypted_content").empty()) return {}; items.push_back(item); } if (items.empty()) return {}; @@ -242,6 +241,11 @@ Json native_group(const Json& normalized, const ChatMessage* original) { if (parsed.content != normalized_text || parsed.content != original->content || !calls_equal(parsed.tool_calls, normalized_calls) || !calls_equal(parsed.tool_calls, original_calls)) return {}; + // Validate the complete group first. Storage is disabled, so omit only + // unresolvable reasoning IDs, preserving the unchanged messages' phases. + items.erase(std::remove_if(items.begin(), items.end(), [](const Json& item) { + return text_field(item, "type") == "reasoning" && text_field(item, "encrypted_content").empty(); + }), items.end()); return items; } @@ -357,6 +361,7 @@ Json build_openai_responses_request(const Json& chat_body, } } if (request["input"].empty()) return reject("messages do not contain sendable input"); + Json allowed_tools = Json::array(); if (chat_body.contains("tools")) { if (!chat_body["tools"].is_array()) return reject("tools must be an array"); request["tools"] = Json::array(); @@ -369,6 +374,7 @@ Json build_openai_responses_request(const Json& chat_body, flattened["type"] = "function"; flattened["strict"] = false; request["tools"].push_back(std::move(flattened)); + allowed_tools.push_back({{"type", "function"}, {"name", tool["function"]["name"]}}); } } if (chat_body.contains("max_completion_tokens")) { @@ -387,16 +393,28 @@ Json build_openai_responses_request(const Json& chat_body, } request["reasoning"]["effort"] = chat_body["reasoning_effort"]; } - if (chat_body.contains("tool_choice")) { - const auto& choice = chat_body["tool_choice"]; - if (choice.is_string()) { - request["tool_choice"] = choice; - } else if (text_field(choice, "type") == "function" && choice.contains("function") && - !text_field(choice["function"], "name").empty()) { - request["tool_choice"] = {{"type", "function"}, {"name", choice["function"]["name"]}}; + const auto choice = chat_body.value("tool_choice", Json("auto")); + if (choice == "none") { + request["tool_choice"] = "none"; + } else if (choice == "auto" || choice == "required") { + if (allowed_tools.empty()) { + if (choice == "required") return reject("required tool_choice needs an offered function"); + request["tool_choice"] = "none"; } else { - return reject("unsupported tool_choice"); - } + // Gateways may inject hosted tools. Keep automatic selection inside + // the client functions that ACECode can execute and account for. + request["tool_choice"] = {{"type", "allowed_tools"}, {"mode", choice}, + {"tools", allowed_tools}}; + } + } else if (text_field(choice, "type") == "function" && choice.contains("function") && + !text_field(choice["function"], "name").empty()) { + const auto selected = Json{{"type", "function"}, {"name", choice["function"]["name"]}}; + if (std::find(allowed_tools.begin(), allowed_tools.end(), selected) == allowed_tools.end()) { + return reject("tool_choice names a function that was not offered"); + } + request["tool_choice"] = selected; + } else { + return reject("unsupported tool_choice"); } if (chat_body.contains("response_format")) { auto format = chat_body["response_format"]; @@ -450,8 +468,15 @@ ChatResponse parse_openai_responses_response(const Json& envelope) { } result.tool_calls.push_back(std::move(call)); } else { - // Future output types are never turned into local executable calls. - continue; + // Do not turn an unhandled hosted result into a success sentence, + // or copy its potentially large/private payload into diagnostics. + const auto display_type = type.size() <= 80 ? type : std::string(""); + const auto message = "Unsupported OpenAI Responses output type: " + display_type + + ". ACECode supports client function tools only; check the gateway's tool policy."; + auto failure = invalid_response(Json{{"error", {{"code", "unsupported_output_type"}, + {"message", message}}}}, message, ProviderErrorKind::Unknown); + failure.provider_error.retryable = false; + return failure; } result.content_parts.push_back({{"type", "openai_responses_item"}, {"item", item}}); } @@ -488,12 +513,17 @@ bool OpenAiResponsesStreamParser::identify_output(const Json& event, const Json* std::vector OpenAiResponsesStreamParser::fail( const Json& event, ProviderErrorKind kind, const std::string& message, bool retryable) { + auto error = response_error(event, kind, message); + error.retryable = error.retryable || retryable; + return fail(std::move(error)); +} + +std::vector OpenAiResponsesStreamParser::fail(ProviderErrorInfo error) { if (terminal_) return {}; accumulated_.finish_reason = "error"; accumulated_.tool_calls.clear(); accumulated_.content_parts = Json::array(); - accumulated_.provider_error = response_error(event, kind, message); - accumulated_.provider_error.retryable = accumulated_.provider_error.retryable || retryable; + accumulated_.provider_error = std::move(error); StreamEvent failure; failure.type = StreamEventType::Error; failure.error = accumulated_.provider_error.display_message; @@ -528,6 +558,14 @@ std::vector OpenAiResponsesStreamParser::complete(const Json& event !prefix_of(state.arguments, text_field(final_item, "arguments"))) { return fail(event, ProviderErrorKind::MalformedSse, "OpenAI function arguments changed during streaming"); } + if (expected_status == "completed" && type == "function_call") { + for (const char* key : {"call_id", "name"}) { + const auto previous = text_field(state.item, key); + if (!previous.empty() && previous != text_field(final_item, key)) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI function call changed identity"); + } + } + } if (state.done) { // Some gateways omit opaque fields from the terminal snapshot. // Only output_item.done is a safe fallback; added can be partial. @@ -536,18 +574,33 @@ std::vector OpenAiResponsesStreamParser::complete(const Json& event final_item[key] = state.item[key]; } } - if (expected_status == "completed" && type == "function_call" && - (text_field(state.item, "call_id") != text_field(final_item, "call_id") || - text_field(state.item, "name") != text_field(final_item, "name"))) { - return fail(event, ProviderErrorKind::MalformedSse, "OpenAI function call changed identity"); - } } } auto final = parse_openai_responses_response(envelope); if (final.provider_error.has_error()) { - return fail(event, final.provider_error.kind == ProviderErrorKind::MalformedJson - ? ProviderErrorKind::MalformedSse : final.provider_error.kind, - final.provider_error.display_message); + if (final.provider_error.kind == ProviderErrorKind::MalformedJson) { + final.provider_error.kind = ProviderErrorKind::MalformedSse; + } + return fail(std::move(final.provider_error)); + } + final.reasoning_content.clear(); + for (std::size_t i = 0; i < envelope["output"].size(); ++i) { + const auto& item = envelope["output"][i]; + if (text_field(item, "type") != "reasoning") continue; + const auto state = output_.find(static_cast(i)); + if (state == output_.end() || !state->second.prefer_raw_reasoning) { + read_reasoning_text(item, final.reasoning_content); + continue; + } + std::string raw; + std::string summary; + read_reasoning_text(item, raw, true); + read_reasoning_text(item, summary, false); + if (!prefix_of(state->second.raw_reasoning, raw) || + !prefix_of(state->second.summary_reasoning, summary)) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI reasoning changed during streaming"); + } + final.reasoning_content += *state->second.prefer_raw_reasoning ? raw : summary; } if (!prefix_of(accumulated_.content, final.content) || !prefix_of(accumulated_.reasoning_content, final.reasoning_content)) { @@ -613,6 +666,21 @@ std::vector OpenAiResponsesStreamParser::consume(const Json& event) } const auto delta = text_field(event, "delta"); if (delta.empty()) return {}; + auto& state = output_[index]; + const std::string item_type = reasoning_delta ? "reasoning" : "message"; + const auto previous_type = text_field(state.item, "type"); + if (!previous_type.empty() && previous_type != item_type) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI text delta changed output type"); + } + state.item["type"] = item_type; + if (reasoning_delta) { + const bool raw = type == "response.reasoning_text.delta"; + (raw ? state.raw_reasoning : state.summary_reasoning) += delta; + // A gateway can stream both representations. Display the first + // channel for this item, but validate both against the terminal. + if (!state.prefer_raw_reasoning) state.prefer_raw_reasoning = raw; + if (*state.prefer_raw_reasoning != raw) return {}; + } if (text_delta) accumulated_.content += delta; else accumulated_.reasoning_content += delta; StreamEvent result; @@ -635,6 +703,13 @@ std::vector OpenAiResponsesStreamParser::consume(const Json& event) return fail(event, ProviderErrorKind::MalformedSse, "Malformed OpenAI output item field"); } } + const auto previous_call_id = text_field(state.item, "call_id"); + const auto previous_name = text_field(state.item, "name"); + if (item_type == "function_call" && + ((!previous_call_id.empty() && item.contains("call_id") && previous_call_id != text_field(item, "call_id")) || + (!previous_name.empty() && item.contains("name") && previous_name != text_field(item, "name")))) { + return fail(event, ProviderErrorKind::MalformedSse, "OpenAI function call changed identity"); + } if ((!previous_type.empty() && previous_type != item_type) || (state.done && state.item != item)) { return fail(event, ProviderErrorKind::MalformedSse, "OpenAI output item changed after completion"); @@ -664,6 +739,10 @@ std::vector OpenAiResponsesStreamParser::consume(const Json& event) state.done = true; } state.item = item; + if (item_type == "function_call") { + if (!item.contains("call_id") && !previous_call_id.empty()) state.item["call_id"] = previous_call_id; + if (!item.contains("name") && !previous_name.empty()) state.item["name"] = previous_name; + } return {}; } if (type == "response.function_call_arguments.delta" || type == "response.function_call_arguments.done") { diff --git a/src/adapters/provider/openai_responses.hpp b/src/adapters/provider/openai_responses.hpp index 1467bb019..563a2d523 100644 --- a/src/adapters/provider/openai_responses.hpp +++ b/src/adapters/provider/openai_responses.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -31,6 +32,9 @@ class OpenAiResponsesStreamParser { struct OutputState { nlohmann::json item = nlohmann::json::object(); std::string arguments; + std::string raw_reasoning; + std::string summary_reasoning; + std::optional prefer_raw_reasoning; bool arguments_seen = false; bool done = false; }; @@ -39,6 +43,7 @@ class OpenAiResponsesStreamParser { const nlohmann::json* item, int& index); std::vector complete(const nlohmann::json& event); + std::vector fail(ProviderErrorInfo error); std::vector fail(const nlohmann::json& event, ProviderErrorKind kind, const std::string& message, diff --git a/src/adapters/provider/openai_responses_provider.cpp b/src/adapters/provider/openai_responses_provider.cpp index 6cf5c779b..c60d8ce36 100644 --- a/src/adapters/provider/openai_responses_provider.cpp +++ b/src/adapters/provider/openai_responses_provider.cpp @@ -154,6 +154,7 @@ std::string OpenAiResponsesProvider::request_url() const { } bool OpenAiResponsesProvider::supports_prompt_cache_key() const { + if (is_acemodel_request_endpoint("/responses")) return true; const auto url = request_url(); if (ascii_lower(url.substr(0, 8)) != "https://") return false; const auto end = url.find('/', 8); diff --git a/src/apps/desktop/web_host.cpp b/src/apps/desktop/web_host.cpp index 2bcc0c0e9..445f7fd35 100644 --- a/src/apps/desktop/web_host.cpp +++ b/src/apps/desktop/web_host.cpp @@ -1992,7 +1992,7 @@ std::pair adjusted_window_size(int width, int height) { const int min_value = std::min(preferred_min, max_value); return std::max(min_value, std::min(value, max_value)); }; - width = clamp_dimension(width, display_width, 80.0, 900); + width = clamp_dimension(width, display_width, 80.0, kMinimumDesktopWindowWidth); height = clamp_dimension(height, display_height, 120.0, 640); } #endif diff --git a/src/apps/desktop/web_host.hpp b/src/apps/desktop/web_host.hpp index f977435ad..204d4972c 100644 --- a/src/apps/desktop/web_host.hpp +++ b/src/apps/desktop/web_host.hpp @@ -24,7 +24,8 @@ struct TaskbarBadge; inline constexpr int kDefaultDesktopWindowWidth = 1280; inline constexpr int kDefaultDesktopWindowHeight = 820; -inline constexpr int kMinimumDesktopWindowWidth = 1170; +// Allow the responsive Web UI to collapse both sidebars for a narrow transcript. +inline constexpr int kMinimumDesktopWindowWidth = 480; class WebHostInitializationError : public std::runtime_error { public: diff --git a/src/domain/session/global_session_catalog.cpp b/src/domain/session/global_session_catalog.cpp index 1d5cd2a75..a299e5131 100644 --- a/src/domain/session/global_session_catalog.cpp +++ b/src/domain/session/global_session_catalog.cpp @@ -788,7 +788,9 @@ GlobalSessionCatalogIndexSnapshot GlobalSessionCatalogIndex::snapshot() const { GlobalSessionCatalogSelection GlobalSessionCatalogIndex::select_entries( std::size_t max_entries, - const std::function& score) const { + const std::function& score, + const std::function& before) const { std::vector> shard_views; GlobalSessionCatalogSelection selection; { @@ -907,8 +909,9 @@ GlobalSessionCatalogSelection GlobalSessionCatalogIndex::select_entries( if (value > 0) ranked.push_back({value, &entry}); } - const auto ranked_before = [](const auto& a, const auto& b) { + const auto ranked_before = [&before](const auto& a, const auto& b) { if (a.score != b.score) return a.score > b.score; + if (before) return before(*a.entry, *b.entry); const std::string a_updated = effective_updated_at(*a.entry).empty() ? a.entry->meta.created_at : effective_updated_at(*a.entry); diff --git a/src/domain/session/global_session_catalog.hpp b/src/domain/session/global_session_catalog.hpp index ba9de794c..575405d29 100644 --- a/src/domain/session/global_session_catalog.hpp +++ b/src/domain/session/global_session_catalog.hpp @@ -127,9 +127,13 @@ class GlobalSessionCatalogIndex { void request_discovery(); GlobalSessionCatalogIndexSnapshot snapshot() const; + // Optional ordering breaks equal-score ties without copying full shards. + // Callbacks borrow rows only for this synchronous selection. GlobalSessionCatalogSelection select_entries( std::size_t max_entries, - const std::function& score) const; + const std::function& score, + const std::function& before = {}) const; GlobalSessionCatalogProjectSnapshot project_snapshot( std::optional known_generation = std::nullopt) const; std::optional find_project_session( diff --git a/src/domain/session/global_session_search.cpp b/src/domain/session/global_session_search.cpp index 872080925..78a074fe6 100644 --- a/src/domain/session/global_session_search.cpp +++ b/src/domain/session/global_session_search.cpp @@ -176,9 +176,11 @@ struct GlobalSessionSearchService::Impl { struct RecentCache { SessionUserMessageFileSignature signature; SessionRecentActivity activity; + std::uint64_t checked_order = 0; }; std::mutex recent_mu; std::unordered_map recent_cache; + std::uint64_t recent_check_order = 0; GlobalSessionCatalogIndex index; std::mutex jobs_mu; std::unordered_map> jobs; @@ -259,51 +261,85 @@ void GlobalSessionSearchService::stop() { GlobalSessionSearchPage GlobalSessionSearchService::recent_user_sessions(std::size_t limit) { impl_->index.attach_request("desktop-agent-office"); - auto snapshot = impl_->index.snapshot(); GlobalSessionSearchPage result; - result.progress = snapshot.progress; - result.errors = std::move(snapshot.catalog.errors); - const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); std::lock_guard lock(impl_->recent_mu); - for (auto& entry : snapshot.catalog.entries) { - if (entry.meta.archived || !entry.meta.parent_session_id.empty()) continue; + // Rank immutable shard rows and copy only the requested offices. snapshot() + // copies/merges the entire catalog (including drafts) on every UI refresh. + std::vector> legacy; + bool collect_legacy = true; + const auto user_time = [&](const GlobalSessionCatalogEntry& entry) -> const std::string& { + if (entry.active && !entry.active->last_user_message_at.empty()) + return entry.active->last_user_message_at; + if (!entry.meta.last_user_message_at.empty()) return entry.meta.last_user_message_at; + const auto path = SessionStorage::session_path(entry.project_dir, entry.meta.id); + const auto cached = impl_->recent_cache.find(path); + return cached == impl_->recent_cache.end() ? entry.meta.last_user_message_at + : cached->second.activity.last_user_message_at; + }; + const auto score = [&](const GlobalSessionCatalogEntry& entry) { + if (entry.meta.archived || !entry.meta.parent_session_id.empty()) return 0; + if (collect_legacy && entry.meta.last_user_message_at.empty() && + (!entry.active || entry.active->last_user_message_at.empty())) { + const auto path = SessionStorage::session_path(entry.project_dir, entry.meta.id); + legacy.emplace_back(path, &impl_->recent_cache[path]); + } + return user_time(entry).empty() ? 0 : 1; + }; + const auto before = [&](const auto& a, const auto& b) { + const auto& a_time = user_time(a); + const auto& b_time = user_time(b); + if (a_time != b_time) return a_time > b_time; + return a.meta.id < b.meta.id; + }; + auto selection = impl_->index.select_entries(limit, score, before); + collect_legacy = false; + + // File signatures are IO too. Check a bounded batch, oldest check first, + // so cached empty/internal-only records cannot starve later legacy rows. + std::sort(legacy.begin(), legacy.end(), [](const auto& a, const auto& b) { + if (a.second->checked_order != b.second->checked_order) + return a.second->checked_order < b.second->checked_order; + return a.first < b.first; + }); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); + constexpr std::size_t kLegacyChecksPerRefresh = 32; + bool changed = false; + for (std::size_t i = 0; i < legacy.size() && i < kLegacyChecksPerRefresh; ++i) { + if (std::chrono::steady_clock::now() >= deadline) break; + const auto& path = legacy[i].first; + auto& cached = *legacy[i].second; + const auto signature = session_user_message_file_signature(path); + if (!cached.checked_order || cached.signature.mtime != signature.mtime || + cached.signature.size != signature.size) { + try { + auto activity = read_session_recent_activity(path); + changed = changed || activity.last_user_message_at != cached.activity.last_user_message_at || + activity.last_turn_outcome != cached.activity.last_turn_outcome; + cached.activity = std::move(activity); + cached.signature = signature; + } catch (const std::exception& error) { + result.errors.push_back({path, {}, "recent-user-message", error.what()}); + } + } + cached.checked_order = ++impl_->recent_check_order; + } + if (changed) selection = impl_->index.select_entries(limit, score, before); + result.progress = selection.progress; + for (const auto& item : legacy) { + if (!item.second->checked_order) result.progress.complete = false; + } + for (auto& error : selection.errors) result.errors.push_back(std::move(error)); + result.entries = std::move(selection.entries); + for (auto& entry : result.entries) { + entry.meta.last_user_message_at = user_time(entry); if (entry.active && !entry.active->last_user_message_at.empty()) { - entry.meta.last_user_message_at = entry.active->last_user_message_at; entry.meta.last_turn_outcome = entry.active->last_turn_outcome; - } - if (entry.meta.last_user_message_at.empty()) { + } else if (entry.meta.last_turn_outcome.empty()) { const auto path = SessionStorage::session_path(entry.project_dir, entry.meta.id); - const auto signature = session_user_message_file_signature(path); - auto cached = impl_->recent_cache.find(path); - if (cached == impl_->recent_cache.end() || - cached->second.signature.mtime != signature.mtime || - cached->second.signature.size != signature.size) { - if (std::chrono::steady_clock::now() >= deadline) { - result.progress.complete = false; - continue; - } - try { - Impl::RecentCache value{signature, read_session_recent_activity(path)}; - cached = impl_->recent_cache.insert_or_assign(path, std::move(value)).first; - } catch (const std::exception& error) { - result.errors.push_back({entry.project_dir, entry.workspace_hash, - "recent-user-message", error.what()}); - continue; - } - } - entry.meta.last_user_message_at = cached->second.activity.last_user_message_at; - if (entry.meta.last_turn_outcome.empty()) { - entry.meta.last_turn_outcome = cached->second.activity.last_turn_outcome; - } + const auto cached = impl_->recent_cache.find(path); + if (cached != impl_->recent_cache.end()) entry.meta.last_turn_outcome = cached->second.activity.last_turn_outcome; } - if (!entry.meta.last_user_message_at.empty()) result.entries.push_back(std::move(entry)); } - std::sort(result.entries.begin(), result.entries.end(), [](const auto& a, const auto& b) { - if (a.meta.last_user_message_at != b.meta.last_user_message_at) - return a.meta.last_user_message_at > b.meta.last_user_message_at; - return a.meta.id < b.meta.id; - }); - if (result.entries.size() > limit) result.entries.resize(limit); return result; } diff --git a/tests/provider/acemodel_prompt_cache_test.cpp b/tests/provider/acemodel_prompt_cache_test.cpp new file mode 100644 index 000000000..34a754b98 --- /dev/null +++ b/tests/provider/acemodel_prompt_cache_test.cpp @@ -0,0 +1,145 @@ +#include "provider/openai_provider.hpp" +#include "provider/openai_responses.hpp" +#include "provider/openai_responses_provider.hpp" + +#include + +#include +#include + +namespace { + +using namespace acecode; +using Json = nlohmann::json; + +template +class ExposedCacheProvider : public Provider { +public: + using Provider::Provider; + using Provider::build_request_body; + using Provider::remove_rejected_prompt_cache_key; +}; + +using ChatProvider = ExposedCacheProvider; +using ResponsesProvider = ExposedCacheProvider; + +std::vector messages() { + ChatMessage message; + message.role = "user"; + message.content = "cache test"; + return {message}; +} + +template +void check_endpoint(const std::string& url, const std::string& mode, bool supported) { + ProviderRequestOptions options; + options.endpoint_mode = mode; + Provider provider(url, "", "aurora", OpenAiConfig::kDefaultStreamTimeoutMs, {}, options); + ChatRequestOptions call; + call.prompt_cache_key = "parent-session"; + const auto body = provider.build_request_body(messages(), {}, true, false, &call); + EXPECT_EQ(body.contains("prompt_cache_key"), supported) << url; + if (supported) EXPECT_EQ(body["prompt_cache_key"], call.prompt_cache_key) << url; +} + +TEST(AceModelPromptCache, CanonicalBaseAndFullUrlsSendSessionKey) { + for (const auto& base : {"https://ge.bigjuan.xyz/aceapi/v1", + "https://GE.BIGJUAN.XYZ:443/aceapi/v1/"}) { + check_endpoint(base, "base_url", true); + check_endpoint(base, "base_url", true); + } + check_endpoint( + "https://ge.bigjuan.xyz:443/aceapi/v1/chat/completions?test=1", "full_url", true); + check_endpoint( + "https://ge.bigjuan.xyz:443/aceapi/v1/responses?test=1", "full_url", true); +} + +TEST(AceModelPromptCache, OtherOriginsAndProtocolPathsKeepLegacyPolicy) { + for (const auto& base : {"http://ge.bigjuan.xyz/aceapi/v1", + "https://ge.bigjuan.xyz:8443/aceapi/v1", + "https://ge.bigjuan.xyz.evil.test/aceapi/v1", + "https://ge.bigjuan.xyz@evil.test/aceapi/v1", + "https://evil.test/ge.bigjuan.xyz/aceapi/v1", + "https://ge.bigjuan.xyz/v1", + "https://ge.bigjuan.xyz/aceapi/v10", + "https://custom.test/aceapi/v1"}) { + check_endpoint(base, "base_url", false); + check_endpoint(base, "base_url", false); + } + check_endpoint( + "https://ge.bigjuan.xyz/aceapi/v1/responses", "full_url", false); + check_endpoint( + "https://ge.bigjuan.xyz/aceapi/v1/chat/completions", "full_url", false); + check_endpoint( + "https://ge.bigjuan.xyz/aceapi/v1/responses/extra", "full_url", false); + check_endpoint( + "https://api.openai.com/v1/responses", "full_url", true); + check_endpoint( + "https://api.mistral.ai/v1/chat/completions", "full_url", true); +} + +template +void check_session_isolation(bool responses) { + Provider provider("https://ge.bigjuan.xyz/aceapi/v1", "", "aurora"); + provider.set_prompt_cache_key("legacy-default"); + const auto history = messages(); + for (const auto& session : {"parent-session", "child-session", "parent-session"}) { + ChatRequestOptions call; + call.prompt_cache_key = session; + for (const bool stream : {false, true}) { + for (const bool compact : {false, true}) { + auto body = provider.build_request_body(history, {}, stream, compact, &call); + if (responses) { + std::string error; + body = build_openai_responses_request(body, &history, &error); + ASSERT_TRUE(error.empty()) << error; + } + EXPECT_EQ(body["prompt_cache_key"], session); + } + } + } + EXPECT_EQ(provider.build_request_body(history, {})["prompt_cache_key"], "legacy-default"); + ChatRequestOptions no_session; + EXPECT_FALSE(provider.build_request_body(history, {}, false, false, &no_session) + .contains("prompt_cache_key")); +} + +TEST(AceModelPromptCache, ChatCallsKeepParentAndChildKeysIndependent) { + check_session_isolation(false); +} + +TEST(AceModelPromptCache, ResponsesWireKeepsParentAndChildKeysIndependent) { + check_session_isolation(true); +} + +template +void check_rejection_scope() { + Provider provider("https://ge.bigjuan.xyz/aceapi/v1", "", "aurora"); + provider.set_prompt_cache_key("session"); + auto body = provider.build_request_body(messages(), {}); + const auto original = body; + EXPECT_FALSE(provider.remove_rejected_prompt_cache_key(body, provider.request_url(), 500, + R"({"error":{"param":"prompt_cache_key","code":"unsupported_parameter"}})")); + EXPECT_EQ(body, original); + EXPECT_TRUE(provider.remove_rejected_prompt_cache_key(body, provider.request_url(), 400, + R"({"error":{"param":"prompt_cache_key","code":"unsupported_parameter"}})")); + auto expected = original; + expected.erase("prompt_cache_key"); + EXPECT_EQ(body, expected); + EXPECT_FALSE(provider.build_request_body(messages(), {}).contains("prompt_cache_key")); + provider.set_model("moonlight"); + EXPECT_EQ(provider.build_request_body(messages(), {})["prompt_cache_key"], "session"); + provider.set_model("aurora"); + provider.reconfigure("https://api.openai.com/v1", ""); + EXPECT_EQ(provider.build_request_body(messages(), {})["prompt_cache_key"], "session"); +} + +TEST(AceModelPromptCache, ChatRejectionIsScopedToEndpointAndModel) { + check_rejection_scope(); +} + +TEST(AceModelPromptCache, ResponsesRejectionIsScopedToEndpointAndModel) { + check_rejection_scope(); +} + +} // namespace diff --git a/tests/provider/openai_responses_provider_test.cpp b/tests/provider/openai_responses_provider_test.cpp index 3653e9898..e58a1824d 100644 --- a/tests/provider/openai_responses_provider_test.cpp +++ b/tests/provider/openai_responses_provider_test.cpp @@ -242,6 +242,125 @@ TEST(OpenAiResponsesProviderTest, UsesExactFullUrlAndKeepsMissingReasoningToggle EXPECT_EQ(received["reasoning"]["effort"], "none"); } +TEST(OpenAiResponsesProviderTest, ImageRequestsStayOnLocalFunctionRoundTrip) { + std::vector requests; + const ToolDef image_tool{"image_generate", "Generate an image locally", Json{ + {"type", "object"}, {"properties", {{"prompt", {{"type", "string"}}}}}}}; + auto call = call_item(); + call["name"] = image_tool.name; + call["arguments"] = R"({"prompt":"a blue circle"})"; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, httplib::Response& response) { + const auto body = Json::parse(request.body); + requests.push_back(body); + const Json allowed{{"type", "allowed_tools"}, {"mode", "auto"}, {"tools", Json::array({ + Json{{"type", "function"}, {"name", "image_generate"}}, + })}}; + EXPECT_EQ(body.value("tool_choice", Json()), allowed); + auto output = Json::array(); + if (body.value("tool_choice", Json()) != allowed) { + output.push_back({{"type", "image_generation_call"}, {"status", "completed"}, {"result", "AAAA"}}); + output.push_back(message_item("Generated.")); + } else if (requests.size() == 1u) { + output.push_back(call); + } else { + output.push_back(message_item("Local image is attached.")); + } + response.set_content(frame({{"type", "response.completed"}, {"response", completed(output)}}), "text/event-stream"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + std::vector events; + provider.chat_stream({user_message("Draw a blue circle")}, {image_tool}, + [&](const StreamEvent& event) { events.push_back(event); }); + ASSERT_EQ(count_events(events, StreamEventType::ToolCall), 1); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + const auto emitted = last_event(events, StreamEventType::ToolCall)->tool_call; + EXPECT_EQ(emitted.function_name, image_tool.name); + ChatMessage assistant; + assistant.role = "assistant"; + assistant.content_parts = last_event(events, StreamEventType::Done)->content_parts; + assistant.tool_calls = Json::array({Json{{"id", emitted.id}, {"type", "function"}, + {"function", {{"name", emitted.function_name}, {"arguments", emitted.function_arguments}}}}}); + ChatMessage tool_result; + tool_result.role = "tool"; + tool_result.tool_call_id = emitted.id; + tool_result.content = "Image saved as an attachment."; + events.clear(); + provider.chat_stream({user_message(), assistant, tool_result}, {image_tool}, + [&](const StreamEvent& event) { events.push_back(event); }); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); + ASSERT_EQ(requests.size(), 2u); + EXPECT_EQ(requests[1]["input"].back()["type"], "function_call_output"); + EXPECT_EQ(requests[1]["input"].back()["call_id"], emitted.id); +} + +TEST(OpenAiResponsesProviderTest, ToolFreeRequestsExplicitlyDisableGatewayTools) { + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, httplib::Response& response) { + const auto body = Json::parse(request.body); + EXPECT_EQ(body.value("tool_choice", Json()), "none"); + response.set_content(body.value("stream", false) ? completed_stream() : completed().dump(), + body.value("stream", false) ? "text/event-stream" : "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + EXPECT_EQ(provider.chat({user_message()}, {}).content, "ok"); + std::vector events; + provider.chat_stream({user_message()}, {}, [&](const StreamEvent& event) { events.push_back(event); }); + EXPECT_EQ(count_events(events, StreamEventType::Done), 1); +} + +TEST(OpenAiResponsesProviderTest, RejectedAllowlistDoesNotFallBackToUnrestrictedAuto) { + std::atomic requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request&, httplib::Response& response) { + ++requests; + response.status = 400; + response.set_content(R"({"error":{"param":"tool_choice","message":"allowed_tools is unsupported"}})", "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + EXPECT_EQ(provider.chat({user_message()}, {lookup_tool()}).finish_reason, "error"); + const auto events = collect(provider); + EXPECT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(count_events(events, StreamEventType::Retry), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 0); + EXPECT_EQ(requests.load(), 2); +} + +TEST(OpenAiResponsesProviderTest, UnsupportedHostedOutputIsBoundedAndNeverRetried) { + std::atomic requests{0}; + const Json unsupported{{"type", "image_generation_call"}, {"status", "completed"}, + {"result", "PRIVATE_IMAGE_BYTES" + std::string(100000, 'A')}}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, httplib::Response& response) { + ++requests; + const auto body = Json::parse(request.body); + const auto output = completed(Json::array({call_item(), unsupported, message_item("Generated.")})); + response.set_content(body.value("stream", false) + ? frame({{"type", "response.completed"}, {"response", output}}) : output.dump(), + body.value("stream", false) ? "text/event-stream" : "application/json"); + }); + }); + OpenAiResponsesProvider provider(server.url(), "", "test-model", 5000); + const auto response = provider.chat({user_message()}, {lookup_tool()}); + EXPECT_EQ(response.finish_reason, "error"); + EXPECT_TRUE(response.tool_calls.empty()); + EXPECT_EQ(response.provider_error.raw_body.find("PRIVATE_IMAGE_BYTES"), std::string::npos); + const auto events = collect(provider); + ASSERT_EQ(count_events(events, StreamEventType::Error), 1); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 0); + EXPECT_EQ(count_events(events, StreamEventType::Done), 0); + EXPECT_EQ(count_events(events, StreamEventType::Retry), 0); + const auto& error = last_event(events, StreamEventType::Error)->provider_error; + EXPECT_FALSE(error.retryable); + EXPECT_EQ(error.raw_body.find("PRIVATE_IMAGE_BYTES"), std::string::npos); + EXPECT_LT(error.raw_body.size(), 2048u); + EXPECT_EQ(requests.load(), 2); +} + TEST(OpenAiResponsesProviderTest, ReplaysEncryptedReasoningAndOriginalToolCallOnNextTurn) { const Json reasoning{{"id", "rs_1"}, {"type", "reasoning"}, {"encrypted_content", "opaque-encrypted-state"}, {"summary", Json::array()}}; diff --git a/tests/provider/openai_responses_test.cpp b/tests/provider/openai_responses_test.cpp index 820600711..53202cc35 100644 --- a/tests/provider/openai_responses_test.cpp +++ b/tests/provider/openai_responses_test.cpp @@ -130,6 +130,202 @@ TEST(OpenAiResponsesTest, ReplaysOrderedNativeGroupWithoutDuplicatingTextOrCalls EXPECT_EQ(request.dump().find("openai_responses_item"), std::string::npos); } +TEST(OpenAiResponsesTest, LimitsAutomaticAndRequiredChoicesToOfferedClientFunctions) { + for (const std::string mode : {"", "auto", "required"}) { + auto chat = chat_body(); + chat["tools"] = Json::array({ + Json{{"type", "function"}, {"function", {{"name", "image_generate"}, {"parameters", Json::object()}}}}, + Json{{"type", "function"}, {"function", {{"name", "read"}, {"parameters", Json::object()}}}}, + }); + if (!mode.empty()) chat["tool_choice"] = mode; + const auto request = acecode::build_openai_responses_request(chat); + ASSERT_TRUE(request.contains("tool_choice")); + EXPECT_EQ(request["tool_choice"], (Json{{"type", "allowed_tools"}, + {"mode", mode.empty() ? "auto" : mode}, {"tools", Json::array({ + Json{{"type", "function"}, {"name", "image_generate"}}, + Json{{"type", "function"}, {"name", "read"}}, + })}})); + } +} + +TEST(OpenAiResponsesTest, DisablesToolFreeCallsAndPreservesExplicitNone) { + auto chat = chat_body(); + for (bool include_empty_tools : {false, true}) { + if (include_empty_tools) chat["tools"] = Json::array(); + const auto request = acecode::build_openai_responses_request(chat); + ASSERT_TRUE(request.contains("tool_choice")); + EXPECT_EQ(request["tool_choice"], "none"); + } + chat["tools"] = Json::array({Json{{"type", "function"}, {"function", {{"name", "read"}}}}}); + chat["tool_choice"] = "none"; + EXPECT_EQ(acecode::build_openai_responses_request(chat)["tool_choice"], "none"); +} + +TEST(OpenAiResponsesTest, RejectsChoicesOutsideOfferedToolSet) { + auto chat = chat_body(); + chat["tool_choice"] = "required"; + EXPECT_TRUE(acecode::build_openai_responses_request(chat).is_null()); + chat["tool_choice"] = "unexpected"; + EXPECT_TRUE(acecode::build_openai_responses_request(chat).is_null()); + chat["tool_choice"] = {{"type", "function"}, {"function", {{"name", "missing"}}}}; + EXPECT_TRUE(acecode::build_openai_responses_request(chat).is_null()); +} + +TEST(OpenAiResponsesTest, UnsupportedOutputCannotSilentlyBecomeTextOnlySuccess) { + for (const auto* type : {"image_generation_call", "web_search_call", "custom_tool_call", "future_output", "rate limit exceeded"}) { + const Json unsupported{{"id", "hosted_1"}, {"type", type}, {"status", "completed"}, + {"result", "PRIVATE_PAYLOAD" + std::string(100000, 'A')}}; + const auto wire = envelope(Json::array({function_item(), unsupported, message_item("Generated.")})); + const auto response = acecode::parse_openai_responses_response(wire); + EXPECT_EQ(response.finish_reason, "error") << type; + EXPECT_TRUE(response.tool_calls.empty()); + EXPECT_TRUE(response.content_parts.empty()); + EXPECT_FALSE(response.provider_error.retryable); + EXPECT_NE(response.provider_error.display_message.find(type), std::string::npos); + EXPECT_EQ(response.provider_error.raw_body.find("PRIVATE_PAYLOAD"), std::string::npos); + EXPECT_LT(response.provider_error.raw_body.size(), 2048u); + + OpenAiResponsesStreamParser parser; + parser.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", function_item()}}); + parser.consume({{"type", "response.output_item.done"}, {"output_index", 1}, {"item", unsupported}}); + const auto events = parser.consume({{"type", "response.completed"}, {"response", wire}}); + const auto* failure = first_event(events, StreamEventType::Error); + ASSERT_NE(failure, nullptr) << type; + EXPECT_EQ(first_event(events, StreamEventType::Done), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + EXPECT_TRUE(parser.accumulated().content_parts.empty()); + EXPECT_FALSE(failure->provider_error.retryable); + EXPECT_EQ(failure->provider_error.raw_body.find("PRIVATE_PAYLOAD"), std::string::npos); + EXPECT_LT(failure->provider_error.raw_body.size(), 2048u); + } +} + +TEST(OpenAiResponsesTest, KeepsMessagePhaseWhenOmittingNonReplayableReasoning) { + auto reasoning = reasoning_item(); + reasoning.erase("encrypted_content"); + const auto message = message_item("Working."); + const auto parsed = acecode::parse_openai_responses_response(envelope( + Json::array({reasoning, message, function_item()}))); + ChatMessage original; + original.role = "assistant"; + original.content = parsed.content; + original.content_parts = parsed.content_parts; + original.tool_calls = Json::array({chat_function(function_item())}); + std::vector originals{original}; + auto chat = chat_body(); + chat["messages"].push_back({{"role", "assistant"}, {"content", original.content}, + {"tool_calls", original.tool_calls}}); + const auto request = acecode::build_openai_responses_request(chat, &originals); + ASSERT_EQ(request["input"].size(), 4u); + EXPECT_EQ(request["input"][2], message); + EXPECT_EQ(request["input"][3], function_item()); + EXPECT_EQ(request.dump().find("rs_1"), std::string::npos); +} + +TEST(OpenAiResponsesTest, MixedReasoningChannelsDoNotInvalidateToolCompletion) { + for (bool raw_first : {false, true}) { + OpenAiResponsesStreamParser parser; + const auto selected_type = raw_first ? "response.reasoning_text.delta" : "response.reasoning_summary_text.delta"; + const auto other_type = raw_first ? "response.reasoning_summary_text.delta" : "response.reasoning_text.delta"; + auto events = parser.consume({{"type", selected_type}, {"output_index", 0}, {"delta", "selected"}}); + ASSERT_NE(first_event(events, StreamEventType::ReasoningDelta), nullptr); + events = parser.consume({{"type", other_type}, {"output_index", 0}, {"delta", "other"}}); + EXPECT_EQ(first_event(events, StreamEventType::ReasoningDelta), nullptr); + auto reasoning = reasoning_item(raw_first ? "other" : "selected complete"); + reasoning["content"] = Json::array({Json{{"type", "reasoning_text"}, + {"text", raw_first ? "selected complete" : "other"}}}); + events = parser.consume(terminal(Json::array({reasoning, function_item()}))); + EXPECT_EQ(first_event(events, StreamEventType::Error), nullptr); + EXPECT_EQ(count_events(events, StreamEventType::ToolCall), 1u); + ASSERT_NE(first_event(events, StreamEventType::ReasoningDelta), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ReasoningDelta)->content, " complete"); + EXPECT_EQ(parser.accumulated().reasoning_content, "selected complete"); + } +} + +TEST(OpenAiResponsesTest, RejectsFunctionIdentityChangesBeforeItemCompletion) { + for (const auto* field : {"name", "call_id"}) { + for (bool via_done : {false, true}) { + OpenAiResponsesStreamParser parser; + auto added = function_item(); + added["status"] = "in_progress"; + added["arguments"] = ""; + parser.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", added}}); + auto changed = function_item(); + changed[field] = "changed_identity"; + const auto events = via_done + ? parser.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", changed}}) + : parser.consume(terminal(Json::array({changed}))); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr) << field; + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + EXPECT_TRUE(parser.consume(terminal(Json::array({changed}))).empty()); + } + } +} + +TEST(OpenAiResponsesTest, ValidatesHiddenReasoningChannelAndSelectsPerItem) { + OpenAiResponsesStreamParser changed; + changed.consume({{"type", "response.reasoning_summary_text.delta"}, {"output_index", 0}, {"delta", "summary"}}); + changed.consume({{"type", "response.reasoning_text.delta"}, {"output_index", 0}, {"delta", "raw"}}); + auto reasoning = reasoning_item("summary"); + reasoning["content"] = Json::array({Json{{"type", "reasoning_text"}, {"text", "changed"}}}); + auto events = changed.consume(terminal(Json::array({reasoning, function_item()}))); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); + + OpenAiResponsesStreamParser separate; + separate.consume({{"type", "response.reasoning_summary_text.delta"}, {"output_index", 0}, {"delta", "summary"}}); + separate.consume({{"type", "response.reasoning_text.delta"}, {"output_index", 1}, {"delta", "raw"}}); + auto raw = reasoning_item(); + raw["id"] = "rs_2"; + raw["content"] = Json::array({Json{{"type", "reasoning_text"}, {"text", "raw"}}}); + events = separate.consume(terminal(Json::array({reasoning_item("summary"), raw, function_item()}))); + EXPECT_NE(first_event(events, StreamEventType::Done), nullptr); + EXPECT_EQ(separate.accumulated().reasoning_content, "summaryraw"); +} + +TEST(OpenAiResponsesTest, FunctionIdentityCanArriveLateButCannotBeClearedThenReplaced) { + const Json preliminary{{"id", "fc_call_1"}, {"type", "function_call"}, {"status", "in_progress"}}; + OpenAiResponsesStreamParser late; + late.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", preliminary}}); + late.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", function_item()}}); + EXPECT_EQ(count_events(late.consume(terminal(Json::array({function_item()}))), StreamEventType::ToolCall), 1u); + + OpenAiResponsesStreamParser changed; + auto known = function_item(); + known["status"] = "in_progress"; + changed.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", known}}); + changed.consume({{"type", "response.output_item.added"}, {"output_index", 0}, {"item", preliminary}}); + auto renamed = function_item(); + renamed["name"] = "different"; + const auto events = changed.consume({{"type", "response.output_item.done"}, {"output_index", 0}, {"item", renamed}}); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr); +} + +TEST(OpenAiResponsesTest, UnsupportedOutputDiagnosticHandlesLongUtf8Type) { + const Json unsupported{{"type", std::string(79, 'A') + "\xc3\xa9"}, {"result", "PRIVATE_PAYLOAD"}}; + const auto wire = envelope(Json::array({unsupported})); + acecode::ChatResponse response; + ASSERT_NO_THROW(response = acecode::parse_openai_responses_response(wire)); + EXPECT_EQ(response.finish_reason, "error"); + EXPECT_FALSE(response.provider_error.retryable); + EXPECT_EQ(response.provider_error.raw_body.find("PRIVATE_PAYLOAD"), std::string::npos); + OpenAiResponsesStreamParser parser; + std::vector events; + ASSERT_NO_THROW(events = parser.consume({{"type", "response.completed"}, {"response", wire}})); + ASSERT_NE(first_event(events, StreamEventType::Error), nullptr); + EXPECT_FALSE(first_event(events, StreamEventType::Error)->provider_error.retryable); +} + +TEST(OpenAiResponsesTest, ReasoningDeltasMustMatchTheirOwnTerminalItemType) { + OpenAiResponsesStreamParser parser; + parser.consume({{"type", "response.reasoning_summary_text.delta"}, {"output_index", 0}, {"delta", "selected"}}); + parser.consume({{"type", "response.reasoning_text.delta"}, {"output_index", 0}, {"delta", "other"}}); + const auto events = parser.consume(terminal(Json::array({function_item(), reasoning_item("selected")}))); + EXPECT_NE(first_event(events, StreamEventType::Error), nullptr); + EXPECT_EQ(first_event(events, StreamEventType::ToolCall), nullptr); +} + TEST(OpenAiResponsesTest, StaleNativeCallsCannotUndoCanonicalHistoryRepair) { const auto parsed = acecode::parse_openai_responses_response(envelope( Json::array({reasoning_item(), message_item("answer"), function_item()}))); diff --git a/tests/session/session_recent_activity_test.cpp b/tests/session/session_recent_activity_test.cpp index eeb1f31a2..b492f872e 100644 --- a/tests/session/session_recent_activity_test.cpp +++ b/tests/session/session_recent_activity_test.cpp @@ -109,6 +109,81 @@ TEST_F(RecentFixture, GlobalFiveUseUserTimeAcrossProjectsAndExcludeChildrenAndAr EXPECT_EQ(result.entries.back().meta.id,"20261006-100001-abcd"); } +TEST_F(RecentFixture, LegacyBatchesReachLaterRowsAndRecheckChangedFiles) { + const auto directory = root / "legacy"; + fs::create_directories(directory); + for (int i = 0; i < 96; ++i) { + SessionMeta meta; + meta.id = "legacy-" + std::to_string(100 + i); + meta.cwd = directory.string(); + meta.updated_at = "2099-01-01T00:00:00Z"; + // Empty/internal-only files must not monopolize the recovery budget. + auto message = input("2026-10-18T10:00:00Z"); + if (i != 95) message.metadata = {{"source_tool", "tool_search"}}; + ASSERT_TRUE(SessionStorage::append_message(SessionStorage::session_path(directory.string(), meta.id), message)); + ASSERT_TRUE(SessionStorage::write_meta(SessionStorage::meta_path(directory.string(), meta.id), meta)); + } + GlobalSessionSearchService service(root.string()); + service.start(); service.notify_startup_interaction(); + GlobalSessionSearchPage result; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + do { + result = service.recent_user_sessions(); + if (result.progress.complete) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } while (std::chrono::steady_clock::now() < deadline); + ASSERT_TRUE(result.progress.complete); + ASSERT_EQ(result.entries.size(), 1u); + EXPECT_EQ(result.entries.front().meta.id, "legacy-195"); + + ASSERT_TRUE(SessionStorage::append_message(SessionStorage::session_path(directory.string(), "legacy-100"), + input("2026-10-18T11:00:00Z"))); + for (int i = 0; i < 6; ++i) { + result = service.recent_user_sessions(); + if (result.entries.front().meta.id == "legacy-100") break; + } + ASSERT_EQ(result.entries.size(), 2u); + EXPECT_EQ(result.entries.front().meta.id, "legacy-100"); + service.stop(); +} + +TEST_F(RecentFixture, RecentRankingUsesLiveHumanTimeAndKeepsSearchOrdering) { + const auto directory = root / "project"; + fs::create_directories(directory); + for (int i = 0; i < 100; ++i) { + SessionMeta meta; + meta.id = "session-" + std::to_string(100 + i); + meta.cwd = directory.string(); + meta.updated_at = i == 0 ? "2099-01-01T00:00:00Z" : "2026-10-18T00:00:00Z"; + meta.last_user_message_at = i == 99 ? "2026-10-18T11:00:00Z" : "2026-10-18T10:00:00Z"; + meta.input_draft = std::string(16384, 'x'); + ASSERT_TRUE(SessionStorage::write_meta(SessionStorage::meta_path(directory.string(), meta.id), meta)); + } + SessionInfo active; + active.id = "session-150"; active.cwd = directory.string(); active.workspace_hash = "project"; + active.last_user_message_at = "2026-10-18T12:00:00Z"; + GlobalSessionSearchService service(root.string(), [&active] { return std::vector{active}; }); + service.start(); service.notify_startup_interaction(); + GlobalSessionSearchPage result; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + do { + result = service.recent_user_sessions(); + if (result.progress.complete) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } while (std::chrono::steady_clock::now() < deadline); + ASSERT_TRUE(result.progress.complete); + ASSERT_EQ(result.entries.size(), 5u); + EXPECT_EQ(result.entries[0].meta.id, "session-150"); + EXPECT_EQ(result.entries[0].meta.last_user_message_at, active.last_user_message_at); + EXPECT_EQ(result.entries[1].meta.id, "session-199"); + EXPECT_EQ(result.entries[2].meta.id, "session-100"); + const auto search = service.search_sessions("ordering", "", 5); + ASSERT_EQ(search.entries.size(), 5u); + EXPECT_EQ(search.entries.front().meta.id, "session-100"); + EXPECT_TRUE(service.recent_user_sessions(0).entries.empty()); + service.stop(); +} + TEST_F(RecentFixture, ForkUsesRetainedInputTimeWithoutPretendingItWasSentNow) { SessionManager manager; manager.start_session(root.string(), "test", "test"); diff --git a/web/scripts/test-composer-model-label.mjs b/web/scripts/test-composer-model-label.mjs new file mode 100644 index 000000000..467b30f8e --- /dev/null +++ b/web/scripts/test-composer-model-label.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = await fs.mkdtemp(path.join(os.tmpdir(), 'ace-model-label-')); +const html = ` +
`; +const server = await createServer({ root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + cacheDir: path.join(output, 'node_modules/.vite'), + optimizeDeps: { include: ['react', 'react-dom/client', 'react/jsx-runtime', 'react-i18next', 'i18next'], noDiscovery: true }, + server: { host: '127.0.0.1', port: 0, open: false, hmr: false }, + plugins: [{ name: 'composer-label-fixture', configureServer(vite) { + vite.middlewares.use(async (req, res, next) => { + if (req.url !== '/composer-fixture.html') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml(req.url, html)); + }); + } }], +}); +let browser; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1100, height: 500 } }); + page.on('pageerror', error => { errors.push(error.message); console.error('[pageerror]', error.message); }); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/composer-fixture.html`, { waitUntil: 'networkidle' }); + await page.locator('.ace-composer-model-label').waitFor(); + await page.waitForLoadState('networkidle'); + const set = async patch => { + await page.evaluate(patch => window.updateFixture(patch), patch); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(100); + return page.evaluate(() => { + const control = document.querySelector('[data-composer-control="model"]'); + const label = control.querySelector('.ace-composer-model-label'); + const footer = document.querySelector('.ace-composer-session-footer'); + const right = footer.querySelector('.ace-composer-session-right').getBoundingClientRect(); + const leftEdge = Math.max(...[...footer.querySelector('.ace-composer-session-left').children] + .map(element => element.getBoundingClientRect().right)); + return { compact: control.dataset.compact, text: label.textContent, + labelVisible: getComputedStyle(label).display !== 'none' && label.getBoundingClientRect().width > 0, + clipped: label.scrollWidth > label.clientWidth + 1, + overlap: leftEdge > right.left, + leftEdge, rightLeft: right.left, rootWidth: footer.getBoundingClientRect().width, rightWidth: right.width, + inlineWidth: control.style.width, flex: control.style.flex, + controls: [...footer.querySelector('.ace-composer-session-left').children].map(element => ({ + name: element.dataset.composerControl, compact: element.dataset.compact, + width: element.getBoundingClientRect().width, right: element.getBoundingClientRect().right, + })), + overflow: footer.scrollWidth > footer.clientWidth + 1, + width: control.getBoundingClientRect().width }; + }); + }; + for (const name of ['ACEModel-gpt-6-astra', 'ACEModel-a-much-longer-model-name-with-a-version-suffix']) { + let result = await set({ width: 850, name, extras: false }); + assert.equal(result.compact, 'false', JSON.stringify(result)); + assert.equal(result.clipped, false, JSON.stringify(result)); + assert.equal(result.text, name); + assert.equal(result.text.includes('backend-model'), false); + assert.equal(result.overflow, false); + await page.screenshot({ path: path.join(output, name.length > 25 ? 'wide-long.png' : 'wide.png') }); + for (const width of [480, 360, 320]) { + result = await set({ width, extras: true }); + assert.equal(result.labelVisible, true, JSON.stringify(result)); + assert.equal(result.overlap, false, JSON.stringify({ containerWidth: width, ...result })); + assert.equal(result.overflow, false, JSON.stringify({ containerWidth: width, ...result })); + } + assert.equal(result.compact, 'true', JSON.stringify(result)); + assert.equal(result.clipped, true, JSON.stringify(result)); + await page.screenshot({ path: path.join(output, 'narrow.png') }); + result = await set({ width: 850, extras: false }); + assert.equal(result.compact, 'false'); + assert.equal(result.clipped, false); + } + await page.locator('.ace-composer-model-button').click(); + await page.locator('.ace-composer-model-options').waitFor(); + assert.equal(await page.locator('.ace-composer-model-option').count(), 1); + assert.match(await page.locator('.ace-composer-model-option').innerText(), /backend-model/); + const openMenuResize = await set({ width: 900 }); + assert.equal(openMenuResize.clipped, false); + await page.keyboard.press('Escape'); + await page.setViewportSize({ width: 480, height: 500 }); + const mobile = await set({ width: 400, extras: false, name: 'ACEModel-gpt-6-astra' }); + assert.equal(mobile.compact, 'false', JSON.stringify(mobile)); + assert.equal(mobile.clipped, false, JSON.stringify(mobile)); + await page.screenshot({ path: path.join(output, 'mobile-full-name.png') }); + assert.deepEqual(errors, []); + console.log('[pass] production composer keeps full names, clips only when constrained, restores on resize, and preserves menu details'); + console.log(`Screenshots: ${output}`); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-desktop-office.mjs b/web/scripts/test-desktop-office.mjs index 7c2a3a27a..ac486941c 100644 --- a/web/scripts/test-desktop-office.mjs +++ b/web/scripts/test-desktop-office.mjs @@ -200,6 +200,23 @@ try { assert.deepEqual(await rendered(),original,'returning to a session restores its original layout and actor'); switchChecks.push('returning restores the original layout and actor'); } finally {controller.dispose();} + const pendingModel=deferred(),modelPublished=[]; + const slowModelController=createDesktopOfficeController({ + fetchSnapshot:async()=>snapshot('slow-model'),fetchModel:()=>pendingModel.promise, + publish:value=>modelPublished.push(value),schedule:()=>0,cancel:()=>{}, + }); + try { + const loading=slowModelController.refresh(); + await Promise.resolve(); + for(const state of modelPublished.splice(0))await apply(state); + const beforeModel=await rendered(); + assert.equal(beforeModel.selected,'slow-model','optional model request does not hold the target room'); + assert.deepEqual(beforeModel.agents.map(a=>a.id),['slow-model']); + pendingModel.resolve({context_window:128000});await loading;await Promise.resolve(); + for(const state of modelPublished.splice(0))await apply(state); + assert.deepEqual(await rendered(),beforeModel,'paper sizing does not rebuild the room or change its identity'); + switchChecks.push('slow model enrichment never delays room selection or rebuilds the layout'); + } finally {slowModelController.dispose();} await apply(data({complete:false})); assert.equal(await page.locator('#officeNotice').textContent(),'修复登录流程','selected title replaces the recent-session loading notice'); await apply(data({complete:false,selected:{sessionId:'root',workspaceHash:'project',title:'当前会话已改名'}})); diff --git a/web/scripts/test-responsive-sidebars.mjs b/web/scripts/test-responsive-sidebars.mjs new file mode 100644 index 000000000..ecc4fba64 --- /dev/null +++ b/web/scripts/test-responsive-sidebars.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +// Mount the production App (including TopBar, Sidebar, ChatView and persistence). +// Network fixtures are isolated from the user's daemon and browser profile. +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = process.env.ACE_RESPONSIVE_TEST_OUTPUT || await fs.mkdtemp(path.join(os.tmpdir(), 'ace-responsive-sidebars-')); +await fs.mkdir(output, { recursive: true }); +const server = await createServer({ root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0, open: false, hmr: false } }); +const session = { id: 'resize-session', session_id: 'resize-session', title: 'Resize fixture', + cwd: 'C:/fixture', workspace_hash: 'fixture', active: true, updated_at: '2026-10-17T10:00:00Z' }; +const messages = [ + { id: 'user-1', role: 'user', content: '请检查工作台缩放时的对话阅读体验。' }, + { id: 'assistant-1', role: 'assistant', content: '这里是用于验证布局的对话正文。窗口变窄时,对话应优先保留足够的阅读空间。\n\n打开 [source.js](source.js) 查看文件预览。\n\n' + '继续阅读这段内容,侧栏自动收起后,消息和输入框仍应完整可见。'.repeat(12) }, +]; +let browser; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + page.setDefaultTimeout(30000); + page.on('pageerror', e => { errors.push(e.message); console.error('[pageerror]', e.message); }); + await page.addInitScript(() => { + class FixtureWebSocket extends EventTarget { + static OPEN = 1; static CLOSED = 3; + constructor() { super(); this.readyState = 1; setTimeout(() => { this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); } + send() {} close() { this.readyState = 3; } + } + window.WebSocket = FixtureWebSocket; + if (!localStorage.getItem('responsive-fixture-seeded')) { + localStorage.setItem('acecode.uiPrefs.v1', JSON.stringify({ view: 'single', sidebarCollapsed: false, + sidePanelCollapsed: false, sidePanelListCollapsed: false, fontSize: 'medium' })); + localStorage.setItem('responsive-fixture-seeded', 'true'); + } + }); + await page.route('**/api/**', async route => { + const { pathname, searchParams } = new URL(route.request().url()); + let data = {}; + if (pathname === '/api/health') data = { status: 'ok', version: '1.0.0', cwd: 'C:/fixture' }; + else if (pathname === '/api/config/ui-preferences') data = { theme: 'light', color_theme: 'blue', font_size: 'medium' }; + else if (pathname === '/api/workspaces') data = [{ hash: 'fixture', cwd: 'C:/fixture', name: 'Resize project' }]; + else if (pathname.endsWith('/messages')) data = { messages, events: [], busy: false, title: session.title, cwd: session.cwd }; + else if (pathname.endsWith('/sessions') || pathname.endsWith('/session-sidebar')) data = pathname === '/api/session-sidebar' ? [] + : searchParams.has('parent') ? [{ ...session, id: 'resize-child', session_id: 'resize-child', title: 'Child transcript', parent_session_id: searchParams.get('parent') }] : [session]; + else if (pathname.endsWith('/resume') || /^\/api\/sessions\/[^/]+$/.test(pathname)) { + const id = pathname.match(/\/sessions\/([^/]+)/)?.[1] || session.id; + data = { ...session, id, session_id: id }; + } + else if (pathname.endsWith('/model')) data = { name: 'fixture', provider: 'openai', model: 'test' }; + else if (pathname.includes('models')) data = { models: [], profiles: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + else if (pathname.includes('loops') || pathname.includes('/pty') || pathname.includes('/themes')) data = []; + else if (pathname === '/api/files') data = [{ name: 'source.js', path: 'source.js', kind: 'file' }]; + else if (pathname.includes('/files/content')) { + await route.fulfill({ status: 200, contentType: 'text/plain', body: 'export const answer = 42;\n' }); return; + } else if (pathname.includes('/files/edit')) data = { text: 'export const answer = 42;\n', read_id: 'fixture-read' }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + const settle = async () => { + await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); + await page.waitForTimeout(300); + }; + const geometry = () => page.evaluate(() => { + const rect = selector => { + const e = document.querySelector(selector); const r = e?.getBoundingClientRect(); + return { width: r?.width || 0, x: r?.x || 0, right: r?.right || 0, visible: !!e && r.width > 0 && r.height > 0 && getComputedStyle(e).visibility !== 'hidden' }; + }; + return { left: rect('.ace-responsive-sidebar-left'), right: rect('.ace-responsive-sidebar-right'), + transcript: rect('[data-conversation-find-root]'), chat: rect('.ace-session-panel'), + popup: document.querySelector('[data-sidebar-popup]')?.getAttribute('data-sidebar-popup') || null, + overflow: document.documentElement.scrollWidth > innerWidth }; + }); + const prefs = () => page.evaluate(async () => { + const { sessionWorkbench } = await import('/src/lib/sessionWorkbench.js'); + sessionWorkbench.flush(); + return { global: JSON.parse(localStorage.getItem('acecode.uiPrefs.v1')), + panels: sessionWorkbench.get('session:resize-session', 'panels', {}), + widths: localStorage.getItem('acecode.singleLayoutWidths.v1') }; + }); + const resize = async width => { await page.setViewportSize({ width, height: 1080 }); await settle(); return geometry(); }; + const left = page.locator('[data-sidebar-toggle="left"]'); + const right = page.locator('[data-sidebar-toggle="right"]'); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/`, { waitUntil: 'commit' }); + await page.locator('.ace-sidebar-session-title-button').filter({ hasText: 'Resize fixture' }).first().click(); + await page.locator('[data-conversation-find-root]').waitFor(); + await settle(); + if (!(await geometry()).right.visible) { await right.click(); await settle(); } + let g; + if (!process.argv.includes('--popup-resize-only')) { + const initial = await prefs(); + assert.equal((await geometry()).left.visible, true); + assert.equal((await geometry()).right.visible, true); + await page.screenshot({ path: path.join(output, '1920-wide.png') }); + g = await resize(906); + assert.equal(g.left.visible, true); assert.equal(g.right.visible, true); assert.equal(g.transcript.width, 350); + g = await resize(905); + assert.equal(g.left.visible, true); assert.equal(g.right.visible, false); + g = await resize(969); assert.equal(g.right.visible, false); + g = await resize(970); assert.equal(g.right.visible, true); + g = await resize(626); + assert.equal(g.left.visible, true); assert.equal(g.right.visible, false); assert.equal(g.transcript.width, 350); + g = await resize(625); assert.equal(g.left.visible, false); + g = await resize(689); assert.equal(g.left.visible, false); + g = await resize(690); assert.equal(g.left.visible, true); + g = await resize(600); + assert.equal(g.left.visible, false); assert.equal(g.right.visible, false); assert.ok(g.transcript.width >= 350, JSON.stringify(g)); + await page.screenshot({ path: path.join(output, '600-transcript.png') }); + for (const side of ['left', 'right']) { + const button = side === 'left' ? left : right; + const before = await geometry(); await button.click(); await settle(); g = await geometry(); + assert.equal(g.popup, side); assert.equal(g.transcript.width, before.transcript.width); + await page.locator(`[data-sidebar-popup="${side}"]`).click({ position: { x: 30, y: 10 } }); + assert.equal((await geometry()).popup, side); + await page.locator('[data-conversation-find-root]').click({ position: { x: side === 'left' ? 450 : 20, y: 180 } }); + await settle(); assert.equal((await geometry()).popup, null); + await button.click(); await settle(); await page.keyboard.press('Escape'); await settle(); + assert.equal((await geometry()).popup, null); assert.equal(await button.evaluate(e => e === document.activeElement), true); + if (side === 'right') { + await right.click(); await settle(); + await page.locator('[data-sidebar-popup="right"]').getByRole('button', { name: '收起列表面板', exact: true }).click(); + await settle(); assert.equal((await geometry()).popup, null); + assert.equal(await right.getAttribute('aria-expanded'), 'false'); + } + } + await left.click(); await settle(); await right.click(); await settle(); assert.equal((await geometry()).popup, 'right'); + await right.click(); await settle(); assert.equal((await geometry()).popup, null); + assert.deepEqual(await prefs(), initial); + g = await resize(1920); assert.equal(g.left.visible, true); assert.equal(g.right.visible, true); + console.log('[pass] production App collapses right then left, popup does not squeeze transcript, outside/Escape/toggle/mutual exclusion preserve preferences'); + + for (const leftClosed of [false, true]) for (const rightClosed of [false, true]) { + g = await geometry(); if (g.left.visible === leftClosed) await left.click(); if (g.right.visible === rightClosed) await right.click(); await settle(); + const saved = await prefs(); + await resize(600); await left.click(); await settle(); await right.click(); await settle(); + await resize(1920); g = await geometry(); + assert.equal(g.left.visible, !leftClosed, JSON.stringify({ g, prefs: await prefs(), saved })); + assert.equal(g.right.visible, !rightClosed, JSON.stringify({ g, prefs: await prefs(), saved })); + assert.deepEqual(await prefs(), saved); + await resize(600); + await page.evaluate(() => history.replaceState(null, '', '/?open=resize-session&workspace=fixture')); + await page.reload({ waitUntil: 'commit' }); + await page.locator('[data-conversation-find-root]').waitFor(); await settle(); + await resize(1920); g = await geometry(); + assert.equal(g.left.visible, !leftClosed); assert.equal(g.right.visible, !rightClosed); + assert.deepEqual(await prefs(), saved); + } + console.log('[pass] all four open/closed combinations survive popup, resize and reload'); + + // Opening a real file changes the right-hand width requirement without resize. + await left.click(); await right.click(); await settle(); + // Exercise the shared Desktop editor in Chromium without starting a native shell. + await page.evaluate(() => { window.__ACECODE_DESKTOP_SHELL__ = true; }); + await page.locator('[data-file-path="source.js"]').first().click(); + await page.locator('.ace-preview-details-shell').waitFor(); await settle(); + const editor = page.locator('.ace-file-text-editor'); + await editor.waitFor(); await editor.fill('export const edited = 123;\n'); + await editor.evaluate(e => { e.setSelectionRange(5, 10); window.responsiveSavedEditor = e; }); + const beforePreviewResize = await prefs(); + g = await resize(1300); assert.equal(g.right.visible, false); assert.ok(g.transcript.width >= 350); + await right.click(); await settle(); + assert.equal((await geometry()).popup, 'right'); + assert.equal(await editor.inputValue(), 'export const edited = 123;\n'); + assert.equal(await editor.evaluate(e => e === window.responsiveSavedEditor), true); + await page.screenshot({ path: path.join(output, '1300-preview-popup.png') }); + g = await resize(800); assert.equal(g.popup, 'right'); assert.ok(g.right.right <= 800); + await resize(600); await editor.waitFor(); + const expandList = page.locator('.ace-preview-details-panel').getByRole('button', { name: '展开列表面板', exact: true }); + await expandList.click(); await settle(); + assert.equal(await page.locator('.ace-popup-navigation').isVisible(), true); + await page.locator('.ace-popup-navigation').getByRole('button', { name: '收起列表面板', exact: true }).click(); + await settle(); assert.equal((await geometry()).popup, 'right'); + assert.equal(await editor.isVisible(), true); + await page.locator('[data-conversation-find-root]').click({ position: { x: 5, y: 150 } }); await settle(); + assert.equal((await geometry()).popup, null); + g = await resize(1920); assert.equal(g.right.visible, true); + assert.equal(await editor.inputValue(), 'export const edited = 123;\n'); + assert.equal(await editor.evaluate(e => e === window.responsiveSavedEditor), true); + assert.deepEqual(await prefs(), beforePreviewResize); + console.log('[pass] real preview width, compact popup navigation, editor DOM and unsaved draft survive responsive transitions'); + + // A left-sidebar menu is a React portal outside the popup's DOM subtree. + await resize(600); await left.click(); await settle(); + await page.getByRole('button', { name: 'ACECode 快捷菜单', exact: true }).click(); + const menu = page.locator('[data-shortcut-menu="true"]'); await menu.waitFor(); + await menu.dispatchEvent('pointerdown', { bubbles: true, pointerId: 1 }); + assert.equal((await geometry()).popup, 'left'); + await page.keyboard.press('Escape'); await settle(); assert.equal((await geometry()).popup, 'left'); + await page.keyboard.press('Escape'); await settle(); assert.equal((await geometry()).popup, null); + console.log('[pass] portal menu interaction and nested Escape keep the sidebar alive until the outer dismissal'); + + for (const theme of ['light', 'dark']) { + await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); + for (const width of [1920, 1100, 800, 320]) { + g = await resize(width); assert.equal(g.overflow, false, JSON.stringify(g)); + assert.ok(g.transcript.width >= Math.min(350, width - 6) - 1, JSON.stringify(g)); + if (width === 320) { + await right.click(); await settle(); g = await geometry(); + assert.ok(g.right.right <= width && g.right.x >= 0, JSON.stringify(g)); + } + await page.screenshot({ path: path.join(output, `${theme}-${width}.png`) }); + if (g.popup) { await page.keyboard.press('Escape'); await settle(); } + } + } + console.log('[pass] light/dark at 1920, 1100, 800 and 320 CSS pixels keep transcript and popup inside the viewport'); + + await resize(1920); + await page.getByRole('button', { name: '最大化预览面板', exact: true }).click(); await settle(); + assert.equal((await geometry()).transcript.visible, false); + await resize(1200); assert.equal(await editor.isVisible(), true); + await page.getByRole('button', { name: '还原预览面板', exact: true }).click(); await settle(); + assert.equal((await geometry()).transcript.visible, true); + await resize(1920); await editor.fill('export const answer = 42;\n'); await settle(); + await page.getByRole('button', { name: '隐藏详情面板', exact: true }).click(); await settle(); + await resize(1000); + await page.getByRole('button', { name: '后台任务', exact: true }).click(); await settle(); + g = await geometry(); assert.equal(g.right.visible, false); assert.equal(g.left.visible, false); + assert.ok(g.transcript.width >= 350, JSON.stringify(g)); + await page.getByRole('button', { name: '后台任务', exact: true }).click(); await settle(); + console.log('[pass] explicit preview maximization remains usable and child transcript pressure collapses sidebars'); + + const originalOwnerPrefs = await prefs(); + await resize(600); await right.click(); await settle(); + await page.evaluate(() => window.aceDesktop_focusSessionFromBridge('resize-other', 'fixture')); + await settle(); assert.equal((await geometry()).popup, null); + assert.deepEqual(await prefs(), originalOwnerPrefs); + await page.evaluate(() => window.aceDesktop_focusSessionFromBridge('resize-session', 'fixture')); + await settle(); assert.equal((await geometry()).popup, null); + console.log('[pass] changing workbench owners discards temporary popup without changing the original owner preferences'); + } + + // Exercise the actual popup edges; floating preferences must never change + // the geometry used to decide whether a sidebar can dock. + await resize(600); + const resizeBase = await prefs(); + const withoutPopupWidths = value => { + const widths = JSON.parse(value.widths); + delete widths.sidebarPopup; delete widths.rightPanelPopup; + return { ...value, widths }; + }; + const dragPopup = async (side, delta) => { + const before = await geometry(); + const handle = page.locator(`[data-popup-resize="${side}"]`); + const box = await handle.boundingBox(); + const x = box.x + box.width / 2, y = box.y + 160; + await page.mouse.move(x, y); await page.mouse.down(); + await page.mouse.move(x + delta, y, { steps: 8 }); await settle(); + const during = await geometry(); + assert.equal(during.popup, side); + assert.equal(during.transcript.width, before.transcript.width); + await page.mouse.up(); await settle(); + assert.equal(await page.evaluate(() => document.body.classList.contains('ace-resizing')), false); + return geometry(); + }; + await left.click(); await settle(); + const initialLeftWidth = (await geometry()).left.width; + g = await dragPopup('left', 170); assert.equal(g.left.width, initialLeftWidth + 170); + await left.click(); await left.click(); await settle(); + assert.equal((await geometry()).left.width, initialLeftWidth + 170); + g = await dragPopup('left', -700); assert.equal(g.left.width, 160); assert.equal(g.popup, 'left'); + await page.locator('[data-popup-resize="left"]').focus(); await page.keyboard.press('ArrowRight'); await settle(); + assert.equal((await geometry()).left.width, 172); + await dragPopup('left', 400); + const wideLeftPrefs = await prefs(); + g = await resize(480); assert.equal(g.left.width, 456); + assert.deepEqual(await prefs(), wideLeftPrefs); + g = await dragPopup('left', -20); assert.equal(g.left.width, 436, 'drag starts at the rendered, clamped edge'); + g = await resize(600); assert.equal(g.left.width, 436); + await page.screenshot({ path: path.join(output, 'left-popup-resized.png') }); + await left.click(); await settle(); + console.log('[pass] left popup supports drag/keyboard, remains floating even at 160px, and starts clamped drags without jumping'); + + await right.click(); await settle(); + const initialRightWidth = (await geometry()).right.width; + g = await dragPopup('right', -120); assert.equal(g.right.width, initialRightWidth + 120); assert.equal(g.right.right, 600); + assert.equal(await page.locator('.ace-side-panel-shell').evaluate(e => e.getBoundingClientRect().width), g.right.width); + await page.locator('[data-popup-resize="right"]').focus(); await page.keyboard.press('ArrowLeft'); await settle(); + const filePopupWidth = (await geometry()).right.width; + assert.equal(filePopupWidth, initialRightWidth + 132); + await right.click(); await right.click(); await settle(); assert.equal((await geometry()).right.width, filePopupWidth); + console.log('[pass] navigation-only popup expands its content with the outer frame and remembers the selected width'); + + await page.evaluate(() => { window.__ACECODE_DESKTOP_SHELL__ = true; }); + await page.locator('[data-file-path="source.js"]').first().click(); + const resizeEditor = page.locator('.ace-file-text-editor'); await resizeEditor.waitFor(); + await resizeEditor.fill('export const resizedDraft = 321;\n'); + await resizeEditor.evaluate(e => { window.popupResizeEditor = e; }); + g = await resize(1300); + const detailStartWidth = g.right.width; + g = await dragPopup('right', -160); assert.equal(g.right.width, detailStartWidth + 160); assert.equal(g.right.right, 1300); + assert.equal(await resizeEditor.evaluate(e => e === window.popupResizeEditor), true); + assert.equal(await resizeEditor.inputValue(), 'export const resizedDraft = 321;\n'); + await page.screenshot({ path: path.join(output, 'detail-popup-resized.png') }); + assert.deepEqual(withoutPopupWidths(await prefs()), withoutPopupWidths(resizeBase)); + + // Closing or cancelling in the middle of a captured drag releases all state. + let handle = page.locator('[data-popup-resize="right"]'); + let box = await handle.boundingBox(); + await page.mouse.move(box.x + 4, box.y + 160); await page.mouse.down(); + await page.keyboard.press('Escape'); await settle(); + assert.equal((await geometry()).popup, null); + assert.equal(await page.evaluate(() => document.body.classList.contains('ace-resizing')), false); + const afterClose = await prefs(); await page.mouse.move(box.x - 80, box.y + 160); await page.mouse.up(); await settle(); + assert.deepEqual(await prefs(), afterClose); + await right.click(); await settle(); + handle = page.locator('[data-popup-resize="right"]'); box = await handle.boundingBox(); + await handle.evaluate(e => e.addEventListener('pointerdown', event => { window.popupDragPointer = event.pointerId; }, { once: true })); + await page.mouse.move(box.x + 4, box.y + 160); await page.mouse.down(); + await handle.dispatchEvent('pointercancel', { bubbles: true, pointerId: await page.evaluate(() => window.popupDragPointer) }); + assert.equal(await page.evaluate(() => document.body.classList.contains('ace-resizing')), false); + const afterCancel = await prefs(); await page.mouse.move(box.x - 80, box.y + 160); await page.mouse.up(); await settle(); + assert.deepEqual(await prefs(), afterCancel); assert.equal((await geometry()).popup, 'right'); + console.log('[pass] preview drag preserves transcript and draft; Escape and pointercancel release capture and listeners'); + + const savedPopupPrefs = await prefs(); + const savedPopupWidth = (await geometry()).right.width; + g = await resize(480); assert.equal(g.right.width, 456); assert.deepEqual(await prefs(), savedPopupPrefs); + g = await resize(900); assert.equal(g.right.width, savedPopupWidth); + await page.evaluate(() => history.replaceState(null, '', '/?open=resize-session&workspace=fixture')); + await page.reload({ waitUntil: 'commit' }); await page.locator('[data-conversation-find-root]').waitFor(); await settle(); + await right.click(); await settle(); assert.equal((await geometry()).right.width, savedPopupWidth); + assert.deepEqual(await prefs(), savedPopupPrefs); + await resize(600); + await left.click(); await settle(); assert.equal((await geometry()).left.width, 436); + g = await resize(1920); assert.equal(g.popup, null); assert.equal(g.left.width, 270); + assert.deepEqual(withoutPopupWidths(await prefs()), withoutPopupWidths(resizeBase)); + console.log('[pass] popup widths persist across reload and temporary viewport clamps without changing docked widths or open/closed preferences'); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ output, status: 'passed' })); +} catch (error) { + if (browser) { + const page = browser.contexts()[0]?.pages()[0]; + if (page) { await page.screenshot({ path: path.join(output, 'failure.png') }); + await fs.writeFile(path.join(output, 'failure.html'), await page.content()); } + } + console.error('Artifacts:', output); throw error; +} finally { await browser?.close(); server.httpServer?.closeAllConnections(); await server.close(); } diff --git a/web/src/App.jsx b/web/src/App.jsx index 0b39d37e2..c2fe8a139 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -110,6 +110,9 @@ import { SettingsPage } from './components/SettingsPage.jsx'; import { WorkspaceCleanupNotice } from './components/WorkspaceCleanupNotice.jsx'; import { DesktopContextMenu } from './components/DesktopContextMenu.jsx'; import { usePreviewPresentation } from './lib/usePreviewPresentation.js'; +import { useResponsiveSidebars } from './lib/useResponsiveSidebars.js'; +import { clampPopupWidth, popupWidthRange } from './lib/responsiveSidebars.js'; +import { ResponsiveSidebar } from './components/ResponsiveSidebar.jsx'; import { Toaster, toast } from './components/Toast.jsx'; import { SlashCommandsProvider } from './components/SlashCommandsContext.jsx'; import { FramelessResizeHandles } from './components/FramelessResizeHandles.jsx'; @@ -134,6 +137,7 @@ import { nextHomeLogoEffectEnabled } from './lib/homeLogoEffectPolicy.js'; import { homeRefFromWorkspace, noHomeWorkspaceOption } from './lib/homeWorkspaceSelection.js'; import { DEFAULT_SINGLE_LAYOUT, + MIN_SIDEBAR_WIDTH, normalizeSingleLayoutPreference, normalizePreviewPanelWidth, normalizeSidePanelWidth, @@ -512,6 +516,14 @@ export function App() { }); const [guidedTourPreparing, setGuidedTourPreparing] = useState(false); const [guidedTourRun, setGuidedTourRun] = useState(false); + const responsiveSidebars = useResponsiveSidebars({ + shellRef: singleShellRef, + owner: workbenchOwner, + enabled: view === 'single' && authState === 'ok' && !guidedTourPreparing && !guidedTourRun, + widths: singleLayout, + preferences: uiPrefs, + previewAutoFit: !previewPanelUserSized, + }); const [guidedTourForced, setGuidedTourForced] = useState(false); const guidedTourAutoAttemptedRef = useRef(false); const homeLogoActiveSessionId = sessionJumpId(activeRef || {}); @@ -1497,15 +1509,21 @@ export function App() { }, [probe]); const toggleSidePanel = useCallback(() => { + if (!responsiveSidebars.canDockRight || responsiveSidebars.popup === 'right') { + responsiveSidebars.togglePopup('right'); + return; + } + responsiveSidebars.closePopup(); const approval = rightPanelHidden(uiPrefs, previewPanelVisible) ? true : requestPreviewLeave(); return runAfterFileApproval(approval, () => { setUiPrefs((prev) => toggleRightPanel(prev, previewPanelVisible)); }); - }, [previewPanelVisible, requestPreviewLeave, setUiPrefs, uiPrefs]); + }, [previewPanelVisible, requestPreviewLeave, setUiPrefs, uiPrefs, responsiveSidebars]); const revealPreviewPanel = useCallback(() => { + if (responsiveSidebars.requestRightPopup(true)) return; setUiPrefs(revealRightPanelDetails); - }, [setUiPrefs]); + }, [setUiPrefs, responsiveSidebars.requestRightPopup]); const toggleSidePanelList = useCallback(() => { setUiPrefs((prev) => ({ @@ -1515,6 +1533,7 @@ export function App() { }, [setUiPrefs]); const revealSidePanelList = useCallback(() => { + if (responsiveSidebars.requestRightPopup()) return; setUiPrefs((prev) => { if (!prev.sidePanelCollapsed && !effectiveSidePanelListCollapsed(prev)) return prev; return { @@ -1523,7 +1542,7 @@ export function App() { sidePanelListCollapsed: false, }; }); - }, [setUiPrefs]); + }, [setUiPrefs, responsiveSidebars.requestRightPopup]); // 最大化 / 还原中间预览面板。沿用旧字段名保存偏好,但 UI 控件已迁到预览面板。 // 最大化时强制确保右侧 SidePanel 未折叠,符合"右侧文件栏仍然可用"的行为。 @@ -1539,11 +1558,16 @@ export function App() { }, [setUiPrefs]); const toggleProjectSidebar = useCallback(() => { + if (!responsiveSidebars.canDockLeft || responsiveSidebars.popup === 'left') { + responsiveSidebars.togglePopup('left'); + return; + } + responsiveSidebars.closePopup(); setUiPrefs((prev) => ({ ...prev, sidebarCollapsed: !prev.sidebarCollapsed, })); - }, [setUiPrefs]); + }, [setUiPrefs, responsiveSidebars]); const openUpdateDialog = useCallback(() => { if (!updateJobIsActive(updateJob) @@ -2022,6 +2046,13 @@ export function App() { }, } : {}); + const setSidebarPopupWidth = useCallback((width) => { + setSingleLayout((prev) => width > 0 && prev.sidebarPopup !== width ? { ...prev, sidebarPopup: width } : prev); + }, [setSingleLayout]); + const setRightPanelPopupWidth = useCallback((width) => { + setSingleLayout((prev) => width > 0 && prev.rightPanelPopup !== width ? { ...prev, rightPanelPopup: width } : prev); + }, [setSingleLayout]); + const setSidebarWidth = useCallback((nextWidth, shellWidth = 0) => { const sidePanelVisible = !sidePanelNavigationCollapsed; setSingleLayout((prev) => { @@ -2222,7 +2253,14 @@ export function App() { } const sidebarCollapsed = view !== 'single' - || (projectSidebarCollapsed && !guidedTourPreparing && !guidedTourRun); + || ((projectSidebarCollapsed || responsiveSidebars.leftAutoCollapsed) + && !guidedTourPreparing && !guidedTourRun); + const sidebarPopup = responsiveSidebars.popup === 'left'; + const rightSidebarPopup = responsiveSidebars.popup === 'right'; + const sidebarPopupRange = popupWidthRange(responsiveSidebars.shellWidth, MIN_SIDEBAR_WIDTH); + const renderedSidebarWidth = sidebarPopup + ? clampPopupWidth(singleLayout.sidebarPopup ?? singleLayout.sidebar, sidebarPopupRange) + : singleLayout.sidebar; const nativeSurfacesVisible = !showSettings && !desktopAnnouncementOpen @@ -2277,10 +2315,10 @@ export function App() { onToggleConsole={toggleConsoleDock} consoleAvailable={consoleAvailable} consoleOpen={consoleDock.open} - rightPanelCollapsed={rightPanelHidden(uiPrefs, previewPanelVisible)} + rightPanelCollapsed={!rightSidebarPopup && (responsiveSidebars.rightAutoCollapsed || rightPanelHidden(uiPrefs, previewPanelVisible))} onToggleRightPanel={toggleSidePanel} - sidebarCollapsed={sidebarCollapsed} - sidebarWidth={singleLayout.sidebar} + sidebarCollapsed={sidebarCollapsed && !sidebarPopup} + sidebarWidth={sidebarPopup ? 0 : singleLayout.sidebar} onToggleSidebar={toggleProjectSidebar} onGoBack={goBackActiveRef} onGoForward={goForwardActiveRef} @@ -2294,6 +2332,10 @@ export function App() { activeRef?.expertComponents ? 'ace-expert-components-shell' : '', ].filter(Boolean).join(' ')} > + {view === 'single' && !sidebarCollapsed && (
- {label} + {basenameForPath(label)} )} diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index fa984ffa3..d09e22353 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -237,6 +237,9 @@ import { notifySessionListChanged } from '../lib/sessionListEvents.js'; import { DEFAULT_SUBAGENT_PANEL_WIDTH, MIN_CHAT_WIDTH, + MIN_PREVIEW_PANEL_WIDTH, + MIN_SIDE_PANEL_WIDTH, + MIN_SUBAGENT_PANEL_WIDTH, normalizeSubagentPanelWidth, solveSingleContentLayout, subagentPanelWidthRange, @@ -293,6 +296,9 @@ import { import { createUnsavedFileGuard, runAfterFileApproval } from '../lib/unsavedFileGuard.js'; import { sessionWorkbench } from '../lib/sessionWorkbench.js'; import { useWorkbenchState } from '../lib/useWorkbenchState.js'; +import { ResponsiveSidebar } from './ResponsiveSidebar.jsx'; +import { clampPopupWidth, popupWidthRange } from '../lib/responsiveSidebars.js'; +import { notifyNativeSurfaceOverlayChange } from '../lib/agentBrowserSurfaceCoordinator.js'; import { transferPreviewTabs } from '../lib/previewTabs.js'; import { AGENT_BROWSER_STATE_EVENT, @@ -641,7 +647,7 @@ const EXPERT_SWITCH_CANONICAL_POLL_ATTEMPTS = 6; const EXPERT_SWITCH_CANONICAL_POLL_INTERVAL_MS = 160; const FORK_ACTION_KEY = 'fork-session'; -export function ChatView({ titleTarget, actionsTarget, children, sessionRef, sessionId, homeLogoEffectEnabled = true, homeComposerDrafts = {}, homeComposerAttentionRequest = 0, onHomeComposerDraftLoad, onHomeComposerDraftChange, onHomeComposerDraftAccepted, onHomeComposerDraftPatch, modelProfileRevision = 0, onSessionPromoted, onSessionExpertChanged, onHomeWorkspaceChange, onCommandWorkspaceChange, onConsoleCwdChange, onFindInConversation, onOpenModelSettings, health, autoFocusOnDesktopWindowFocus = false, onPermissionRequest, onQuestionRequest, permissionRequests = [], onPermissionDecision, questionRequest, onQuestionResolve, onPermissionModeChanged, onSubagentTasksChange, recentExpertIds = [], onRememberExpert, onInitialDraftConsumed, showSidePanel = false, sidePanelWidth = 280, onSidePanelResize, previewPanelWidth = 640, previewPanelAutoFit = false, onPreviewPanelResize, subagentPanelWidth = DEFAULT_SUBAGENT_PANEL_WIDTH, onSubagentPanelResize, onPreviewPanelVisibleChange, onRegisterPreviewLeaveGuard, sidePanelCollapsed = false, sidePanelListCollapsed = false, onToggleSidePanel, onRevealPreviewPanel, onToggleSidePanelList, onRevealSidePanelList, sidePanelMaximized = false, onToggleSidePanelMaximized, previewPresenting = false, onTogglePreviewPresentation, showAceCodeAvatar = false, showSessionTime = true, messageAutoCollapse = true, nativeSurfacesVisible = true }) { +export function ChatView({ titleTarget, actionsTarget, children, sessionRef, sessionId, homeLogoEffectEnabled = true, homeComposerDrafts = {}, homeComposerAttentionRequest = 0, onHomeComposerDraftLoad, onHomeComposerDraftChange, onHomeComposerDraftAccepted, onHomeComposerDraftPatch, modelProfileRevision = 0, onSessionPromoted, onSessionExpertChanged, onHomeWorkspaceChange, onCommandWorkspaceChange, onConsoleCwdChange, onFindInConversation, onOpenModelSettings, health, autoFocusOnDesktopWindowFocus = false, onPermissionRequest, onQuestionRequest, permissionRequests = [], onPermissionDecision, questionRequest, onQuestionResolve, onPermissionModeChanged, onSubagentTasksChange, recentExpertIds = [], onRememberExpert, onInitialDraftConsumed, showSidePanel = false, responsiveSidebars = null, rightPanelPopupWidth, onRightPanelPopupResize, sidePanelWidth = 280, onSidePanelResize, previewPanelWidth = 640, previewPanelAutoFit = false, onPreviewPanelResize, subagentPanelWidth = DEFAULT_SUBAGENT_PANEL_WIDTH, onSubagentPanelResize, onPreviewPanelVisibleChange, onRegisterPreviewLeaveGuard, sidePanelCollapsed = false, sidePanelListCollapsed = false, onToggleSidePanel, onRevealPreviewPanel, onToggleSidePanelList, onRevealSidePanelList, sidePanelMaximized = false, onToggleSidePanelMaximized, previewPresenting = false, onTogglePreviewPresentation, showAceCodeAvatar = false, showSessionTime = true, messageAutoCollapse = true, nativeSurfacesVisible = true }) { const ref = useMemo(() => normalizeSessionRef(sessionRef, sessionId), [sessionRef, sessionId]); const sid = ref?.sessionId || ref?.id || ''; const workbenchOwner = sessionWorkbench.ownerFor(ref); @@ -951,6 +957,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const layoutRef = useRef(null); const subagentSplitRef = useRef(null); const [layoutWidth, setLayoutWidth] = useState(0); + const [popupNavigationCollapsed, setPopupNavigationCollapsed] = useState(null); + const rightPopup = responsiveSidebars?.popup === 'right'; + const rightDocked = !rightPopup && !responsiveSidebars?.rightAutoCollapsed; + const reportResponsiveContent = responsiveSidebars?.reportContent; + useEffect(() => { setPopupNavigationCollapsed(null); }, [rightPopup, workbenchOwner]); const sidePanelResizeActiveRef = useRef(false); const previewPanelResizeActiveRef = useRef(false); const subagentPanelResizeActiveRef = useRef(false); @@ -5296,7 +5307,6 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const sidePanelMounted = showSidePanel; - const sidePanelNavigationCollapsed = sidePanelCollapsed || sidePanelListCollapsed; const previewScope = useMemo( () => { if (!sidePanelFilesEnabled) return sid || ''; @@ -5379,7 +5389,32 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const previewTabsOpen = previewTabs.length > 0; // 总开关必须连最大化详情一起隐藏;恢复时仍保留最大化偏好与原页签。 const previewPanelVisible = previewTabsOpen && !sidePanelCollapsed && !previewPanelHidden; - const previewPanelMaximized = sidePanelMaximized && previewPanelVisible; + const previewPanelMaximized = sidePanelMaximized && previewPanelVisible && rightDocked; + const rightPopupRange = popupWidthRange(layoutWidth, previewPanelVisible ? MIN_PREVIEW_PANEL_WIDTH : MIN_SIDE_PANEL_WIDTH); + const rightPopupWidth = clampPopupWidth(rightPanelPopupWidth + ?? ((previewPanelVisible ? previewPanelWidth : 0) + (!sidePanelListCollapsed ? sidePanelWidth : 0)), rightPopupRange); + const popupNavigationWidth = previewPanelVisible ? Math.min(sidePanelWidth, rightPopupWidth) : rightPopupWidth; + const compactRightPopup = rightPopup && previewPanelVisible + && rightPopupWidth < MIN_PREVIEW_PANEL_WIDTH + sidePanelWidth; + const sidePanelNavigationCollapsed = sidePanelCollapsed || (rightPopup + ? (popupNavigationCollapsed ?? (compactRightPopup || sidePanelListCollapsed)) + : sidePanelListCollapsed); + const toggleVisibleSidePanelList = useCallback(() => { + if (rightPopup) setPopupNavigationCollapsed(!sidePanelNavigationCollapsed); + else onToggleSidePanelList?.(); + }, [rightPopup, sidePanelNavigationCollapsed, onToggleSidePanelList]); + useLayoutEffect(() => { + notifyNativeSurfaceOverlayChange(); + }, [compactRightPopup, sidePanelNavigationCollapsed]); + useLayoutEffect(() => { + if (rightPopup && !previewPanelVisible && sidePanelNavigationCollapsed) responsiveSidebars.closePopup(); + }, [rightPopup, previewPanelVisible, sidePanelNavigationCollapsed, responsiveSidebars?.closePopup]); + useLayoutEffect(() => { + reportResponsiveContent?.({ + previewAvailable: previewTabsOpen && !previewPanelHidden, + subagentOpen: !!sid && subagentPanelOpen, + }); + }, [reportResponsiveContent, previewTabsOpen, previewPanelHidden, sid, subagentPanelOpen]); const previewCloseConfirmMessage = previewCloseConfirm ? `${previewCloseConfirm.dirtyCount} 个文件有未保存的修改。是否保存后继续?` : ''; @@ -5396,11 +5431,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses contentWidth: layoutWidth, sidePanelWidth, previewPanelWidth, - sidePanelVisible: sidePanelMounted, + sidePanelVisible: sidePanelMounted && rightDocked, sidePanelCollapsed: sidePanelNavigationCollapsed, - previewPanelVisible, + previewPanelVisible: previewPanelVisible && rightDocked, previewPanelMaximized, previewPanelAutoFit, + minimumChatWidth: responsiveSidebars?.minimumChatWidth, }), [ layoutWidth, previewPanelAutoFit, @@ -5410,12 +5446,17 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses sidePanelNavigationCollapsed, sidePanelMounted, sidePanelWidth, + rightDocked, + responsiveSidebars?.minimumChatWidth, ]); const effectiveChatWidth = layoutWidth > 0 ? contentLayout.chatWidth : 0; const effectivePreviewPanelWidth = layoutWidth > 0 ? contentLayout.previewPanelWidth : previewPanelWidth; const effectiveSidePanelWidth = layoutWidth > 0 ? contentLayout.sidePanelWidth : sidePanelWidth; const renderedSubagentPanelWidth = normalizeSubagentPanelWidth( - subagentPanelWidth, + responsiveSidebars + ? Math.min(subagentPanelWidth, Math.max(MIN_SUBAGENT_PANEL_WIDTH, + effectiveChatWidth - responsiveSidebars.transcriptMinimum - 6)) + : subagentPanelWidth, effectiveChatWidth, ); const subagentPanelRange = subagentPanelWidthRange(effectiveChatWidth); @@ -5449,10 +5490,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses path: location.path, line, }), () => { - if (sidePanelCollapsed) onToggleSidePanel?.(); + if (!responsiveSidebars?.requestRightPopup(true) && sidePanelCollapsed) onToggleSidePanel?.(); + setPopupNavigationCollapsed(null); setPreviewPanelHidden(false); }); - }, [previewContext, previewScope, selectPreview, sidePanelCwd, sidePanelCollapsed, onToggleSidePanel]); + }, [previewContext, previewScope, selectPreview, sidePanelCwd, sidePanelCollapsed, onToggleSidePanel, responsiveSidebars?.requestRightPopup]); // transcript 里本地文件链接的兜底入口。assistant 气泡在 Message.jsx 里自带 // 拦截,但同一片区域还有别的 markdown 渲染位置没有各自的拦截器 @@ -5681,10 +5723,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses : changeSummary.fileCount, turnUserMessageId, }), () => { - if (sidePanelCollapsed) onToggleSidePanel?.(); + if (!responsiveSidebars?.requestRightPopup(true) && sidePanelCollapsed) onToggleSidePanel?.(); + setPopupNavigationCollapsed(null); setPreviewPanelHidden(false); }); - }, [changeSummary.fileCount, onToggleSidePanel, previewScope, selectPreview, sid, sidePanelCollapsed, turnChangeSets]); + }, [changeSummary.fileCount, onToggleSidePanel, previewScope, selectPreview, sid, sidePanelCollapsed, turnChangeSets, responsiveSidebars?.requestRightPopup]); // git 变更点击文件 → 在中间详情栏开/聚焦「变更」页签(复刻会话级变更旧行为)。 // gitBase 只有从 SidePanel 导航列表点击时才带;详情栏内点文件不带,由 @@ -5698,10 +5741,11 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses expandedFile: filePath, fileCount: gitFileCount, }), () => { - if (sidePanelCollapsed) onToggleSidePanel?.(); + if (!responsiveSidebars?.requestRightPopup(true) && sidePanelCollapsed) onToggleSidePanel?.(); + setPopupNavigationCollapsed(null); setPreviewPanelHidden(false); }); - }, [onToggleSidePanel, previewContext, selectPreview, sidePanelCollapsed, sidePanelCwd]); + }, [onToggleSidePanel, previewContext, selectPreview, sidePanelCollapsed, sidePanelCwd, responsiveSidebars?.requestRightPopup]); // SidePanel 切基线时,若「变更」页签已打开则同步其 base(详情栏跟着换比较对象)。 const updateGitChangeBase = useCallback((gitBase) => { @@ -6048,10 +6092,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses width: 'auto', } : { - width: Math.max(0, effectivePreviewPanelWidth), + width: rightPopup ? rightPopupWidth - (compactRightPopup || sidePanelNavigationCollapsed ? 0 : sidePanelWidth) + : Math.max(0, effectivePreviewPanelWidth), }; const sidePanelShellStyle = { - width: sidePanelNavigationCollapsed ? 0 : Math.max(0, effectiveSidePanelWidth), + width: sidePanelNavigationCollapsed ? 0 : (rightPopup ? popupNavigationWidth + : Math.max(0, effectiveSidePanelWidth)), }; return ( @@ -6515,7 +6561,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses
)} - {previewPanelVisible && !previewPanelMaximized && ( +