From 9a64783b63047cb776a8295e6a7404230004730d Mon Sep 17 00:00:00 2001 From: juelongchen Date: Thu, 1 Oct 2026 21:25:05 +0800 Subject: [PATCH 01/10] fix(macos): show full-height Markdown outline by default (#1002) --- docs/development/platform-parity-matrix.csv | 1 + docs/development/platform-parity-matrix.md | 9 +++--- .../Resources/MarkdownPreview/preview.css | 8 ++++-- .../Resources/MarkdownPreview/preview.js | 28 ++++++++++++------- shared/platform-feature-matrix.json | 23 +++++++++++++++ 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 1a5bd70e4..7886c51b3 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -69,6 +69,7 @@ terminal-sessions,工作台,终端,多会话与标签,已实现,待验证,已实 terminal-interaction,工作台,终端,复制粘贴、搜索与调整大小,已实现,待验证,已实现,待验证,Terminal,验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal terminal-long-command-input,工作台,终端,Windows Shell 超长命令输入,平台专属,不适用,已实现,待验证,Terminal,在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。,仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。,macos/Sources/Lithe/Views/Terminal,windows/tauri/crates/terminal/src/connection.rs; windows/tauri/src-tauri/src/terminal.rs markdown-rendering,编辑器,Markdown,Markdown 预览与富文本渲染,已实现,待验证,已实现,待验证,Editor,使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。,,macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift; macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift,windows/tauri/src/features/editor/markdown +markdown-preview-outline,编辑器,Markdown,Markdown 预览目录默认展开、全高滚动与标题跳转,已实现,已验证,未实现,不适用,Editor,在 macOS 原生 Lithe 打开含 60 节标题的 Markdown,验证默认展开、全高目录独立滚动、末尾标题跳转后保持展开、按钮与 Escape 收起;分栏中编辑保存及外部刷新保留折叠选择,删除全部标题隐藏目录,恢复标题保留展开偏好;检查短文档与无标题文档、宽预览正文避让和窄预览可收起。Windows 目录入口待实现。,,macos/Sources/Lithe/Resources/MarkdownPreview/preview.js; macos/Sources/Lithe/Resources/MarkdownPreview/preview.css,windows/tauri/src/features/editor/markdown/markdown-preview.tsx markdown-images-links,编辑器,Markdown,图片导入与链接定位,已实现,待验证,已实现,待验证,Editor,验证本地图片、远程图片、相对链接和打开源文件行为。,,macos/Sources/Lithe/Services/Markdown; macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift,windows/tauri/src/features/editor/markdown; windows/tauri/src/features/viewer history-snapshots,工作台,本地历史,快照列表与 Diff,已实现,待验证,已实现,待验证,Local History,编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。,,macos/Sources/Lithe/Views/History; macos/Sources/Lithe/Services,windows/tauri/src/features/local-history history-restore,工作台,本地历史,恢复、删除与持久化,已实现,待验证,已实现,待验证,Local History,验证恢复、删除、应用重启后持久化和失败回滚。,,macos/Sources/Lithe/Views/History,windows/tauri/src/features/local-history diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 3533caaa1..04bc577f9 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:117 -- macOS:实现:✅ 103 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 106 待验证,— 11 不适用 -- Windows:实现:✅ 99 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 110 待验证,— 7 不适用 +- 功能项:118 +- macOS:实现:✅ 104 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 1 已验证,🔍 106 待验证,— 11 不适用 +- Windows:实现:✅ 99 已实现,🟡 11 部分实现,❌ 6 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 110 待验证,— 8 不适用 ## 实现状态定义 @@ -72,7 +72,7 @@
-编辑器 · 16 个能力点 +编辑器 · 17 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -84,6 +84,7 @@ | 文本编辑 | **LSP 语义高亮**
editor-semantic-highlighting | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift`、`macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift`、`frontend/editor/src/semantic-tokens.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/features/editor/lsp/lsp-client.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts`、`windows/tauri/src/platform/lsp-core-adapter.test.ts` | Editor / Language Tooling | 打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。 | Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。 | | 文本编辑 | **多行标签与标签导航**
editor-multiline-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/tabs` | Editor | 打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。 | | | Markdown | **Markdown 预览与富文本渲染**
markdown-rendering | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift`、`macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown` | Editor | 使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。 | | +| Markdown | **Markdown 预览目录默认展开、全高滚动与标题跳转**
markdown-preview-outline | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Resources/MarkdownPreview/preview.js`、`macos/Sources/Lithe/Resources/MarkdownPreview/preview.css` | ❌ 未实现
— 不适用
`windows/tauri/src/features/editor/markdown/markdown-preview.tsx` | Editor | 在 macOS 原生 Lithe 打开含 60 节标题的 Markdown,验证默认展开、全高目录独立滚动、末尾标题跳转后保持展开、按钮与 Escape 收起;分栏中编辑保存及外部刷新保留折叠选择,删除全部标题隐藏目录,恢复标题保留展开偏好;检查短文档与无标题文档、宽预览正文避让和窄预览可收起。Windows 目录入口待实现。 | | | Markdown | **图片导入与链接定位**
markdown-images-links | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Markdown`、`macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown`、`windows/tauri/src/features/viewer` | Editor | 验证本地图片、远程图片、相对链接和打开源文件行为。 | | | 代码结构 | **当前文件 Outline 与符号树**
outline-symbols | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Language`、`macos/Sources/Lithe/Views/Search` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/outline` | Language Tooling | 使用 Java 文件验证符号树、展开折叠、排序和跳转;确认 macOS 是否提供同等独立 Outline 入口。 | | | 代码结构 | **引用结果面板与引用跳转**
references-pane | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Language/JavaReferencesView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/references` | Language Tooling | 从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。 | | diff --git a/macos/Sources/Lithe/Resources/MarkdownPreview/preview.css b/macos/Sources/Lithe/Resources/MarkdownPreview/preview.css index ccfc1bbc2..fd52e20ba 100644 --- a/macos/Sources/Lithe/Resources/MarkdownPreview/preview.css +++ b/macos/Sources/Lithe/Resources/MarkdownPreview/preview.css @@ -293,7 +293,7 @@ audio { width: min(100%, 520px); } top: 52px; right: 16px; width: min(280px, calc(100vw - 32px)); - max-height: min(66vh, 560px); + bottom: 16px; overflow: auto; padding: 12px 10px; border: 1px solid var(--border); @@ -309,6 +309,10 @@ audio { width: min(100%, 520px); } .toc a[data-level="2"] { padding-left: 18px; } .toc a[data-level="3"] { padding-left: 29px; color: var(--muted); } +@media (min-width: 681px) { + body.toc-open { padding-right: 312px; } +} + .image-viewer { position: fixed; z-index: 100; @@ -333,5 +337,5 @@ audio { width: min(100%, 520px); } @media (max-width: 440px) { .markdown-body { padding: 22px 18px 54px; } .toc-toggle { top: 10px; right: 10px; } - .toc { top: 46px; right: 10px; } + .toc { top: 46px; right: 10px; bottom: 10px; } } diff --git a/macos/Sources/Lithe/Resources/MarkdownPreview/preview.js b/macos/Sources/Lithe/Resources/MarkdownPreview/preview.js index 3f8f93571..836ce0263 100644 --- a/macos/Sources/Lithe/Resources/MarkdownPreview/preview.js +++ b/macos/Sources/Lithe/Resources/MarkdownPreview/preview.js @@ -13,6 +13,8 @@ let scrollCommandVersion = 0; let suppressScrollReportsUntil = 0; let scrollReportFrame = 0; + let tocExpanded = true; + let tocDocumentURL; function setTheme(theme) { document.documentElement.dataset.theme = theme === 'light' ? 'light' : 'dark'; @@ -172,24 +174,29 @@ link.addEventListener('click', event => { event.preventDefault(); heading.scrollIntoView({ behavior: 'smooth', block: 'start' }); - closeTOC(); }); tocList.append(link); }); tocToggle.hidden = headings.length === 0; - if (headings.length === 0) closeTOC(); + updateTOCVisibility(); + } + + function updateTOCVisibility() { + const visible = !tocToggle.hidden && tocExpanded; + toc.hidden = !visible; + tocToggle.setAttribute('aria-expanded', String(visible)); + document.body.classList.toggle('toc-open', visible); } function closeTOC() { - toc.hidden = true; - tocToggle.setAttribute('aria-expanded', 'false'); + tocExpanded = false; + updateTOCVisibility(); } function toggleTOC() { - const opening = toc.hidden; - toc.hidden = !opening; - tocToggle.setAttribute('aria-expanded', String(opening)); + tocExpanded = !tocExpanded; + updateTOCVisibility(); } function closeImage() { @@ -243,6 +250,10 @@ const version = ++renderVersion; const initialScrollCommandVersion = scrollCommandVersion; const scrollRatio = currentScrollRatio(); + if (tocDocumentURL !== payload?.documentURL) { + tocDocumentURL = payload?.documentURL; + tocExpanded = true; + } setTheme(payload?.appearance); content.innerHTML = payload?.html || ''; rewriteLocalAssets(); @@ -263,9 +274,6 @@ event.stopPropagation(); toggleTOC(); }); - document.addEventListener('click', event => { - if (!toc.hidden && !toc.contains(event.target) && event.target !== tocToggle) closeTOC(); - }); viewer.addEventListener('click', event => { if (event.target === viewer || event.target === imageClose) closeImage(); }); diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index ef781746f..42862d30e 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1822,6 +1822,29 @@ "owner": "Editor", "verification": "使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。" }, + { + "id": "markdown-preview-outline", + "area": "编辑器", + "group": "Markdown", + "capability": "Markdown 预览目录默认展开、全高滚动与标题跳转", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Resources/MarkdownPreview/preview.js", + "macos/Sources/Lithe/Resources/MarkdownPreview/preview.css" + ], + "implementationStatus": "implemented", + "verificationStatus": "verified" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/editor/markdown/markdown-preview.tsx" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Editor", + "verification": "在 macOS 原生 Lithe 打开含 60 节标题的 Markdown,验证默认展开、全高目录独立滚动、末尾标题跳转后保持展开、按钮与 Escape 收起;分栏中编辑保存及外部刷新保留折叠选择,删除全部标题隐藏目录,恢复标题保留展开偏好;检查短文档与无标题文档、宽预览正文避让和窄预览可收起。Windows 目录入口待实现。" + }, { "id": "markdown-images-links", "area": "编辑器", From 7248ba100d16d44fe2ec114d61f4198c944d5836 Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:02:01 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(agent):=20=E7=BB=9F=E4=B8=80=E6=9D=83?= =?UTF-8?q?=E9=99=90=E8=8F=9C=E5=8D=95=E4=B8=8E=E8=8B=B1=E6=96=87=E6=80=9D?= =?UTF-8?q?=E8=80=83=E5=BC=BA=E5=BA=A6=E6=98=BE=E7=A4=BA=20(#1008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-09-25-shared-acp-agent-conversation.md | 4 +- docs/development/platform-parity-matrix.csv | 2 + docs/development/platform-parity-matrix.md | 10 +-- macos/Resources/en.lproj/Localizable.strings | 5 ++ .../zh-Hans.lproj/Localizable.strings | 5 ++ .../Views/Agent/AgentSessionSelectors.swift | 30 ++++---- ...gentSessionSelectorPresentationTests.swift | 72 +++++++++++++++++++ shared/platform-feature-matrix.json | 48 +++++++++++++ 8 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 19dd4d4dc..37da72617 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -17,6 +17,8 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 ## 决策 +- **思考强度名称**:强度选项使用上游英文名称,例如 Low、Medium、High、Xhigh、Max、Ultra,不经过通用本地化。子菜单、设置行当前值和模型底栏复用同一个呈现入口,避免同一强度在不同位置显示中英文混合。菜单标题“思考强度”和其他配置仍按界面语言显示;不改上游选项 ID、可选范围或确认流程,也不手工补齐当前 Agent 未提供的强度。 +- **权限模式呈现**:菜单和底栏保留 Agent 上游名称,例如 Claude 的 Manual、Accept edits、Plan、Auto、Bypass permissions;模型和思考强度也使用上游名称。名称不能进入全局翻译表,否则 `Plan` 会碰到其他界面的“计划”翻译,导致个别选项与其余名称混用语言。Claude 与 Codex 按已知权限 ID 共享对应图标,Claude 继续显示上游实际提供的编辑、计划等额外选项。依据 [Claude 权限模式说明](https://code.claude.com/docs/en/permissions),说明可按界面语言呈现,但保留实际 Agent 的审批与环境约束,不把 Codex 的沙箱说明直接套到 Claude。只改变显示,不修改选项 ID、数量、顺序或权限命令;上游确认继续决定当前值。未知选项保留其原文和默认图标,不按列表位置猜测权限,也不把 Claude 的五项折叠成三项。不新增状态、资源或运行时写入。 - **侧栏入口图标**:Agent 使用 Lithe 原创的“对话气泡 + 星光”线性 SVG,由工作台 renderer 显式绑定,避免落入通用模块图标而让用户误认成插件管理。图标作为固定源资源随应用打包,复用现有模板着色与选中态;不下载、不生成运行时文件,不影响安装包的只读边界。面板内 Codex/Claude 标志仍表示具体供应商。 - **安装包图标解析**:Agent 品牌图标从应用的 `Contents/Resources/Lithe_Lithe.bundle` 只读加载;资源包或图标缺失时使用默认标志。不要在已安装的 `.app` 中直接调用 SwiftPM(Swift 包管理器)生成的 `Bundle.module`:它只查应用旁的资源包和编译目录,找不到会直接终止进程,开发机器残留的构建资源还会掩盖问题。开发和测试入口保留 SwiftPM 回退;安装版不使用编译目录,也不通过启动时复制资源来修补发行包,避免改变代码签名和 Sparkle 增量更新基线。 @@ -54,7 +56,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **面板始终显示完整布局**:功能关闭或没有配置 Agent 时,面板照样显示会话标题、消息区和输入框,用户可以输入;发送时先校验(功能是否开启、是否有已配置的 Agent、模块是否启动完成),不通过就在输入框上方给出提示并提供进入设置的按钮,不会启动任何进程。 - **文件拖入对话框**:参考 CC GUI 普通文件的路径引用交互,原生输入框接受 Finder 和项目树的文件 URL,显示可移除标签,并支持文件选择器。一次最多 32 个引用,按拖入顺序去重;切换会话清除旧文件草稿,发送失败保留文字和引用。功能模型把引用与文字一起排队,Rust Host 用上游 ACP SDK 的 `resource_link` 发送,Agent 自己读取文件并遵守现有权限。文件 URL 是这次消息的原生上下文,不作为跨平台项目标识持久化。正确做法是发送“说明这两个文件”加两个引用;不要在 View 里读文件、复制进缓存或拼装隐式文件内容提示词。图片目前同样作为文件引用,没有缩略图或多模态字节上传;这避免了与本次需求无关的内存、大小和格式策略,后续需要图片输入时再按上游能力协商。 - **对话面板**:停靠在编辑器右侧,与 Maven 共用右侧槽位。输入框下方可以切换 Agent,列出的是已经绑定服务商或 Codex 订阅的 Agent;打开的会话以标签形式排列。每个 Agent 第一次被选中时才建立连接;项目里所有 Agent 的权限提醒会合并显示到项目标签上。 -- **对话区的视觉与交互**:按 CC GUI 参考设计使用单行会话标题、居中的品牌标志、上下文栏和底部工具栏。只有多会话或用户主动展开时才显示标签条,避免空会话标题重复。空态不放进滚动列表,否则无法在剩余高度内居中。输入区复用 `LitheSplitPaneView`,尺寸约束跟随可用高度,拖动只更新局部容器。搜索只筛选当前已加载消息,不请求 Agent 或改动历史。品牌 SVG 来自参考项目锁定的 `@lobehub/icons` 5.8.0,以静态源资源随 SwiftPM bundle 打包并携带 MIT 许可,不新增运行时下载或可复用缓存。配置选择器按参考项目的底栏设计:权限与模型入口位于输入区底部,模型弹出面板提供本地名称/ID/分组搜索、品牌图标、蓝色选中行和绿色勾选,不显示模型说明;权限弹出面板保留上游说明并本地化已有文案。思考强度、速度及其他上游配置放到模型面板底部的子菜单,未提供时不显示。选项数量和权限语义仍以 ACP 为准,不照截图补选项。选择后收起面板,原确认/失败状态机继续拥有当前值,等待确认时禁止再次配置。弹出与搜索状态只在视图内保存,会话切换重建选择器以清除旧会话的弹出状态;不重建输入框或清空草稿。使用 macOS 原生 popover 管理定位和关闭,设置子选项在同一个 popover 内展开侧栏,避免嵌套 popover 关闭父面板;不另写鼠标全局监听或位置缓存。上下文百分比使用 Agent 上报的用量,缺失时展示 0% 占位与“上下文: 0.0%”简短提示;占位不作为已上报用量写回会话状态。 +- **对话区的视觉与交互**:按 CC GUI 参考设计使用单行会话标题、居中的品牌标志、上下文栏和底部工具栏。只有多会话或用户主动展开时才显示标签条,避免空会话标题重复。空态不放进滚动列表,否则无法在剩余高度内居中。输入区复用 `LitheSplitPaneView`,尺寸约束跟随可用高度,拖动只更新局部容器。搜索只筛选当前已加载消息,不请求 Agent 或改动历史。品牌 SVG 来自参考项目锁定的 `@lobehub/icons` 5.8.0,以静态源资源随 SwiftPM bundle 打包并携带 MIT 许可,不新增运行时下载或可复用缓存。配置选择器按参考项目的底栏设计:权限与模型入口位于输入区底部,模型弹出面板提供本地名称/ID/分组搜索、品牌图标、蓝色选中行和绿色勾选,不显示模型说明;权限弹出面板保留上游名称,只对说明使用界面语言。思考强度、速度及其他上游配置放到模型面板底部的子菜单,未提供时不显示。选项数量和权限语义仍以 ACP 为准,不照截图补选项。选择后收起面板,原确认/失败状态机继续拥有当前值,等待确认时禁止再次配置。弹出与搜索状态只在视图内保存,会话切换重建选择器以清除旧会话的弹出状态;不重建输入框或清空草稿。使用 macOS 原生 popover 管理定位和关闭,设置子选项在同一个 popover 内展开侧栏,避免嵌套 popover 关闭父面板;不另写鼠标全局监听或位置缓存。上下文百分比使用 Agent 上报的用量,缺失时展示 0% 占位与“上下文: 0.0%”简短提示;占位不作为已上报用量写回会话状态。 - **进程与诊断**:启动时把 Agent 可执行文件所在目录放到 `PATH` 最前面。npm 把 `codex-acp` 和 `node` 装在同一个目录,而 Mac 图形应用拿不到登录 shell 的 `PATH`。关闭时先向整棵进程树发 SIGTERM,并记录树里的每个进程 ID,稍等后再对仍存活的进程发 SIGKILL,因为 codex-acp 的 app-server 会比外层进程晚几秒退出。API Key 模式下 Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉;订阅模式仅显示受控错误,不附加账号工具的原始输出。 正确做法:新平台的界面通过平台适配器把 fixture 里的命令交给 `lithe-agent-host`,并把工具权限选择交给用户。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 241d81460..4364fcbe7 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -7,6 +7,8 @@ agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,rust/lithe-agent-host +agent-thinking-level-labels,AI,Agent 对话,Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称,已实现,已验证,未实现,待验证,Agent,2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md +agent-approval-mode-presentation,AI,Agent 对话,Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义,已实现,待验证,未实现,待验证,Agent,中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Resources/en.lproj/Localizable.strings; macos/Resources/zh-Hans.lproj/Localizable.strings; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features agent-turn-statistics,AI,Agent 对话,发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情,已实现,待验证,部分实现,待验证,Agent,macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。,,macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/prompt.rs; rust/lithe-agent-host/src/tests.rs; shared/fixtures/agent/acp-events-v1.json agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,部分实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。,,macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift; macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift; macos/Tests/LitheTests/AgentFileReferenceTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/tests.rs,rust/lithe-agent-host/src/prompt.rs; shared/fixtures/agent/acp-events-v1.json diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 4305bb633..50256bcb0 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:120 -- macOS:实现:✅ 105 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 0 已验证,🔍 108 待验证,— 12 不适用 -- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 113 待验证,— 7 不适用 +- 功能项:122 +- macOS:实现:✅ 107 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 1 已验证,🔍 109 待验证,— 12 不适用 +- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 115 待验证,— 7 不适用 ## 实现状态定义 @@ -30,7 +30,7 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。
-AI · 19 个能力点 +AI · 21 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -42,6 +42,8 @@ | Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | | Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | | Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | +| Agent 对话 | **Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称**
agent-thinking-level-labels | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。 | | +| Agent 对话 | **Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义**
agent-approval-mode-presentation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Resources/en.lproj/Localizable.strings`、`macos/Resources/zh-Hans.lproj/Localizable.strings`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。 | | | Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | | Agent 对话 | **发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情**
agent-turn-statistics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`rust/lithe-agent-host/src/tests.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。 | | | Agent 对话 | **从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿**
agent-file-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift`、`macos/Tests/LitheTests/AgentFileReferenceTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 355fc7a60..2e090e0a8 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1160,6 +1160,11 @@ "Ask for approval" = "Ask for approval"; "Approve for me" = "Approve for me"; "Full access" = "Full access"; +"Always ask before making changes" = "Ask before making changes or running actions that require approval."; +"Automatically accept all file edits" = "Automatically approve file edits; other actions follow permission rules."; +"Create a plan before making changes" = "Read and plan without modifying files."; +"Claude handles permission decisions" = "Claude evaluates and approves actions automatically."; +"Accepts all permissions" = "Skip routine approval prompts; environment and managed policies still apply."; "Always ask to edit external files and use the internet" = "Always ask to edit external files and use the internet"; "Only ask for actions detected as potentially unsafe" = "Only ask for actions detected as potentially unsafe"; "Unrestricted access to the internet and any file on your computer" = "Unrestricted access to the internet and any file on your computer"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 2e8b7b013..689960b16 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2399,6 +2399,11 @@ "Ask for approval" = "请求批准"; "Approve for me" = "替我审批"; "Full access" = "完全访问"; +"Always ask before making changes" = "修改文件或执行需要权限的操作前请求你的批准。"; +"Automatically accept all file edits" = "自动批准文件编辑,其他操作仍按权限规则处理。"; +"Create a plan before making changes" = "阅读和分析后制定计划,不修改文件。"; +"Claude handles permission decisions" = "由 Claude 自动评估并审批操作。"; +"Accepts all permissions" = "跳过常规权限审批,仍受环境和管理策略限制。"; "Always ask to edit external files and use the internet" = "修改工作区外文件或访问网络时先请求批准。"; "Only ask for actions detected as potentially unsafe" = "自动审批,仅在检测到潜在风险时请求你的确认。"; "Unrestricted access to the internet and any file on your computer" = "允许访问网络和电脑上的所有文件。"; diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift index e26f085db..785028132 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -17,29 +17,35 @@ enum AgentSessionSelectorPresentation { return localized(option.name) } - static func choiceTitle(_ choice: AgentSessionConfigOption.Choice, in option: AgentSessionConfigOption) -> String { + static func choiceTitle(_ choice: AgentSessionConfigOption.Choice, in option: AgentSessionConfigOption, bundle: Bundle = .main) -> String { if option.id == "fast-mode" { - if choice.id == "off" { return String(localized: "Standard") } - if choice.id == "on" { return String(localized: "Fast") } + if choice.id == "off" { return localized("Standard", bundle: bundle) } + if choice.id == "on" { return localized("Fast", bundle: bundle) } } - return option.category == "model" ? choice.name : localized(choice.name) + // Agent-owned names must not collide with translations used elsewhere in the app. + if option.category == "model" || option.category == "mode" || option.category == "thought_level" { + return choice.name + } + return localized(choice.name, bundle: bundle) } - static func currentTitle(_ option: AgentSessionConfigOption) -> String { - option.choices.first { $0.id == option.currentValue }.map { choiceTitle($0, in: option) } ?? option.currentValue + static func currentTitle(_ option: AgentSessionConfigOption, bundle: Bundle = .main) -> String { + option.choices.first { $0.id == option.currentValue }.map { choiceTitle($0, in: option, bundle: bundle) } ?? option.currentValue } static func modeIcon(_ id: String) -> String { switch id { - case "read-only": "bubble.left.and.bubble.right" - case "agent": "checkmark.shield" - case "agent-full-access": "bolt" + case "read-only", "default", "manual": "bubble.left.and.bubble.right" + case "agent", "auto": "checkmark.shield" + case "agent-full-access", "bypassPermissions": "bolt" + case "acceptEdits": "pencil" + case "plan": "list.bullet.rectangle" default: "slider.horizontal.3" } } - static func localized(_ text: String) -> String { - String(localized: String.LocalizationValue(text)) + static func localized(_ text: String, bundle: Bundle = .main) -> String { + String(localized: String.LocalizationValue(text), bundle: bundle) } } @@ -116,7 +122,7 @@ struct AgentSessionSelectors: View { private func modelSummary(_ model: AgentSessionConfigOption) -> String { let effort = options.first { $0.category == "thought_level" } - return [model.currentLabel, effort.map(AgentSessionSelectorPresentation.currentTitle)].compactMap { $0 }.joined(separator: " ") + return [model.currentLabel, effort.map { AgentSessionSelectorPresentation.currentTitle($0) }].compactMap { $0 }.joined(separator: " ") } private func selectorLabel(@ViewBuilder content: () -> Content) -> some View { diff --git a/macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift b/macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift new file mode 100644 index 000000000..e0d05e527 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Agent selector names") +struct AgentSessionSelectorPresentationTests { + @Test + func approvalMenuAndCurrentValueKeepAgentNamesDespiteChineseTranslationCollisions() throws { + let chinese = try localizationBundle("zh-Hans") + // The unrelated app translation must remain available while Agent names bypass it. + #expect(AgentSessionSelectorPresentation.localized("Plan", bundle: chinese) == "计划") + let names = ["Manual", "Accept edits", "Plan", "Auto", "Bypass permissions", "Future mode"] + let ids = ["manual", "acceptEdits", "plan", "auto", "bypassPermissions", "future"] + var option = AgentSessionConfigOption( + id: "mode", name: "Mode", category: "mode", currentValue: "plan", + choices: zip(ids, names).map { .init(id: $0.0, name: $0.1) } + ) + for language in ["zh-Hans", "en"] { + let bundle = try localizationBundle(language) + #expect(option.choices.map { + AgentSessionSelectorPresentation.choiceTitle($0, in: option, bundle: bundle) + } == names) + for choice in option.choices { + option.currentValue = choice.id + #expect(AgentSessionSelectorPresentation.currentTitle(option, bundle: bundle) == choice.name) + } + } + } + + @Test + func thinkingAndModelNamesUseTheSameUpstreamRuleInBothLanguages() throws { + let names = ["Low", "Medium", "High", "Xhigh", "Max", "Ultra"] + for language in ["zh-Hans", "en"] { + let bundle = try localizationBundle(language) + for category in ["thought_level", "model"] { + var option = AgentSessionConfigOption( + id: category, name: category, category: category, currentValue: "Medium", + choices: names.map { .init(id: $0, name: $0) } + ) + for choice in option.choices { + option.currentValue = choice.id + #expect(AgentSessionSelectorPresentation.choiceTitle(choice, in: option, bundle: bundle) == choice.name) + #expect(AgentSessionSelectorPresentation.currentTitle(option, bundle: bundle) == choice.name) + } + } + } + } + + @Test + func speedAndOtherControlsStillUseTheSelectedLanguage() throws { + let bundle = try localizationBundle("zh-Hans") + let speed = AgentSessionConfigOption( + id: "fast-mode", name: "Speed", category: "model_config", currentValue: "on", + choices: [.init(id: "off", name: "Disabled"), .init(id: "on", name: "Enabled")] + ) + #expect(AgentSessionSelectorPresentation.currentTitle(speed, bundle: bundle) + == bundle.localizedString(forKey: "Fast", value: nil, table: nil)) + let other = AgentSessionConfigOption( + id: "other", name: "Other", category: "custom", currentValue: "plan", + choices: [.init(id: "plan", name: "Plan")] + ) + #expect(AgentSessionSelectorPresentation.currentTitle(other, bundle: bundle) == "计划") + } + + private func localizationBundle(_ language: String) throws -> Bundle { + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + return try #require(Bundle(url: repository.appendingPathComponent("macos/Resources/\(language).lproj"))) + } +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index ad707092a..3a0e9fc33 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -266,6 +266,54 @@ "owner": "Agent", "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。" }, + { + "id": "agent-thinking-level-labels", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "shared/contracts/application-boundary.md" + ] + }, + "owner": "Agent", + "verification": "2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。" + }, + { + "id": "agent-approval-mode-presentation", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Resources/en.lproj/Localizable.strings", + "macos/Resources/zh-Hans.lproj/Localizable.strings", + "macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "shared/contracts/application-boundary.md" + ] + }, + "owner": "Agent", + "verification": "中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。" + }, { "id": "agent-tool-activity-timeline", "area": "AI", From 4a87f571fc668380fe3117386ac8ce73d7e79c57 Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:17:12 +0800 Subject: [PATCH 03/10] Merge pull request #1023 from Mucheen/fix/agent-selector-labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(macos): 收紧 Agent 配置菜单并修复双行说明裁剪 --- ...026-09-25-shared-acp-agent-conversation.md | 1 + docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- .../Views/Agent/AgentSessionSelectors.swift | 43 ++++-- .../AgentSessionSelectorLayoutTests.swift | 128 ++++++++++++++++++ shared/platform-feature-matrix.json | 8 +- 6 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 37da72617..909bfa59f 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -31,6 +31,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **历史以 Agent 为准**:会话列表来自 `session/list`,打开旧会话用 `session/load`,由 Agent 回放历史。Lithe 不自己保存聊天记录。Agent 进程重启后,旧会话必须先加载才能继续发消息。导出按 [ACP 会话加载契约](https://agentclientprotocol.com/protocol/v1/session-setup) 等待完整回放后返回的 `session/load` 响应;Swift 接收到 `sessionLoaded` 时先刷新缓冲文本,再生成导出快照,不靠固定延迟猜测回放完成。 - **历史页与本地管理**:参考 [CC GUI 历史页](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/tree/main/webview/src/components/history) 使用面板内返回、搜索、统计和分隔列表。搜索只查询标题和 ID,数量只统计已加载的用户/Agent 消息,不扫描 CLI 文件,也不伪造上游未提供的消息总数。`AgentHistoryFeatureModel` 通过 `AgentHistoryPersisting` 保存收藏、标题覆盖和隐藏标记;macOS adapter 使用既有偏好设置,键为 `lithe.agent-history.v1.`,按标准化工作区与配置 Agent ID 隔离。变更前重新读取,避免另一窗口的旧快照覆盖最近注释;解码或保存失败明确显示,不重置损坏状态。移除仅隐藏本地列表,提供“已移除的会话”筛选与恢复,不宣称删除 Agent 原始会话。单条和批量移除都先弹出确认框,取消不写元数据;确认操作使用弹窗出现时的会话 ID 快照,避免把后续选择变化带入删除。进入历史页保留对话视图和输入草稿,不停止正在进行的轮次。 - **导出与资源所有权**:导出为用户选择位置的 Markdown,包含用户、Agent 和工具消息,工具输入/输出/内容一并保留。未打开会话复用现有、有请求期限的 ACP 加载路径,逐个加载,不改变选中标签;加载失败不写部分导出;只有会话成功创建或完整加载后才标记历史快照可复用,消息非空不能证明完整性。失败或断连留下的部分回放必须重载,已完整加载的快照在断连后仍可导出。取消与模块关闭释放等待 continuation(异步结果回调),原加载请求可自然结束。正在回复或加载中的会话不导出。导出 Task 由历史功能模型拥有并在模块停止时取消、等待结束;平台 adapter 拥有保存对话框与原子文件写入,最大 32 MiB。偏好设置与用户导出都不是构建缓存,不新增下载、不跨 worktree 复制;见 `scripts/worktree-resources.json`。它们不写发行 bundle、不影响代码签名和 Sparkle delta。 +- **配置菜单间距**:模型和设置选项使用较小的最小行高,权限选项保留标题与最多两行说明需要的空间;行内容较高时允许自然增高,不用固定行高裁剪文字。权限面板按实际内容高度收紧,测量值在最大可视高度处截断并只更新弹出面板的局部状态,避免滚动时反复调整面板大小;长列表继续由原生滚动容器承载,不能把行距变小却保留旧面板高度,或为了紧凑隐藏上游选项。名称和权限语义由既有呈现规则及 ACP 确认流程决定,布局不改写选项 ID 或当前值。 - **上下文用量以上游为准**:输入框顶部参考 CC GUI 显示圆环与整数百分比,悬停显示一位小数百分比及已用/总容量 token。提示复用工作台已有的悬停浮层与面板局部 scope(浮层可绘制的范围),圆环和百分比共用完整命中区域,鼠标进入立即显示在指示器上方,移开或面板退出时关闭;不依赖系统原生 `.help` 的延迟提示,避免宿主视图内看不到详情。使用现有 ACP `usage_update.used/size`,共享 host 已按 SDK 原样转发,不另接 Codex 私有事件、不扫描历史文件、不根据文字长度或模型名推算。计费用量是累计消耗,不是当前上下文容量,不能混用。用量按会话保存,每条有效更新替换之前值,压缩后允许下降;没有上报、容量为零或无效数据在会话状态中仍保持未知;展示层按 CC GUI 使用 0% 占位,悬停仅提示“上下文: 0.0%”,真实零使用量也使用相同简短提示,不编造已用 token 或总容量。断连、确认模型改变、重新加载会话清除旧数据,权限或思考选项改变不清除。超过容量时保留上游数值与百分比,仅圆环限制为一整圈。数据只是内存会话状态,不写入 bundle 或新增缓存资源。 - **停止必须等上游确认**:只发送一次 `session/cancel`,撤销本轮权限请求,界面进入“正在停止”。收到原 prompt 的结束响应后才能发送下一轮。旧方案只屏蔽迟到的 prompt 响应,却不能阻止上游把新消息并入旧轮次,也不能识别没有轮次编号的迟到通知。因此改为十秒确认期限:超过期限则明确报错、停止该 Agent 的进程树,保留界面记录,用户重连后通过 `session/load` 恢复。正常取消不重启进程。这不是伪装成正常结束,用户会看到恢复原因;同一 Agent 进程里的其他会话也会断开,不能静默自动重试消息。 - **会话配置由上游提供**:面板连接完成后准备空会话,让用户发第一条消息前就能选择模型、权限模式、思考强度。选项、分组和当前值均来自 `session/new`、`session/load`、配置更新通知及 `session/set_config_option` 的响应;不硬编码模型列表。每次启动 Agent 连接前,通过既有本机配置端口刷新已绑定导入服务商的默认模型,避免旧导入值(例如已移除的模型名)再次成为所有新会话的默认值;只刷新模型,不改提交信息的服务商选择、手动服务商、端点或凭据。模型读取独立于完整 API 配置:Codex 没有自定义服务商或 API Key 时仍能读取顶层 `model`,不能把其他 TOML 表里的同名字段当成默认模型;凭据校验失败时也更新面板的模型回退显示。API Key 模式仍校验凭据;订阅模式绕过这条服务商模型同步路径,使用 Codex 自己的模型目录。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 4364fcbe7..d5b8acf6e 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -6,7 +6,7 @@ ide-mcp-connection,AI,IDE MCP,从设置侧栏独立的 MCP 配置页按项目开 agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift; macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg; macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift,windows/tauri/src/features agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json -agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,rust/lithe-agent-host +agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift,rust/lithe-agent-host agent-thinking-level-labels,AI,Agent 对话,Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称,已实现,已验证,未实现,待验证,Agent,2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-approval-mode-presentation,AI,Agent 对话,Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义,已实现,待验证,未实现,待验证,Agent,中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Resources/en.lproj/Localizable.strings; macos/Resources/zh-Hans.lproj/Localizable.strings; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 50256bcb0..54d95ba5b 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -41,7 +41,7 @@ | Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift`、`macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg`、`macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。 | | | Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | | Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | -| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | +| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。 | | | Agent 对话 | **Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称**
agent-thinking-level-labels | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。 | | | Agent 对话 | **Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义**
agent-approval-mode-presentation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Resources/en.lproj/Localizable.strings`、`macos/Resources/zh-Hans.lproj/Localizable.strings`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。 | | | Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift index 785028132..e39b1f2ba 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -1,6 +1,13 @@ import SwiftUI import LitheAgentConversationModule +private enum AgentSelectorLayout { + static let choiceRowHeight: CGFloat = 26 + static let modeRowHeight: CGFloat = 44 + static let modeViewportHeight: CGFloat = 320 + static let modeVerticalPadding: CGFloat = 5 +} + /// Search and presentation only; the Agent still owns IDs, choices and confirmed values. enum AgentSessionSelectorPresentation { static func filteredChoices(_ option: AgentSessionConfigOption, query: String) -> [AgentSessionConfigOption.Choice] { @@ -177,7 +184,7 @@ struct AgentModelPopover: View { } } } - .frame(height: min(240, CGFloat(setting.choices.count * 32))) + .frame(height: min(240, CGFloat(setting.choices.count) * AgentSelectorLayout.choiceRowHeight)) } .padding(.bottom, 5) .frame(width: 180) @@ -222,7 +229,7 @@ struct AgentModelPopover: View { } } } - .frame(height: min(240, CGFloat(choices.count * 32 + choices.filter { $0.group != nil }.count * 20))) + .frame(height: min(240, CGFloat(choices.count) * AgentSelectorLayout.choiceRowHeight + CGFloat(choices.filter { $0.group != nil }.count * 20))) } if !settings.isEmpty { Divider().overlay(AgentPanelStyle.border).padding(.vertical, 4) @@ -264,17 +271,18 @@ private struct AgentModelSettingRow: View { } } -private struct AgentModePopover: View { +struct AgentModePopover: View { let option: AgentSessionConfigOption let onSelect: (String) -> Void + @State private var contentHeight: CGFloat = 0 var body: some View { ScrollView { LazyVStack(spacing: 0) { ForEach(option.choices) { choice in - AgentSelectorRow(isSelected: choice.id == option.currentValue, minimumHeight: 64, action: { onSelect(choice.id) }) { + AgentSelectorRow(isSelected: choice.id == option.currentValue, minimumHeight: AgentSelectorLayout.modeRowHeight, action: { onSelect(choice.id) }) { Image(systemName: AgentSessionSelectorPresentation.modeIcon(choice.id)).frame(width: 16) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { Text(AgentSessionSelectorPresentation.choiceTitle(choice, in: option)).lineLimit(1) if let description = choice.description, !description.isEmpty { Text(AgentSessionSelectorPresentation.localized(description)) @@ -287,16 +295,33 @@ private struct AgentModePopover: View { } } } + .background { + GeometryReader { geometry in + // Cap the measurement so scrolling a long lazy list cannot keep resizing the popover. + Color.clear.preference(key: AgentModeContentHeightKey.self, + value: min(AgentSelectorLayout.modeViewportHeight, geometry.size.height)) + } + } } - .padding(.vertical, 5) - .frame(width: 350, height: min(330, CGFloat(option.choices.count * 64 + 10))) + .onPreferenceChange(AgentModeContentHeightKey.self) { height in + if abs(height - contentHeight) > 0.5 { contentHeight = height } + } + .padding(.vertical, AgentSelectorLayout.modeVerticalPadding) + .frame(width: 350, height: min(AgentSelectorLayout.modeViewportHeight, + max(CGFloat(option.choices.count) * AgentSelectorLayout.modeRowHeight, contentHeight)) + + 2 * AgentSelectorLayout.modeVerticalPadding) .background(AgentPanelStyle.header) } } +private struct AgentModeContentHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + private struct AgentSelectorRow: View { let isSelected: Bool - var minimumHeight: CGFloat = 32 + var minimumHeight: CGFloat = AgentSelectorLayout.choiceRowHeight let action: () -> Void @ViewBuilder let content: Content @State private var isHovering = false @@ -314,7 +339,7 @@ private struct AgentSelectorRow: View { .font(.system(size: 12)) .foregroundStyle(AgentPanelStyle.text) .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.vertical, 4) .frame(maxWidth: .infinity, minHeight: minimumHeight, alignment: .leading) .background(isSelected ? AgentPanelStyle.selected : (isHovering ? AgentPanelStyle.context : .clear)) .contentShape(Rectangle()) diff --git a/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift b/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift new file mode 100644 index 000000000..b5481a536 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift @@ -0,0 +1,128 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Agent selector layout") +struct AgentSessionSelectorLayoutTests { + @Test + func twoLinePermissionDescriptionsHaveRoomAndKeepTheirSelectionAction() async throws { + let description = "批准后才能修改文件。\n其他操作继续遵守权限规则。" + let option = AgentSessionConfigOption( + id: "mode", name: "Mode", category: "mode", currentValue: "manual", + choices: [.init(id: "manual", name: "Manual", description: description), + .init(id: "auto", name: "Auto", description: description)] + ) + for scheme in [ColorScheme.dark, .light] { + var selected: String? + let host = NSHostingView(rootView: AgentModePopover(option: option) { selected = $0 } + .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "zh_Hans"))) + let window = makeWindow(host, size: NSSize(width: 350, height: 110)) + defer { window.close() } + let scroll = try #require(scrollView(in: host)) + let document = try #require(scroll.documentView) + try #require(await waitUntil { + host.layoutSubtreeIfNeeded() + return host.fittingSize.height >= document.bounds.height + 10 + }, "A short permission list must fit both description lines without scrolling") + window.setContentSize(host.fittingSize) + host.layoutSubtreeIfNeeded() + let rowHeight = document.bounds.height / CGFloat(option.choices.count) + #expect(rowHeight >= 48, + "The row must grow to fit a title and both description lines") + #expect(rowHeight < 64, + "Wrapping must not restore the old excessive spacing") + try click(NSPoint(x: 100, y: rowHeight / 2), in: document, window: window) + #expect(await waitUntil { selected == "manual" }, "The rendered choice must preserve the upstream ID") + try capture(host, name: "permission-two-lines-\(scheme)") + } + } + + @Test + func longPermissionAndModelListsScrollToTheirLastChoice() async throws { + let choices: [AgentSessionConfigOption.Choice] = (0..<30).map { + .init(id: "choice-\($0)", name: "Choice \($0)") + } + for scheme in [ColorScheme.dark, .light] { + for category in ["mode", "model"] { + let option = AgentSessionConfigOption( + id: category, name: category, category: category, + currentValue: "choice-0", choices: choices + ) + var selected: String? + let view = category == "mode" + ? AnyView(AgentModePopover(option: option) { selected = $0 }) + : AnyView(AgentModelPopover(option: option, settings: [], agentName: "Claude") { id, value in + #expect(id == category) + selected = value + }) + let size = NSSize(width: category == "mode" ? 350 : 330, + height: category == "mode" ? 330 : 290) + let host = NSHostingView(rootView: view.environment(\.colorScheme, scheme)) + let window = makeWindow(host, size: size) + defer { window.close() } + let scroll = try #require(scrollView(in: host)) + let document = try #require(scroll.documentView) + #expect(document.bounds.height > scroll.contentView.bounds.height, + "A long list must exceed the viewport and remain scrollable") + scroll.contentView.scroll(to: NSPoint(x: 0, y: document.bounds.maxY - scroll.contentView.bounds.height)) + scroll.reflectScrolledClipView(scroll.contentView) + host.layoutSubtreeIfNeeded() + #expect(scroll.contentView.bounds.minY > 0) + let lastPoint = NSPoint(x: 100, y: document.bounds.maxY - 13) + #expect(scroll.contentView.bounds.contains(lastPoint), "The final choice must be inside the scrolled viewport") + try click(lastPoint, in: document, window: window) + #expect(await waitUntil { selected == "choice-29" }, "Scrolling must make the final upstream choice selectable") + try capture(host, name: "\(category)-scrolled-\(scheme)") + } + } + } + + private func makeWindow(_ host: NSHostingView, size: NSSize) -> NSWindow { + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + host.frame.size = size + window.orderFront(nil) + host.layoutSubtreeIfNeeded() + return window + } + + private func scrollView(in view: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView { return scroll } + return view.subviews.lazy.compactMap { scrollView(in: $0) }.first + } + + private func click(_ point: NSPoint, in view: NSView, window: NSWindow) throws { + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent( + with: type, location: view.convert(point, to: nil), modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 1, clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0 + )) + window.sendEvent(event) + } + } + + private func waitUntil(_ condition: () -> Bool) async -> Bool { + // Native mouse events can publish a SwiftUI action asynchronously; wait on the callback, not a delay. + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline { + if condition() { return true } + await Task.yield() + } + return condition() + } + + private func capture(_ host: NSView, name: String) throws { + guard let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_SELECTOR_SCREENSHOTS"] else { return } + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent("\(name).png")) + } +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 3a0e9fc33..740f368a5 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -245,13 +245,15 @@ "id": "agent-acp-conversation", "area": "AI", "group": "Agent 对话", - "capability": "可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", + "capability": "可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", "macos": { "evidence": [ "macos/Sources/LitheAgentConversationModule", "macos/Sources/Lithe/Views/Agent", "rust/lithe-agent-host", - "macos/Tests/LitheTests/AgentBrandIconResourceTests.swift" + "macos/Tests/LitheTests/AgentBrandIconResourceTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -264,7 +266,7 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。" + "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。" }, { "id": "agent-thinking-level-labels", From 2f70823c3723eed45d0724a51c67cde44a635856 Mon Sep 17 00:00:00 2001 From: sinth <2235390062@qq.com> Date: Fri, 2 Oct 2026 17:18:33 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix=EF=BC=9AmacOS=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=A0=91=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=96=87=E4=BB=B6=E6=93=8D=E4=BD=9C=20(#991)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macOS): 支持项目树多选与批量文件操作 (#987) * docs: 记录 #987 macOS 项目树手动验证结果 * docs: 添加 #987 项目树多选手动验证截图 * fix(macOS): 处理 #991 审查意见并补齐项目树批量操作边界 - 批量删除:每项删除前重新检查未保存文档;废纸篓操作期间被改脏的文档保持打开, 仅关闭未修改的文档;中途失败或被未保存文档阻止时提示剩余项目未移动。 - 快捷键归属:点击项目树后若键盘焦点移到文本框(如全局搜索)或其他控件, ⌘A/⌘C/⌘V 交还给当前焦点,不再误复制或粘贴文件。 - Control 点击恢复为 macOS 右键菜单,⌘ 点击负责增减选择;移除为此新增的 litheContextMenu(capturesControlClick:) 参数。 - 单选高亮跟随活动编辑器文档;去掉 ProjectFileTreeContent 中冗余的可见节点比较。 - 中文“拷贝”与“复制(创建副本)”区分;补齐粘贴校验和批量删除提示的中英文案。 - 新增单元、焦点与应用级集成测试;功能矩阵改回待验证。 * fix(macOS): 项目树快捷键被处理后不再同时传给编辑器 本地事件监听里的 `self?.handle(event) ?? event` 会把表示“已处理”的 nil 展平成原事件,导致 ⌘A/⌘C/⌘V 在项目树处理后仍传给编辑器:⌘A 同时全选编辑器文本, ⌘V 同时把剪贴板文本粘进编辑器。改为直接返回 handle 的结果并补回归测试。 粘贴完成的提示由“已复制文件”改为“已粘贴文件”,避免复制和粘贴各弹一条相同提示。 * fix(macOS): 项目树 ⌘A 按焦点所在目录全选 焦点在展开的子目录内时,⌘A 只选中该目录下的可见项目;焦点在项目顶层时选中项目下全部可见项目(含子目录及其展开内容,不含根行);焦点在根行或无焦点时选中整棵可见树。重复 ⌘A 保持同一范围,并补充同名前缀目录不被误选的测试。 * fix(macOS): 项目树 ⌘A 选中焦点项的同级项目 点击文件夹后按 ⌘A,选中与该文件夹同级的项目,而不是它的内容;焦点在文件上时选中同目录文件;无焦点或焦点在项目行时选中项目顶层项目。同级关系按显示的树结构计算,压缩显示的 Java 包也使用其显示父节点。批量操作会自动包含已选文件夹的内容。 * fix(macOS): 已选文件夹下的展开内容同时显示为选中 文件夹被选中时,其下展开的文件和子目录也显示选中高亮,与批量操作会包含文件夹内容保持一致。这些子项只用于显示,不会重复加入操作集合;在多选组内右键这些子项仍保持整组,单独选中一个文件夹时右键其中的文件则以该文件为目标。 * ci: 重新触发 macOS Plugin CI 上次运行在 960 秒时限内仍在冷编译 LitheTests(Rust 4m39s,正常约 3m),尚未进入测试执行。同一提交的 macOS 主 Swift lane 已通过,本地按相同过滤条件运行 59 项插件测试全部通过。 * fix(macOS): 项目树 Shift/⌘ 点击从鼠标按下事件读取修饰键 SwiftUI Button 的动作在鼠标抬起后才执行,此时 NSApp.currentEvent 已不再是这次点击, 读到的修饰键为空,Shift 点击因此退化成普通单选、范围不生效。 - 新增 ProjectTreeModifiedClick 覆盖在每一行上:只在带 ⌘/Shift 的左键按下时命中, 直接用该 mouseDown 事件的修饰键更新选择,并且不再传给 Button,因此不会打开文件或折叠目录。 - 普通点击和 Control 点击(右键菜单)都穿透到原有的 Button 和右键捕获视图,行为不变。 - selectRow 改为接收修饰键参数,Button 动作只做普通单选。 - 新增窗口级回归测试,覆盖 Shift 正向/反向范围选择、⌘ 取消选择、修饰点击不触发打开,以及 Control+Shift 不被捕获。 * test(macOS): 项目树修饰点击回归改为经窗口命中测试分发 审查指出原测试直接调用 overlay.mouseDown,绕过 hitTest 和窗口分发,即使 生产 hitTest 恒返回 nil 也会通过。 - 测试改为把合成的鼠标按下/抬起事件交给 NSWindow.sendEvent,由窗口命中测试 决定交给覆盖层还是底层 Button;覆盖新增普通点击建立锚点、Shift 正反向范围、 ⌘ 增减选择、修饰点击不触发 Button(不打开文件、不折叠目录)以及 Control 点击穿透到底层行。 - ProjectTreeModifiedClickView 的当前事件来源改为可替换的 currentEvent,默认仍是 NSApp.currentEvent。测试进程里经 NSApp 事件队列取出事件会让 swift-testing 的 主队列提前退出,后续测试不再执行,所以测试只替换这一处查询。 - 反向验证:hitTest 恒返回 nil、或捕获 Control 点击时,新测试均失败。 * fix(macOS): 批量文件操作在整批结束前持有操作锁 处理 #991 审查的 P2/P3: - 批量创建副本和批量移到废纸篓原来逐项调用单项入口,每项结束后都会先释放 isPerformingProjectItemOperation 再 await 刷新。刷新期间用户可以启动别的文件操作, 原批次的后续项随后被入口守卫静默跳过。现在由外层批次持有操作锁直到整批结束, 内层拆成不自行加锁、不刷新的单项实现,整批完成后只刷新一次。 - 单项粘贴和单项删除仍通过同一实现,行为不变。 - 中文“Copied files”改为“已拷贝文件”,与菜单“拷贝”一致。 - 新增回归:第一组复制暂停时操作锁保持占用、插入的粘贴被拒绝,批量复制和 批量删除各只在最后一项完成后刷新一次。恢复旧实现时该测试失败。 --------- Co-authored-by: Lichenkang <2188718831@qq.com> --- .../project-tree-multi-selection.png | Bin 0 -> 202334 bytes docs/development/platform-parity-matrix.csv | 1 + docs/development/platform-parity-matrix.md | 9 +- macos/Resources/en.lproj/Localizable.strings | 8 + .../zh-Hans.lproj/Localizable.strings | 9 + .../Sources/Lithe/Core/Ports/PlatformUI.swift | 5 + .../Lithe/Models/AppModel/AppModel.swift | 16 + .../Platform/MacOS/UI/MacFileClipboard.swift | 18 + .../Platform/MacOS/UI/MacPlatformUI.swift | 8 + .../UI/ProjectTreeKeyboardCommands.swift | 121 +++++++ .../MacOS/UI/ProjectTreeModifiedClick.swift | 44 +++ .../Views/Workspace/ProjectSidebarView.swift | 131 ++++++- .../Workspace/ProjectTreeSelection.swift | 86 +++++ .../Workspace/WorkspaceFeatureContracts.swift | 1 + .../Application/WorkspaceFeatureModel.swift | 174 +++++++++- .../LitheTests/LitheCoreLogicTests.swift | 319 +++++++++++++++++- .../ProjectTreeBatchFileWorkflowTests.swift | 122 +++++++ .../ProjectTreeSelectionTests.swift | 299 ++++++++++++++++ scripts/build-official-plugins.sh | 3 +- shared/platform-feature-matrix.json | 28 ++ 20 files changed, 1374 insertions(+), 28 deletions(-) create mode 100644 docs/assets/screenshots/project-tree-multi-selection.png create mode 100644 macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift create mode 100644 macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift create mode 100644 macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift create mode 100644 macos/Tests/LitheTests/ProjectTreeSelectionTests.swift diff --git a/docs/assets/screenshots/project-tree-multi-selection.png b/docs/assets/screenshots/project-tree-multi-selection.png new file mode 100644 index 0000000000000000000000000000000000000000..9b8d77a77eba109ca76e5c447643a486dd186b97 GIT binary patch literal 202334 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%0AL;`342+LOMEy0CcOJZ)_`ng(F_g_sbtp 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 literal 0 HcmV?d00001 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index d5b8acf6e..da9e056ef 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -18,6 +18,7 @@ agent-codex-subscription,AI,Agent 对话,Codex 供应商选择官方订阅:复 agent-subscription-quota,AI,Agent 对话,Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离,已实现,待验证,部分实现,待验证,Agent,macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。,,macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/subscription/tests.rs,rust/lithe-agent-host/src/subscription.rs; shared/fixtures/agent/acp-events-v1.json workspace-open-switch,工作区,工作区生命周期,打开工作区与切换项目,已实现,待验证,已实现,待验证,Workspace,打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/workspace workspace-files,工作区,工作区生命周期,文件树与文件操作,已实现,待验证,已实现,待验证,Workspace,新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。,,macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/file-system +workspace-tree-batch-files,工作区,工作区生命周期,项目树多选、范围选择及批量复制、重复和移到废纸篓,已实现,待验证,未实现,不适用,Workspace,macOS 项目树中使用 ⌘ 点击增减选择、Shift 点击正反向连续选择,Control 点击仍打开右键菜单,右键已选项保持集合;⌘A 全选焦点项的同级项目(点击文件夹时选它旁边的项目而不是其内容,未选中时选项目顶层项目),已选文件夹下展开的内容同时显示为选中;打开文件后单选高亮跟随编辑器;拷贝并粘贴到目标目录、批量创建副本和移到废纸篓,确认同名不覆盖、父子去重、未保存保护及取消删除;确认删除后、废纸篓操作完成前修改的文件不会被删除或关闭,失败时提示剩余项目未移动;点击编辑器或在点击项目树后用快捷键打开全局搜索时,⌘A/⌘C/⌘V 恢复文本操作。Windows 尚无该多选入口。,,macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift; macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift; macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift; macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift; macos/Tests/LitheTests/ProjectTreeSelectionTests.swift; macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift,windows/tauri/src/features/file-system workspace-document-sync,工作区,工作区生命周期,脏状态、保存与外部修改,已实现,待验证,已实现,待验证,Workspace,编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。,,macos/Sources/Lithe/Models/Editor; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/editor; windows/tauri/src/features/file-system workspace-tabs-projects,工作区,工作区生命周期,多项目与多标签,已实现,待验证,已实现,待验证,Workspace,同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/workspace; windows/tauri/src/features/tabs; windows/tauri/src-tauri/src/project_windows.rs; windows/tauri/src-tauri/src/project_window_registry.rs; windows/tauri/src/features/window/services/project-window-router.ts editor-text-tabs,编辑器,文本编辑,文本编辑与标签生命周期,已实现,待验证,已实现,待验证,Editor,打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;右键标签页确认关闭分组排在其他操作之前;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; windows/tauri/src/features/tabs; windows/tauri/src/features/tabs/components/tab-context-menu.tsx; windows/tauri/src/features/tabs/components/tab-context-menu.test.ts diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 54d95ba5b..7068a421f 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:122 -- macOS:实现:✅ 107 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 1 已验证,🔍 109 待验证,— 12 不适用 -- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 115 待验证,— 7 不适用 +- 功能项:123 +- macOS:实现:✅ 108 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 1 已验证,🔍 110 待验证,— 12 不适用 +- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 8 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 115 待验证,— 8 不适用 ## 实现状态定义 @@ -59,12 +59,13 @@
-工作区 · 7 个能力点 +工作区 · 8 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | 工作区生命周期 | **打开工作区与切换项目**
workspace-open-switch | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace`、`macos/Sources/Lithe/Services/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/workspace` | Workspace | 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。 | | | 工作区生命周期 | **文件树与文件操作**
workspace-files | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system` | Workspace | 新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 | | +| 工作区生命周期 | **项目树多选、范围选择及批量复制、重复和移到废纸篓**
workspace-tree-batch-files | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift`、`macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift`、`macos/Tests/LitheTests/ProjectTreeSelectionTests.swift`、`macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features/file-system` | Workspace | macOS 项目树中使用 ⌘ 点击增减选择、Shift 点击正反向连续选择,Control 点击仍打开右键菜单,右键已选项保持集合;⌘A 全选焦点项的同级项目(点击文件夹时选它旁边的项目而不是其内容,未选中时选项目顶层项目),已选文件夹下展开的内容同时显示为选中;打开文件后单选高亮跟随编辑器;拷贝并粘贴到目标目录、批量创建副本和移到废纸篓,确认同名不覆盖、父子去重、未保存保护及取消删除;确认删除后、废纸篓操作完成前修改的文件不会被删除或关闭,失败时提示剩余项目未移动;点击编辑器或在点击项目树后用快捷键打开全局搜索时,⌘A/⌘C/⌘V 恢复文本操作。Windows 尚无该多选入口。 | | | 工作区生命周期 | **脏状态、保存与外部修改**
workspace-document-sync | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Editor`、`macos/Sources/Lithe/Services/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/file-system` | Workspace | 编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。 | | | 工作区生命周期 | **多项目与多标签**
workspace-tabs-projects | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace`、`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/workspace`、`windows/tauri/src/features/tabs`、`windows/tauri/src-tauri/src/project_windows.rs`、`windows/tauri/src-tauri/src/project_window_registry.rs`、`windows/tauri/src/features/window/services/project-window-router.ts` | Workspace | 同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。 | | | 项目浏览 | **项目文件树与资源打开**
file-explorer | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Models/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-explorer`、`windows/tauri/src/features/sidebar` | Workspace | 浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 2e090e0a8..253fe617e 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1318,6 +1318,14 @@ "Could not open HTML in browser" = "Could not open HTML in browser"; "Choose a default web browser in System Settings, then retry." = "Choose a default web browser in System Settings, then retry."; +"Paste" = "Paste"; +"Copied files" = "Copied files"; +"Move %lld items to Trash?" = "Move %lld items to Trash?"; +"Pasted files" = "Pasted files"; +"Copy Files" = "Copy"; +"Choose a directory inside the project" = "Choose a directory inside the project"; +"Cannot copy a directory into itself" = "Cannot copy a directory into itself"; +"Stopped moving the remaining items to Trash" = "Stopped moving the remaining items to Trash"; "AI tool connections (MCP)" = "AI tool connections (MCP)"; "Authorize this project to expose environment, Maven, run configurations and output to connected agents. Output may contain sensitive project data. Access ends when this project or Lithe closes." = "Authorize this project to expose environment, Maven, run configurations and output to connected agents. Output may contain sensitive project data. Access ends when this project or Lithe closes."; "Allow project environment and Maven configuration changes" = "Allow project environment and Maven configuration changes"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 689960b16..76284b84c 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2557,6 +2557,15 @@ "Could not open HTML in browser" = "无法在浏览器中打开 HTML"; "Choose a default web browser in System Settings, then retry." = "请在系统设置中选择默认网页浏览器,然后重试。"; + +"Paste" = "粘贴"; +"Copied files" = "已拷贝文件"; +"Move %lld items to Trash?" = "将 %lld 个项目移到废纸篓?"; +"Pasted files" = "已粘贴文件"; +"Copy Files" = "拷贝"; +"Choose a directory inside the project" = "请选择项目内的目录"; +"Cannot copy a directory into itself" = "不能将目录复制到其自身或子目录中"; +"Stopped moving the remaining items to Trash" = "已停止将剩余项目移到废纸篓"; "%llds" = "%lld秒"; "%lldm %llds" = "%lld分 %lld秒"; "%lldh %lldm %llds" = "%lld小时 %lld分 %lld秒"; diff --git a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift index 364b0c4d1..accaca7d6 100644 --- a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift +++ b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift @@ -11,6 +11,8 @@ protocol PlatformUI: AnyObject { func open(_ url: URL) func openHTMLInBrowser(_ url: URL) async throws func copyToClipboard(_ value: String) + func copyFilesToClipboard(_ urls: [URL]) -> Bool + func fileURLsFromClipboard() -> [URL] func markdownImageFromClipboard() -> MarkdownImageSource? func startAccessingProject(_ url: URL) -> Bool func stopAccessingProject(_ url: URL) @@ -21,6 +23,9 @@ extension PlatformUI { throw CocoaError(.featureUnsupported) } + func copyFilesToClipboard(_ urls: [URL]) -> Bool { false } + func fileURLsFromClipboard() -> [URL] { [] } + func activateApplication() {} func startAccessingProject(_ url: URL) -> Bool { false } func stopAccessingProject(_ url: URL) {} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 47bfd49f7..f2820f9f1 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1046,6 +1046,14 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { await workspaceFeature.duplicateProjectItem(at: sourceURL) } + func duplicateProjectItems(_ urls: [URL]) async { + await workspaceFeature.duplicateProjectItems(urls) + } + + func requestDeleteProjectItems(_ urls: [URL]) { + workspaceFeature.requestDeleteProjectItems(urls) + } + func requestDeleteProjectItem(at url: URL, isDirectory: Bool) { workspaceFeature.requestDeleteProjectItem(at: url, isDirectory: isDirectory) } @@ -1062,6 +1070,14 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { platformUI.revealInFileBrowser(url) } + func copyProjectItems(_ urls: [URL]) { + if platformUI.copyFilesToClipboard(urls) { showNotification("Copied files") } + } + + func pasteProjectItems(in directory: URL) async { + await workspaceFeature.pasteProjectItems(platformUI.fileURLsFromClipboard(), in: directory) + } + func copyProjectItemPath(_ url: URL, relative: Bool) { let relativeValue = relativePath(for: url) let value = relative ? (relativeValue.isEmpty ? "." : relativeValue) : url.path diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift new file mode 100644 index 000000000..ff91d7baa --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacFileClipboard.swift @@ -0,0 +1,18 @@ +import AppKit +import Foundation + +/// File URLs use the native pasteboard format shared with Finder. +@MainActor +enum MacFileClipboard { + static func write(_ urls: [URL], to pasteboard: NSPasteboard) -> Bool { + guard !urls.isEmpty else { return false } + pasteboard.clearContents() + return pasteboard.writeObjects(urls.map { $0 as NSURL }) + } + + static func read(from pasteboard: NSPasteboard) -> [URL] { + (pasteboard.readObjects( + forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] + ) as? [URL]) ?? [] + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift index cf7498d85..6fb54dd8a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift @@ -46,6 +46,14 @@ final class MacPlatformUI: PlatformUI { NSPasteboard.general.setString(value, forType: .string) } + func copyFilesToClipboard(_ urls: [URL]) -> Bool { + MacFileClipboard.write(urls, to: .general) + } + + func fileURLsFromClipboard() -> [URL] { + MacFileClipboard.read(from: .general) + } + func markdownImageFromClipboard() -> MarkdownImageSource? { MarkdownClipboardImageReader.read(from: .general) } diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift new file mode 100644 index 000000000..3504f2f56 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift @@ -0,0 +1,121 @@ +import AppKit +import SwiftUI + +/// Routes clipboard shortcuts only after a pointer interaction inside the tree. +/// A click elsewhere, or a keyboard focus change after the click, returns +/// keyboard ownership to the focused view. +struct ProjectTreeKeyboardCommands: NSViewRepresentable { + let copy: () -> Void + let paste: () -> Void + let selectAll: () -> Void + + func makeNSView(context: Context) -> ProjectTreeKeyboardCommandView { + let view = ProjectTreeKeyboardCommandView() + updateNSView(view, context: context) + return view + } + + func updateNSView(_ view: ProjectTreeKeyboardCommandView, context: Context) { + view.copyItems = copy + view.pasteItems = paste + view.selectAllItems = selectAll + } + + static func dismantleNSView(_ view: ProjectTreeKeyboardCommandView, coordinator: ()) { + view.removeMonitor() + } +} + +final class ProjectTreeKeyboardCommandView: NSView { + var copyItems: (() -> Void)? + var pasteItems: (() -> Void)? + var selectAllItems: (() -> Void)? + private var monitor: Any? + private var ownsKeyboard = false + // The tree has no focusable view, and opening a file may move focus to the + // editor asynchronously. The responder seen by the first key after a tree + // click is therefore the baseline; any later change came from the keyboard. + private var hasKeyboardResponder = false + private weak var keyboardResponder: NSResponder? + private weak var responderAtClick: NSResponder? + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + removeMonitor() + guard window != nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown]) { [weak self] event in + Self.monitorResult(for: event, view: self) + } + } + + /// The local monitor's return value: `nil` stops AppKit from also sending a + /// consumed shortcut to the editor. `view?.handle(event) ?? event` would + /// flatten that `nil` back into the event and deliver it twice. + static func monitorResult(for event: NSEvent, view: ProjectTreeKeyboardCommandView?) -> NSEvent? { + guard let view else { return event } + return view.handle(event) + } + + /// Returns `nil` when the tree consumed the event. + func handle(_ event: NSEvent) -> NSEvent? { + guard let window else { return event } + guard (event.window ?? NSApp.keyWindow) === window else { + releaseKeyboard() + return event + } + if event.type != .keyDown { + let point = event.window == nil + ? window.convertPoint(fromScreen: NSEvent.mouseLocation) + : event.locationInWindow + releaseKeyboard() + ownsKeyboard = !isHiddenOrHasHiddenAncestor && bounds.contains(convert(point, from: nil)) + responderAtClick = Self.focusOwner(window.firstResponder) + return event + } + guard ownsKeyboard, !isHiddenOrHasHiddenAncestor else { return event } + let responder = Self.focusOwner(window.firstResponder) + // A text field focused after the tree click, such as Search Everywhere, + // keeps standard text shortcuts. Some panels open from modifier-only + // gestures, so their field editor can be the first key responder. + if responder is NSTextField, responder !== responderAtClick { + releaseKeyboard() + return event + } + if !hasKeyboardResponder { + hasKeyboardResponder = true + keyboardResponder = responder + } else if keyboardResponder !== responder { + releaseKeyboard() + return event + } + guard event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command else { return event } + switch event.charactersIgnoringModifiers?.lowercased() { + case "c": copyItems?() + case "v": pasteItems?() + case "a": selectAllItems?() + default: return event + } + return nil + } + + func removeMonitor() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + releaseKeyboard() + } + + /// Text fields in one window share a field editor; compare the edited field. + private static func focusOwner(_ responder: NSResponder?) -> NSResponder? { + guard let editor = responder as? NSTextView, editor.isFieldEditor else { return responder } + return editor.delegate as? NSTextField ?? editor + } + + private func releaseKeyboard() { + ownsKeyboard = false + hasKeyboardResponder = false + keyboardResponder = nil + responderAtClick = nil + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift new file mode 100644 index 000000000..4437765b6 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift @@ -0,0 +1,44 @@ +import AppKit +import SwiftUI + +/// Captures ⌘/Shift clicks on a tree row and reads their modifiers from the +/// mouse-down event itself. A SwiftUI Button action runs after that event, when +/// NSApp.currentEvent no longer describes the click. +struct ProjectTreeModifiedClick: NSViewRepresentable { + let select: (NSEvent.ModifierFlags) -> Void + + func makeNSView(context: Context) -> ProjectTreeModifiedClickView { + let view = ProjectTreeModifiedClickView() + view.select = select + return view + } + + func updateNSView(_ view: ProjectTreeModifiedClickView, context: Context) { + view.select = select + } +} + +final class ProjectTreeModifiedClickView: NSView { + var select: ((NSEvent.ModifierFlags) -> Void)? + /// The event AppKit is dispatching. Tests substitute the synthesized event, + /// because pulling it through NSApp's event queue ends the test process. + var currentEvent: () -> NSEvent? = { NSApp.currentEvent } + + /// Plain clicks stay with the Button, and Control-click stays the macOS + /// secondary click handled by the context menu. + static func captures(_ event: NSEvent) -> Bool { + event.type == .leftMouseDown + && !event.modifierFlags.contains(.control) + && !event.modifierFlags.intersection([.shift, .command]).isEmpty + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let event = currentEvent(), Self.captures(event) else { return nil } + return super.hitTest(point) + } + + override func mouseDown(with event: NSEvent) { + guard Self.captures(event) else { return } + select?(event.modifierFlags) + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index aeea8f06f..e4458365e 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -34,9 +34,16 @@ struct ProjectSidebarView: View { @State private var expandedDirectoryPaths: Set = [] @State private var expandedTreeRootPath: String? @State private var contextMenuPath: String? + @State private var selection = ProjectTreeSelection() @State private var selectedContent: ProjectSidebarContent = .project @State private var dependencyRefreshRevision = 0 + private func selectedURLs(in root: FileNode) -> [URL] { + ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths) + .filter { selection.paths.contains($0.url.path) && $0.url != root.url } + .map(\.url) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { sidebarHeader @@ -72,6 +79,9 @@ struct ProjectSidebarView: View { ), directoryMarks: model.projectDirectoryMarks, actions: ProjectTreeActions(model: model), + selectionSnapshot: selection, + selection: $selection, + visibleNodes: ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths), expandedDirectoryPathsSnapshot: expandedDirectoryPaths, expandedDirectoryPaths: $expandedDirectoryPaths, contextMenuPath: $contextMenuPath @@ -85,6 +95,27 @@ struct ProjectSidebarView: View { alignment: .topLeading ) } + .background(ProjectTreeKeyboardCommands( + copy: { model.copyProjectItems(selectedURLs(in: root)) }, + paste: { + let nodes = ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths) + let focused = nodes.first { $0.url.path == selection.focusedPath } + let destination = focused.map { $0.isDirectory ? $0.url : $0.url.deletingLastPathComponent() } ?? root.url + Task { await model.pasteProjectItems(in: destination) } + }, + selectAll: { + selection.selectAll(in: root) + } + ).frame(maxWidth: .infinity, maxHeight: .infinity)) + .onChange(of: ProjectTreeSelection.visibleNodes(in: root, expandedPaths: expandedDirectoryPaths).map { $0.url.path }) { paths in + selection.retain(visiblePaths: paths) + } + .onChange(of: model.activeDocument?.url.standardizedFileURL.path) { path in + // A single selection follows the editor, as it did before + // multi-selection; an explicit group stays until changed. + guard let path, selection.paths.count <= 1 else { return } + selection.select(path, visiblePaths: [], extending: false, toggling: false) + } .scrollContentBackground(.hidden) .litheScrollViewChrome(usesCompactScrollers: true) .task( @@ -97,6 +128,7 @@ struct ProjectSidebarView: View { let revealRequest = model.projectTreeRevealRequest let shouldRefreshGit = expandedTreeRootPath != rootPath if shouldRefreshGit { + selection = ProjectTreeSelection() expandedTreeRootPath = rootPath expandedDirectoryPaths = [rootPath] } @@ -162,7 +194,11 @@ struct ProjectSidebarView: View { .allowsHitTesting(false) } .confirmationDialog( - "Move '\(model.pendingProjectItemDeletion?.url.lastPathComponent ?? "")' to Trash?", + model.pendingProjectItemDeletion.map { request -> LocalizedStringKey in + request.additionalItems.isEmpty + ? "Move '\(request.url.lastPathComponent)' to Trash?" + : "Move \(request.additionalItems.count + 1) items to Trash?" + } ?? "Move to Trash?", isPresented: Binding( get: { model.pendingProjectItemDeletion != nil }, set: { if !$0 { model.cancelProjectItemDeletion() } } @@ -320,6 +356,18 @@ private final class ProjectTreeActions: @unchecked Sendable { nonisolated func copyPath(_ url: URL, relative: Bool) { Task { @MainActor in self.model.copyProjectItemPath(url, relative: relative) } } + nonisolated func copyFiles(_ urls: [URL]) { + Task { @MainActor in self.model.copyProjectItems(urls) } + } + nonisolated func duplicateFiles(_ urls: [URL]) { + Task { await self.model.duplicateProjectItems(urls) } + } + nonisolated func deleteFiles(_ urls: [URL]) { + Task { @MainActor in self.model.requestDeleteProjectItems(urls) } + } + nonisolated func pasteFiles(in directory: URL) { + Task { await self.model.pasteProjectItems(in: directory) } + } nonisolated func duplicate(_ url: URL) { Task { await self.model.duplicateProjectItem(at: url) } } @@ -369,6 +417,9 @@ private struct ProjectFileTreeContent: View, Equatable { let gitStatus: ProjectGitStatusSnapshot let directoryMarks: [String: WorkspaceDirectoryMark] let actions: ProjectTreeActions + let selectionSnapshot: ProjectTreeSelection + @Binding var selection: ProjectTreeSelection + let visibleNodes: [FileNode] let expandedDirectoryPathsSnapshot: Set @Binding var expandedDirectoryPaths: Set @Binding var contextMenuPath: String? @@ -382,6 +433,7 @@ private struct ProjectFileTreeContent: View, Equatable { && lhs.directoryMarks == rhs.directoryMarks && lhs.expandedDirectoryPathsSnapshot == rhs.expandedDirectoryPathsSnapshot && lhs.contextMenuPath == rhs.contextMenuPath + && lhs.selectionSnapshot == rhs.selectionSnapshot } var body: some View { @@ -395,6 +447,8 @@ private struct ProjectFileTreeContent: View, Equatable { projectRootURL: root.url, directoryMarks: directoryMarks, actions: actions, + selection: $selection, + visibleNodes: visibleNodes, expandedDirectoryPaths: $expandedDirectoryPaths, contextMenuPath: $contextMenuPath ) @@ -412,6 +466,8 @@ private struct FileNodeRow: View { let projectRootURL: URL let directoryMarks: [String: WorkspaceDirectoryMark] let actions: ProjectTreeActions + @Binding var selection: ProjectTreeSelection + let visibleNodes: [FileNode] @Binding var expandedDirectoryPaths: Set @Binding var contextMenuPath: String? @State private var resolvedJavaIconKind: LitheIconKind? @@ -448,6 +504,8 @@ private struct FileNodeRow: View { projectRootURL: projectRootURL, directoryMarks: directoryMarks, actions: actions, + selection: $selection, + visibleNodes: visibleNodes, expandedDirectoryPaths: $expandedDirectoryPaths, contextMenuPath: $contextMenuPath ) @@ -463,6 +521,7 @@ private struct FileNodeRow: View { private var directoryRow: some View { Button { contextMenuPath = nil + selectRow() if isExpanded { expandedDirectoryPaths.remove(node.url.path) node.collapsedAncestorPaths.forEach { expandedDirectoryPaths.remove($0) } @@ -493,7 +552,7 @@ private struct FileNodeRow: View { .frame(height: rowHeight) .contentShape(Rectangle()) .litheRowHover( - isActive: contextMenuPath == node.url.standardizedFileURL.path, + isActive: selection.covers(node.url.path) || contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, activeBackground: LitheTheme.subtleSelection, animation: nil @@ -502,15 +561,25 @@ private struct FileNodeRow: View { .buttonStyle(.litheNoPress) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) + .overlay { + ProjectTreeModifiedClick { flags in + contextMenuPath = nil + selectRow(flags: flags) + } + } .litheContextMenu( - items: { directoryContextMenuItems }, - onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + items: { selection.paths.count > 1 ? batchMenuItems : clipboardMenuItems + [.separator] + directoryContextMenuItems }, + onRightClick: { + selection.selectForContextMenu(node.url.path) + contextMenuPath = node.url.standardizedFileURL.path + } ) } private var fileRow: some View { Button { contextMenuPath = nil + selectRow() ProjectFileRowActivation.performPrimary(isExecutableBinary: isExecutableFile) { actions.openFile(node.url) } @@ -539,8 +608,8 @@ private struct FileNodeRow: View { .frame(height: rowHeight) .contentShape(Rectangle()) .litheRowHover( - isActive: activeDocumentURL?.standardizedFileURL.path - == node.url.standardizedFileURL.path + isActive: selection.covers(node.url.path) + || (selection.paths.isEmpty && activeDocumentURL?.standardizedFileURL.path == node.url.standardizedFileURL.path) || contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, activeBackground: LitheTheme.subtleSelection, @@ -550,9 +619,18 @@ private struct FileNodeRow: View { .buttonStyle(.litheNoPress) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) + .overlay { + ProjectTreeModifiedClick { flags in + contextMenuPath = nil + selectRow(flags: flags) + } + } .litheContextMenu( - items: { fileContextMenuItems }, - onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + items: { selection.paths.count > 1 ? batchMenuItems : clipboardMenuItems + [.separator] + fileContextMenuItems }, + onRightClick: { + selection.selectForContextMenu(node.url.path) + contextMenuPath = node.url.standardizedFileURL.path + } ) .simultaneousGesture( TapGesture(count: 2).onEnded { @@ -571,6 +649,43 @@ private struct FileNodeRow: View { } } + /// Modified clicks update selection without opening files or folding directories. + /// Control-click stays the macOS secondary click and opens the context menu. + private func selectRow(flags: NSEvent.ModifierFlags = []) { + selection.select( + node.url.path, + visiblePaths: visibleNodes.map { $0.url.path }, + extending: flags.contains(.shift), + toggling: flags.contains(.command) + ) + } + + private var selectedItemURLs: [URL] { + visibleNodes.filter { selection.paths.contains($0.url.path) && $0.url != projectRootURL }.map(\.url) + } + + private var batchMenuItems: [LitheContextMenuItem] { + let urls = selectedItemURLs + return clipboardMenuItems + [ + .separator, + .action("Duplicate", isEnabled: !urls.isEmpty) { actions.duplicateFiles(urls) }, + .action("Move to Trash", systemImage: "trash", role: .destructive, isEnabled: !urls.isEmpty) { + actions.deleteFiles(urls) + } + ] + } + + private var clipboardMenuItems: [LitheContextMenuItem] { + let urls = selectedItemURLs + let destination = node.isDirectory ? node.url : node.url.deletingLastPathComponent() + return [ + .action("Copy Files", systemImage: "doc.on.doc", shortcut: "⌘C", isEnabled: !urls.isEmpty) { + actions.copyFiles(urls) + }, + .action("Paste", shortcut: "⌘V") { actions.pasteFiles(in: destination) } + ] + } + private var directoryContextMenuItems: [LitheContextMenuItem] { var items: [LitheContextMenuItem] = [] diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift b/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift new file mode 100644 index 000000000..6a3a3d13d --- /dev/null +++ b/macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift @@ -0,0 +1,86 @@ +import Foundation +import LitheCoreContracts + +/// Selection follows the displayed tree order, including only expanded children. +struct ProjectTreeSelection: Equatable { + private(set) var paths: Set = [] + private(set) var anchorPath: String? + private(set) var focusedPath: String? + + mutating func select(_ path: String, visiblePaths: [String], extending: Bool, toggling: Bool) { + focusedPath = path + if extending, let anchorPath, + let start = visiblePaths.firstIndex(of: anchorPath), + let end = visiblePaths.firstIndex(of: path) { + let range = Set(visiblePaths[min(start, end)...max(start, end)]) + paths = toggling ? paths.union(range) : range + } else if toggling { + if !paths.insert(path).inserted { paths.remove(path) } + anchorPath = path + } else { + paths = [path] + anchorPath = path + } + } + + /// A selected folder also shows its descendants as selected; batch actions + /// already operate on everything inside it. + func covers(_ path: String) -> Bool { + var current = path + while true { + if paths.contains(current) { return true } + let parent = (current as NSString).deletingLastPathComponent + guard parent != current, !parent.isEmpty else { return false } + current = parent + } + } + + mutating func selectForContextMenu(_ path: String) { + focusedPath = path + // Inside a single selected folder the row is its own target, because the + // single-item menu acts on the clicked row rather than the folder. + guard !paths.contains(path), !(paths.count > 1 && covers(path)) else { return } + paths = [path] + anchorPath = path + } + + mutating func retain(visiblePaths: [String]) { + let visible = Set(visiblePaths) + paths.formIntersection(visible) + if let anchorPath, !visible.contains(anchorPath) { self.anchorPath = nil } + if let focusedPath, !visible.contains(focusedPath) { self.focusedPath = nil } + } + + /// ⌘A selects the focused row's siblings in the displayed tree, so a + /// folder selects the items beside it rather than its own contents. Batch + /// actions already include a selected folder's descendants. With no focus, + /// or focus on the project row, the project's top-level items are selected. + mutating func selectAll(in root: FileNode) { + let parent = focusedPath.flatMap { Self.displayedParent(of: $0, in: root) } ?? root + let siblings = (parent.children ?? []).map(\.url.path) + guard let first = siblings.first else { return } + paths = Set(siblings) + anchorPath = first + if focusedPath.map({ !paths.contains($0) }) ?? true { focusedPath = first } + } + + /// Uses tree structure instead of path components because compacted Java + /// packages display below an ancestor that is not their filesystem parent. + private static func displayedParent(of path: String, in node: FileNode) -> FileNode? { + for child in node.children ?? [] { + if child.url.path == path { return node } + if let parent = displayedParent(of: path, in: child) { return parent } + } + return nil + } + + static func visibleNodes(in root: FileNode, expandedPaths: Set) -> [FileNode] { + var nodes = [root] + if root.isDirectory, expandedPaths.contains(root.url.path) { + for child in root.children ?? [] { + nodes += visibleNodes(in: child, expandedPaths: expandedPaths) + } + } + return nodes + } +} diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift index e15099dd2..3a3b496a1 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -152,6 +152,7 @@ package struct ProjectItemDeletionRequest: Identifiable, Sendable { package let id: UUID package let url: URL package let isDirectory: Bool + package var additionalItems: [ProjectItemDeletionRequest] = [] package init(id: UUID = UUID(), url: URL, isDirectory: Bool) { self.id = id diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 5051a39cb..807524627 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -598,6 +598,76 @@ package final class WorkspaceFeatureModel: ObservableObject { if request.kind == .createFile { openFile?(destination) } } + package func pasteProjectItems(_ urls: [URL], in directory: URL) async { + guard !isPerformingProjectItemOperation else { return } + isPerformingProjectItemOperation = true + let copied = await pasteProjectItemsWithinOperation(urls, in: directory) + isPerformingProjectItemOperation = false + if copied { await refreshCurrent() } + } + + /// Copies into one directory. The caller owns `isPerformingProjectItemOperation` + /// and the refresh, so a batch is not interleaved with another file operation. + private func pasteProjectItemsWithinOperation(_ urls: [URL], in directory: URL) async -> Bool { + guard isWorkspaceURL(directory), fileOperations.isDirectory(at: directory), + let operationWorkspaceURL = workspaceURL else { return false } + let generation = workspaceGeneration + guard let sources = validatedCopySources(urls, in: directory, workspace: operationWorkspaceURL) else { return false } + let fileOperations = self.fileOperations + let result = await Task.detached(priority: .userInitiated) { + Self.copyProjectItems(sources, in: directory, fileOperations: fileOperations) + }.value + guard workspaceURL == operationWorkspaceURL, workspaceGeneration == generation else { return false } + if let error = result.1 { notify?(error) } else { notify?("Pasted files") } + return result.0 > 0 + } + + private func validatedCopySources(_ urls: [URL], in directory: URL, workspace: URL) -> [URL]? { + let sources = Array(Set(urls.filter { $0.isFileURL }.map { $0.standardizedFileURL })) + .sorted { $0.path < $1.path } + let topLevelSources = sources.filter { source in + !sources.contains { parent in parent != source && source.path.hasPrefix(parent.path + "/") } + } + guard !topLevelSources.isEmpty else { return nil } + // Resolving links prevents a destination alias from copying a directory into itself. + let target = directory.resolvingSymlinksInPath().standardizedFileURL + let resolvedRoot = workspace.resolvingSymlinksInPath().standardizedFileURL + guard target == resolvedRoot || target.path.hasPrefix(resolvedRoot.path + "/") else { + notify?("Choose a directory inside the project") + return nil + } + for source in topLevelSources where fileOperations.isDirectory(at: source) { + let resolved = source.resolvingSymlinksInPath().standardizedFileURL + if target == resolved || target.path.hasPrefix(resolved.path + "/") { + notify?("Cannot copy a directory into itself") + return nil + } + } + return topLevelSources + } + + private nonisolated static func copyProjectItems( + _ sources: [URL], in directory: URL, fileOperations: any WorkspaceFileOperations + ) -> (Int, String?) { + var copied = 0 + do { + for source in sources { + var destination = directory.appendingPathComponent(source.lastPathComponent) + var index = 1 + while fileOperations.fileExists(at: destination) { + let ext = source.pathExtension + let base = ext.isEmpty ? source.lastPathComponent : source.deletingPathExtension().lastPathComponent + let suffix = index == 1 ? " copy" : " copy \(index)" + destination = directory.appendingPathComponent(base + suffix + (ext.isEmpty ? "" : "." + ext)) + index += 1 + } + try fileOperations.copyItem(at: source, to: destination) + copied += 1 + } + return (copied, nil) + } catch { return (copied, error.localizedDescription) } + } + package func duplicateProjectItem(at sourceURL: URL) async { guard !isPerformingProjectItemOperation, isWorkspaceURL(sourceURL), @@ -622,6 +692,46 @@ package final class WorkspaceFeatureModel: ObservableObject { } } + package func duplicateProjectItems(_ urls: [URL]) async { + guard !isPerformingProjectItemOperation else { return } + let generation = workspaceGeneration + let sources = topLevelProjectItems(urls) + isPerformingProjectItemOperation = true + var copied = false + for directory in Set(sources.map { $0.deletingLastPathComponent() }).sorted(by: { $0.path < $1.path }) { + guard workspaceGeneration == generation else { break } + copied = await pasteProjectItemsWithinOperation( + sources.filter { $0.deletingLastPathComponent() == directory }, in: directory + ) || copied + } + isPerformingProjectItemOperation = false + if copied { await refreshCurrent() } + } + + private func topLevelProjectItems(_ urls: [URL]) -> [URL] { + let sources = Array(Set(urls.map { $0.standardizedFileURL })) + .filter { isWorkspaceURL($0) && $0 != workspaceURL?.standardizedFileURL } + .sorted { $0.path < $1.path } + return sources.filter { source in + !sources.contains { parent in parent != source && source.path.hasPrefix(parent.path + "/") } + } + } + + package func requestDeleteProjectItems(_ urls: [URL]) { + guard !isPerformingProjectItemOperation else { return } + let sources = topLevelProjectItems(urls) + guard let first = sources.first else { return } + guard !sources.contains(where: hasDirtyDocument(in:)) else { + notify?("Save or discard unsaved files before deleting this item") + return + } + var request = ProjectItemDeletionRequest(url: first, isDirectory: fileOperations.isDirectory(at: first)) + request.additionalItems = sources.dropFirst().map { + ProjectItemDeletionRequest(url: $0, isDirectory: fileOperations.isDirectory(at: $0)) + } + pendingProjectItemDeletion = request + } + package func requestDeleteProjectItem(at url: URL, isDirectory: Bool) { guard !isPerformingProjectItemOperation, isWorkspaceURL(url), @@ -641,13 +751,44 @@ package final class WorkspaceFeatureModel: ObservableObject { if pendingProjectItemDeletion?.id == request.id { pendingProjectItemDeletion = nil } - guard !isPerformingProjectItemOperation, - isWorkspaceURL(request.url), - request.url.standardizedFileURL != workspaceURL?.standardizedFileURL else { return } - guard let operationWorkspaceURL = workspaceURL else { return } + guard !isPerformingProjectItemOperation else { return } + let generation = workspaceGeneration + var first = request + first.additionalItems = [] + let items = [first] + request.additionalItems + // The whole batch owns the operation flag, so no other file operation + // can start between items and make later items skip silently. + isPerformingProjectItemOperation = true + var changed = false + for (index, item) in items.enumerated() { + guard workspaceGeneration == generation else { break } + changed = await trashProjectItemWithinOperation(item) || changed + guard workspaceGeneration == generation else { break } + if fileOperations.fileExists(at: item.url) { + // Each item reports its own failure; also say that the rest + // of the batch was intentionally left in place. + if index < items.count - 1 { notify?("Stopped moving the remaining items to Trash") } + break + } + } + isPerformingProjectItemOperation = false + if changed { await refreshCurrent() } + } + + /// Moves one item to the Trash and returns whether the tree needs a refresh. + /// The caller owns `isPerformingProjectItemOperation` and the refresh. + private func trashProjectItemWithinOperation(_ request: ProjectItemDeletionRequest) async -> Bool { + guard isWorkspaceURL(request.url), + request.url.standardizedFileURL != workspaceURL?.standardizedFileURL, + let operationWorkspaceURL = workspaceURL else { return false } + // Editors stay usable while the dialog is open and while earlier batch + // items wait for the Trash, so the request-time check can be stale. + guard !hasDirtyDocument(in: request.url) else { + notify?("Save or discard unsaved files before deleting this item") + return false + } let operationWorkspaceGeneration = workspaceGeneration let operationDirectoryMarks = directoryMarks - isPerformingProjectItemOperation = true // Update the visible tree before waiting for the native Trash operation. // A failed operation reloads the disk snapshot below to restore the item. removeProjectItemFromSnapshot(request.url) @@ -666,11 +807,9 @@ package final class WorkspaceFeatureModel: ObservableObject { continuation.resume(returning: errorMessage) } } - isPerformingProjectItemOperation = false if let errorMessage { notify?(errorMessage) - await refreshCurrent() - return + return true } if request.isDirectory { let marksToRemoveFrom: [String: WorkspaceDirectoryMark] @@ -693,10 +832,19 @@ package final class WorkspaceFeatureModel: ObservableObject { ) } guard workspaceURL == operationWorkspaceURL, - workspaceGeneration == operationWorkspaceGeneration else { return } - closeDocuments?(request.url) + workspaceGeneration == operationWorkspaceGeneration else { return false } + if hasDirtyDocument(in: request.url) { + // Edits made while the Trash operation ran exist only in memory. + // Keep those documents open so the user can still save them. + for document in documentsProvider?() ?? [] + where !document.isDirty && urlContains(request.url, child: document.url) { + closeDocuments?(document.url) + } + } else { + closeDocuments?(request.url) + } notify?("Moved \(request.url.lastPathComponent) to Trash") - await refreshCurrent() + return true } package func readFile(at workspaceURL: URL, relativePath: String) async -> String? { @@ -1039,6 +1187,10 @@ package final class WorkspaceFeatureModel: ObservableObject { return childPath == parentPath || childPath.hasPrefix(parentPath + "/") } + private func hasDirtyDocument(in url: URL) -> Bool { + documentsProvider?().contains { $0.isDirty && urlContains(url, child: $0.url) } == true + } + private func removeProjectItemFromSnapshot(_ targetURL: URL) { projectFiles.removeAll { urlContains(targetURL, child: $0) } rootNode = rootNode.flatMap { removingProjectItem(targetURL, from: $0) } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 74791fa93..d92069441 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4367,6 +4367,216 @@ struct EditorDocumentTests { #expect(markStore.savedMarks == expected) } + @Test + @MainActor + func batchPasteKeepsExistingFilesAndAllocatesDistinctNames() async { + let workspace = URL(fileURLWithPath: "/batch-copy") + let destination = workspace.appendingPathComponent("target") + let first = workspace.appendingPathComponent("first/a.txt") + let second = workspace.appendingPathComponent("second/a.txt") + let operations = RecordingBatchProjectFileOperations( + files: [first, second, destination.appendingPathComponent("a.txt"), destination.appendingPathComponent("a copy.txt")], + directories: [workspace, destination] + ) + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + await model.pasteProjectItems([second, first, first], in: destination) + #expect(operations.copiedDestinations == [destination.appendingPathComponent("a copy 2.txt"), destination.appendingPathComponent("a copy 3.txt")]) + #expect(operations.fileExists(at: destination.appendingPathComponent("a.txt"))) + } + + @Test + @MainActor + func batchPasteRejectsCopyingDirectoryIntoItsDescendant() async { + let workspace = URL(fileURLWithPath: "/batch-copy") + let folder = workspace.appendingPathComponent("folder") + let destination = folder.appendingPathComponent("child") + let operations = RecordingBatchProjectFileOperations(files: [], directories: [workspace, folder, destination]) + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + await model.pasteProjectItems([folder], in: destination) + #expect(operations.copiedDestinations.isEmpty) + } + + @Test + @MainActor + func batchTrashDeduplicatesParentsAndSupportsCancellingConfirmation() async throws { + let workspace = URL(fileURLWithPath: "/batch-trash") + let folder = workspace.appendingPathComponent("folder") + let child = folder.appendingPathComponent("child.txt") + let file = workspace.appendingPathComponent("a.txt") + let operations = RecordingBatchProjectFileOperations(files: [child, file], directories: [workspace, folder]) + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItems([folder, child, file, workspace, URL(fileURLWithPath: "/outside")]) + let request = try #require(model.pendingProjectItemDeletion) + #expect(Set(([request] + request.additionalItems).map(\.url)) == [folder, file]) + model.cancelProjectItemDeletion() + #expect(model.pendingProjectItemDeletion == nil) + #expect(operations.trashedURLs.isEmpty) + await model.confirmProjectItemDeletion(request) + #expect(Set(operations.trashedURLs) == [folder, file]) + } + + @Test + @MainActor + func batchFileOperationsHoldTheOperationUntilTheWholeBatchFinishes() async throws { + let workspace = URL(fileURLWithPath: "/batch-ownership") + let first = workspace.appendingPathComponent("a/x.txt") + let second = workspace.appendingPathComponent("b/y.txt") + let other = workspace.appendingPathComponent("z.txt") + let operations = RecordingBatchProjectFileOperations( + files: [first, second, other], + // Duplicate targets each source's parent URL as returned by Foundation. + directories: [workspace, first.deletingLastPathComponent(), second.deletingLastPathComponent()], + pausesFirstCopy: true + ) + defer { operations.releaseFirstCopy() } + let scans = BatchProgressScanOperations(files: operations) + let model = makeWorkspaceObservationUnitModel( + operations: scans, fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {} + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + + let duplicate = Task { await model.duplicateProjectItems([first, second]) } + #expect(await operations.waitUntilFirstCopyStarted()) + // Another file operation cannot start in the middle of the batch. + #expect(model.isPerformingProjectItemOperation) + await model.pasteProjectItems([other], in: second.deletingLastPathComponent()) + operations.releaseFirstCopy() + await duplicate.value + #expect(operations.copiedDestinations == [ + workspace.appendingPathComponent("a/x copy.txt"), workspace.appendingPathComponent("b/y copy.txt") + ]) + #expect(!model.isPerformingProjectItemOperation) + + // Trash moves both items before the single refresh, too. + model.requestDeleteProjectItems([first, second]) + await model.confirmProjectItemDeletion(try #require(model.pendingProjectItemDeletion)) + #expect(Set(operations.trashedURLs) == [first, second]) + // Each batch rescans once, after its last item instead of between items. + #expect(scans.progressAtScans == [[2, 0], [2, 2]]) + } + + @Test + @MainActor + func batchTrashKeepsLaterItemEditedWhileEarlierItemIsTrashed() async throws { + let workspace = URL(fileURLWithPath: "/batch-trash-dirty") + let folder = workspace.appendingPathComponent("a-folder") + let child = folder.appendingPathComponent("child.txt") + let edited = workspace.appendingPathComponent("b.txt") + let operations = RecordingBatchProjectFileOperations( + files: [child, edited], directories: [workspace, folder], pausesFirstTrash: true + ) + defer { operations.releaseFirstTrash() } + let childDocument = EditorDocument(url: child, text: "child", modificationDate: nil) + let editedDocument = EditorDocument(url: edited, text: "saved", modificationDate: nil) + let recorder = WorkspaceCallbackRecorder() + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {}, + documentsProvider: { [childDocument, editedDocument] }, + notify: { recorder.messages.append($0) }, + closeDocuments: { recorder.closedURLs.append($0) } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItems([edited, folder]) + let request = try #require(model.pendingProjectItemDeletion) + + // Order: the folder's Trash call starts, b.txt becomes dirty in the + // editor, then the folder finishes and the batch reaches b.txt. + let deletion = Task { await model.confirmProjectItemDeletion(request) } + #expect(await operations.waitUntilFirstTrashStarted()) + editedDocument.text = "unsaved edit" + operations.releaseFirstTrash() + await deletion.value + + #expect(operations.trashedURLs == [folder]) + #expect(operations.fileExists(at: edited)) + #expect(recorder.closedURLs == [folder]) + #expect(editedDocument.text == "unsaved edit") + // b.txt is the last item, so only the unsaved-file reason is reported. + #expect(recorder.messages.last == "Save or discard unsaved files before deleting this item") + } + + @Test + @MainActor + func trashKeepsDocumentEditedWhileItsTrashOperationRuns() async throws { + let workspace = URL(fileURLWithPath: "/trash-edit-race") + let folder = workspace.appendingPathComponent("folder") + let clean = folder.appendingPathComponent("clean.txt") + let edited = folder.appendingPathComponent("edited.txt") + let operations = RecordingBatchProjectFileOperations( + files: [clean, edited], directories: [workspace, folder], pausesFirstTrash: true + ) + defer { operations.releaseFirstTrash() } + let cleanDocument = EditorDocument(url: clean, text: "clean", modificationDate: nil) + let editedDocument = EditorDocument(url: edited, text: "saved", modificationDate: nil) + let recorder = WorkspaceCallbackRecorder() + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {}, + documentsProvider: { [cleanDocument, editedDocument] }, + closeDocuments: { recorder.closedURLs.append($0) } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItem(at: folder, isDirectory: true) + let request = try #require(model.pendingProjectItemDeletion) + + let deletion = Task { await model.confirmProjectItemDeletion(request) } + #expect(await operations.waitUntilFirstTrashStarted()) + editedDocument.text = "unsaved edit" + operations.releaseFirstTrash() + await deletion.value + + // The edit exists only in memory now, so its tab must stay open. + #expect(operations.trashedURLs == [folder]) + #expect(recorder.closedURLs == [clean]) + } + + @Test + @MainActor + func batchTrashReportsSkippedItemsAfterFailure() async throws { + let workspace = URL(fileURLWithPath: "/batch-trash-failure") + let first = workspace.appendingPathComponent("a.txt") + let blocked = workspace.appendingPathComponent("b.txt") + let last = workspace.appendingPathComponent("c.txt") + let operations = RecordingBatchProjectFileOperations( + files: [first, blocked, last], directories: [workspace], failingTrashURLs: [blocked] + ) + let recorder = WorkspaceCallbackRecorder() + let model = makeWorkspaceObservationUnitModel( + fileOperations: operations, provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), refreshGit: {}, + notify: { recorder.messages.append($0) } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + model.requestDeleteProjectItems([last, blocked, first]) + let request = try #require(model.pendingProjectItemDeletion) + await model.confirmProjectItemDeletion(request) + + #expect(operations.trashedURLs == [first]) + #expect(operations.fileExists(at: last)) + #expect(recorder.messages.last == "Stopped moving the remaining items to Trash") + } + @Test @MainActor func deletingDirectoryRemovesItsMarkAndDescendantMarks() async throws { @@ -6162,7 +6372,10 @@ private func makeWorkspaceObservationUnitModel( reloadProjectServices: @escaping @MainActor () async -> Void = {}, recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void = { _, _ in }, directoryMarkStore: any WorkspaceDirectoryMarkStoring = EmptyWorkspaceDirectoryMarkStore(), - observationDelay: (@Sendable (Duration) async throws -> Void)? = nil + observationDelay: (@Sendable (Duration) async throws -> Void)? = nil, + documentsProvider: @escaping @MainActor @Sendable () -> [EditorDocument] = { [] }, + notify: @escaping @MainActor @Sendable (String) -> Void = { _ in }, + closeDocuments: @escaping @MainActor @Sendable (URL) -> Void = { _ in } ) -> WorkspaceFeatureModel { let model = WorkspaceFeatureModel( operations: operations, @@ -6175,17 +6388,17 @@ private func makeWorkspaceObservationUnitModel( observationDelay: observationDelay ) model.configure( - documentsProvider: { [] }, + documentsProvider: documentsProvider, activeDocumentProvider: { nil }, selectedSidebarProvider: { "project" }, setSelectedSidebar: { _ in }, restoreSession: { _, _ in }, openFile: { _ in }, - notify: { _ in }, + notify: notify, recordHistory: recordHistory, relocateHistory: { _, _ in }, relocateOpenDocuments: { _, _ in }, - closeDocuments: { _ in }, + closeDocuments: closeDocuments, processExternalChanges: processExternalChanges, notifyWorkspaceFileChanges: notifyWorkspaceFileChanges, reloadProjectServices: reloadProjectServices, @@ -7365,3 +7578,101 @@ private struct PreviewExternalChangeLifecycleDecider: DocumentLifecycleDeciding } } } + +/// Records how many copies and Trash moves had finished each time the tree +/// was rescanned, to show when a batch refreshes the project tree. +private final class BatchProgressScanOperations: WorkspaceOperations, @unchecked Sendable { + private let files: RecordingBatchProjectFileOperations + private let lock = NSLock() + private var progress: [[Int]] = [] + + init(files: RecordingBatchProjectFileOperations) { self.files = files } + + var progressAtScans: [[Int]] { lock.withLock { progress } } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + let current = [files.copiedDestinations.count, files.trashedURLs.count] + lock.withLock { progress.append(current) } + return nil + } + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) {} + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + +/// Records workspace callbacks, which always run on the main actor. +@MainActor +private final class WorkspaceCallbackRecorder { + var messages: [String] = [] + var closedURLs: [URL] = [] +} + +/// Mutable fake filesystem state is shared only under the lock. +private final class RecordingBatchProjectFileOperations: WorkspaceFileOperations, @unchecked Sendable { + private let lock = NSLock() + private var files: Set + private let directories: Set + private var copies: [URL] = [] + private var trash: [URL] = [] + private let firstTrashStarted = TestGate() + private let firstTrashRelease: TestGate? + private let firstCopyStarted = TestGate() + private let firstCopyRelease: TestGate? + private let failingTrashURLs: Set + + /// `pausesFirstTrash` / `pausesFirstCopy` hold the first such call on the + /// production worker thread until released so a test can act mid-batch. + init( + files: [URL], directories: [URL], pausesFirstTrash: Bool = false, pausesFirstCopy: Bool = false, + failingTrashURLs: Set = [] + ) { + self.files = Set(files) + self.directories = Set(directories) + firstTrashRelease = pausesFirstTrash ? TestGate() : nil + firstCopyRelease = pausesFirstCopy ? TestGate() : nil + self.failingTrashURLs = failingTrashURLs + } + + func waitUntilFirstTrashStarted() async -> Bool { await firstTrashStarted.waitUntilOpen() } + func releaseFirstTrash() { firstTrashRelease?.open() } + func waitUntilFirstCopyStarted() async -> Bool { await firstCopyStarted.waitUntilOpen() } + func releaseFirstCopy() { firstCopyRelease?.open() } + + var copiedDestinations: [URL] { lock.withLock { copies } } + var trashedURLs: [URL] { lock.withLock { trash } } + func fileExists(at url: URL) -> Bool { + lock.withLock { (files.contains(url) || directories.contains(url)) && !trash.contains(url) } + } + func isDirectory(at url: URL) -> Bool { directories.contains(url) } + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { + if !firstCopyStarted.isOpen { + firstCopyStarted.open() + if let firstCopyRelease, !firstCopyRelease.waitSynchronously() { throw CocoaError(.userCancelled) } + } + try lock.withLock { + guard !files.contains(destinationURL), !directories.contains(destinationURL) else { throw CocoaError(.fileWriteFileExists) } + guard files.contains(sourceURL) || directories.contains(sourceURL) else { throw CocoaError(.fileReadNoSuchFile) } + files.insert(destinationURL) + copies.append(destinationURL) + } + } + func trashItem(at url: URL) throws { + if !firstTrashStarted.isOpen { + firstTrashStarted.open() + if let firstTrashRelease, !firstTrashRelease.waitSynchronously() { throw CocoaError(.userCancelled) } + } + if failingTrashURLs.contains(url) { throw CocoaError(.fileWriteNoPermission) } + lock.withLock { + files = files.filter { $0 != url && !$0.path.hasPrefix(url.path + "/") } + trash.append(url) + } + } + func createFile(at url: URL) throws {} + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {} + func moveItem(at sourceURL: URL, to destinationURL: URL) throws {} + func removeItem(at url: URL) throws {} + func writeText(_ text: String, to url: URL) throws {} + func readText(from url: URL) throws -> String { "" } +} diff --git a/macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift b/macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift new file mode 100644 index 000000000..2b42114af --- /dev/null +++ b/macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import Lithe + +/// Drives project tree batch actions through the production app model, service +/// container, Rust workspace scan and native file manager on a temporary project. +/// Successful Trash moves stay in unit tests so this suite never touches ~/.Trash. +@Suite("Project tree batch file workflow", .serialized) +@MainActor +struct ProjectTreeBatchFileWorkflowTests { + @Test + func copyPasteDuplicateAndTrashUseRealFilesAndKeepUnsavedEdits() async throws { + let fixture = try BatchFileFixture() + defer { fixture.remove() } + let clipboard = BatchFileClipboardUI() + let model = fixture.makeAppModel(platformUI: clipboard) + model.openProjectDirectly(fixture.root) + #expect(await awaitChange(on: model) { fixture.treeContains(model, "b.txt") }) + + // ⌘C writes both files; ⌘V into a folder holding a.txt keeps it and adds a copy. + model.copyProjectItems([fixture.url("a.txt"), fixture.url("b.txt")]) + #expect(clipboard.files == [fixture.url("a.txt"), fixture.url("b.txt")]) + await model.pasteProjectItems(in: fixture.url("dest")) + #expect(try fixture.text("dest/a.txt") == "existing") + #expect(try fixture.text("dest/a copy.txt") == "alpha") + #expect(try fixture.text("dest/b.txt") == "beta") + #expect(await awaitChange(on: model) { fixture.treeContains(model, "dest/a copy.txt") }) + + // Duplicate keeps each copy next to its own source. + await model.duplicateProjectItems([fixture.url("a.txt"), fixture.url("dest/b.txt")]) + #expect(try fixture.text("a copy.txt") == "alpha") + #expect(try fixture.text("dest/b copy.txt") == "beta") + + // Pasting a folder into its own child is rejected without copying. + model.copyProjectItems([fixture.url("dest")]) + await model.pasteProjectItems(in: fixture.url("dest")) + #expect(!FileManager.default.fileExists(atPath: fixture.url("dest/dest").path)) + + // An edit made after the dialog opened keeps its file and editor tab. + // Only that file is requested so the test never writes to the user's Trash. + model.openFile(fixture.url("b.txt")) + #expect(await awaitChange(on: model) { model.openDocuments.contains { $0.url == fixture.url("b.txt") } }) + let edited = try #require(model.openDocuments.first { $0.url == fixture.url("b.txt") }) + model.requestDeleteProjectItems([fixture.url("b.txt"), fixture.root]) + let request = try #require(model.pendingProjectItemDeletion) + #expect(request.url == fixture.url("b.txt") && request.additionalItems.isEmpty) + model.cancelProjectItemDeletion() + edited.text = "unsaved" + await model.confirmProjectItemDeletion(request) + #expect(try fixture.text("b.txt") == "beta") + #expect(model.openDocuments.contains { $0 === edited }) + #expect(edited.text == "unsaved") + #expect(model.notifications.contains { $0.message == "Save or discard unsaved files before deleting this item" }) + } +} + +@MainActor +private final class BatchFileClipboardUI: PlatformUI { + private(set) var files: [URL] = [] + + func copyFilesToClipboard(_ urls: [URL]) -> Bool { + files = urls + return !urls.isEmpty + } + func fileURLsFromClipboard() -> [URL] { files } + func activateApplication() {} + func chooseDirectory(title: String, prompt: String) -> URL? { nil } + func chooseFile(title: String, prompt: String) -> URL? { nil } + func revealInFileBrowser(_ url: URL) {} + func open(_ url: URL) {} + func copyToClipboard(_ value: String) {} + func markdownImageFromClipboard() -> MarkdownImageSource? { nil } + func startAccessingProject(_ url: URL) -> Bool { false } + func stopAccessingProject(_ url: URL) {} +} + +@MainActor +private struct BatchFileFixture { + let root: URL + + init() throws { + // `target` is hidden by the default build-output rules, so use `dest`. + let created = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-batch-files-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: created, withIntermediateDirectories: true) + root = created.standardizedFileURL + try FileManager.default.createDirectory(at: url("dest"), withIntermediateDirectories: true) + try Data("alpha".utf8).write(to: url("a.txt")) + try Data("beta".utf8).write(to: url("b.txt")) + try Data("existing".utf8).write(to: url("dest/a.txt")) + } + + func url(_ path: String) -> URL { root.appendingPathComponent(path).standardizedFileURL } + func text(_ path: String) throws -> String { try String(contentsOf: url(path), encoding: .utf8) } + func remove() { try? FileManager.default.removeItem(at: root) } + + func treeContains(_ model: AppModel, _ path: String) -> Bool { + func contains(_ node: FileNode) -> Bool { + node.url.standardizedFileURL == url(path) || (node.children ?? []).contains(where: contains) + } + return model.rootNode.map(contains) == true + } + + func makeAppModel(platformUI: any PlatformUI) -> AppModel { + let store = BatchFileTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: platformUI + ).services + return AppModel(settings: settings, services: services) + } +} + +private final class BatchFileTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/ProjectTreeSelectionTests.swift b/macos/Tests/LitheTests/ProjectTreeSelectionTests.swift new file mode 100644 index 000000000..74dd562f5 --- /dev/null +++ b/macos/Tests/LitheTests/ProjectTreeSelectionTests.swift @@ -0,0 +1,299 @@ +import AppKit +import Foundation +import LitheCoreContracts +import SwiftUI +import Testing +@testable import Lithe + +struct ProjectTreeSelectionTests { + @Test + @MainActor + func nativeClipboardRoundTripsMultipleFilesAndIgnoresText() { + let pasteboard = NSPasteboard(name: .init("lithe-file-test-" + UUID().uuidString)) + defer { pasteboard.releaseGlobally() } + let urls = [URL(fileURLWithPath: "/workspace/a.txt"), URL(fileURLWithPath: "/workspace/b.txt")] + #expect(MacFileClipboard.write(urls, to: pasteboard)) + #expect(MacFileClipboard.read(from: pasteboard) == urls) + pasteboard.clearContents() + pasteboard.setString("ordinary editor text", forType: .string) + #expect(MacFileClipboard.read(from: pasteboard).isEmpty) + #expect(!MacFileClipboard.write([], to: pasteboard)) + #expect(pasteboard.string(forType: .string) == "ordinary editor text") + } + + @Test + @MainActor + func treeShortcutsYieldToKeyboardFocusMovedAfterTheTreeClick() throws { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let tree = ProjectTreeKeyboardCommandView(frame: NSRect(x: 0, y: 0, width: 200, height: 300)) + let other = ProjectTreeFocusTestView(frame: NSRect(x: 200, y: 0, width: 200, height: 150)) + let search = NSTextField(frame: NSRect(x: 200, y: 200, width: 180, height: 24)) + let filter = NSTextField(frame: NSRect(x: 200, y: 160, width: 180, height: 24)) + defer { tree.removeMonitor(); window.makeFirstResponder(nil); window.close() } + [tree, other, search, filter].forEach { window.contentView?.addSubview($0) } + var copies = 0 + tree.copyItems = { copies += 1 } + + // Clicking the tree claims ⌘C even though the tree has no focusable view. + #expect(tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) != nil) + #expect(tree.handle(try commandKey("c", in: window)) == nil) + #expect(copies == 1) + + // A text field focused afterwards, such as Search Everywhere, keeps ⌘C/⌘V/⌘A. + #expect(window.makeFirstResponder(search)) + #expect((window.firstResponder as? NSTextView)?.isFieldEditor == true) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(tree.handle(try commandKey("v", in: window)) != nil) + #expect(copies == 1) + + // Ownership stays released until the tree is clicked again. + #expect(window.makeFirstResponder(nil)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + _ = tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) + #expect(tree.handle(try commandKey("c", in: window)) == nil) + #expect(copies == 2) + + // Any other keyboard focus change after the first shortcut also releases it. + #expect(window.makeFirstResponder(other)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(copies == 2) + + // Fields share one field editor, so focusing another field still releases. + #expect(window.makeFirstResponder(filter)) + _ = tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) + #expect(tree.handle(try commandKey("c", in: window)) == nil) + #expect(copies == 3) + #expect(window.makeFirstResponder(search)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(copies == 3) + + // A click outside the tree returns shortcuts to the clicked area. + _ = tree.handle(try mouseDown(at: NSPoint(x: 50, y: 50), in: window)) + _ = tree.handle(try mouseDown(at: NSPoint(x: 300, y: 50), in: window)) + #expect(tree.handle(try commandKey("c", in: window)) != nil) + #expect(copies == 3) + } + + @Test + @MainActor + func consumedTreeShortcutIsNotAlsoDeliveredToTheEditor() throws { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let tree = ProjectTreeKeyboardCommandView(frame: NSRect(x: 0, y: 0, width: 200, height: 300)) + defer { tree.removeMonitor(); window.close() } + window.contentView?.addSubview(tree) + var selections = 0 + tree.selectAllItems = { selections += 1 } + + // The installed monitor returns this value to AppKit; a non-nil result + // means the editor would also select all text or paste the clipboard. + _ = ProjectTreeKeyboardCommandView.monitorResult(for: try mouseDown(at: NSPoint(x: 50, y: 50), in: window), view: tree) + #expect(ProjectTreeKeyboardCommandView.monitorResult(for: try commandKey("a", in: window), view: tree) == nil) + #expect(selections == 1) + #expect(ProjectTreeKeyboardCommandView.monitorResult(for: try commandKey("a", in: window), view: nil) != nil) + } + + @MainActor + private func mouseDown(at point: NSPoint, in window: NSWindow) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: .leftMouseDown, location: point, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + } + + @MainActor + private func commandKey(_ character: String, in window: NSWindow) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [.command], timestamp: 0, + windowNumber: window.windowNumber, context: nil, characters: character, + charactersIgnoringModifiers: character, isARepeat: false, keyCode: 0)) + } + + @Test + func modifiedClicksToggleAndShiftKeepsItsAnchor() { + var selection = ProjectTreeSelection() + let rows = ["a", "b", "c", "d"] + selection.select("b", visiblePaths: rows, extending: false, toggling: false) + selection.select("d", visiblePaths: rows, extending: true, toggling: false) + #expect(selection.paths == ["b", "c", "d"]) + selection.select("a", visiblePaths: rows, extending: true, toggling: false) + #expect(selection.paths == ["a", "b"]) + selection.select("d", visiblePaths: rows, extending: false, toggling: true) + #expect(selection.paths == ["a", "b", "d"]) + selection.select("b", visiblePaths: rows, extending: false, toggling: true) + #expect(selection.paths == ["a", "d"]) + } + + @Test + @MainActor + func modifiedRowClicksReachTheOverlayThroughWindowDispatch() throws { + let rows = ["folder", "a", "b", "c"] + var selection = ProjectTreeSelection() + var activated: [String] = [] + let rowHeight: CGFloat = 22 + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(rows.count)), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.orderOut(nil); window.close() } + // Mirrors FileNodeRow: the Button selects and opens a file or folds a + // folder, and the overlay handles modified clicks. + window.contentView = NSHostingView(rootView: VStack(spacing: 0) { + ForEach(rows, id: \.self) { row in + Button { + selection.select(row, visiblePaths: rows, extending: false, toggling: false) + activated.append(row) + } label: { + Color.clear.frame(width: 240, height: rowHeight).contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .overlay { + ProjectTreeModifiedClick { flags in + selection.select(row, visiblePaths: rows, extending: flags.contains(.shift), + toggling: flags.contains(.command)) + } + } + } + }) + // Hosting views hit-test only in an on-screen window; it never becomes key. + window.orderFrontRegardless() + window.layoutIfNeeded() + // The window hit-tests each event and routes it to the overlay or the + // Button as in the app; only the current-event lookup is substituted. + var dispatching: NSEvent? + for overlay in try #require(window.contentView).descendants.compactMap({ $0 as? ProjectTreeModifiedClickView }) { + overlay.currentEvent = { dispatching } + } + func click(_ index: Int, _ flags: NSEvent.ModifierFlags) throws { + let point = NSPoint(x: 50, y: rowHeight * (CGFloat(rows.count - index) - 0.5)) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent( + with: type, location: point, modifierFlags: flags, timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + dispatching = event + window.sendEvent(event) + } + dispatching = nil + } + + // A plain click goes to the Button and sets the range anchor. + try click(1, []) + #expect(activated == ["a"]) + #expect(selection.paths == ["a"]) + // Shift extends forward and backward, ⌘ toggles; none of these clicks + // reach the Button, so files stay closed and the folder stays as is. + try click(3, .shift) + #expect(selection.paths == ["a", "b", "c"]) + try click(2, .command) + #expect(selection.paths == ["a", "c"]) + try click(3, []) + try click(1, .shift) + #expect(selection.paths == ["a", "b", "c"]) + try click(0, .command) + #expect(selection.paths == ["folder", "a", "b", "c"]) + #expect(activated == ["a", "c"]) + + // Control-click passes through the overlay to the row below, where + // FileNodeRow's context menu capture handles it. + try click(2, [.control, .shift]) + #expect(activated == ["a", "c", "b"]) + } + + @Test + func rightClickPreservesTheGroupOnlyForSelectedRows() { + let root = FileNode(url: URL(fileURLWithPath: "/p"), isDirectory: true, children: [ + FileNode(url: URL(fileURLWithPath: "/p/a"), isDirectory: false, children: nil), + FileNode(url: URL(fileURLWithPath: "/p/b"), isDirectory: false, children: nil) + ]) + var selection = ProjectTreeSelection() + selection.selectAll(in: root) + selection.selectForContextMenu("/p/a") + #expect(selection.paths == ["/p/a", "/p/b"]) + selection.selectForContextMenu("/p/c") + #expect(selection.paths == ["/p/c"]) + #expect(selection.anchorPath == "/p/c") + } + + @Test + func selectAllUsesTheFocusedRowsSiblings() { + func node(_ path: String, _ children: [FileNode]? = nil, collapsed: [String] = []) -> FileNode { + FileNode(url: URL(fileURLWithPath: path), isDirectory: children != nil, children: children, + collapsedAncestorPaths: collapsed) + } + // `src/com/acme` is a compacted package shown directly below `src`. + let root = node("/p", [ + node("/p/dest", [node("/p/dest/a.txt")]), + node("/p/dest copy", [node("/p/dest copy/a.txt"), node("/p/dest copy/b.txt")]), + node("/p/src", [node("/p/src/com/acme", [node("/p/src/com/acme/App.java")], collapsed: ["/p/src/com"])]), + node("/p/alpha.txt"), node("/p/gamma.txt") + ]) + let topLevel: Set = ["/p/dest", "/p/dest copy", "/p/src", "/p/alpha.txt", "/p/gamma.txt"] + var selection = ProjectTreeSelection() + func selectAll(focusing path: String) -> Set { + selection.select(path, visiblePaths: [path], extending: false, toggling: false) + selection.selectAll(in: root) + return selection.paths + } + + // A file inside a folder selects that folder's items only. + #expect(selectAll(focusing: "/p/dest copy/a.txt") == ["/p/dest copy/a.txt", "/p/dest copy/b.txt"]) + #expect(selection.focusedPath == "/p/dest copy/a.txt") + // A folder selects the items beside it, not its own contents. + #expect(selectAll(focusing: "/p/dest copy") == topLevel) + #expect(selectAll(focusing: "/p/alpha.txt") == topLevel) + // A similarly named sibling folder stays out of the scope. + #expect(selectAll(focusing: "/p/dest/a.txt") == ["/p/dest/a.txt"]) + // Compacted packages use their displayed parent. + #expect(selectAll(focusing: "/p/src/com/acme") == ["/p/src/com/acme"]) + #expect(selectAll(focusing: "/p/src/com/acme/App.java") == ["/p/src/com/acme/App.java"]) + // Selected folders show their contents as selected too, without adding + // them to the action set or matching a similarly named sibling. + #expect(selectAll(focusing: "/p/dest copy") == topLevel) + #expect(selection.covers("/p/dest copy/a.txt")) + #expect(selection.covers("/p/src/com/acme/App.java")) + #expect(!selection.paths.contains("/p/dest copy/a.txt")) + // Right-clicking a covered row keeps the group for the batch menu. + selection.selectForContextMenu("/p/dest copy/a.txt") + #expect(selection.paths == topLevel) + // Inside a single selected folder, right-click targets the clicked row. + selection.select("/p/dest", visiblePaths: ["/p/dest"], extending: false, toggling: false) + #expect(selection.covers("/p/dest/a.txt") && !selection.covers("/p/dest copy/a.txt")) + selection.selectForContextMenu("/p/dest/a.txt") + #expect(selection.paths == ["/p/dest/a.txt"]) + // The project row, or no focus, selects the top-level items. + #expect(selectAll(focusing: "/p") == topLevel) + var unfocused = ProjectTreeSelection() + unfocused.selectAll(in: root) + #expect(unfocused.paths == topLevel) + } + + @Test + func collapsedChildrenAreExcludedFromRangesAndSelection() { + let rootURL = URL(fileURLWithPath: "/workspace") + let child = FileNode(url: rootURL.appendingPathComponent("folder/hidden"), isDirectory: false, children: nil) + let folder = FileNode(url: rootURL.appendingPathComponent("folder"), isDirectory: true, children: [child]) + let last = FileNode(url: rootURL.appendingPathComponent("last"), isDirectory: false, children: nil) + let root = FileNode(url: rootURL, isDirectory: true, children: [folder, last]) + let visible = ProjectTreeSelection.visibleNodes(in: root, expandedPaths: [rootURL.path]).map { $0.url.path } + #expect(visible == [rootURL.path, folder.url.path, last.url.path]) + var selection = ProjectTreeSelection() + selection.select(last.url.path, visiblePaths: visible, extending: false, toggling: false) + selection.select(child.url.path, visiblePaths: visible, extending: false, toggling: true) + selection.retain(visiblePaths: visible) + #expect(!selection.paths.contains(child.url.path)) + #expect(selection.focusedPath == nil) + selection.select(folder.url.path, visiblePaths: visible, extending: false, toggling: false) + selection.select(last.url.path, visiblePaths: visible, extending: true, toggling: false) + #expect(selection.paths == [folder.url.path, last.url.path]) + } +} + +/// A non-text responder standing in for a focused editor or panel. +private final class ProjectTreeFocusTestView: NSView { + override var acceptsFirstResponder: Bool { true } +} + +private extension NSView { + var descendants: [NSView] { subviews + subviews.flatMap(\.descendants) } +} diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index 369f3b35f..f0a051964 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -47,7 +47,8 @@ fi if [[ -z "$OUTPUT_DIR" ]]; then OUTPUT_DIR="$BUILD_DIR/OfficialPlugins" fi -SDK_PATH=$(/usr/bin/xcrun --sdk macosx --show-sdk-path) +# Match the host build's explicit SDK when multiple SDKs are installed. +SDK_PATH="${SDKROOT:-$(/usr/bin/xcrun --sdk macosx --show-sdk-path)}" if ! SWIFT_COMPILER=$(command -v swiftc); then print -u2 -- "Swift compiler is not available on PATH" exit 1 diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 740f368a5..9fa0f707c 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -552,6 +552,34 @@ "owner": "Workspace", "verification": "新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。" }, + { + "id": "workspace-tree-batch-files", + "area": "工作区", + "group": "工作区生命周期", + "capability": "项目树多选、范围选择及批量复制、重复和移到废纸篓", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", + "macos/Sources/Lithe/Views/Workspace/ProjectTreeSelection.swift", + "macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift", + "macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift", + "macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeModifiedClick.swift", + "macos/Tests/LitheTests/ProjectTreeSelectionTests.swift", + "macos/Tests/LitheTests/ProjectTreeBatchFileWorkflowTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "windows/tauri/src/features/file-system" + ] + }, + "owner": "Workspace", + "verification": "macOS 项目树中使用 ⌘ 点击增减选择、Shift 点击正反向连续选择,Control 点击仍打开右键菜单,右键已选项保持集合;⌘A 全选焦点项的同级项目(点击文件夹时选它旁边的项目而不是其内容,未选中时选项目顶层项目),已选文件夹下展开的内容同时显示为选中;打开文件后单选高亮跟随编辑器;拷贝并粘贴到目标目录、批量创建副本和移到废纸篓,确认同名不覆盖、父子去重、未保存保护及取消删除;确认删除后、废纸篓操作完成前修改的文件不会被删除或关闭,失败时提示剩余项目未移动;点击编辑器或在点击项目树后用快捷键打开全局搜索时,⌘A/⌘C/⌘V 恢复文本操作。Windows 尚无该多选入口。" + }, { "id": "workspace-document-sync", "area": "工作区", From 8c86fc6cbf8d51d838006872930c8c542ca3eb05 Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:09:52 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=20Claude?= =?UTF-8?q?=20=E9=89=B4=E6=9D=83=E3=80=81=E4=BC=9A=E8=AF=9D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=92=8C=E5=93=81=E7=89=8C=E5=9B=BE=E6=A0=87=20(#1007?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent): 修复 Claude 鉴权、会话切换和品牌图标 通过 Claude 适配器公开会话参数传递 API Key 路由,同时覆盖新建与历史恢复,清除冲突鉴权并保留 Codex gateway 行为。 直接观察所选 Agent 连接,等待会话设置确认后展示模型,防止旧配置闪现及准备期间提交。 统一底栏、模型入口与 Agent 菜单品牌图标,保留原生单选勾选,并补充回归、共享契约、Agent Note 与功能矩阵。 * test(git): bound graph frame sampling to visible viewports 修复 PR #1007 CI:原用例重复绘制完整 1000 行历史,CI 耗时 11.791 秒超过未调整的 10 秒上限。改为开头、中段、末段 40 行视口采样,校验实际像素,保留完整结构、单帧预算与 Release 基线;同步 CI 文档和中文工程笔记。 --- ...026-09-25-shared-acp-agent-conversation.md | 27 +- ...13-ci-build-cache-and-artifact-strategy.md | 19 + docs/ci-builds.md | 5 +- docs/development/platform-parity-matrix.csv | 5 +- docs/development/platform-parity-matrix.md | 13 +- macos/Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Lithe/Views/Agent/AgentComposerView.swift | 47 ++- .../Views/Agent/AgentConversationView.swift | 57 ++- .../Lithe/Views/Agent/AgentPanelStyle.swift | 48 ++- .../Views/Agent/AgentSessionSelectors.swift | 6 +- .../Application/AgentConnectionModel.swift | 3 + .../GitGraphPerformanceBaselineTests.swift | 47 ++- .../AgentBrandIconResourceTests.swift | 39 ++ .../AgentConversationSelectionTests.swift | 346 ++++++++++++++++++ rust/lithe-agent-host/src/catalog.rs | 4 +- rust/lithe-agent-host/src/lib.rs | 28 +- rust/lithe-agent-host/src/session_defaults.rs | 8 +- rust/lithe-agent-host/src/session_routing.rs | 57 +++ rust/lithe-agent-host/src/tests.rs | 97 ++++- rust/lithe-agent-host/tests/real_agent.rs | 69 +++- shared/contracts/application-boundary.md | 2 +- shared/contracts/rust-core-api.md | 23 +- shared/fixtures/agent/acp-events-v1.json | 15 + shared/platform-feature-matrix.json | 84 ++++- 25 files changed, 954 insertions(+), 97 deletions(-) create mode 100644 macos/Tests/LitheTests/AgentConversationSelectionTests.swift create mode 100644 rust/lithe-agent-host/src/session_routing.rs diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 909bfa59f..de6293e2e 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -19,12 +19,14 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **思考强度名称**:强度选项使用上游英文名称,例如 Low、Medium、High、Xhigh、Max、Ultra,不经过通用本地化。子菜单、设置行当前值和模型底栏复用同一个呈现入口,避免同一强度在不同位置显示中英文混合。菜单标题“思考强度”和其他配置仍按界面语言显示;不改上游选项 ID、可选范围或确认流程,也不手工补齐当前 Agent 未提供的强度。 - **权限模式呈现**:菜单和底栏保留 Agent 上游名称,例如 Claude 的 Manual、Accept edits、Plan、Auto、Bypass permissions;模型和思考强度也使用上游名称。名称不能进入全局翻译表,否则 `Plan` 会碰到其他界面的“计划”翻译,导致个别选项与其余名称混用语言。Claude 与 Codex 按已知权限 ID 共享对应图标,Claude 继续显示上游实际提供的编辑、计划等额外选项。依据 [Claude 权限模式说明](https://code.claude.com/docs/en/permissions),说明可按界面语言呈现,但保留实际 Agent 的审批与环境约束,不把 Codex 的沙箱说明直接套到 Claude。只改变显示,不修改选项 ID、数量、顺序或权限命令;上游确认继续决定当前值。未知选项保留其原文和默认图标,不按列表位置猜测权限,也不把 Claude 的五项折叠成三项。不新增状态、资源或运行时写入。 - +- **Agent 菜单标志与勾选**:输入框的 Agent 下拉菜单为每个选项显示 16 点品牌图标,复用底栏同一套只读资源与内存染色。原生单选控件(Picker)将当前选项的勾选状态与品牌图像分别呈现,避免选中项用勾选图标替换品牌。控件直接绑定面板已有的选择值与切换动作,不在 View 里再存一份当前 Agent;图标和勾选以实际选择为准,不改变连接、会话或权限语义。 - **侧栏入口图标**:Agent 使用 Lithe 原创的“对话气泡 + 星光”线性 SVG,由工作台 renderer 显式绑定,避免落入通用模块图标而让用户误认成插件管理。图标作为固定源资源随应用打包,复用现有模板着色与选中态;不下载、不生成运行时文件,不影响安装包的只读边界。面板内 Codex/Claude 标志仍表示具体供应商。 - **安装包图标解析**:Agent 品牌图标从应用的 `Contents/Resources/Lithe_Lithe.bundle` 只读加载;资源包或图标缺失时使用默认标志。不要在已安装的 `.app` 中直接调用 SwiftPM(Swift 包管理器)生成的 `Bundle.module`:它只查应用旁的资源包和编译目录,找不到会直接终止进程,开发机器残留的构建资源还会掩盖问题。开发和测试入口保留 SwiftPM 回退;安装版不使用编译目录,也不通过启动时复制资源来修补发行包,避免改变代码签名和 Sparkle 增量更新基线。 +- **底栏品牌标志**:参考 [CC GUI 的紧凑入口](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/blob/7bcd6c3803ec44944cef90f5bff0aa666cab99a2/webview/src/components/ChatInputBox/selectors/ProviderSelect.tsx) 与[品牌组件](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/blob/7bcd6c3803ec44944cef90f5bff0aa666cab99a2/webview/src/components/shared/ProviderModelIcon.tsx),Claude 使用彩色星芒,Codex 使用主题单色结形标志。上游 Web 入口为 16 像素;Lithe 原生工具栏使用 18 点视觉尺寸、28 点点击区域,模型入口保留较小的同品牌标志。Claude 的 `#D97757` 与既有 `@lobehub/icons 5.8.0` 彩色组件一致。轮廓复用既有 SVG,欢迎区和历史页继续使用模板着色。SwiftUI 的前景色在原生 Menu 转换图像时可能丢失,因此底栏通过只读 SVG 的透明度蒙版在内存中生成带颜色的原始图像,不依赖原生控件替模板补色。图像缓存身份包含资源包、尺寸和颜色,深浅主题切换不会拿到旧色,也不修改其他视图共用的模板。正确做法是按品牌和主题选择颜色;不要复制 SVG 路径、改写源图或把染色图写进 bundle。该派生图像只在应用内存中存活,不新增磁盘缓存、下载或跨工作树复用资源,不影响代码签名和 Sparkle delta。 - **共享实现**:`rust/lithe-agent-host` 使用官方 `agent-client-protocol` SDK。一个 `AgentHandle` 对应一个项目的 Agent 进程和 ACP 连接,负责初始化、网关登录、会话新建/列出/加载、消息、权限、取消和进程树清理。Mac 通过 Rust Core C ABI(`lithe_agent_open_json`、`lithe_agent_send_json`、`lithe_agent_close`)调用;Windows 以后直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。 - **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。 -- **API Key 模式显式登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer ` 放进 `authenticate` 请求,经 stdio 传给 Agent。Key 不进命令行参数、环境变量或文件,Lithe 也不设置 `APP_SERVER_LOGS`。Agent 不提供 `gateway` 登录时直接报错,不会退而使用它的账号登录。Codex 使用 Responses,Claude 使用 Anthropic Messages;二者均不静默回退到账户订阅。 +- **API Key 按上游公开接口传递**:Lithe 通过 ACP 的标准输入输出(stdio)通道把凭据传给适配器,不进入 Lithe 启动适配器的命令行参数、环境变量或文件,也不设置 `APP_SERVER_LOGS`。Codex 使用 Responses,在初始化时声明 `auth._meta.gateway = true`,通过 `gateway` 登录传服务商地址和 `Authorization: Bearer `;没有该登录能力就报错。Claude 使用 Anthropic Messages,通过上游公开的 `_meta.claudeCode.options` 在 `session/new` 与 `session/load` 同时传入地址、API Key 和模型;由上游适配器与 Claude SDK 管理其内部原生凭据转换。SDK 的环境配置和程序化 settings 两层都显式指定同一路由,并清除旧 Bearer、OAuth、自定义请求头、云平台路由与 `apiKeyHelper`,防止用户或项目设置覆盖所选服务商。缺少凭据时直接报错,二者都不静默回退到账户订阅。 +- **Claude SDK 子进程的凭据边界**:上面的保证只覆盖 Lithe 启动 ACP 适配器这一层,不覆盖整个子进程树。当前固定版本的 Claude SDK 会把对象形式的 `options.settings` 序列化为 Claude Code CLI 的 `--settings` 命令行参数,因此 `settings.env.ANTHROPIC_API_KEY` 会出现在该 CLI 子进程的参数里;开启 SDK debug 还可能把完整参数写进调试文件。同用户进程和诊断工具可以读取这些参数,不能把这一实现描述为全进程树都不暴露 Key。原 gateway 路径也把带 Key 的自定义请求头放进程序化 settings,本次路由修复没有消除这项上游暴露。后续独立评估把 Key 仅放在 `options.env`、settings 仅保留非敏感路由;采用前必须验证用户和项目 settings 中旧 Key 的覆盖优先级,以及新建、历史恢复和切换服务商都使用所选凭据,不在当前修复中直接删去凭据覆盖层。 - **Codex 订阅是 Agent 绑定方式**:供应商管理增加“Codex 订阅”选项,但不往提交信息共用的 HTTP 服务商注册表写一个假供应商。已有配置缺少认证类型时仍解释为 API Key;订阅绑定不保存 provider ID,启动时不读取该服务商的 Key、URL 或默认模型。只在当前子进程中指定官方 `openai` 路由并清除继承的 API 覆盖,会话配置同时清空 `openai_base_url` 并指定官方 `chatgpt_base_url`,防止本机旧地址继续覆盖订阅路由;额度探测也使用同样的覆盖。保留用户的 `CODEX_HOME`、MCP 和 Skills。本机 Codex 拥有账号存储和刷新,Lithe 不读取或复制其订阅凭据。已有账号直接复用;未登录先提示,用户点击才通过 ACP `chat-gpt` 方法打开官方浏览器登录。等待有五分钟期限,取消停止连接并停留在可重试状态,避免 idle 空态自动重连;认证通知与请求响应先后不作为成功依据,必须等上游确认账号。账号退出或邮箱改变时断开旧连接,不静默改计费来源。订阅错误不附加未经脱敏的上游 stderr,因为 Lithe 并不知道其凭据值。 - **额度复用官方查询能力**:核对 [codex-acp v1.13.1](https://github.com/agentclientprotocol/codex-acp/blob/v1.13.1/src/CodexAcpServer.ts) 的扩展方法后,确认它提供连接级认证状态推送,但没有结构化额度接口;`/status` 只有显示文本,token 统计也不是订阅窗口。因此共享 host 短暂启动已检测到的本机 `codex app-server`,用官方 `account/rateLimits/read` 查询,禁止创建线程或发送 prompt。查询前后读取账号并与 ACP 提供的邮箱比较;没有邮箱时不能确认归属,只显示未知。上游 ACP 没有稳定账户 ID,同邮箱切换组织仍是当前身份校验的限制。每连接最多一分钟一次、不并发,查询二十秒超时;连接取消或查询结束清理整个进程树。将来 ACP 暴露结构化额度后用同一连接替换此探测,不长期维护第二套认证或私有 HTTP API。 - **额度显示和资源边界**:参考 PR #870 保留真实窗口时长、未知不当零、失败显示过期的原则,不整合其独立页面和日志扫描。输入框上下文栏右侧显示使用率最高的窗口,例如 `5h · 已用 68%`;悬停列出各额度窗口、重置与更新时间。窗口按实际上报时长标注,primary 可以是七天。面板可见且应用活跃时每分钟刷新,回复结束也请求一次受限刷新;历史页、隐藏面板停止定时请求。短暂失败保留并灰显旧值,断连与身份失败清除旧额度。API Key 模式没有额度查询。快照只在连接内存中存活;未增加下载、缓存文件或可复用工作树资源,CLI 仍使用自身用户目录,安装目录和发行 bundle 只读,不影响代码签名或 Sparkle delta。 @@ -35,6 +37,8 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **上下文用量以上游为准**:输入框顶部参考 CC GUI 显示圆环与整数百分比,悬停显示一位小数百分比及已用/总容量 token。提示复用工作台已有的悬停浮层与面板局部 scope(浮层可绘制的范围),圆环和百分比共用完整命中区域,鼠标进入立即显示在指示器上方,移开或面板退出时关闭;不依赖系统原生 `.help` 的延迟提示,避免宿主视图内看不到详情。使用现有 ACP `usage_update.used/size`,共享 host 已按 SDK 原样转发,不另接 Codex 私有事件、不扫描历史文件、不根据文字长度或模型名推算。计费用量是累计消耗,不是当前上下文容量,不能混用。用量按会话保存,每条有效更新替换之前值,压缩后允许下降;没有上报、容量为零或无效数据在会话状态中仍保持未知;展示层按 CC GUI 使用 0% 占位,悬停仅提示“上下文: 0.0%”,真实零使用量也使用相同简短提示,不编造已用 token 或总容量。断连、确认模型改变、重新加载会话清除旧数据,权限或思考选项改变不清除。超过容量时保留上游数值与百分比,仅圆环限制为一整圈。数据只是内存会话状态,不写入 bundle 或新增缓存资源。 - **停止必须等上游确认**:只发送一次 `session/cancel`,撤销本轮权限请求,界面进入“正在停止”。收到原 prompt 的结束响应后才能发送下一轮。旧方案只屏蔽迟到的 prompt 响应,却不能阻止上游把新消息并入旧轮次,也不能识别没有轮次编号的迟到通知。因此改为十秒确认期限:超过期限则明确报错、停止该 Agent 的进程树,保留界面记录,用户重连后通过 `session/load` 恢复。正常取消不重启进程。这不是伪装成正常结束,用户会看到恢复原因;同一 Agent 进程里的其他会话也会断开,不能静默自动重试消息。 - **会话配置由上游提供**:面板连接完成后准备空会话,让用户发第一条消息前就能选择模型、权限模式、思考强度。选项、分组和当前值均来自 `session/new`、`session/load`、配置更新通知及 `session/set_config_option` 的响应;不硬编码模型列表。每次启动 Agent 连接前,通过既有本机配置端口刷新已绑定导入服务商的默认模型,避免旧导入值(例如已移除的模型名)再次成为所有新会话的默认值;只刷新模型,不改提交信息的服务商选择、手动服务商、端点或凭据。模型读取独立于完整 API 配置:Codex 没有自定义服务商或 API Key 时仍能读取顶层 `model`,不能把其他 TOML 表里的同名字段当成默认模型;凭据校验失败时也更新面板的模型回退显示。API Key 模式仍校验凭据;订阅模式绕过这条服务商模型同步路径,使用 Codex 自己的模型目录。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 +- **会话配置准备期间的显示**:本地供应商默认模型只是启动输入,不是当前会话的已确认模型。切换到尚未初始化的 Agent 时,工具栏从连接启动、准备空会话到默认模型修正完成,连续显示“正在加载会话设置”;加载历史也等待该会话自己的配置,不先闪现本地默认模型或另一个会话的选项。进程报告 ready 只表示连接可用,不能当成模型目录已就绪。已有连接和已确认会话在来回切换时直接复用,不额外加载;创建或加载失败、需要登录、连接失败以及上游不提供配置时退出加载状态,保留真实错误和既有默认模型回退。发送按钮和回车发送都遵循准备状态。这是原生界面根据现有连接事件推导的显示行为,不新增 ACP 状态、模型缓存、资源目录或运行时 bundle 写入。 +- **关闭最后一个标签后准备新会话**:连接已 ready 时,关闭最后一个会话由连接模型立即调用既有 `prepareConversation()`,发起一次新建请求,不等待视图重新出现或连接状态再变化。准备中的创建请求复用已有 token,重复关闭或准备不会重复创建;旧确认不能重新选中已关闭的会话。保留“正在加载会话设置”的显示与发送限制,收到新会话确认后才恢复模型选择和发送。还有其他标签时直接选中最后一个标签,断连时不创建;这只移除本地标签,不删除上游历史。不要仅为解除阻塞取消准备状态判定,否则会重新闪现未确认的本地默认模型。 - **工具证据与文档保护**:工具详情合并上游的部分更新,展示类型、输入、输出、文件位置和修改前后文本;权限卡复用已收到的工具证据,区分允许和拒绝。显示文本每段限制 32 Ki 字符,内容和位置各限制 100 条,避免大工具输出堵住界面;截断处标记 `[...]`。点击项目内文件交给现有编辑器导航,不在视图中直接读写文件。磁盘刷新、脏缓冲区保护和 Git diff 沿用文档/Git 模块。Agent 从项目目录启动,不需要先附加当前文件才能读写磁盘;未保存的编辑器快照是后续独立能力。 - **未发送的空会话不能当成历史恢复**:为了提前显示配置,界面会先创建空会话,但 Codex 在首条消息前还没有把会话内容写入磁盘(rollout),旧进程关闭后用原 ID 加载会报 `no rollout found`。连接模型只跟踪本连接新建、未成功提交消息且未出现在上游历史中的会话;断开时移除这些会话的本地标签和临时记录,重连后通过原有准备流程新建。已有消息、上游列出或成功加载的会话继续恢复,不根据“当前消息列表为空”推断历史不存在,也不按错误字符串给真实历史自动新建替身。这个操作不删除用户的上游历史文件;代价是未发送会话的临时配置选择需要在新连接重新确认。 - **历史加载失败不清空记录**:回放时保留原会话快照,成功后使用上游回放,失败或连接退出时恢复之前记录并丢弃不完整的回放片段。旧连接事件任务被取消后不再消费缓冲事件。 @@ -49,7 +53,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **CLI 更新保留安装来源**:以 PATH 中实际命令及其真实文件为准,不能仅看到用户装了 npm 就把所有 CLI 交给 npm。Homebrew 通过自己报告的 Cellar/Caskroom 位置和已安装记录确认归属,保留 cask/formula 及 `claude-code@latest` 等渠道;npm 必须确认当前 global root、包名、bin 声明和链接都指向同一 CLI,另一套 Node 环境不能代更新;Claude 标准原生 launcher 使用上游 `claude update`。未知来源、损坏链接、缺少原安装器或安装记录时拒绝自动覆盖,明确提供手动指引。更新后重新读取登录 shell 的 PATH 并验证最低版本;更新命令退出成功但实际 CLI 仍过旧也应失败。安装器可能在首次下载失败后重试成功,却保留非零退出状态,因此正常结束的命令无论退出状态如何,都要检查实际版本。只有新安装或数字版本严格提升且达到最低要求时,才能把非零退出降为“已成功、带警告”,返回有界日志并在界面折叠展示;原本可用但版本没变、降级、仍过旧或找不到命令时继续报错。不能根据日志中的“successfully upgraded”字样猜测成功,也不能把取消、超时或启动失败改判成功。读取来源只使用有界的本地查询,不更新包管理器索引、不改变用户配置。Homebrew 和原生下载归原安装器拥有,仅显示“正在更新”与耗时,不伪造字节进度;它们的全局安装和缓存不注册成可复制的工作树构建资源,排除清单与测试同步维护。 - **下载进度以 npm 的真实传输为准**:安装与 CLI 升级通过现有 Core 事件回调报告已接收软件包字节数、最近采样速度、耗时和等待时间。npm 没有提供整次安装的总量,且会继续发现依赖,所以不显示总体百分比。内嵌的 Node 观察模块只统计 HTTP 响应进入流缓冲区的字节,不添加消费数据的监听器,也不重写下载、代理、重试、校验或缓存行为。模块通过内存中的 data URL 加载,启动后先恢复用户原有 `NODE_OPTIONS`,防止 npm 子脚本继承观察器;不生成辅助文件或新的可复用缓存。正确做法是显示“已下载 25 MB、75 KB/秒、已用时 300 秒”;不要把 npm 静默时的日志时间或整个共享缓存大小当成下载进度。Core 事件只携带数字和阶段,界面按操作标识丢弃迟到事件,完成、失败或取消后清除进度。 - **Rust Core 命令**:`agent.status`、`agent.install`、`agent.uninstall`、`agent.installCli`,复用现有信封的取消和超时。 - - **Key 和模型的传法**:API Key 模式的适配器通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 + - **Key 和模型的传法**:API Key 经 ACP stdio 传给适配器。Responses 使用 `gateway` 的 `Authorization: Bearer`;Claude 使用公开会话参数中的原生 API Key 配置,由 SDK 发出 `x-api-key`。Codex 的模型用 `CODEX_CONFIG`,Claude 的模型用 `ANTHROPIC_MODEL` 并在会话 options 中显式提供。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 - **设置放在面板里,只有 Agent 管理一页**:Agent 的开关、预检清单(Node、npm、CLI、适配器、本机配置)、适配器和 CLI 的一键安装都在 Agent 面板右上角的设置视图里,不进全局设置窗口。布局仿照 Codeg 和 CC GUI:左侧图标栏,右侧标题加分段切换各个 Agent。 - **本机配置保留 CLI 所有权**:每个 Agent 通过本机配置行读取用户自己 CLI 的地址、模型和密钥(Codex 读 `~/.codex/config.toml` 和 `auth.json`,Claude 读 `~/.claude/settings.json` 和 `~/.claude.json`),生成的服务商配置绑定到该 Agent。本机模式的密钥不复制进 Lithe,启动时从用户文件现读;要改本机地址或密钥时编辑自己的文件再刷新。需要独立配置时使用上面的自定义供应商编辑器,不改写 CLI 文件,也不改变提交信息使用的服务商选择。 - **关闭时仍能找到入口**:工作台始终保留 Agent 的静态入口声明,首次安装没有模块配置、手动关闭后也能打开面板。模块仍默认禁用,入口读取不调用 factory,不创建连接;禁用模块的通用贡献目录和其他模块的入口规则保持原样。未配置的面板立即显示原因和“打开 Agent 设置”操作,用户在面板内开启开关,无需修改本机配置文件。开启后复用运行时入口,按 ID 避免重复;关闭后保留面板和入口。不要把默认开关改为启用来修复发现入口的问题,否则会改变所有用户的后台启动行为。 @@ -57,6 +61,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **面板始终显示完整布局**:功能关闭或没有配置 Agent 时,面板照样显示会话标题、消息区和输入框,用户可以输入;发送时先校验(功能是否开启、是否有已配置的 Agent、模块是否启动完成),不通过就在输入框上方给出提示并提供进入设置的按钮,不会启动任何进程。 - **文件拖入对话框**:参考 CC GUI 普通文件的路径引用交互,原生输入框接受 Finder 和项目树的文件 URL,显示可移除标签,并支持文件选择器。一次最多 32 个引用,按拖入顺序去重;切换会话清除旧文件草稿,发送失败保留文字和引用。功能模型把引用与文字一起排队,Rust Host 用上游 ACP SDK 的 `resource_link` 发送,Agent 自己读取文件并遵守现有权限。文件 URL 是这次消息的原生上下文,不作为跨平台项目标识持久化。正确做法是发送“说明这两个文件”加两个引用;不要在 View 里读文件、复制进缓存或拼装隐式文件内容提示词。图片目前同样作为文件引用,没有缩略图或多模态字节上传;这避免了与本次需求无关的内存、大小和格式策略,后续需要图片输入时再按上游能力协商。 - **对话面板**:停靠在编辑器右侧,与 Maven 共用右侧槽位。输入框下方可以切换 Agent,列出的是已经绑定服务商或 Codex 订阅的 Agent;打开的会话以标签形式排列。每个 Agent 第一次被选中时才建立连接;项目里所有 Agent 的权限提醒会合并显示到项目标签上。 +- **选择状态由面板直接观察**:切换 Agent 时,解析当前连接的视图必须直接观察 `AgentConversationFeatureModel`,连接内的模型与权限则由 `AgentConnectionModel` 发布。不能只观察外层 AppModel 再读取可选模块,否则选择已经改变,品牌、模型列表与发送目标仍可能停留在旧连接。正确做法是让 `AgentConfiguredConversationView` 持有观察关系,并按 Agent ID 重建连接视图;每个 Agent 的原连接和会话继续保留。不要靠通知整个工作台或重启进程来刷新选择,这会增加无关重绘或中断后台会话。代价是视图明确分为项目选择与连接内容两层;新增菜单必须继续使用同一选择源。该修复只调整内存观察,不新增文件、缓存或下载资源,也不写发行 bundle。 - **对话区的视觉与交互**:按 CC GUI 参考设计使用单行会话标题、居中的品牌标志、上下文栏和底部工具栏。只有多会话或用户主动展开时才显示标签条,避免空会话标题重复。空态不放进滚动列表,否则无法在剩余高度内居中。输入区复用 `LitheSplitPaneView`,尺寸约束跟随可用高度,拖动只更新局部容器。搜索只筛选当前已加载消息,不请求 Agent 或改动历史。品牌 SVG 来自参考项目锁定的 `@lobehub/icons` 5.8.0,以静态源资源随 SwiftPM bundle 打包并携带 MIT 许可,不新增运行时下载或可复用缓存。配置选择器按参考项目的底栏设计:权限与模型入口位于输入区底部,模型弹出面板提供本地名称/ID/分组搜索、品牌图标、蓝色选中行和绿色勾选,不显示模型说明;权限弹出面板保留上游名称,只对说明使用界面语言。思考强度、速度及其他上游配置放到模型面板底部的子菜单,未提供时不显示。选项数量和权限语义仍以 ACP 为准,不照截图补选项。选择后收起面板,原确认/失败状态机继续拥有当前值,等待确认时禁止再次配置。弹出与搜索状态只在视图内保存,会话切换重建选择器以清除旧会话的弹出状态;不重建输入框或清空草稿。使用 macOS 原生 popover 管理定位和关闭,设置子选项在同一个 popover 内展开侧栏,避免嵌套 popover 关闭父面板;不另写鼠标全局监听或位置缓存。上下文百分比使用 Agent 上报的用量,缺失时展示 0% 占位与“上下文: 0.0%”简短提示;占位不作为已上报用量写回会话状态。 - **进程与诊断**:启动时把 Agent 可执行文件所在目录放到 `PATH` 最前面。npm 把 `codex-acp` 和 `node` 装在同一个目录,而 Mac 图形应用拿不到登录 shell 的 `PATH`。关闭时先向整棵进程树发 SIGTERM,并记录树里的每个进程 ID,稍等后再对仍存活的进程发 SIGKILL,因为 codex-acp 的 app-server 会比外层进程晚几秒退出。API Key 模式下 Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉;订阅模式仅显示受控错误,不附加账号工具的原始输出。 @@ -104,6 +109,16 @@ Zed 就是这么做的。但会装 Agent 的用户本机通常已经有 Node。 定时重发取消针对单个适配器缺陷,不能成为通用协议策略,因此不采用。单纯超时结束并清空会话会丢上下文,也不采用。当前选择保留记录、显式报错并重连加载的有界恢复:代价是重启连接,但能保证下一轮不与未结束的旧轮混在一起。 +### Claude 使用通用 gateway、手工修改适配器或另建 HTTP 代理 + +Claude ACP 0.81.2 的 gateway 模式会将 `ANTHROPIC_AUTH_TOKEN` 设成占位值 `acp-proxy`;0.84.0 仍使用同样逻辑。原生 CLI 请求头实验显示它与真实 `x-api-key` 同时发送,而部分服务商优先校验 Bearer,结果是有效密钥也收到 401。额外自定义 Authorization 未能覆盖占位值,单纯升级同样无法消除此原因。 + +上游已经公开 `NewSessionMeta.claudeCode.options`,且恢复会话同样接受它。实测不走 gateway、通过这条接口传 SDK 原生 API Key 配置时,同一固定适配器能够完成回复,只发正确的 x-api-key。因此采用此接口完整复用上游 Agent、权限、工具、取消和历史;不修改下载后的第三方文件、不维护供应商专用分支、不增加本地 HTTP 代理。代价是凭据路由需要在每次新建和恢复会话时携带,升级适配器时必须验证这条公开扩展。 + +正确做法:在共享 Host 的 `session_routing` 中生成两层一致的会话配置,并让新建、历史恢复共用它。不要只清理启动环境或只给 session/new 配置;用户 settings 后加载会覆盖环境,历史恢复也会重新创建 SDK Query。手工修改用户级 npm 包会被重装覆盖,新增代理则增加本可避免的监听端口和生命周期,不采用。 + +路由仅在连接内存中保存,不产生下载、缓存或生成资源,工作树复用清单无需新增资源;不写发行 bundle 或安装目录,不影响代码签名和 Sparkle delta。 + ### 环境变量传 Key、或把 `CODEX_HOME` 指到 Lithe 目录 `OPENAI_API_KEY` 这类环境变量只适用于官方地址,也更容易泄露。隔离 `CODEX_HOME` 会让 Agent 读不到用户自己的 MCP 服务器、Skills 和全局指令;而一旦设置了网关,请求本来就不会用到用户的账号登录,所以没有必要隔离。 @@ -130,14 +145,18 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentConversationSelectionTests`:原生 SwiftUI 宿主不替换根视图,直接改变模块选择,验证 Agent、模型与连接立即同步;配置确认前保留旧值、确认后刷新,来回切换保持各自配置,删除当前 Agent 时正确回退,关闭全部测试窗口和连接。另对 Claude/Codex 显式驱动最后一个标签关闭→唯一新建请求→过期响应→新会话确认→发送,确认连接不变、准备期间不闪现模型、确认后底栏恢复且消息只交给新会话。显式控制切换→连接→ready→创建确认及历史加载的事件顺序,比较原生工具栏,验证准备期间改变本地默认模型不影响显示、过期响应不能解除等待、已确认连接复用、失败和无配置能力不会一直加载。2026-10-01 修复版真实窗口已验证 Codex 初始化加载提示到 Astra/审批控件、Astra→Luna→Astra 模型切换和重新展开的勾选、模型名称搜索与空结果、Claude/Codex 来回切换保留配置,Codex 最小消息收到预期回复。关闭测试项目后确认无 Agent 子进程,990 个 bundle 文件哈希不变;Windows 对话 UI 与运行验收仍待完成。 + - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationFeatureModelTests|AgentConversationPresentationTests'`:注入可推进的单调时钟验证排队、后台会话、取消确认、失败和断连;共享 fixture 校验用量、零值与缺失值,统计行的分组边界与历史回放不伪造。真实 Agent 的 token 口径和连续计时仍需人工验收,不能以合成数据截图代替供应商运行验证。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源;原生输入框 Menu 的像素验证同时覆盖 Claude/Codex 和深浅主题,确保橙色/灰色标志实际可见,染色不改变共用模板。 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 - 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和 host,但订阅选择和额度 UI 待接入。 - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` - `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) +- Claude 路由回归使用 `shared/fixtures/agent/acp-events-v1.json` 的 `upstream.claudeSessionRouting`,覆盖凭据缺失不回退、冲突鉴权清理、新建和历史恢复都带路由、Codex gateway 不变;真实验收通过 `LITHE_ACP_E2E_AGENT_ID=claude-acp` 选择已安装的 Claude 适配器,验证回复、读文件、修改、执行测试与追问。 +- 真实模型切换集成测试必须选择不同的上游模型值,并在确认后发送最小消息,不能仅对当前值做一次成功往返。后续选项取自最新确认的目录;例如 Claude 切到 Haiku 后不再提供思考强度,应验证该能力消失,不向它发送旧目录里的 effort 选项。 - 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 - `shared/fixtures/agent/acp-events-v1.json` 同时由 Rust 序列化测试和 Swift 功能模型测试读取。 - `macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` 使用真实模块运行时覆盖默认关闭、启用和再次关闭,验证静态入口及 action/renderer 绑定可用、不重复、不提前调用 factory,其他关闭模块不会跟着暴露。 diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index ebc1a2269..23c3ee677 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -50,6 +50,16 @@ Git 性能测试增加等待时间。推送到 `main` 或手动运行时,再 组装通用 DMG。Windows PR 把前端验证与 Rust 测试放在两个独立 job 中并行 执行;Windows x64 NSIS 安装包只由 preview 和稳定版发布工作流生成。 +Git 图形的离屏帧采样保留完整 1,000 行历史,但每次只绘制 40 行的可见区域, +分别覆盖开头、中间和末尾。绘制前把该区域的坐标映射到小位图,绘制后检查 +实际有图形像素,避免中段被裁剪为空时得到虚假的快速结果。完整 1,000/5,000 +行图结构、顺序和签名仍由独立用例与 Release 验证器检查;单帧中位数和整个 +测试的时间上限保持不变。 + +正确做法:模拟滚动窗口的一帧,把可见区域作为绘制输入。不要把全部历史 +重复绘制 220 次当作单帧采样:这会把画面之外的工作混进测试总耗时,在共享 +runner 上可能出现断言都通过、计时工具仍因超过十秒而失败的情况。 + CI 缓存 Cargo fingerprints、build script outputs 和依赖 outputs,不缓存 最终可执行文件。缓存覆盖 `rust/target/macos` 的 Rust Core 和 `rust/target` 的数据库辅助 crate;缓存键必须包含运行器架构、编译器、Xcode/SDK/macOS @@ -110,11 +120,19 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 任务结束。双架构验证因此保留给真正影响包内容和构建边界的改动;开发者需要 临时安装包时使用手动工作流。 +### 提高 Git 帧采样的总时间上限 + +这能容纳重复绘制完整历史的耗时,却保留了与可见窗口不符的工作量,也削弱 +其他测试的时间门禁。因此修正采样范围与位图坐标,继续使用原有上限;完整 +图的正确性检查不减少,批量绘制不能替代可见区域的帧成本测量。 + ## 后果 - 普通 macOS Swift PR 更快得到必需检查结果;被选中的打包改动仍获得两个架构 的真实安装物。 - Git 专项验证不会再延长无关 Swift 改动的反馈时间。 +- 帧采样只绘制可见区域,减少与屏幕无关的测试工作;它不衡量完整历史的一次 + 批量导出,也不等同于需要显式开启的真实窗口帧率采样。 - Windows 前端失败与 Rust 失败可以独立、并行反馈,不再等待 NSIS 安装包。 - 缓存命中时可减少 Rust 相关重复编译,同时通过完整缓存键避免跨环境误复用。 - artifact、校验和、合并提交与 gate 结果共同提供可追溯的测试交付物。 @@ -126,6 +144,7 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `actionlint .github/workflows/ci-macos.yml .github/workflows/ci-windows.yml` - `./scripts/test-macos.sh` +- `./scripts/test-git-performance-baseline.sh` - `./scripts/build-macos.sh --configuration debug --triple arm64-apple-macosx` - `./scripts/build-macos.sh --configuration debug --triple x86_64-apple-macosx` - `./scripts/build-official-plugins.sh --configuration debug --triple arm64-apple-macosx` diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 5eec402cb..ce828fe20 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -97,7 +97,10 @@ change should be evaluated separately if queueing continues to dominate. 普通 `./scripts/test-macos.sh` 和 `./scripts/test-git-performance-baseline.sh` 默认跳过两个 WindowServer/display-link 真实窗口采样用例,继续运行 Git 图布局、 -离屏绘制和其他性能回归验证。真实窗口采样需要 macOS 14+ 和可用的桌面显示; +离屏绘制和其他性能回归验证。图形离屏帧采样保留完整 1,000 行历史,分别在 +开头、中间、末尾采样 40 行可见区域,并检查位图确实绘制了图形。完整图的 +结构和 Release 基线仍覆盖 1,000/5,000 行;单帧与测试总耗时上限保持不变。 +真实窗口采样需要 macOS 14+ 和可用的桌面显示; 只在专门测量滚动帧率时显式开启: ```bash diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index da9e056ef..43a6140c4 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -6,7 +6,10 @@ ide-mcp-connection,AI,IDE MCP,从设置侧栏独立的 MCP 配置页按项目开 agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift; macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg; macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift,windows/tauri/src/features agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json -agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift,rust/lithe-agent-host +agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 鉴权、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift,rust/lithe-agent-host +agent-toolbar-brand-marks,AI,Agent 对话,Agent 品牌标志:Claude 橙色星芒、Codex 随主题变化的灰色结形图标,底栏切换入口、Agent 下拉菜单条目与模型入口保持清晰一致,已实现,已验证,未实现,待验证,Agent,macOS:原生真实输入框的 Menu 像素测试通过四种品牌/深浅主题组合,共用模板图像保持不变;87 项相关 Agent 测试通过。打包预览应用的深色界面实际展开 Agent 与模型菜单,确认底栏图标居中、点击区域一致、模型小标志同步;Agent 菜单中 Claude 与 Codex 都带对应品牌图标,Claude 选中与切到 Codex 后的勾选均与品牌图标并列正确显示,模型与权限跟随所选 Agent。菜单验证前后 bundle 的 990 个文件清单及 SHA-256 一致,旧版应用与辅助进程已退出,仅保留用户新版主应用及项目正常运行的进程。Windows 对话 UI 和验收待完成。,,macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift; macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,shared/contracts/application-boundary.md +agent-connection-model-switching,AI,Agent 对话,Agent 与模型切换:所选连接即时同步,准备期间明确等待会话设置,确认后显示模型,来回切换保留各自会话,已实现,已验证,未实现,待验证,Agent,macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;搜索名称/ID与空结果,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。Windows 对话 UI 与验收待完成。,2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。Windows 对话 UI 与验收仍待完成。 同日 review 回归补齐:Claude/Codex 关闭最后一个标签后立即准备替代会话,忽略旧确认并在新确认后正常发送;两组参数化场景通过,测试窗口与模拟连接均关闭。,macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentConversationSelectionTests.swift; rust/lithe-agent-host/tests/real_agent.rs,shared/contracts/application-boundary.md +agent-claude-api-key-authentication,AI,Agent 对话,Claude API Key 鉴权:通过公开会话参数使用所选供应商,清除冲突占位 Bearer,新建与历史恢复保持地址、密钥和模型,已实现,已验证,部分实现,待验证,Agent,macOS:使用支持 Anthropic Messages 的供应商,在 Claude 新会话中验证正常和流式回复、读取临时文件、修改错误实现、执行两项 Node 断言并追问;验证停止、重连、历史加载后仍使用原供应商并记住上下文。用本地假服务观察同版原生 CLI 与固定 ACP 适配器请求仅含有效 x-api-key、没有占位 Bearer;新建与恢复共用 fixture 配置,环境与程序化 settings 都清除旧鉴权和路由;关闭项目后检查子进程退出与 bundle 哈希不变。Windows 共享 Host 已具备路由修复,对话 UI 与真实 Windows 验收仍待完成。,,rust/lithe-agent-host/src/session_routing.rs; rust/lithe-agent-host/src/tests.rs; rust/lithe-agent-host/tests/real_agent.rs; shared/fixtures/agent/acp-events-v1.json; .agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md,rust/lithe-agent-host/src/session_routing.rs; shared/contracts/rust-core-api.md agent-thinking-level-labels,AI,Agent 对话,Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称,已实现,已验证,未实现,待验证,Agent,2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-approval-mode-presentation,AI,Agent 对话,Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义,已实现,待验证,未实现,待验证,Agent,中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Resources/en.lproj/Localizable.strings; macos/Resources/zh-Hans.lproj/Localizable.strings; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 7068a421f..52360c051 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:123 -- macOS:实现:✅ 108 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 1 已验证,🔍 110 待验证,— 12 不适用 -- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 8 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 115 待验证,— 8 不适用 +- 功能项:126 +- macOS:实现:✅ 111 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 4 已验证,🔍 110 待验证,— 12 不适用 +- Windows:实现:✅ 102 已实现,🟡 12 部分实现,❌ 10 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 118 待验证,— 8 不适用 ## 实现状态定义 @@ -30,7 +30,7 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。
-AI · 21 个能力点 +AI · 24 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -41,7 +41,10 @@ | Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift`、`macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg`、`macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。 | | | Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | | Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | -| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。 | | +| Agent 对话 | **可选 ACP Agent 对话:API Key 鉴权、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。 | | +| Agent 对话 | **Agent 品牌标志:Claude 橙色星芒、Codex 随主题变化的灰色结形图标,底栏切换入口、Agent 下拉菜单条目与模型入口保持清晰一致**
agent-toolbar-brand-marks | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | macOS:原生真实输入框的 Menu 像素测试通过四种品牌/深浅主题组合,共用模板图像保持不变;87 项相关 Agent 测试通过。打包预览应用的深色界面实际展开 Agent 与模型菜单,确认底栏图标居中、点击区域一致、模型小标志同步;Agent 菜单中 Claude 与 Codex 都带对应品牌图标,Claude 选中与切到 Codex 后的勾选均与品牌图标并列正确显示,模型与权限跟随所选 Agent。菜单验证前后 bundle 的 990 个文件清单及 SHA-256 一致,旧版应用与辅助进程已退出,仅保留用户新版主应用及项目正常运行的进程。Windows 对话 UI 和验收待完成。 | | +| Agent 对话 | **Agent 与模型切换:所选连接即时同步,准备期间明确等待会话设置,确认后显示模型,来回切换保留各自会话**
agent-connection-model-switching | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentConversationSelectionTests.swift`、`rust/lithe-agent-host/tests/real_agent.rs` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;搜索名称/ID与空结果,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。Windows 对话 UI 与验收待完成。 | 2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。Windows 对话 UI 与验收仍待完成。 同日 review 回归补齐:Claude/Codex 关闭最后一个标签后立即准备替代会话,忽略旧确认并在新确认后正常发送;两组参数化场景通过,测试窗口与模拟连接均关闭。 | +| Agent 对话 | **Claude API Key 鉴权:通过公开会话参数使用所选供应商,清除冲突占位 Bearer,新建与历史恢复保持地址、密钥和模型**
agent-claude-api-key-authentication | ✅ 已实现
✔️ 已验证
`rust/lithe-agent-host/src/session_routing.rs`、`rust/lithe-agent-host/src/tests.rs`、`rust/lithe-agent-host/tests/real_agent.rs`、`shared/fixtures/agent/acp-events-v1.json`、`.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/session_routing.rs`、`shared/contracts/rust-core-api.md` | Agent | macOS:使用支持 Anthropic Messages 的供应商,在 Claude 新会话中验证正常和流式回复、读取临时文件、修改错误实现、执行两项 Node 断言并追问;验证停止、重连、历史加载后仍使用原供应商并记住上下文。用本地假服务观察同版原生 CLI 与固定 ACP 适配器请求仅含有效 x-api-key、没有占位 Bearer;新建与恢复共用 fixture 配置,环境与程序化 settings 都清除旧鉴权和路由;关闭项目后检查子进程退出与 bundle 哈希不变。Windows 共享 Host 已具备路由修复,对话 UI 与真实 Windows 验收仍待完成。 | | | Agent 对话 | **Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称**
agent-thinking-level-labels | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。 | | | Agent 对话 | **Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义**
agent-approval-mode-presentation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Resources/en.lproj/Localizable.strings`、`macos/Resources/zh-Hans.lproj/Localizable.strings`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。 | | | Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 253fe617e..309780e95 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1286,6 +1286,7 @@ "Repository needs attention" = "Repository needs attention"; "Codex subscription" = "Codex subscription"; +"Loading session settings…" = "Loading session settings…"; "Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 76284b84c..f5585d50c 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2523,6 +2523,7 @@ "Repository needs attention" = "仓库需要处理"; "Codex subscription" = "Codex 官方订阅(本机账号)"; +"Loading session settings…" = "正在加载会话设置…"; "Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index 1df5e9fc8..a03136fb1 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -15,6 +15,7 @@ struct AgentComposerView: View { let onError: (String?) -> Void var configOptions: [AgentSessionConfigOption] = [] var sessionID: String? + var isPreparingSession = false var isConfiguring = false var isCancelling = false var contextUsage: AgentContextUsage? @@ -108,7 +109,14 @@ struct AgentComposerView: View { .buttonStyle(AgentToolbarButtonStyle()) .help("Agent Settings") agentMenu - if !configOptions.isEmpty { + if isPreparingSession { + Label("Loading session settings…", systemImage: "hourglass") + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .padding(.horizontal, 4) + .accessibilityIdentifier("agent-session-settings-loading") + } else if !configOptions.isEmpty { AgentSessionSelectors( options: configOptions, agentName: selectedAgent?.name, @@ -117,10 +125,9 @@ struct AgentComposerView: View { ) .id(sessionID ?? selectedAgent?.id) if isConfiguring { ProgressView().controlSize(.mini) } - } - if configOptions.isEmpty, let model = selectedAgent?.modelName, !model.isEmpty { + } else if let model = selectedAgent?.modelName, !model.isEmpty { HStack(spacing: 5) { - AgentBrandIcon(name: selectedAgent?.name, size: 12) + AgentBrandIcon(name: selectedAgent?.name, size: 12, style: .brand) Text(model).lineLimit(1).truncationMode(.middle) } .font(.system(size: 11)) @@ -138,7 +145,7 @@ struct AgentComposerView: View { } .buttonStyle(.litheNoPress) .lithePointer() - .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isConfiguring))) + .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isPreparingSession || isConfiguring))) .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) } .padding(.horizontal, 5) @@ -149,20 +156,31 @@ struct AgentComposerView: View { private var agentMenu: some View { Menu { - if agents.isEmpty { Text("No Agent is set up yet") } - ForEach(agents) { agent in - Button { onSelectAgent(agent.id) } label: { - if agent.id == selectedAgent?.id { - Label(agent.name, systemImage: "checkmark") - } else { - Text(agent.name) + if agents.isEmpty { + Text("No Agent is set up yet") + } else { + // The native picker owns the selection checkmark separately + // from each item's brand image. + Picker("Choose an Agent", selection: Binding( + get: { selectedAgent?.id }, + set: { if let id = $0 { onSelectAgent(id) } } + )) { + ForEach(agents) { agent in + Label { + Text(agent.name) + } icon: { + AgentBrandIcon(name: agent.name, size: 16, style: .brand) + } + .tag(Optional(agent.id)) } } + .pickerStyle(.inline) + .labelsHidden() } Divider() Button("Agent Settings…", action: onOpenSettings) } label: { - AgentBrandIcon(name: selectedAgent?.name, size: 16) + AgentBrandIcon(name: selectedAgent?.name, size: 18, style: .brand) .foregroundStyle(AgentPanelStyle.secondary) .frame(width: 28, height: 28) } @@ -171,6 +189,7 @@ struct AgentComposerView: View { .fixedSize() .help(selectedAgent?.name ?? String(localized: "Choose an Agent")) .accessibilityLabel("Switch Agent") + .accessibilityIdentifier("agent-composer-agent-selector") } private func addFiles(_ urls: [URL]) -> Bool { @@ -188,7 +207,7 @@ struct AgentComposerView: View { private func send() { guard hasContent, !isResponding else { return } - if isBlocked { + if isBlocked || isPreparingSession { onError(String(localized: "The conversation is still being prepared. Try again in a moment.")) return } diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index f01e37aa5..f2535fb3e 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -18,19 +18,16 @@ struct AgentConversationView: View { feature: model.agentManagementFeature, onDone: { showsSettings = false } ) - } else if let feature, let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { - AgentConnectionView( - feature: connection, - history: feature.history(for: agentID), - agents: feature.agents, - selectedAgentID: feature.selectedAgentID, + } else if let feature { + AgentConfiguredConversationView( + feature: feature, + setupError: model.agentConversationSetupError, onSelectAgent: { model.selectAgentConversationAgent($0) }, onConnect: { model.connectAgentConversation() }, onOpenSettings: { showsSettings = true }, onCopySessionID: { model.copyAgentSessionID($0) }, onOpenFile: { model.openAgentFile($0) } ) - .id(feature.selectedAgentID) } else { AgentUnconfiguredConversationView( setupError: model.agentConversationSetupError, @@ -44,6 +41,37 @@ struct AgentConversationView: View { } } +/// Observe selection where the selected connection is resolved. AppModel does +/// not forward this optional module's changes, and each connection owns its UI. +struct AgentConfiguredConversationView: View { + @ObservedObject var feature: AgentConversationFeatureModel + let setupError: AgentConversationError? + let onSelectAgent: (String) -> Void + let onConnect: () -> Void + let onOpenSettings: () -> Void + let onCopySessionID: (String) -> Void + let onOpenFile: (AgentToolDetails.Location) -> Void + + var body: some View { + if let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { + AgentConnectionView( + feature: connection, + history: feature.history(for: agentID), + agents: feature.agents, + selectedAgentID: agentID, + onSelectAgent: onSelectAgent, + onConnect: onConnect, + onOpenSettings: onOpenSettings, + onCopySessionID: onCopySessionID, + onOpenFile: onOpenFile + ) + .id(agentID) + } else { + AgentUnconfiguredConversationView(setupError: setupError, onOpenSettings: onOpenSettings) + } + } +} + /// Title on the left and icon actions on the right, like a chat client's /// session header: new conversation, history, settings. private struct AgentPanelHeader: View { @@ -131,6 +159,20 @@ private struct AgentConnectionView: View { private var selectedAgent: AgentOption? { agents.first { $0.id == selectedAgentID } } + /// A saved provider model is not the current session's confirmed model. + /// Include the ready-to-create gap, but stop waiting on failure or sign-in. + private var isPreparingSession: Bool { + switch feature.connectionState { + case .idle, .connecting: + return true + case .ready: + return feature.isCreatingSession || feature.selectedConversation?.isLoading == true + || (feature.selectedSessionID == nil && feature.errorMessage == nil) + case .authenticationRequired, .authenticating, .failed: + return false + } + } + var body: some View { ZStack { conversation @@ -218,6 +260,7 @@ private struct AgentConnectionView: View { onError: { localError = $0 }, configOptions: feature.selectedConversation?.configOptions ?? [], sessionID: feature.selectedSessionID, + isPreparingSession: isPreparingSession, isConfiguring: feature.selectedConversation?.pendingConfigToken != nil, isCancelling: feature.selectedConversation?.isCancelling == true, contextUsage: feature.selectedConversation?.contextUsage, diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift index f01612380..4702ee991 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift @@ -17,6 +17,14 @@ enum AgentPanelStyle { static let versionText = adaptive(dark: 0xddd6fe, light: 0x6d28d9) static let versionAccent = Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255) + static func brandTint(for name: String?, isDark: Bool) -> UInt32? { + switch name?.lowercased() { + case "claude", "claude code": 0xd97757 + case "codex": isDark ? 0xcccccc : 0x333333 + default: nil + } + } + private static func adaptive(dark: UInt32, light: UInt32) -> Color { Color(nsColor: NSColor(name: nil) { appearance in let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light @@ -30,17 +38,25 @@ enum AgentPanelStyle { } } -/// Template SVGs use the same vendor silhouettes at welcome and toolbar sizes. +/// Reuse the vendor silhouettes; toolbar marks retain their color in native menus. struct AgentBrandIcon: View { + enum Style { case template, brand } + let name: String? var size: CGFloat = 16 + var style: Style = .template + @Environment(\.colorScheme) private var colorScheme + + private var tint: UInt32? { + style == .brand ? AgentPanelStyle.brandTint(for: name, isDark: colorScheme == .dark) : nil + } var body: some View { Group { - if let image = AgentBrandIconLoader.image(name: name, size: size) { + if let image = AgentBrandIconLoader.image(name: name, size: size, tint: tint) { Image(nsImage: image) .resizable() - .renderingMode(.template) + .renderingMode(tint == nil ? .template : .original) .aspectRatio(contentMode: .fit) } else { Image(systemName: "sparkles") @@ -59,11 +75,13 @@ enum AgentBrandIconLoader { let bundleURL: URL let filename: String let size: Int + let tint: UInt32? } private static var images: [CacheKey: NSImage] = [:] static func image( name: String?, size: CGFloat = 64, + tint: UInt32? = nil, resourceBundle: Bundle? = resolveResourceBundle() ) -> NSImage? { let filename: String @@ -73,15 +91,33 @@ enum AgentBrandIconLoader { default: return nil } guard let resourceBundle else { return nil } - let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, size: max(1, Int(size.rounded()))) + let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, + size: max(1, Int(size.rounded())), tint: tint) if let image = images[key] { return image } guard let url = resourceBundle.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"), let image = NSImage(contentsOf: url) else { return nil } image.isTemplate = true // Native Menu labels read NSImage.size rather than the SwiftUI frame. image.size = NSSize(width: key.size, height: key.size) - images[key] = image - return image + let rendered: NSImage + if let tint { + let color = NSColor(srgbRed: CGFloat((tint >> 16) & 255) / 255, + green: CGFloat((tint >> 8) & 255) / 255, + blue: CGFloat(tint & 255) / 255, alpha: 1) + // SwiftUI foreground styles can be lost when Menu converts its label + // to AppKit. Color the SVG's alpha mask in memory and keep it original. + rendered = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect, from: .zero, operation: .copy, fraction: 1) + color.setFill() + rect.fill(using: .sourceIn) + return true + } + rendered.isTemplate = false + } else { + rendered = image + } + images[key] = rendered + return rendered } nonisolated static func resolveResourceBundle( diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift index e39b1f2ba..346b69da9 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -87,6 +87,7 @@ struct AgentSessionSelectors: View { .help(AgentSessionSelectorPresentation.localized(mode.name)) .accessibilityLabel(Text("Approval mode")) .accessibilityValue(AgentSessionSelectorPresentation.currentTitle(mode)) + .accessibilityIdentifier("agent-session-mode-selector") .popover(isPresented: $showsModes, arrowEdge: .top) { AgentModePopover(option: mode) { value in select(mode.id, value) } .onExitCommand { showsModes = false } @@ -95,7 +96,7 @@ struct AgentSessionSelectors: View { if let model { Button { showsModels.toggle() } label: { selectorLabel { - AgentBrandIcon(name: agentName, size: 12) + AgentBrandIcon(name: agentName, size: 12, style: .brand) Text(modelSummary(model)) } } @@ -103,6 +104,7 @@ struct AgentSessionSelectors: View { .help(model.currentLabel) .accessibilityLabel(Text("Model")) .accessibilityValue(model.currentLabel) + .accessibilityIdentifier("agent-session-model-selector") .popover(isPresented: $showsModels, arrowEdge: .top) { AgentModelPopover(option: model, settings: settings, agentName: agentName, onSelect: select) .onExitCommand { showsModels = false } @@ -223,7 +225,7 @@ struct AgentModelPopover: View { .padding(.horizontal, 12).padding(.vertical, 4) } AgentSelectorRow(isSelected: choice.id == option.currentValue, action: { onSelect(option.id, choice.id) }) { - AgentBrandIcon(name: agentName, size: 16) + AgentBrandIcon(name: agentName, size: 16, style: .brand) Text(choice.name).lineLimit(1).truncationMode(.middle) } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift index c4d0d744f..b3d8f9c83 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -252,6 +252,9 @@ public final class AgentConnectionModel: ObservableObject { queuedPrompts[sessionID] = nil if selectedSessionID == sessionID { selectedSessionID = openSessionIDs.last + // Closing the final tab does not change connectionState or make + // the view appear again, so start its replacement settings here. + if selectedSessionID == nil { prepareConversation() } } } diff --git a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift index 8a0550d76..4ad9656d9 100644 --- a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift +++ b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift @@ -74,17 +74,25 @@ struct GitGraphPerformanceBaselineTests { #expect(benchmark.viewportDrawReductionPercent == 97.5) } - @Test("The native graph view frame sample stays within the test budget") + @Test("The native graph view samples only a visible viewport", arguments: [0, 480, 960]) @MainActor - func nativeGraphViewFrameSample() { - let commits = SyntheticGitGraphFixture.mergeHeavy(commitCount: 1_000) + func nativeGraphViewFrameSample(startRow: Int) throws { + let rowCount = 1_000 + let viewportRowCount = 40 + let rowHeight = GitGraphGeometry.rowHeight + let commits = SyntheticGitGraphFixture.mergeHeavy(commitCount: rowCount) let layout = GitGraphLayoutService.layout(commits: commits) - let view = GitGraphNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 1_000 * 30)) - view.update(snapshot: GitGraphLayoutService.routingSnapshot(for: layout), width: 120, rowHeight: 30) - let bitmap = NSBitmapImageRep( + let view = GitGraphNSView(frame: NSRect(x: 0, y: 0, width: 120, height: CGFloat(rowCount) * rowHeight)) + view.update(snapshot: GitGraphLayoutService.routingSnapshot(for: layout), width: 120, rowHeight: rowHeight) + // Keep the complete history, but draw a viewport as AppKit does while + // scrolling. Repainting all 1,000 rows 220 times measures bulk raster + // work rather than a frame and can exceed the unchanged CI test budget. + let viewport = NSRect(x: 0, y: CGFloat(startRow) * rowHeight, + width: 120, height: CGFloat(viewportRowCount) * rowHeight) + let bitmap = try #require(NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: 120, - pixelsHigh: 1_000 * 30, + pixelsHigh: Int(viewport.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, @@ -93,26 +101,38 @@ struct GitGraphPerformanceBaselineTests { bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 - )! - let context = NSGraphicsContext(bitmapImageRep: bitmap)! + )) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + context.cgContext.clear(NSRect(origin: .zero, size: viewport.size)) + // Mid-history view coordinates must land inside the viewport bitmap; + // otherwise clipping would make an empty render look artificially fast. + context.cgContext.translateBy(x: 0, y: -viewport.minY) let clock = ContinuousClock() var samples: [Double] = [] samples.reserveCapacity(10) for _ in 0..<1 { - _ = sampleFrame(view: view, context: context, clock: clock) + _ = sampleFrame(view: view, dirtyRect: viewport, context: context, clock: clock) } for _ in 0..<10 { - samples.append(sampleFrame(view: view, context: context, clock: clock)) + samples.append(sampleFrame(view: view, dirtyRect: viewport, context: context, clock: clock)) } let sorted = samples.sorted() let median = sorted[sorted.count / 2] let p95 = sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1] let maximum = sorted.last ?? 0 - print("GitGraph frame sample: rows=1000, samples=10, median=\(String(format: "%.3f", median))ms, p95=\(String(format: "%.3f", p95))ms, max=\(String(format: "%.3f", maximum))ms") + print("GitGraph frame sample: rows=\(rowCount), viewportRows=\(viewportRowCount), startRow=\(startRow), samples=10, median=\(String(format: "%.3f", median))ms, p95=\(String(format: "%.3f", p95))ms, max=\(String(format: "%.3f", maximum))ms") #expect(samples.count == 10) #expect(median < 100) + let bytes = try #require(bitmap.bitmapData) + // Only inspect pixel alpha, never uninitialized row-padding bytes. + let didPaint = (0.. Double { @@ -997,7 +1018,7 @@ private func sampleFrame( NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = context for _ in 0..<20 { - view.draw(view.bounds) + view.draw(dirtyRect) } NSGraphicsContext.restoreGraphicsState() return milliseconds(clock.now - start) / 20 diff --git a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift index 4e85e96d6..04f429c60 100644 --- a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift +++ b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift @@ -1,10 +1,49 @@ import AppKit +import SwiftUI import Testing @testable import Lithe @MainActor @Suite("Agent brand icon resources") struct AgentBrandIconResourceTests { + @Test(arguments: ["Claude", "Codex"], [false, true]) + func composerBrandMarksStayVisibleInNativeMenus(name: String, isDark: Bool) throws { + let host = NSHostingView(rootView: AgentComposerView( + agents: [.init(id: "example-agent", name: name)], + selectedAgent: .init(id: "example-agent", name: name), + isResponding: false, isBlocked: false, onSend: { _, _ in }, onCancel: {}, + onSelectAgent: { _ in }, onOpenSettings: {}, onError: { _ in } + ).environment(\.colorScheme, isDark ? .dark : .light)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 200), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + defer { window.close() } + window.contentView = host + host.layoutSubtreeIfNeeded() + + // Crop the real native Menu label, excluding neighboring controls and + // the text-field caret. No visible window or image baseline is required. + let region = NSRect(x: 46, y: host.isFlipped ? host.bounds.height - 42 : 10, width: 28, height: 26) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: region)) + host.cacheDisplay(in: region, to: bitmap) + var markPixels = 0 + for y in 0.. 0.5, red > green * 1.3, green > blue * 1.1 { markPixels += 1 } + } else if abs(red - green) < 0.05, abs(green - blue) < 0.05 { + if isDark ? min(red, green, blue) > 0.6 : max(red, green, blue) < 0.4 { markPixels += 1 } + } + } + } + #expect(markPixels > 10, "The native selector must show a visible orange Claude or contrasting Codex mark") + #expect(try #require(AgentBrandIconLoader.image(name: name, size: 18)).isTemplate, + "Coloring the toolbar must not mutate the shared template used by other views") + } + @Test func installedAppLoadsBothMarksWithoutDevelopmentResources() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/macos/Tests/LitheTests/AgentConversationSelectionTests.swift b/macos/Tests/LitheTests/AgentConversationSelectionTests.swift new file mode 100644 index 000000000..169bb9988 --- /dev/null +++ b/macos/Tests/LitheTests/AgentConversationSelectionTests.swift @@ -0,0 +1,346 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite(.serialized) +struct AgentConversationSelectionTests { + @Test(arguments: ["claude-acp", "codex-acp"]) + func closingTheLastTabPreparesSettingsAndAllowsSendingAfterConfirmation(agentID: String) async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + panel.setAgents([.init(id: agentID, name: agentID)]) + do { + let feature = try prepare(panel, transport: transport, agentID: agentID, model: "Confirmed model") + let native = try #require(transport.connections.last) + let initialToken = try #require(native.commands.last?["token"] as? String) + let commandCount = native.commands.count + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let readyToolbar = try toolbarPixels(in: host) + + // Closing the final tab must start real work without another view + // appearance or connection-state change to escape the loading gate. + feature.closeConversation(agentID) + #expect(feature.connectionState == .ready) + #expect(feature.selectedSessionID == nil) + #expect(feature.openSessionIDs.isEmpty) + #expect(feature.conversations[agentID] == nil) + #expect(feature.isCreatingSession) + #expect(native.commands.count == commandCount + 1) + let command = try #require(native.commands.last) + #expect(command["kind"] as? String == "newSession") + let token = try #require(command["token"] as? String) + #expect(token != initialToken) + feature.closeConversation(agentID) + feature.prepareConversation() + #expect(native.commands.count == commandCount + 1, "Pending creation must not be duplicated") + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + #expect(loadingToolbar != readyToolbar) + + feature.receive(try json(["kind": "sessionCreated", "sessionId": agentID, "token": initialToken, + "configOptions": options(model: "Stale model", current: "default")])) + #expect(feature.selectedSessionID == nil, "The closed session's response must not select it again") + #expect(feature.isCreatingSession) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + + let newSessionID = "replacement-session" + feature.receive(try json(["kind": "sessionCreated", "sessionId": newSessionID, "token": token, + "configOptions": options(model: "Confirmed model", current: "default")])) + #expect(feature.selectedSessionID == newSessionID) + #expect(feature.openSessionIDs == [newSessionID]) + #expect(feature.selectedConversation?.isAttached == true) + #expect(!feature.isCreatingSession) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == readyToolbar, "Confirmed settings must release the composer loading gate") + + try feature.send("Message after closing the last tab") + #expect(native.commands.last?["kind"] as? String == "prompt") + #expect(native.commands.last?["sessionId"] as? String == newSessionID) + #expect(feature.selectedConversation?.messages.last?.text == "Message after closing the last tab") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func preparingSessionSettingsHidesSavedModelsUntilTheSessionConfirms() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + func agents(_ model: String) -> [AgentOption] { + [.init(id: "claude-acp", name: "Claude"), .init(id: "codex-acp", name: "Codex", modelName: model)] + } + panel.setAgents(agents("Saved model")) + do { + _ = try prepare(panel, transport: transport, agentID: "claude-acp", model: "Claude model") + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let claudeToolbar = try toolbarPixels(in: host) + + // Hold each ACP boundary explicitly: first selection, process ready, + // pending session (including default repair), then the confirmed catalog. + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + #expect(loadingToolbar != claudeToolbar) + panel.setAgents(agents("Another saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "Unconfirmed saved models must not flash") + + let codex = panel.connection(for: "codex-acp") + try connect(codex, agentID: "codex-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "ready", "agentName": "Codex", "canLoadSessions": true])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "Process readiness does not confirm session settings") + codex.prepareConversation() + let token = try #require(transport.connections.last?.commands.last?["token"]) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "codex-session", "token": "stale", + "configOptions": options(model: "Wrong model", current: "default")])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "codex-session", "token": token, + "configOptions": options(model: "Confirmed model", current: "default")])) + host.layoutSubtreeIfNeeded() + let confirmedToolbar = try toolbarPixels(in: host) + #expect(confirmedToolbar != loadingToolbar) + #expect(codex.selectedConversation?.configOptions.first?.currentLabel == "Confirmed model") + panel.setAgents(agents("Saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == confirmedToolbar, "Confirmed choices remain authoritative") + + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == claudeToolbar) + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == confirmedToolbar, "Prepared connections do not reload on every switch") + #expect(transport.connections.count == 2) + + codex.selectSession("history-session") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "History waits for its own settings") + codex.receive(try json(["kind": "sessionLoaded", "sessionId": "history-session", + "token": transport.connections.last?.commands.last?["token"] as Any, + "configOptions": options(model: "History model", current: "default")])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) != loadingToolbar) + #expect(codex.selectedConversation?.configOptions.first?.currentLabel == "History model") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func failedOrUnsupportedSettingsStopLoadingAndKeepTheConfiguredFallback() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + func agents(_ model: String) -> [AgentOption] { + [.init(id: "codex-acp", name: "Codex", modelName: model)] + } + panel.setAgents(agents("Saved model")) + do { + let codex = panel.connection(for: "codex-acp") + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + + codex.reportConnectionFailure("Missing API key") + host.layoutSubtreeIfNeeded() + let fallbackToolbar = try toolbarPixels(in: host) + #expect(fallbackToolbar != loadingToolbar, "Connection failures must not keep showing loading") + panel.setAgents(agents("Another saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) != fallbackToolbar, "Failed setup still shows the configured model") + panel.setAgents(agents("Saved model")) + + try connect(codex, agentID: "codex-acp") + codex.receive(try json(["kind": "ready", "agentName": "Codex", "canLoadSessions": true])) + host.layoutSubtreeIfNeeded() + codex.prepareConversation() + codex.receive(try json(["kind": "requestFailed", "message": "Session failed", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "Creation failures stop waiting") + #expect(codex.errorMessage == "Session failed") + + codex.prepareConversation() + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "no-settings", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "An adapter without config options is ready, not loading") + #expect(codex.selectedConversation?.isAttached == true) + #expect(codex.selectedConversation?.configOptions.isEmpty == true) + + codex.selectSession("history-session") + codex.receive(try json(["kind": "requestFailed", "message": "History failed", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "History failures stop waiting") + #expect(codex.selectedConversation?.errorMessage == "History failed") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func selectedAgentAndConfirmedModelRefreshWithoutReplacingTheRootView() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + panel.setAgents([.init(id: "codex-acp", name: "Codex"), .init(id: "claude-acp", name: "Claude")]) + do { + let codex = try prepare(panel, transport: transport, agentID: "codex-acp", model: "Codex model") + let claude = try prepare(panel, transport: transport, agentID: "claude-acp", model: "Claude model") + let host = NSHostingView(rootView: AgentConfiguredConversationView( + feature: panel, setupError: nil, onSelectAgent: panel.selectAgent, + onConnect: {}, onOpenSettings: {}, onCopySessionID: { _ in }, onOpenFile: { _ in } + )) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.layoutSubtreeIfNeeded() + let codexToolbar = try toolbarPixels(in: host) + + // Only the module selection changes: no AppModel notification or + // rootView replacement may be needed to render the new connection. + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + let claudeToolbar = try toolbarPixels(in: host) + #expect(claudeToolbar != codexToolbar, "The native toolbar must render Claude and its model") + #expect(panel.selectedConnection === claude) + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Claude model") + + claude.setConfigOption("model", value: "alternate") + let command = try #require(transport.connections[1].commands.last) + #expect(command["kind"] as? String == "setConfigOption") + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Claude model") + claude.receive(try json(["kind": "sessionConfigured", "sessionId": "claude-acp", + "token": command["token"] as Any, + "configOptions": options(model: "Claude model", current: "alternate")])) + host.layoutSubtreeIfNeeded() + let alternateToolbar = try toolbarPixels(in: host) + #expect(alternateToolbar != claudeToolbar, "Confirmed model changes must render in the native toolbar") + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Alternate model") + + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + #expect(panel.selectedConnection === codex) + #expect(try toolbarPixels(in: host) == codexToolbar) + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == alternateToolbar) + #expect(transport.connections.count == 2, "Switching reuses the project connections") + + panel.setAgents([.init(id: "codex-acp", name: "Codex")]) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == codexToolbar) + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + private func prepare(_ panel: AgentConversationFeatureModel, transport: SelectionTransport, + agentID: String, model: String) throws -> AgentConnectionModel { + let connection = panel.connection(for: agentID) + try connect(connection, agentID: agentID) + connection.receive(try json(["kind": "ready", "agentName": agentID, "canLoadSessions": true])) + connection.prepareConversation() + let native = try #require(transport.connections.last) + connection.receive(try json(["kind": "sessionCreated", "sessionId": agentID, + "token": native.commands.last?["token"] as Any, + "configOptions": options(model: model, current: "default")])) + return connection + } + + private func connect(_ connection: AgentConnectionModel, agentID: String) throws { + try connection.connect(configuration: .init( + agentID: agentID, command: "", arguments: [], workspaceURL: URL(fileURLWithPath: "/example/project"), + dataDirectory: URL(fileURLWithPath: "/example/agents"), providerProtocol: "responses", + providerEndpoint: "https://gateway.example.com/v1", apiKey: "test-key", providerName: "Example", + model: "", allowsInsecureHTTP: false + )) + } + + private func hostingView(_ panel: AgentConversationFeatureModel) -> NSHostingView { + NSHostingView(rootView: AgentConfiguredConversationView( + feature: panel, setupError: nil, onSelectAgent: panel.selectAgent, + onConnect: {}, onOpenSettings: {}, onCopySessionID: { _ in }, onOpenFile: { _ in } + )) + } + + private func hiddenWindow(_ host: NSView) -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + return window + } + + private func options(model: String, current: String) -> [[String: Any]] { + [["id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": current, + "options": [["value": "default", "name": model], ["value": "alternate", "name": "Alternate model"]]]] + } + + private func json(_ value: [String: Any]) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self) + } + + private func toolbarPixels(in view: NSView) throws -> Data { + // Capture only the static bottom toolbar; text-field caret blinking and + // the transcript are outside the comparison. No image files are written. + let region = NSRect(x: 16, y: view.isFlipped ? view.bounds.height - 44 : 8, + width: view.bounds.width - 32, height: 32) + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: region)) + view.cacheDisplay(in: region, to: bitmap) + let bytes = try #require(bitmap.bitmapData) + return Data(bytes: bytes, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } +} + +@MainActor +private final class SelectionTransport: AgentConversationTransport { + var connections: [SelectionConnection] = [] + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + let connection = SelectionConnection() + connections.append(connection) + return connection + } +} + +@MainActor +private final class SelectionConnection: AgentConnection { + var commands: [[String: Any]] = [] + var closeCount = 0 + func send(commandJSON: String) throws { + commands.append(try #require(JSONSerialization.jsonObject(with: Data(commandJSON.utf8)) as? [String: Any])) + } + func close() async { closeCount += 1 } +} diff --git a/rust/lithe-agent-host/src/catalog.rs b/rust/lithe-agent-host/src/catalog.rs index 73418954b..18fe121b8 100644 --- a/rust/lithe-agent-host/src/catalog.rs +++ b/rust/lithe-agent-host/src/catalog.rs @@ -1,7 +1,7 @@ //! Agents Lithe can install and launch, and how each receives its settings. //! -//! Every adapter signs in through the ACP `gateway` method, so the user's API -//! key travels over stdio in the header of the agent's provider protocol. +//! API keys travel over ACP stdio. Codex uses `gateway` authentication; Claude +//! uses its public session options to avoid gateway placeholder credentials. //! //! Entries mirror the official ACP registry //! (`cdn.agentclientprotocol.com/registry/v1/latest/registry.json`): the same diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index aa545fbbc..afc521e0b 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -12,6 +12,7 @@ pub mod environment; pub mod install; mod prompt; mod session_defaults; +mod session_routing; mod subscription; pub use catalog::{ModelDelivery, ProviderProtocol}; @@ -52,7 +53,7 @@ const MAX_SESSION_LIST_PAGES: usize = 50; const STDERR_TAIL_BYTES: usize = 16 * 1024; const STDERR_TAIL_LINES: usize = 20; /// Auth method id and `_meta` key of the ACP custom model gateway extension. -/// API-key mode uses this method and never falls back to account login. +/// Responses API-key mode uses this method and never falls back to account login. const GATEWAY_AUTH_METHOD: &str = "gateway"; /// Launch configuration supplied by the owning desktop product. @@ -150,13 +151,15 @@ impl std::fmt::Debug for ProviderCredentials { } } -/// Gateway sign-in sent in ACP `authenticate`. +/// API-key routing sent over ACP stdio, using each adapter's supported interface. #[derive(Clone)] struct GatewaySignIn { + protocol: ProviderProtocol, base_url: String, /// Authentication headers in the dialect of the provider's protocol. headers: Vec<(String, String)>, provider_name: Option, + model: Option, } /// A validated launch: what to run and how the key reaches the agent. @@ -164,7 +167,7 @@ struct ResolvedLaunch { command: PathBuf, args: Vec, cwd: PathBuf, - /// Extra environment, used only by adapters that accept no other key input. + /// Non-secret CLI paths and model defaults for the native adapters. env: Vec<(String, String)>, gateway: Option, /// Key to redact from diagnostics. @@ -205,8 +208,8 @@ fn resolve_with( if !launch.cwd.is_absolute() { return Err("The workspace path must be absolute".into()); } - // Every adapter signs in through the ACP `gateway` method; the key travels - // over stdio in the header its protocol expects. + // Credentials travel over ACP stdio. Claude uses its public session options + // because its gateway mode adds a conflicting placeholder Bearer token. let gateway = |provider: &ProviderCredentials| -> Result { let (base_url, header) = match provider.protocol { ProviderProtocol::AnthropicMessages => ( @@ -222,9 +225,11 @@ fn resolve_with( ), }; Ok(GatewaySignIn { + protocol: provider.protocol, base_url, headers: vec![header], provider_name: provider.name.clone(), + model: provider.model.clone(), }) }; let Some(agent_id) = launch.agent_id else { @@ -829,6 +834,7 @@ where OB: futures::io::AsyncWrite + Send + 'static, IB: futures::io::AsyncRead + Send + 'static, { + let session_meta = session_routing::metadata(gateway.as_ref())?; let (auth_tx, mut auth_rx) = tokio::sync::watch::channel(None::); let turns: RunningTurns = Arc::new(Mutex::new(HashMap::new())); let updates = emit.clone(); @@ -945,7 +951,7 @@ where .await .map_err(|_| internal("The Agent did not finish initialization in time"))??; // API-key routing is explicit and never falls back to account login. - if let Some(gateway) = &gateway { + if let Some(gateway) = gateway.as_ref().filter(|route| route.protocol != ProviderProtocol::AnthropicMessages) { if !initialized .auth_methods .iter() @@ -1046,10 +1052,13 @@ where let connection = connection.clone(); let emit = emit.clone(); let cwd = cwd.clone(); + let session_meta = session_meta.clone(); tasks.spawn(async move { + let mut request = agent_client_protocol::schema::v1::NewSessionRequest::new(cwd); + request.meta = session_meta; let result = request_with_timeout( SESSION_REQUEST_TIMEOUT, - session_defaults::new_session(&connection, cwd), + session_defaults::new_session(&connection, request), ) .await; emit(match result { @@ -1066,11 +1075,14 @@ where let connection = connection.clone(); let emit = emit.clone(); let cwd = cwd.clone(); + let session_meta = session_meta.clone(); tasks.spawn(async move { + let mut request = LoadSessionRequest::new(session_id.clone(), cwd); + request.meta = session_meta; let result = request_with_timeout( LOAD_SESSION_TIMEOUT, connection - .send_request(LoadSessionRequest::new(session_id.clone(), cwd)) + .send_request(request) .block_task(), ) .await; diff --git a/rust/lithe-agent-host/src/session_defaults.rs b/rust/lithe-agent-host/src/session_defaults.rs index e2b39c8b4..b287150a4 100644 --- a/rust/lithe-agent-host/src/session_defaults.rs +++ b/rust/lithe-agent-host/src/session_defaults.rs @@ -4,8 +4,6 @@ //! Its legacy model state excludes that choice, and its versioned AIR extension //! supplies the actual recommended model. Neither fact is guessed from a name. -use std::path::PathBuf; - use agent_client_protocol::schema::v1::{ NewSessionRequest, NewSessionResponse, SessionConfigKind, SessionConfigOptionCategory, SessionConfigSelectOptions, SetSessionConfigOptionRequest, @@ -103,12 +101,10 @@ fn replacement(response: &CatalogSessionResponse) -> Option<(String, String)> { /// request deadline covers both requests; history and global CLI files stay intact. pub(crate) async fn new_session( connection: &ConnectionTo, - cwd: PathBuf, + request: NewSessionRequest, ) -> Result { let mut response = connection - .send_request(CatalogSessionRequest { - request: NewSessionRequest::new(cwd), - }) + .send_request(CatalogSessionRequest { request }) .block_task() .await?; if let Some((id, model)) = replacement(&response) { diff --git a/rust/lithe-agent-host/src/session_routing.rs b/rust/lithe-agent-host/src/session_routing.rs new file mode 100644 index 000000000..35e4756a2 --- /dev/null +++ b/rust/lithe-agent-host/src/session_routing.rs @@ -0,0 +1,57 @@ +//! Claude API-key routing through the upstream adapter's public session options. +//! +//! Credentials reach the adapter over ACP stdio, never through host launch +//! arguments, environment, or files. The adapter and Claude SDK own their native +//! credential conversion; Lithe does not patch installed third-party packages. + +use serde_json::{json, Map, Value}; + +use crate::{GatewaySignIn, ProviderProtocol}; + +/// Apply the same explicit route when creating or restoring a Claude session. +pub(crate) fn metadata( + route: Option<&GatewaySignIn>, +) -> Result>, String> { + let Some(route) = route.filter(|route| route.protocol == ProviderProtocol::AnthropicMessages) + else { + return Ok(None); + }; + let key = route + .headers + .iter() + .find(|(name, value)| name == "x-api-key" && !value.trim().is_empty()) + .ok_or("The Claude API key route is missing its credential")?; + // Gateway mode injects `Bearer acp-proxy`, even with a real x-api-key. Native + // API-key options avoid that token and retain the complete upstream agent. + let env = json!({ + "ANTHROPIC_BASE_URL": route.base_url, + "ANTHROPIC_API_KEY": key.1, + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_CUSTOM_HEADERS": "", + "CLAUDE_CODE_OAUTH_TOKEN": "", + "CLAUDE_CODE_USE_BEDROCK": "0", + "CLAUDE_CODE_USE_VERTEX": "0", + "CLAUDE_CODE_USE_FOUNDRY": "0", + "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "", + "ANTHROPIC_VERTEX_BASE_URL": "" + }); + let mut options = json!({ + "env": env, + // Claude loads settings after subprocess env. Both public SDK tiers + // must carry the selected route so user/project settings cannot replace it. + "settings": {"apiKeyHelper": "", "env": env} + }); + if let Some(model) = route + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + options["model"] = json!(model); + } + Ok(Some(Map::from_iter([( + "claudeCode".into(), + json!({"options": options}), + )]))) +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 9a7f98750..12839266a 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -26,12 +26,27 @@ fn provider() -> ProviderCredentials { fn gateway() -> Option { Some(GatewaySignIn { + protocol: ProviderProtocol::Responses, base_url: "https://gateway.example.com/v1".into(), headers: vec![("Authorization".into(), "Bearer test-key-123".into())], provider_name: Some("Example".into()), + model: None, }) } +fn claude_route() -> GatewaySignIn { + let provider: ProviderCredentials = + serde_json::from_value(fixture()["upstream"]["claudeSessionRouting"]["provider"].clone()) + .expect("Claude provider fixture"); + GatewaySignIn { + protocol: provider.protocol, + base_url: provider.anthropic_base_url().expect("valid endpoint"), + headers: vec![("x-api-key".into(), provider.api_key)], + provider_name: provider.name, + model: provider.model, + } +} + fn custom_launch(command: &str, provider: ProviderCredentials) -> AgentLaunch { AgentLaunch { agent_id: None, @@ -116,6 +131,10 @@ impl Harness { } fn start_with(subscription: bool) -> Self { + Self::start_with_route(subscription, if subscription { None } else { gateway() }) + } + + fn start_with_route(subscription: bool, route: Option) -> Self { let (client, peer) = tokio::io::duplex(64 * 1024); let (client_reader, client_writer) = tokio::io::split(client); let (peer_reader, peer_writer) = tokio::io::split(peer); @@ -125,7 +144,7 @@ impl Harness { let connection = tokio::spawn(run_connection( ByteStreams::new(client_writer.compat_write(), client_reader.compat()), std::env::temp_dir(), - if subscription { None } else { gateway() }, + route, subscription.then(|| PathBuf::from("/fixture/codex")), receiver, permissions.clone(), @@ -446,6 +465,78 @@ async fn handshake_signs_in_through_the_gateway_with_the_user_key() { assert_eq!(harness.stop().await, Ok(())); } +#[test] +fn claude_session_routing_clears_conflicting_credentials_and_preserves_sdk_options() { + let mut route = claude_route(); + let meta = session_routing::metadata(Some(&route)).unwrap().unwrap(); + assert_eq!( + json!(meta), + fixture()["upstream"]["claudeSessionRouting"]["meta"] + ); + let options = &meta["claudeCode"]["options"]; + assert!(options.get("permissionMode").is_none()); + assert!(options.get("tools").is_none()); + // No explicit model keeps the upstream default rather than inventing one. + route.model = Some(" ".into()); + let meta = session_routing::metadata(Some(&route)).unwrap().unwrap(); + assert!(meta["claudeCode"]["options"].get("model").is_none()); + assert!(session_routing::metadata(gateway().as_ref()) + .unwrap() + .is_none()); + assert!(session_routing::metadata(None).unwrap().is_none()); +} + +#[test] +fn claude_session_routing_rejects_missing_credentials_without_native_account_fallback() { + let mut route = claude_route(); + route.headers.clear(); + assert!(session_routing::metadata(Some(&route)).is_err()); + route.headers.push(("x-api-key".into(), " ".into())); + assert!(session_routing::metadata(Some(&route)).is_err()); +} + +#[tokio::test(flavor = "current_thread")] +async fn claude_new_and_restored_sessions_receive_credentials_over_stdio_without_gateway_login() { + let mut harness = Harness::start_with_route(false, Some(claude_route())); + let initialize = harness.agent.expect("initialize").await; + harness + .agent + .reply( + &initialize, + json!({ + "protocolVersion": 1, + "agentInfo": {"name": "claude-acp", "version": "fixture"}, + "agentCapabilities": {"loadSession": true}, + "authMethods": [] + }), + ) + .await; + assert!(matches!(harness.event().await, AgentEvent::Ready { .. })); + let expected = fixture()["upstream"]["claudeSessionRouting"]["meta"].clone(); + harness.send(json!({"kind": "newSession", "token": "new-claude"})); + // The very next request must be session/new, never gateway authenticate. + let created = harness.agent.expect("session/new").await; + assert_eq!(created["params"]["_meta"], expected); + harness + .agent + .reply(&created, json!({"sessionId": "claude-1"})) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionCreated { .. } + )); + harness.send(json!({"kind": "loadSession", "token": "load-claude", "sessionId": "claude-1"})); + let loaded = harness.agent.expect("session/load").await; + assert_eq!(loaded["params"]["_meta"], expected); + assert_eq!(loaded["params"]["sessionId"], "claude-1"); + harness.agent.reply(&loaded, json!({})).await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionLoaded { .. } + )); + assert_eq!(harness.stop().await, Ok(())); +} + #[tokio::test(flavor = "current_thread")] async fn agent_without_gateway_sign_in_is_rejected_without_account_login() { let mut harness = Harness::start(); @@ -1139,8 +1230,8 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { }, )) .unwrap(); - // Claude signs in through the gateway too, in the Anthropic dialect; the - // key never enters the adapter's environment. + // Claude uses ACP session options; the key never enters the adapter's + // launch environment or arguments. let sign_in = claude.gateway.expect("gateway sign-in"); assert_eq!(sign_in.base_url, "https://api.example"); assert_eq!( diff --git a/rust/lithe-agent-host/tests/real_agent.rs b/rust/lithe-agent-host/tests/real_agent.rs index ed00d7ac4..6596903fb 100644 --- a/rust/lithe-agent-host/tests/real_agent.rs +++ b/rust/lithe-agent-host/tests/real_agent.rs @@ -11,9 +11,10 @@ //! //! `LITHE_ACP_E2E_MODEL` optionally selects the provider model and //! `LITHE_ACP_E2E_ARGS` holds newline-separated arguments. With -//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the Codex adapter is +//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the catalog adapter is //! installed there with the user's npm (if missing) and launched as a catalog -//! agent, covering the one-click install path. +//! agent, covering the one-click install path. `LITHE_ACP_E2E_AGENT_ID` selects +//! the adapter and its provider protocol (defaults to `codex-acp`). use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; @@ -129,15 +130,20 @@ fn required(name: &str) -> String { fn open(workspace: &std::path::Path) -> Session { let (sender, events) = mpsc::channel(); let data_directory = std::env::var_os("LITHE_ACP_E2E_DATA_DIR").map(std::path::PathBuf::from); + let agent_id = std::env::var("LITHE_ACP_E2E_AGENT_ID").unwrap_or_else(|_| "codex-acp".into()); + let agent = lithe_agent_host::catalog::find(&agent_id).expect("catalog E2E agent"); + let protocol = if data_directory.is_some() { + agent.protocol + } else { + ProviderProtocol::Responses + }; if let Some(data) = &data_directory { - if install::installed_version(data, lithe_agent_host::catalog::find("codex-acp").unwrap()) - .is_none() - { - install::install(data, "codex-acp", &|| false).expect("adapter installs with npm"); + if install::installed_version(data, agent).is_none() { + install::install(data, &agent_id, &|| false).expect("adapter installs with npm"); } } let launch = AgentLaunch { - agent_id: data_directory.as_ref().map(|_| "codex-acp".to_owned()), + agent_id: data_directory.as_ref().map(|_| agent_id), command: std::env::var("LITHE_ACP_E2E_COMMAND").ok(), args: std::env::var("LITHE_ACP_E2E_ARGS") .map(|args| { @@ -151,7 +157,7 @@ fn open(workspace: &std::path::Path) -> Session { data_directory, authentication: lithe_agent_host::AgentAuthentication::ApiKey, provider: Some(ProviderCredentials { - protocol: ProviderProtocol::Responses, + protocol, base_url: required("LITHE_ACP_E2E_BASE_URL"), api_key: required("LITHE_ACP_E2E_API_KEY"), name: Some("Lithe end-to-end test".into()), @@ -289,7 +295,7 @@ fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { ) .unwrap(); std::fs::write(workspace.0.join("sum.test.cjs"), - "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); + "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nassert.equal(sum(-2, 4), 2);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); let session = open(&workspace.0); let id = session.new_session("workflow"); session.send(AgentCommand::Prompt { @@ -353,7 +359,7 @@ fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { } #[test] -#[ignore = "requires a real Codex ACP agent and API key configuration"] +#[ignore = "requires a real catalog ACP agent and API key configuration"] fn real_agent_configuration_options_are_selectable() { let workspace = TemporaryProject::new(); let session = open(&workspace.0); @@ -377,23 +383,29 @@ fn real_agent_configuration_options_are_selectable() { _ => None, }, ); - let options = options.as_array().expect("Codex reports config options"); + let mut options = options + .as_array() + .expect("Agent reports config options") + .clone(); for category in ["model", "mode", "thought_level"] { - let option = options - .iter() - .find(|option| option["category"] == category) - .unwrap_or_else(|| panic!("missing category {category}")); + let option = options.iter().find(|option| option["category"] == category); + // Thinking choices are optional upstream capabilities, not a product list. + if category == "thought_level" && option.is_none() { + continue; + } + let option = option.unwrap_or_else(|| panic!("missing category {category}")); let config_id = option["id"].as_str().unwrap(); let current = option["currentValue"].as_str().unwrap(); - // Change reasoning to another supported value; model and permission - // round trips preserve the user's effective defaults without a prompt. - let value = if category == "thought_level" { + // Switch models as well as reasoning; a same-value acknowledgement + // would miss the user-visible failure. Keep the permission mode intact. + let value = if category != "mode" { option["options"] .as_array() .unwrap() .iter() .filter_map(|choice| choice["value"].as_str()) - .find(|value| *value != current) + .filter(|value| *value != current) + .next_back() .unwrap_or(current) } else { current @@ -424,6 +436,25 @@ fn real_agent_configuration_options_are_selectable() { .find(|o| o["id"] == config_id) .unwrap(); assert_eq!(actual["currentValue"], value); + println!("Configuration {category}: {current} -> {value}"); + if category == "model" { + assert_ne!( + value, current, + "the integration must switch to another model" + ); + } + // Changing the model can remove thinking options (e.g. Claude Haiku). + // Subsequent choices must come from the confirmed current catalog. + options = updated.as_array().unwrap().clone(); } + let (reason, reply) = session.prompt( + &id, + "Do not use any tools. Reply with only LITHE_MODEL_SWITCH_OK.", + ); + assert_eq!(reason, "end_turn"); + assert!( + reply.contains("LITHE_MODEL_SWITCH_OK"), + "no reply after switching: {reply:?}" + ); session.handle.close(); } diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 31fa664d0..7647d1c69 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -35,7 +35,7 @@ Missing usage and unmeasured replayed history remain unknown. See the | Git | workspace commit plans, dependency ordering, guarded steps and partial-success retry, changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | [AI commit messages](ai-commit.md) | provider configuration parsing, commit rules, bounded diff evidence, request plans, and response text | local configuration discovery, credentials, HTTP, cancellation, and draft UI | -| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery, explicit Codex subscription authentication via the installed CLI, bounded official App Server quota reads with account checks, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI and compact subscription quota presentation, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | +| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery over ACP stdio (Codex gateway authentication; Claude public session options), explicit Codex subscription authentication via the installed CLI, bounded official App Server quota reads with account checks, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI and compact subscription quota presentation, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | | [IDE capability API / MCP](ide-api/v1.md) | tool catalog, permission/argument validation, stable execution IDs, bounded output cursors and errors | explicit project authorization, live environment/Maven/Run application actions, helper discovery and writable connection storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 1297342b1..73f24f205 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -62,9 +62,26 @@ per workspace and agent; one connection carries many conversation sessions. "baseUrl": string, "apiKey": string, "name"?: string, "model"?: string, "allowInsecureHttp"?: bool } }`. With `agentId`, the host starts the adapter installed by `agent.install` under `dataDirectory`. Omitted `authentication` -defaults to `apiKey`, which requires `provider` and signs in through -the ACP `gateway` method over stdio: Responses providers send -`Authorization: Bearer ` and Anthropic providers send `x-api-key`. The +defaults to `apiKey`, which requires `provider`. Credentials travel over ACP +stdio, never in Lithe's adapter launch arguments, environment, or files. +This guarantee covers Lithe launching the ACP adapter, not the entire child +process tree. The pinned Claude SDK serializes object-valued `options.settings` +into the Claude Code CLI's `--settings` argument. Consequently, +`settings.env.ANTHROPIC_API_KEY` is visible in that child process's arguments; +SDK debug output may also record those arguments. This route does not eliminate +that upstream exposure. Moving the key solely to `options.env` requires separate +validation of precedence against user/project settings to preserve the selected +provider's credentials. +Responses providers use `gateway` authentication with `Authorization: Bearer +`. Claude uses the pinned adapter's public `_meta.claudeCode.options` on +both `session/new` and `session/load`: its native API-key route is supplied in +SDK `env` and programmatic `settings.env`, with `apiKeyHelper` and competing +Bearer/OAuth/custom-header/cloud routes cleared. This avoids the adapter's +gateway placeholder Bearer token overriding a valid `x-api-key`; it does not +patch the adapter or change native CLI configuration files. Missing Claude +credentials fail before session creation instead of falling back to an account. +The route fixture is `shared/fixtures/agent/acp-events-v1.json`'s +`upstream.claudeSessionRouting`. The user's own CLI is passed as `CODEX_PATH` or `CLAUDE_CODE_EXECUTABLE`, and a non-empty `model` as `CODEX_CONFIG` or `ANTHROPIC_MODEL`. Without `agentId`, `command` runs a user-provided agent that must support gateway sign-in with a Responses diff --git a/shared/fixtures/agent/acp-events-v1.json b/shared/fixtures/agent/acp-events-v1.json index acd9a305c..7cb57fbf6 100644 --- a/shared/fixtures/agent/acp-events-v1.json +++ b/shared/fixtures/agent/acp-events-v1.json @@ -101,6 +101,21 @@ "stopped": { "kind": "stopped", "message": "The Agent connection closed unexpectedly" } }, "upstream": { + "claudeSessionRouting": { + "provider": {"protocol": "anthropicMessages", "baseUrl": "https://gateway.example.com/v1/messages", "apiKey": "test-key-123", "name": "Example Claude", "model": "model-configured"}, + "meta": { + "claudeCode": { + "options": { + "model": "model-configured", + "env": {"ANTHROPIC_BASE_URL": "https://gateway.example.com", "ANTHROPIC_API_KEY": "test-key-123", "ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_CUSTOM_HEADERS": "", "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_USE_BEDROCK": "0", "CLAUDE_CODE_USE_VERTEX": "0", "CLAUDE_CODE_USE_FOUNDRY": "0", "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", "ANTHROPIC_BEDROCK_BASE_URL": "", "ANTHROPIC_VERTEX_BASE_URL": ""}, + "settings": { + "apiKeyHelper": "", + "env": {"ANTHROPIC_BASE_URL": "https://gateway.example.com", "ANTHROPIC_API_KEY": "test-key-123", "ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_CUSTOM_HEADERS": "", "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_USE_BEDROCK": "0", "CLAUDE_CODE_USE_VERTEX": "0", "CLAUDE_CODE_USE_FOUNDRY": "0", "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", "ANTHROPIC_BEDROCK_BASE_URL": "", "ANTHROPIC_VERTEX_BASE_URL": ""} + } + } + } + } + }, "promptResponseWithUsage": { "stopReason": "end_turn", "usage": { "totalTokens": 25000, "inputTokens": 18000, "outputTokens": 2000, "thoughtTokens": 1000, "cachedReadTokens": 3000, "cachedWriteTokens": 1000 } }, "staleModelSession": { "sessionId": "session-repaired", diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 9fa0f707c..a3102611d 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -245,7 +245,7 @@ "id": "agent-acp-conversation", "area": "AI", "group": "Agent 对话", - "capability": "可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", + "capability": "可选 ACP Agent 对话:API Key 鉴权、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格紧凑的可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", "macos": { "evidence": [ "macos/Sources/LitheAgentConversationModule", @@ -266,7 +266,87 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。" + "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;搜索及选中标记正常。" + }, + { + "id": "agent-toolbar-brand-marks", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 品牌标志:Claude 橙色星芒、Codex 随主题变化的灰色结形图标,底栏切换入口、Agent 下拉菜单条目与模型入口保持清晰一致", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Tests/LitheTests/AgentBrandIconResourceTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "shared/contracts/application-boundary.md" + ] + }, + "owner": "Agent", + "verification": "macOS:原生真实输入框的 Menu 像素测试通过四种品牌/深浅主题组合,共用模板图像保持不变;87 项相关 Agent 测试通过。打包预览应用的深色界面实际展开 Agent 与模型菜单,确认底栏图标居中、点击区域一致、模型小标志同步;Agent 菜单中 Claude 与 Codex 都带对应品牌图标,Claude 选中与切到 Codex 后的勾选均与品牌图标并列正确显示,模型与权限跟随所选 Agent。菜单验证前后 bundle 的 990 个文件清单及 SHA-256 一致,旧版应用与辅助进程已退出,仅保留用户新版主应用及项目正常运行的进程。Windows 对话 UI 和验收待完成。" + }, + { + "id": "agent-connection-model-switching", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 与模型切换:所选连接即时同步,准备期间明确等待会话设置,确认后显示模型,来回切换保留各自会话", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentConversationView.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Tests/LitheTests/AgentConversationSelectionTests.swift", + "rust/lithe-agent-host/tests/real_agent.rs" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "shared/contracts/application-boundary.md" + ] + }, + "owner": "Agent", + "verification": "macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;搜索名称/ID与空结果,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。Windows 对话 UI 与验收待完成。", + "notes": "2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。Windows 对话 UI 与验收仍待完成。 同日 review 回归补齐:Claude/Codex 关闭最后一个标签后立即准备替代会话,忽略旧确认并在新确认后正常发送;两组参数化场景通过,测试窗口与模拟连接均关闭。" + }, + { + "id": "agent-claude-api-key-authentication", + "area": "AI", + "group": "Agent 对话", + "capability": "Claude API Key 鉴权:通过公开会话参数使用所选供应商,清除冲突占位 Bearer,新建与历史恢复保持地址、密钥和模型", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "rust/lithe-agent-host/src/session_routing.rs", + "rust/lithe-agent-host/src/tests.rs", + "rust/lithe-agent-host/tests/real_agent.rs", + "shared/fixtures/agent/acp-events-v1.json", + ".agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/session_routing.rs", + "shared/contracts/rust-core-api.md" + ] + }, + "owner": "Agent", + "verification": "macOS:使用支持 Anthropic Messages 的供应商,在 Claude 新会话中验证正常和流式回复、读取临时文件、修改错误实现、执行两项 Node 断言并追问;验证停止、重连、历史加载后仍使用原供应商并记住上下文。用本地假服务观察同版原生 CLI 与固定 ACP 适配器请求仅含有效 x-api-key、没有占位 Bearer;新建与恢复共用 fixture 配置,环境与程序化 settings 都清除旧鉴权和路由;关闭项目后检查子进程退出与 bundle 哈希不变。Windows 共享 Host 已具备路由修复,对话 UI 与真实 Windows 验收仍待完成。" }, { "id": "agent-thinking-level-labels", From 513ff63f23b0e1467e6759feb4165bf164bb292d Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:18:42 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(agent):=20=E6=98=BE=E7=A4=BA=20Agent?= =?UTF-8?q?=20=E6=80=9D=E8=80=83=E3=80=81=E8=AE=A1=E5=88=92=E4=B8=8E?= =?UTF-8?q?=E6=96=9C=E6=9D=A0=E5=91=BD=E4=BB=A4=E8=A1=A5=E5=85=A8=20(#1016?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): 显示 Agent 思考、计划与斜杠命令补全 共享 Host 原本就透传 ACP SessionUpdate,但 macOS 只处理消息、工具、 标题、配置和用量,其余更新被丢弃。本次补齐四类标准更新: - agent_thought_chunk:独立的可折叠思考块,流式输出时展开、回复或 工具到达后收起;缓冲按角色切分,思考与回复不再混在一起;导出 Markdown 以引用块保留,历史消息数不计思考 - plan:计划栏固定在活动统计栏上方,显示进度与当前步骤;每次整体 替换,空计划清除 - available_commands_update:输入 / 弹出 Agent 上报的命令补全,前缀 优先;Codex 的 $Skill 补全为 $name(codex-acp 不解析 /$name), 输入 $ 只列 Skill;macOS 14+ 支持方向键与 Tab - current_mode_update:Agent 自行切换模式时同步权限模式选择器 另外把模型层的兜底文案、stopReason 提示和工具证据标题改为本地化, 补齐 zh-Hans 翻译。 fixture 新增四类事件样例并加入 Rust 往返测试;更新功能矩阵与 shared-acp Agent Note。真实 Agent 已在协议层确认 Codex/Claude 会上报 命令列表、Claude 会上报思考,界面层验证仍待完成。 * fix(agent): 修复命令补全布局和思考搜索展开 为输入区保留可编辑行,空间不足时在对话布局中显示可点击的命令浮层。搜索临时展开匹配思考块,清空后恢复手动选择。补充三项回归测试并同步功能矩阵与架构笔记。 * fix(ci): release Windows Bun install process trees before retry --------- Co-authored-by: Yao Jingxi <23722032@bjtu.edu.cn> --- ...026-09-25-shared-acp-agent-conversation.md | 14 + ...13-ci-build-cache-and-artifact-strategy.md | 27 ++ .github/workflows/ci-windows.yml | 4 + docs/ci-builds.md | 7 + docs/development/platform-parity-matrix.csv | 4 + docs/development/platform-parity-matrix.md | 12 +- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 21 ++ .../Views/Agent/AgentCommandCompletion.swift | 54 ++++ .../Views/Agent/AgentComposerContent.swift | 50 ++++ .../Views/Agent/AgentComposerDraftArea.swift | 81 ++++++ .../Lithe/Views/Agent/AgentComposerView.swift | 104 +++++--- .../Views/Agent/AgentConversationView.swift | 3 +- .../Views/Agent/AgentFileReferenceList.swift | 2 +- .../Lithe/Views/Agent/AgentHistoryView.swift | 2 +- .../Agent/AgentSessionGuidanceViews.swift | 241 +++++++++++++++++ .../Views/Agent/AgentTranscriptView.swift | 44 +++- .../Views/Agent/AgentTurnStatisticsView.swift | 8 +- .../Application/AgentConnectionModel.swift | 49 +++- .../Application/AgentConversationState.swift | 7 +- .../AgentHistoryFeatureModel.swift | 7 +- .../AgentSessionConfiguration.swift | 9 +- .../Application/AgentSessionGuidance.swift | 101 ++++++++ .../AgentConversationFeatureModelTests.swift | 152 +++++++++++ .../AgentConversationPresentationTests.swift | 245 ++++++++++++++++++ .../AgentThoughtTranscriptTests.swift | 195 ++++++++++++++ rust/lithe-agent-host/src/tests.rs | 4 + scripts/classify-ci-changes.sh | 2 +- .../install-windows-frontend-dependencies.ps1 | 38 ++- scripts/invoke-windows-bun-install.ps1 | 94 +++++++ scripts/reuse-worktree-resources.mjs | 2 + scripts/test-reuse-worktree-resources.mjs | 8 + scripts/windows-frontend-install.test.ts | 161 ++++++++++++ scripts/worktree-resources.json | 7 + shared/fixtures/agent/acp-events-v1.json | 20 ++ shared/platform-feature-matrix.json | 108 ++++++++ 36 files changed, 1822 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift create mode 100644 macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift create mode 100644 macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift create mode 100644 macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift create mode 100644 scripts/invoke-windows-bun-install.ps1 create mode 100644 scripts/windows-frontend-install.test.ts diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index de6293e2e..62b2f7df5 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -69,6 +69,19 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 不要这样做:在 Windows React 层重新实现 JSON-RPC 协议;在打开 IDE 时就启动 Agent;取消尚未确认就解锁发送;把取消超时伪装成成功而不提示用户重连;把 API Key 通过环境变量或命令行传给 Agent;替用户下载一份他本机已有的 Agent CLI。 +### 思考、计划、斜杠命令与模式通知 + +共享 Host 一直把 ACP 会话更新(`SessionUpdate`)原样转发,以前 Swift 只消费消息、工具、标题、配置和用量,其余直接丢弃。参考 CC GUI 的可折叠思考块、计划面板与 `/` 命令补全后,macOS 现在消费下面四类标准更新;Host、C ABI 和 fixture 形状都不变,只在 fixture 中补样例并由 Rust 测试确认它们能被官方 SDK 原样往返。 + +- **思考(`agent_thought_chunk`)**:存为独立的 `thought` 角色消息,不并进 Agent 回复。流式缓冲按角色记录,角色切换先刷新上一段,所以“思考 → 回复 → 工具 → 思考”保持原顺序。只有正在流式输出的最后一段思考默认展开,后续回复或工具到达后自动收起,用户手动展开的状态保留;搜索时临时展开匹配的思考,优先于已有的手动折叠选择;搜索期间不允许再次收起命中文字,清空搜索后恢复手动选择,不把搜索结果写回折叠偏好。手动偏好由 `AgentTranscriptView` 在视图局部内存中按会话 ID 与消息 ID 保存,再以绑定传给思考行;搜索先过滤消息,会移除并重建行,因此不能让行内状态成为偏好的所有者。切换会话保留各自选择,关闭会话或移除实际消息时释放对应偏好,销毁对话视图时一并释放;不写入功能模型、共享 Host、历史文件或安装目录,不影响代码签名和 Sparkle delta。`AgentThoughtTranscriptTests` 直接挂载实际对话容器,通过原生鼠标事件和完整渲染快照覆盖搜索移除/重建、命中时临时展开和会话切换。导出 Markdown 用引用块写出思考,便于和回复区分;历史消息数仍只统计用户与 Agent 回复。活跃思考块的标题显示“思考中…”,底部等待行改为“回复中…”并保留本轮计时,避免重复提示;取消时仍显示“正在停止…”。 +- **计划(`plan`)**:ACP 规定每次都发完整计划,所以整体替换,不做合并;空列表清除计划,格式错误的更新忽略,最多保留 100 条。计划条状态只认 `pending`、`in_progress`、`completed`,未知状态的条目丢弃而不是猜测。计划栏固定在活动统计栏上方,折叠时显示进度和当前步骤。`session/load` 回放会自然恢复计划。 +- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。Lithe 不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留一行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 +- **模式通知(`current_mode_update`)**:Agent 自己切换模式(例如退出计划模式)时,只更新 `category == "mode"` 且包含该值的配置选项,不向上游回发设置请求;选项中不存在的模式 ID 忽略,不显示原始 ID。 + +正确做法:新的更新类型先在 fixture 中加入样例,让 Rust 往返测试证明它是合法 ACP,再在平台功能模型中解析。不要这样做:在视图中直接解析原始更新;为了显示命令列表而自己维护一份 Claude 或 Codex 命令表。 + +这些状态只保存在会话内存中,不新增日志、下载、缓存或可复用工作树资源;安装目录和 bundle 仍只读,不影响代码签名和 Sparkle delta。 + ### 每轮耗时与上报 token 发送后用单调时钟(不受系统日期调整影响的计时源)开始测量,包含建会话、加载历史、工具执行和权限等待。界面“思考中”的秒数表示本轮已经过的时间,不声称是模型内部推理时间。只让可见等待行每秒重绘,不每秒发布整个会话。正常完成、请求失败和断连固定耗时;取消仍等上游确认才结束。每轮末尾保留统计,标签切换不会重置,失败发送和历史回放不编造记录。 @@ -145,6 +158,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentThoughtTranscriptTests`:在生产对话容器中通过鼠标展开/收起思考,搜索隐藏行后进入空会话使列表实际销毁,再返回并清空搜索,确认手动选择保留;同时验证命中时保持文字可见、清空后恢复折叠、会话切换独立和关闭后重新加载的默认状态。渲染只等待可观察的原生画面,每次等待最多两秒;比较同一宿主的完整画面,不依赖系统字体快照基线。窗口和功能模型在成功、失败路径都清理。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentConversationSelectionTests`:原生 SwiftUI 宿主不替换根视图,直接改变模块选择,验证 Agent、模型与连接立即同步;配置确认前保留旧值、确认后刷新,来回切换保持各自配置,删除当前 Agent 时正确回退,关闭全部测试窗口和连接。另对 Claude/Codex 显式驱动最后一个标签关闭→唯一新建请求→过期响应→新会话确认→发送,确认连接不变、准备期间不闪现模型、确认后底栏恢复且消息只交给新会话。显式控制切换→连接→ready→创建确认及历史加载的事件顺序,比较原生工具栏,验证准备期间改变本地默认模型不影响显示、过期响应不能解除等待、已确认连接复用、失败和无配置能力不会一直加载。2026-10-01 修复版真实窗口已验证 Codex 初始化加载提示到 Astra/审批控件、Astra→Luna→Astra 模型切换和重新展开的勾选、模型名称搜索与空结果、Claude/Codex 来回切换保留配置,Codex 最小消息收到预期回复。关闭测试项目后确认无 Agent 子进程,990 个 bundle 文件哈希不变;Windows 对话 UI 与运行验收仍待完成。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationFeatureModelTests|AgentConversationPresentationTests'`:注入可推进的单调时钟验证排队、后台会话、取消确认、失败和断连;共享 fixture 校验用量、零值与缺失值,统计行的分组边界与历史回放不伪造。真实 Agent 的 token 口径和连续计时仍需人工验收,不能以合成数据截图代替供应商运行验证。 diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index 23c3ee677..a42a26720 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -79,6 +79,22 @@ PR 的测试合并提交必须在构建摘要中可追溯。被分类器选中 并发缓存主要缩短串行等待和反馈时间,不承诺减少总 runner 分钟;队列等待 和可用 runner 数量属于 CI 基础设施因素,不能与编译优化混为一谈。 +Windows 安装器失败时,Bun 可能已经退出,但并行的生命周期脚本仍在运行, +继续占用依赖目录;直接删除目录会让原本可以重试的下载故障变成文件锁错误。 +每次安装现在由独立 PowerShell worker 拥有:启动 Bun 前把 worker 加入 +Job Object(Windows 用于管理整棵子进程树的对象),设置最后一个句柄关闭时 +终止成员进程。句柄不继承给子进程,由 worker 的进程生命周期持有;worker +正常结束、失败或被取消时,系统释放句柄并终止残留脚本;每次安装还有默认 +300 秒的 worker 内部期限,超时退出码为 124,不能只依赖 CI 总超时。父安装器再清理 +部分缓存和依赖。文件系统释放锁可能稍晚,删除重试有单调计时的 10 秒期限, +超时明确失败,不无限等待。缓存清理后撤销旧 verified 标记,冷安装成功才 +重新生成完整性清单。只重试一次,不把永久安装错误隐藏成成功。 + +正确做法:worker 拥有 Bun 及其脚本,结束后再清理当前工作树生成目录; +不要按进程名结束所有 Node/Bun,因为用户其他工作树或应用可能正在使用它们。 +这些句柄和安装目录属于本次安装,不新增可复用资源;只有经过锁文件、Bun +版本与完整性清单校验的下载缓存可以跨工作树复制,安装包仍只读。 + ## 考虑过的备选方案 ### 在旧 runner 上用 Swiftly 安装独立编译器 @@ -98,6 +114,13 @@ PR 的测试合并提交必须在构建摘要中可追溯。被分类器选中 能减少网络等待,但无法覆盖 Rust Core 和数据库辅助 crate 的主要编译成本, 因此扩展为缓存 Cargo 的中间输出和 build script 结果。 +### 失败后仅限制安装并发或等待固定时间 + +不采用。相同 Bun 版本的本地生命周期探针表明,串行选项也不能保证失败后 +没有残留脚本;固定等待则无法证明进程结束。直接结束全部 Node 还会影响 +其他任务。Windows Job Object 提供系统级所有权和取消清理,代价是每次安装 +多启动一个 PowerShell worker,安装路径的回归测试需要在 Windows 上运行。 + ### 只构建一个 macOS 架构 可以降低 CI 成本,但无法发现另一架构上的编译、链接和打包问题。macOS 产品 @@ -151,6 +174,7 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `./scripts/build-official-plugins.sh --configuration debug --triple x86_64-apple-macosx` - `./scripts/verify-rust-core.sh` - `./scripts/verify-windows-boundaries.sh` +- `node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory . --max-ms 30000 --report .artifacts/test-stability/windows-dependency-install.json -- scripts/windows-frontend-install.test.ts`:Windows 无网络夹具用 IPC 确认子进程占用目录,再让安装失败,验证清理后冷重试成功、永久失败仍报错、成功退出也不留子进程、超时触发本地期限,且不清除有效缓存。 - `gh run download --repo 1lck/Lithe-IDEA --pattern 'Lithe-macos-*'` - `gh workflow run release-preview-windows.yml -f source_branch=` @@ -163,6 +187,9 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `.github/workflows/ci-macos.yml` - `.github/workflows/ci-windows.yml` - `scripts/classify-ci-changes.sh` +- `scripts/install-windows-frontend-dependencies.ps1` +- `scripts/invoke-windows-bun-install.ps1` +- `scripts/windows-frontend-install.test.ts` - `scripts/test-classify-ci-changes.sh` - `scripts/build-macos.sh` - `scripts/build-official-plugins.sh` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 6553e28f5..e98877102 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -123,6 +123,10 @@ jobs: shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory frontend/editor --report .artifacts/test-stability/shared-editor.json + - name: Test Windows dependency install recovery + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory . --max-ms 30000 --report .artifacts/test-stability/windows-dependency-install.json -- scripts/windows-frontend-install.test.ts + - name: Configure isolated dependency caches shell: pwsh run: | diff --git a/docs/ci-builds.md b/docs/ci-builds.md index ce828fe20..77a7fbb6a 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -171,6 +171,13 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 `.swift-version` 和 `.lithe-integrity.json` 校验。 - `.artifacts/bun-cache/`:Bun 下载缓存;按 `bun.lock`、Bun 版本和缓存完整性 清单校验。 + Windows 的每次依赖安装在独立 worker 中执行;worker 在启动 Bun 前加入 + Job Object(Windows 用于管理整棵子进程树的对象),退出时终止残留安装脚本。 + 每次安装默认有 300 秒本地期限,超时终止 worker 及其子进程; + 安装失败后先释放子进程,再清理部分依赖与缓存,并只进行一次冷安装重试; + 文件锁释放有 10 秒本地期限。`node_modules`、两个 workspace 的依赖目录和 + `.artifacts/bun-tmp` 是安装过程的可变状态,不跨 worktree 复制;进程句柄只在 + worker 内存中存活,不增加下载目录,不写发行资源,也不影响签名或增量更新。 - `.artifacts/jdtls-downloads/`:JDTLS、Lombok、Java Debug/Test 和 license。 - `.artifacts/jdk-downloads/`:各平台与架构的 bundled JDK 下载归档。 - `.artifacts/php-language-server-downloads/`:按 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 43a6140c4..b3f5219bb 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -13,6 +13,10 @@ agent-claude-api-key-authentication,AI,Agent 对话,Claude API Key 鉴权:通 agent-thinking-level-labels,AI,Agent 对话,Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称,已实现,已验证,未实现,待验证,Agent,2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-approval-mode-presentation,AI,Agent 对话,Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义,已实现,待验证,未实现,待验证,Agent,中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Resources/en.lproj/Localizable.strings; macos/Resources/zh-Hans.lproj/Localizable.strings; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features +agent-reasoning-display,AI,Agent 对话,Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留,已实现,待验证,未实现,不适用,Agent,macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift; macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; shared/fixtures/agent/acp-events-v1.json; macos/Tests/LitheTests/AgentConversationPresentationTests.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift,windows/tauri/src/features +agent-plan-display,AI,Agent 对话,Agent 上报的执行计划(ACP plan)固定显示在活动统计栏上方,折叠时显示完成进度与当前步骤,展开查看每条状态;每次更新整体替换,空计划清除,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent(例如在计划模式或多步任务中)上报计划,确认计划栏显示“计划 已完成/总数”与当前步骤,展开后每条待办、进行中、已完成状态和图标正确;步骤推进时整体刷新而不重复;切换会话显示各自计划,加载历史会话后计划恢复;不上报计划的 Agent 不显示计划栏。在窄宽面板及深浅主题检查长步骤截断与滚动。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift; macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; shared/fixtures/agent/acp-events-v1.json,windows/tauri/src/features +agent-slash-commands,AI,Agent 对话,输入框以 / 开头(或以 $ 开头选择 Skill)时,按 Agent 为当前会话上报的命令列表(ACP available_commands_update)弹出补全,显示名称、说明与参数提示,选择后填入命令并按普通消息原样发送;长补全列表保留可见输入行,最小输入区中在上方浮出完整列表,已实现,待验证,未实现,不适用,Agent,macOS:分别连接 Codex 与 Claude,输入 / 确认弹出的命令与 Agent 实际上报一致,不出现 Lithe 内置命令;Codex 上报的 $Skill 补全为 $名称 而不是 /$名称,输入 $ 只列 Skill;继续输入时按前缀优先过滤,无匹配时显示“没有匹配的命令”,输入空格后关闭;鼠标选择或 Return 补全为“/命令 ”,完整命令按 Return 直接发送并由 Agent 执行;macOS 14 及以上方向键和 Tab 可导航,macOS 13 仍可鼠标选择;Esc 先关闭补全、回复中再次 Esc 才停止;切换会话后显示各自命令列表;上游重复上报同名命令时只显示首条定义,断连后清空旧补全,重连加载后以新进程上报为准;在默认和最小输入区高度、有无附带文件、窄宽面板中输入 /,用长列表和空结果确认输入行可见可编辑,浮出的命令可鼠标选择,方向键选中最后一条时列表跟随滚动。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift; macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift; macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift; macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift,windows/tauri/src/features +agent-mode-sync,AI,Agent 对话,Agent 自行切换模式(ACP current_mode_update)时同步当前会话的权限模式选择器,仅接受上游配置中存在的模式,不回发设置请求,已实现,待验证,未实现,不适用,Agent,macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步当前会话中包含该模式 ID 的 mode 配置,模型与思考选项不改变,不向 Agent 回发 setConfigOption;未知模式 ID 不改变选择器且不显示原始 ID。切换会话确认各自模式独立,历史加载后以 Agent 回放为准。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; shared/fixtures/agent/acp-events-v1.json,windows/tauri/src/features agent-turn-statistics,AI,Agent 对话,发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情,已实现,待验证,部分实现,待验证,Agent,macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。,,macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/prompt.rs; rust/lithe-agent-host/src/tests.rs; shared/fixtures/agent/acp-events-v1.json agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,部分实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。,,macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift; macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift; macos/Tests/LitheTests/AgentFileReferenceTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/tests.rs,rust/lithe-agent-host/src/prompt.rs; shared/fixtures/agent/acp-events-v1.json agent-management,AI,Agent 对话,Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败,已实现,待验证,部分实现,待验证,Agent,在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。,,macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift; macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift; rust/lithe-agent-host/src/install.rs; rust/lithe-core/src/agent/mod.rs; rust/lithe-agent-host/src/npm-progress.mjs; rust/lithe-agent-host/src/cli_update.rs; macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift,rust/lithe-core/src/agent/mod.rs diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 52360c051..2609cfe43 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:126 -- macOS:实现:✅ 111 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 4 已验证,🔍 110 待验证,— 12 不适用 -- Windows:实现:✅ 102 已实现,🟡 12 部分实现,❌ 10 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 118 待验证,— 8 不适用 +- 功能项:130 +- macOS:实现:✅ 115 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 4 已验证,🔍 114 待验证,— 12 不适用 +- Windows:实现:✅ 102 已实现,🟡 12 部分实现,❌ 14 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 118 待验证,— 12 不适用 ## 实现状态定义 @@ -30,7 +30,7 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。
-AI · 24 个能力点 +AI · 28 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -48,6 +48,10 @@ | Agent 对话 | **Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称**
agent-thinking-level-labels | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。 | | | Agent 对话 | **Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义**
agent-approval-mode-presentation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Resources/en.lproj/Localizable.strings`、`macos/Resources/zh-Hans.lproj/Localizable.strings`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。 | | | Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留**
agent-reasoning-display | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift`、`macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`shared/fixtures/agent/acp-events-v1.json`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **Agent 上报的执行计划(ACP plan)固定显示在活动统计栏上方,折叠时显示完成进度与当前步骤,展开查看每条状态;每次更新整体替换,空计划清除**
agent-plan-display | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`shared/fixtures/agent/acp-events-v1.json` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent(例如在计划模式或多步任务中)上报计划,确认计划栏显示“计划 已完成/总数”与当前步骤,展开后每条待办、进行中、已完成状态和图标正确;步骤推进时整体刷新而不重复;切换会话显示各自计划,加载历史会话后计划恢复;不上报计划的 Agent 不显示计划栏。在窄宽面板及深浅主题检查长步骤截断与滚动。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **输入框以 / 开头(或以 $ 开头选择 Skill)时,按 Agent 为当前会话上报的命令列表(ACP available_commands_update)弹出补全,显示名称、说明与参数提示,选择后填入命令并按普通消息原样发送;长补全列表保留可见输入行,最小输入区中在上方浮出完整列表**
agent-slash-commands | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift`、`macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:分别连接 Codex 与 Claude,输入 / 确认弹出的命令与 Agent 实际上报一致,不出现 Lithe 内置命令;Codex 上报的 $Skill 补全为 $名称 而不是 /$名称,输入 $ 只列 Skill;继续输入时按前缀优先过滤,无匹配时显示“没有匹配的命令”,输入空格后关闭;鼠标选择或 Return 补全为“/命令 ”,完整命令按 Return 直接发送并由 Agent 执行;macOS 14 及以上方向键和 Tab 可导航,macOS 13 仍可鼠标选择;Esc 先关闭补全、回复中再次 Esc 才停止;切换会话后显示各自命令列表;上游重复上报同名命令时只显示首条定义,断连后清空旧补全,重连加载后以新进程上报为准;在默认和最小输入区高度、有无附带文件、窄宽面板中输入 /,用长列表和空结果确认输入行可见可编辑,浮出的命令可鼠标选择,方向键选中最后一条时列表跟随滚动。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **Agent 自行切换模式(ACP current_mode_update)时同步当前会话的权限模式选择器,仅接受上游配置中存在的模式,不回发设置请求**
agent-mode-sync | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`shared/fixtures/agent/acp-events-v1.json` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步当前会话中包含该模式 ID 的 mode 配置,模型与思考选项不改变,不向 Agent 回发 setConfigOption;未知模式 ID 不改变选择器且不显示原始 ID。切换会话确认各自模式独立,历史加载后以 Agent 回放为准。Windows Agent 对话 UI 尚未实现。 | | | Agent 对话 | **发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情**
agent-turn-statistics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`rust/lithe-agent-host/src/tests.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。 | | | Agent 对话 | **从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿**
agent-file-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift`、`macos/Tests/LitheTests/AgentFileReferenceTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 | | | Agent 对话 | **Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败**
agent-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift`、`macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift`、`rust/lithe-agent-host/src/install.rs`、`rust/lithe-core/src/agent/mod.rs`、`rust/lithe-agent-host/src/npm-progress.mjs`、`rust/lithe-agent-host/src/cli_update.rs`、`macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/agent/mod.rs` | Agent | 在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 309780e95..9b58b5600 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1154,6 +1154,8 @@ "Search models" = "Search models"; "No matching models" = "No matching models"; "Thinking level" = "Thinking level"; +"Responding…" = "Responding…"; +"Plan %lld/%lld" = "Plan %lld/%lld"; "Speed" = "Speed"; "Fast" = "Fast"; "Approval mode" = "Approval mode"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f5585d50c..112c6b06d 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2242,6 +2242,7 @@ "Stop" = "停止"; "Deny" = "拒绝"; "Thinking…" = "思考中…"; +"Responding…" = "回复中…"; "Loading conversation…" = "正在加载对话…"; "Starting the Agent…" = "正在启动 Agent…"; "The Agent process starts when this panel opens." = "打开此面板时才会启动 Agent 进程。"; @@ -2259,6 +2260,26 @@ "Completed" = "已完成"; "Interrupted" = "已中断"; +/* Agent reasoning, plan and commands */ +"Thinking" = "思考"; +"Thinking process" = "思考过程"; +"Hide thinking" = "收起思考"; +"Show thinking" = "展开思考"; +"Plan %lld/%lld" = "计划 %lld/%lld"; +"Hide plan" = "收起计划"; +"Show plan" = "展开计划"; +"Agent commands" = "Agent 命令"; +"Tool call" = "工具调用"; +"Allow the Agent to continue?" = "允许 Agent 继续吗?"; +"The Agent request failed." = "Agent 请求失败。"; +"The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; +"The Agent stopped after reaching its request limit for this turn." = "Agent 已达到本轮请求次数上限,已停止。"; +"The Agent declined to continue." = "Agent 拒绝继续。"; +"Content" = "内容"; +"Unsupported content" = "不支持的内容"; +"Tool" = "工具"; +"You" = "你"; + /* Agent panel settings */ "Agent conversation" = "Agent 对话"; "Enable Agent conversation" = "启用 Agent 对话"; diff --git a/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift b/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift new file mode 100644 index 000000000..6a2ab49d6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift @@ -0,0 +1,54 @@ +import LitheAgentConversationModule + +/// View-local command interaction shared by Return, Esc, arrow keys and Tab. +/// Completing a command edits the draft; only a send result starts a prompt. +struct AgentCommandCompletion { + enum Key { case submit, escape, up, down, tab } + enum Result { case ignored, handled, send, cancel } + + var draft = "" { + didSet { + if draft != oldValue { + highlightedIndex = 0 + dismissedDraft = nil + } + } + } + private(set) var highlightedIndex = 0 + private var dismissedDraft: String? + + func suggestions(in commands: [AgentCommand]) -> [AgentCommand]? { + dismissedDraft == draft ? nil : AgentCommand.suggestions(for: draft, in: commands) + } + + mutating func complete(_ command: AgentCommand) { + draft = command.invocation + " " + dismissedDraft = nil + } + + mutating func handle(_ key: Key, commands: [AgentCommand], isResponding: Bool) -> Result { + let suggestions = suggestions(in: commands) + switch key { + case .escape: + if suggestions != nil { + dismissedDraft = draft + return .handled + } + return isResponding ? .cancel : .ignored + case .submit: + guard let suggestions, !suggestions.isEmpty, + !suggestions.contains(where: { $0.invocation == draft }) else { return .send } + complete(suggestions[min(highlightedIndex, suggestions.count - 1)]) + return .handled + case .up, .down, .tab: + guard let suggestions, !suggestions.isEmpty else { return .ignored } + if key == .tab { + complete(suggestions[min(highlightedIndex, suggestions.count - 1)]) + } else { + let offset = key == .up ? -1 : 1 + highlightedIndex = (min(highlightedIndex, suggestions.count - 1) + offset + suggestions.count) % suggestions.count + } + return .handled + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift new file mode 100644 index 000000000..b91083509 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift @@ -0,0 +1,50 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Fixed strips and the flexible editor use the same sizes when computing the +/// split minimum. Commands never increase that minimum; attachments do. +enum AgentComposerMetrics { + static let contextHeight: CGFloat = 30 + static let toolbarHeight: CGFloat = 38 + static let fileHeight: CGFloat = 34 + static let writingLineHeight: CGFloat = 36 + static let bottomInset: CGFloat = 8 + static let splitTopInset: CGFloat = 10 + + static func minimumHeight(hasFiles: Bool) -> CGFloat { + contextHeight + toolbarHeight + writingLineHeight + bottomInset + splitTopInset + (hasFiles ? fileHeight : 0) + } +} + +struct AgentComposerMinimumHeightKey: PreferenceKey { + static let defaultValue = AgentComposerMetrics.minimumHeight(hasFiles: false) + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + +/// The production composer layout, also hosted in native regression tests. +struct AgentComposerContent: View { + let files: [AgentFileReference] + let commands: [AgentCommand]? + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + let onRemoveFile: (String) -> Void + let onFocus: () -> Void + @ViewBuilder let context: () -> Context + @ViewBuilder let editor: () -> Editor + @ViewBuilder let toolbar: () -> Toolbar + + var body: some View { + VStack(spacing: 0) { + context() + if !files.isEmpty { AgentFileReferenceList(files: files, onRemove: onRemoveFile) } + AgentComposerDraftArea(commands: commands, highlightedIndex: highlightedIndex, onSelect: onSelect) { + ScrollView { editor() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + } + toolbar() + } + .preference(key: AgentComposerMinimumHeightKey.self, value: AgentComposerMetrics.minimumHeight(hasFiles: !files.isEmpty)) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift new file mode 100644 index 000000000..6044f9735 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift @@ -0,0 +1,81 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Reserves a writing line before allocating space to command suggestions. +struct AgentComposerDraftArea: View { + let commands: [AgentCommand]? + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + @ViewBuilder let editor: () -> Editor + + var body: some View { + GeometryReader { geometry in + let listHeight = max(0, geometry.size.height - AgentComposerMetrics.writingLineHeight) + let showsInline = listHeight >= AgentCommandSuggestionList.minimumHeight + VStack(spacing: 0) { + if let commands, showsInline { + suggestions(commands, maximumHeight: min(AgentCommandSuggestionList.defaultMaximumHeight, listHeight - 2)) + } + editor() + .frame(minHeight: AgentComposerMetrics.writingLineHeight, maxHeight: .infinity, alignment: .topLeading) + } + .anchorPreference(key: AgentFloatingCommandsKey.self, value: .bounds) { bounds in + guard let commands, !showsInline else { return nil } + return AgentFloatingCommands(bounds: bounds, + list: suggestions(commands, maximumHeight: AgentCommandSuggestionList.defaultMaximumHeight)) + } + } + .zIndex(1) + } + + private func suggestions(_ commands: [AgentCommand], maximumHeight: CGFloat) -> AgentCommandSuggestionList { + AgentCommandSuggestionList( + commands: commands, + highlightedIndex: min(highlightedIndex, max(0, commands.count - 1)), + onSelect: onSelect, + maximumHeight: maximumHeight + ) + } +} + +private struct AgentFloatingCommands { + let bounds: Anchor + let list: AgentCommandSuggestionList +} + +private struct AgentFloatingCommandsKey: PreferenceKey { + static var defaultValue: AgentFloatingCommands? + + static func reduce(value: inout AgentFloatingCommands?, nextValue: () -> AgentFloatingCommands?) { + value = nextValue() ?? value + } +} + +/// Render outside the split pane's hit bounds so floated rows remain clickable. +private struct AgentCommandSuggestionScope: ViewModifier { + func body(content: Content) -> some View { + content + .overlayPreferenceValue(AgentFloatingCommandsKey.self) { suggestion in + GeometryReader { geometry in + if let suggestion { + let frame = geometry[suggestion.bounds] + let list = AgentCommandSuggestionList( + commands: suggestion.list.commands, + highlightedIndex: suggestion.list.highlightedIndex, + onSelect: suggestion.list.onSelect, + maximumHeight: min(AgentCommandSuggestionList.defaultMaximumHeight, max(0, frame.minY - 2)) + ) + list.frame(width: frame.width) + .position(x: frame.midX, y: frame.minY - list.height / 2) + } + } + } + .transformPreference(AgentFloatingCommandsKey.self) { $0 = nil } + } +} + +extension View { + func agentCommandSuggestionScope() -> some View { + modifier(AgentCommandSuggestionScope()) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index a03136fb1..205dfb26b 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -24,36 +24,38 @@ struct AgentComposerView: View { var subscriptionAccount: String? var quotaFailure: String? var onSetConfig: (String, String) -> Void = { _, _ in } - @State private var draft = "" + var commands: [AgentCommand] = [] + @State private var completion = AgentCommandCompletion() @State private var files: [AgentFileReference] = [] @State private var isDropTargeted = false @State private var showsFilePicker = false @State private var isHovering = false @FocusState private var isFocused: Bool - private var hasContent: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !files.isEmpty } + private var hasContent: Bool { !completion.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !files.isEmpty } + private var commandSuggestions: [AgentCommand]? { + completion.suggestions(in: commands) + } var body: some View { - VStack(spacing: 0) { + AgentComposerContent(files: files, commands: commandSuggestions, + highlightedIndex: completion.highlightedIndex, + onSelect: complete, onRemoveFile: { id in files.removeAll { $0.id == id } }, + onFocus: { isFocused = true }) { contextBar - if !files.isEmpty { - AgentFileReferenceList(files: files) { id in files.removeAll { $0.id == id } } - } - ScrollView { - TextField("Message the Agent", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .font(.system(size: 13)) - .foregroundStyle(AgentPanelStyle.text) - .lineLimit(1...) - .focused($isFocused) - .onSubmit(send) - .padding(.horizontal, 8) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .contentShape(Rectangle()) - .onTapGesture { isFocused = true } + } editor: { + TextField("Message the Agent", text: $completion.draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1...) + .focused($isFocused) + .onSubmit { _ = handleCommandKey(.submit) } + .modifier(AgentCommandKeyNavigation(onKey: handleCommandKey)) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } toolbar: { toolbar } .background(AgentPanelStyle.canvas, in: RoundedRectangle(cornerRadius: 8)) @@ -76,9 +78,9 @@ struct AgentComposerView: View { } .onHover { isHovering = $0 } .padding(.horizontal, 8) - .padding(.bottom, 8) + .padding(.bottom, AgentComposerMetrics.bottomInset) .onAppear { isFocused = true } - .onExitCommand { if isResponding { onCancel() } } + .onExitCommand { _ = handleCommandKey(.escape) } } private var contextBar: some View { @@ -98,7 +100,7 @@ struct AgentComposerView: View { .font(.system(size: 11)) .foregroundStyle(AgentPanelStyle.secondary) .padding(.horizontal, 10) - .frame(height: 28) + .frame(height: AgentComposerMetrics.contextHeight - 2) .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) .padding(1) } @@ -149,7 +151,7 @@ struct AgentComposerView: View { .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) } .padding(.horizontal, 5) - .frame(height: 36) + .frame(height: AgentComposerMetrics.toolbarHeight - 2) .background(AgentPanelStyle.toolbar, in: RoundedRectangle(cornerRadius: 7)) .padding(1) } @@ -205,6 +207,22 @@ struct AgentComposerView: View { } } + private func complete(_ command: AgentCommand) { + completion.complete(command) + isFocused = true + } + + private func handleCommandKey(_ key: AgentCommandCompletion.Key) -> AgentCommandCompletion.Result { + let result = completion.handle(key, commands: commands, isResponding: isResponding) + switch result { + case .send: send() + case .cancel: onCancel() + case .handled: isFocused = true + case .ignored: break + } + return result + } + private func send() { guard hasContent, !isResponding else { return } if isBlocked || isPreparingSession { @@ -212,8 +230,8 @@ struct AgentComposerView: View { return } do { - try onSend(draft, files) - draft = "" + try onSend(completion.draft, files) + completion.draft = "" files.removeAll() onError(nil) } catch { @@ -223,23 +241,47 @@ struct AgentComposerView: View { } +/// Arrow keys and Tab drive the command list while it is open. Key handling on a +/// focused text field needs macOS 14; macOS 13 keeps mouse selection and Return. +private struct AgentCommandKeyNavigation: ViewModifier { + let onKey: (AgentCommandCompletion.Key) -> AgentCommandCompletion.Result + + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content + .onKeyPress(.upArrow) { handle(.up) } + .onKeyPress(.downArrow) { handle(.down) } + .onKeyPress(.tab) { handle(.tab) } + } else { + content + } + } + + @available(macOS 14.0, *) + private func handle(_ key: AgentCommandCompletion.Key) -> KeyPress.Result { + onKey(key) == .ignored ? .ignored : .handled + } +} + /// The shared split container keeps resize updates outside the conversation model. struct AgentConversationLayout: View { @ViewBuilder let transcript: Transcript @ViewBuilder let composer: Composer + @State private var composerMinimumHeight = AgentComposerMetrics.minimumHeight(hasFiles: false) var body: some View { GeometryReader { geometry in + let minimum = min(composerMinimumHeight, max(0, geometry.size.height - SplitHandleView.hitThickness)) LitheSplitPaneView( axis: .vertical, placement: .trailing, defaultSize: min(210, geometry.size.height * 0.3), - minimum: min(120, geometry.size.height * 0.4), - maximum: max(0, geometry.size.height * 0.6), + minimum: minimum, + maximum: max(minimum, geometry.size.height * 0.6), showsIdleDivider: false ) { composer - .padding(.top, 10) + .padding(.top, AgentComposerMetrics.splitTopInset) .overlay(alignment: .top) { Capsule().fill(AgentPanelStyle.muted.opacity(0.55)) .frame(width: 54, height: 3) @@ -250,5 +292,7 @@ struct AgentConversationLayout: View { transcript } } + .onPreferenceChange(AgentComposerMinimumHeightKey.self) { composerMinimumHeight = $0 } + .agentCommandSuggestionScope() } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index f2535fb3e..c995dc874 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -268,7 +268,8 @@ private struct AgentConnectionView: View { subscriptionQuota: feature.subscriptionQuota, subscriptionAccount: feature.subscriptionEmail, quotaFailure: feature.quotaFailure, - onSetConfig: { feature.setConfigOption($0, value: $1) } + onSetConfig: { feature.setConfigOption($0, value: $1) }, + commands: feature.selectedConversation?.availableCommands ?? [] ) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift index 950a8f921..1be1da97f 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift @@ -29,6 +29,6 @@ struct AgentFileReferenceList: View { .padding(.horizontal, 8) } .scrollIndicators(.hidden) - .frame(height: 34) + .frame(height: AgentComposerMetrics.fileHeight) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift index 0eb5e6d4c..c0239dc2d 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift @@ -40,7 +40,7 @@ enum AgentHistoryPresentation { static func messageCount(_ conversation: AgentConversation?) -> Int? { guard let conversation, !conversation.isLoading, conversation.isAttached || !conversation.messages.isEmpty else { return nil } - return conversation.messages.filter { $0.role != .tool }.count + return conversation.messages.filter { $0.role == .user || $0.role == .agent }.count } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift new file mode 100644 index 000000000..ff12f535a --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift @@ -0,0 +1,241 @@ +import SwiftUI +import LitheAgentConversationModule + +/// The agent's reasoning, separate from its reply. Open while it streams and +/// collapsed once the reply or a tool call follows; a search keeps it open. +struct AgentThoughtRow: View { + let text: String + let isStreaming: Bool + var isSearching = false + @Binding var expansion: AgentThoughtExpansion + + private var isExpanded: Bool { expansion.isExpanded(isStreaming: isStreaming, isSearching: isSearching) } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { expansion.toggle(isStreaming: isStreaming, isSearching: isSearching) } label: { + HStack(spacing: 6) { + Image(systemName: "brain") + .font(.system(size: 10.5)) + Text(isStreaming ? "Thinking…" : "Thinking process") + .font(.system(size: 11.5, weight: .medium)) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9)) + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.tertiaryText) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .help(isExpanded ? "Hide thinking" : "Show thinking") + .accessibilityValue(isExpanded ? String(localized: "Expanded") : String(localized: "Collapsed")) + + if isExpanded { + Text(text.trimmingCharacters(in: .whitespacesAndNewlines)) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(LitheTheme.panelBorder).frame(width: 2) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Search temporarily reveals a match without replacing the user's disclosure preference. +struct AgentThoughtExpansion { + private var userExpanded: Bool? + + func isExpanded(isStreaming: Bool, isSearching: Bool) -> Bool { + isSearching || (userExpanded ?? isStreaming) + } + + mutating func toggle(isStreaming: Bool, isSearching: Bool) { + // A matching search must keep its text visible, including after a click. + guard !isSearching else { return } + userExpanded = !isExpanded(isStreaming: isStreaming, isSearching: false) + } +} + +/// Plan the agent reported for this conversation, pinned above the activity bar. +/// Collapsed it shows progress and the current step; it stays expandable after the turn. +struct AgentPlanView: View { + let plan: AgentPlan + let isResponding: Bool + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { expanded.toggle() } label: { + HStack(spacing: 7) { + Image(systemName: "list.bullet.clipboard") + .font(.system(size: 10.5)) + .foregroundStyle(plan.isComplete ? LitheTheme.success : LitheTheme.accent) + Text(String(format: String(localized: "Plan %lld/%lld"), plan.completedCount, plan.entries.count)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .monospacedDigit() + if !expanded, let current = plan.currentEntry { + Text(current.content) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 4) + Image(systemName: expanded ? "chevron.down" : "chevron.up") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .padding(.horizontal, 10) + .frame(height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .help(expanded ? "Hide plan" : "Show plan") + + if expanded { + Rectangle().fill(LitheTheme.panelBorder).frame(height: 1) + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(plan.entries.enumerated()), id: \.offset) { _, entry in + entryRow(entry) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .frame(maxHeight: 180) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.header, in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AgentPanelStyle.border, lineWidth: 1)) + .padding(.horizontal, 18) + .padding(.bottom, 4) + } + + private func entryRow(_ entry: AgentPlan.Entry) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Image(systemName: icon(entry.status)) + .font(.system(size: 10.5)) + .foregroundStyle(color(entry.status)) + Text(entry.content) + .font(.system(size: 11.5)) + .foregroundStyle(entry.status == .completed ? LitheTheme.tertiaryText : LitheTheme.primaryText) + .strikethrough(entry.status == .completed, color: LitheTheme.tertiaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + .accessibilityValue(label(entry.status)) + } + + private func icon(_ status: AgentPlan.Entry.Status) -> String { + switch status { + case .completed: "checkmark.circle.fill" + case .inProgress: isResponding ? "circle.dotted" : "circle.lefthalf.filled" + case .pending: "circle" + } + } + + private func color(_ status: AgentPlan.Entry.Status) -> Color { + switch status { + case .completed: LitheTheme.success + case .inProgress: LitheTheme.accent + case .pending: LitheTheme.tertiaryText + } + } + + private func label(_ status: AgentPlan.Entry.Status) -> String { + switch status { + case .completed: String(localized: "Completed") + case .inProgress: String(localized: "Running") + case .pending: String(localized: "Pending") + } + } +} + +/// Slash commands matching the draft, shown above the writing area. +struct AgentCommandSuggestionList: View { + let commands: [AgentCommand] + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + var maximumHeight: CGFloat = defaultMaximumHeight + static let defaultMaximumHeight: CGFloat = 168 + /// One complete command row including the list's vertical padding and border. + static let minimumHeight = rowHeight + 2 * contentInset + 2 * borderInset + private static let rowHeight: CGFloat = 26 + private static let contentInset: CGFloat = 3 + private static let borderInset: CGFloat = 1 + + var height: CGFloat { + let contentHeight = commands.isEmpty ? Self.rowHeight + : min(maximumHeight, CGFloat(commands.count) * Self.rowHeight + 2 * Self.contentInset) + return contentHeight + 2 * Self.borderInset + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if commands.isEmpty { + Text("No matching commands") + .font(.system(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + } else { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in + row(command, isHighlighted: index == highlightedIndex).id(command.id) + } + } + .padding(.vertical, Self.contentInset) + } + .frame(maxHeight: maximumHeight) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: highlightedIndex) { index in + if commands.indices.contains(index) { proxy.scrollTo(commands[index].id) } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) + .padding(Self.borderInset) + .frame(height: height) + .accessibilityLabel("Agent commands") + } + + private func row(_ command: AgentCommand, isHighlighted: Bool) -> some View { + Button { onSelect(command) } label: { + HStack(spacing: 8) { + Text(command.invocation) + .font(.system(size: 11.5, weight: .medium, design: .monospaced)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1) + .layoutPriority(1) + Text(command.description) + .font(.system(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + .background(isHighlighted ? AgentPanelStyle.focus.opacity(0.18) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .litheRowHover() + .help(command.hint.map { "\(command.invocation) \($0)" } ?? command.description) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift index f35837ec0..19f964643 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -76,12 +76,18 @@ struct AgentTranscriptView: View { var searchText = "" var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } @State private var showsAgentPicker = false + // A filtered-out row can be destroyed. Keep its preference in the owning + // transcript view, isolated by session and message, until the actual data goes away. + @State private var thoughtExpansions: [String: [String: AgentThoughtExpansion]] = [:] var body: some View { let conversation = feature.selectedConversation let messages = conversation?.messages ?? [] let transcript = AgentTranscriptItem.grouped(messages, turns: conversation?.completedTurns ?? []) .filter { $0.matches(searchText) } + // Only reasoning that is still streaming opens by default. + let liveThoughtID = conversation?.isResponding == true && messages.last?.role == .thought + ? messages.last?.id : nil VStack(spacing: 0) { if conversation?.isLoading != true && messages.isEmpty && feature.pendingNewConversationPrompt == nil { AgentHeroView(agentName: agentName, agentVersion: agentVersion) { @@ -128,7 +134,13 @@ struct AgentTranscriptView: View { Group { switch item { case .message(let message): - AgentMessageRow(message: message, onOpenFile: onOpenFile) + AgentMessageRow( + message: message, + isStreamingThought: liveThoughtID == message.id, + isSearching: !searchText.isEmpty, + thoughtExpansion: thoughtExpansion(for: message.id), + onOpenFile: onOpenFile + ) case .toolGroup(let tools): AgentToolGroupView(messages: tools, searchText: searchText, onOpenFile: onOpenFile) case .turnSummary(let turn): @@ -144,7 +156,8 @@ struct AgentTranscriptView: View { if conversation?.isResponding == true || feature.isCreatingSession { AgentThinkingRow( isCancelling: conversation?.isCancelling == true, - startedAt: conversation?.activeTurn?.startedAt ?? feature.pendingNewConversationStartedAt + startedAt: conversation?.activeTurn?.startedAt ?? feature.pendingNewConversationStartedAt, + hasStreamingThought: liveThoughtID != nil ).id("responding") } } @@ -169,8 +182,29 @@ struct AgentTranscriptView: View { if let permission = conversation?.permission { AgentPermissionCard(permission: permission, answer: { feature.answerPermission(optionID: $0) }, onOpenFile: onOpenFile) } + if let plan = conversation?.plan { + AgentPlanView(plan: plan, isResponding: conversation?.isResponding == true) + .id(feature.selectedSessionID) + } AgentActivitySummaryBar(messages: messages) } + .onChange(of: feature.openSessionIDs) { sessionIDs in + thoughtExpansions = thoughtExpansions.filter { sessionIDs.contains($0.key) } + } + .onChange(of: messages.map(\.id)) { messageIDs in + // Use unfiltered messages: changing the search must never discard preferences. + guard let sessionID = feature.selectedSessionID, let preferences = thoughtExpansions[sessionID] else { return } + let retainedIDs = Set(messageIDs) + thoughtExpansions[sessionID] = preferences.filter { retainedIDs.contains($0.key) } + } + } + + private func thoughtExpansion(for messageID: String) -> Binding { + guard let sessionID = feature.selectedSessionID else { return .constant(AgentThoughtExpansion()) } + return Binding( + get: { thoughtExpansions[sessionID]?[messageID] ?? AgentThoughtExpansion() }, + set: { thoughtExpansions[sessionID, default: [:]][messageID] = $0 } + ) } } @@ -310,6 +344,9 @@ private struct AgentPermissionCard: View { private struct AgentMessageRow: View { let message: AgentConversationMessage + var isStreamingThought = false + var isSearching = false + var thoughtExpansion: Binding = .constant(AgentThoughtExpansion()) var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } var body: some View { @@ -327,6 +364,9 @@ private struct AgentMessageRow: View { case .agent: AgentMarkdownMessage(text: message.text) .frame(maxWidth: .infinity, alignment: .leading) + case .thought: + AgentThoughtRow(text: message.text, isStreaming: isStreamingThought, isSearching: isSearching, + expansion: thoughtExpansion) case .tool: AgentToolGroupView(messages: [message], searchText: "", onOpenFile: onOpenFile) } diff --git a/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift b/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift index cb1449a4c..c8080073c 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift @@ -39,12 +39,18 @@ enum AgentTurnStatisticsPresentation { struct AgentThinkingRow: View { var isCancelling = false var startedAt: ContinuousClock.Instant? + var hasStreamingThought = false + + var status: String { + if isCancelling { return String(localized: "Stopping…") } + return hasStreamingThought ? String(localized: "Responding…") : String(localized: "Thinking…") + } var body: some View { TimelineView(.periodic(from: .now, by: 1)) { _ in HStack(spacing: 8) { ProgressView().controlSize(.small) - Text(isCancelling ? "Stopping…" : "Thinking…") + Text(status) if let startedAt { let elapsed = AgentTurnStatistics(id: "waiting", startedAt: startedAt).elapsed(at: .now) Text(AgentTurnStatisticsPresentation.duration(elapsed)).monospacedDigit() diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift index b3d8f9c83..683731efd 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -54,7 +54,9 @@ public final class AgentConnectionModel: ObservableObject { /// session ID being loaded. private var queuedPrompts: [String: AgentPrompt] = [:] private var loadTokens: [String: String] = [:] - private var pendingText: [String: String] = [:] + /// Streamed text not yet shown, per session. One buffer holds one role so + /// interleaved reasoning and reply chunks become separate messages. + private var pendingText: [String: (role: AgentConversationMessage.Role, text: String)] = [:] private var flushTask: Task? private var needsAttention = false @Published private var createToken: String? @@ -403,7 +405,7 @@ public final class AgentConnectionModel: ObservableObject { conversations[sessionID]?.errorMessage = stopReasonMessage(event["stopReason"] as? String) updateAttention() case "requestFailed": - requestFailed(token: token, sessionID: sessionID, message: event["message"] as? String ?? "The Agent request failed.") + requestFailed(token: token, sessionID: sessionID, message: event["message"] as? String ?? String(localized: "The Agent request failed.")) case "stopped": let old = detachConnection(failure: event["message"] as? String) if let old { @@ -497,8 +499,24 @@ public final class AgentConnectionModel: ObservableObject { applyConfiguration(update["configOptions"], to: sessionID) case "agent_message_chunk": guard let text = Self.text(of: update) else { return } - pendingText[sessionID, default: ""] += text - scheduleFlush() + buffer(text, role: .agent, in: sessionID) + case "agent_thought_chunk": + guard let text = Self.text(of: update) else { return } + buffer(text, role: .thought, in: sessionID) + case "plan": + guard let plan = AgentPlan.parse(update) else { return } + conversations[sessionID, default: AgentConversation()].plan = plan.entries.isEmpty ? nil : plan + case "available_commands_update": + guard let commands = AgentCommand.parse(update) else { return } + conversations[sessionID, default: AgentConversation()].availableCommands = commands + case "current_mode_update": + // Agents that expose modes as a config option may switch on their own, + // e.g. leaving plan mode; keep the selector on the reported mode. + guard let modeID = update["currentModeId"] as? String, + let index = conversations[sessionID]?.configOptions.firstIndex(where: { + $0.category == "mode" && $0.choices.contains { $0.id == modeID } + }) else { return } + conversations[sessionID]?.configOptions[index].currentValue = modeID case "user_message_chunk": guard let text = Self.text(of: update) else { return } flushPendingText() @@ -552,7 +570,7 @@ public final class AgentConnectionModel: ObservableObject { conversation.messages.append(AgentConversationMessage( id: id, role: .tool, - text: title ?? "Tool call", + text: title ?? String(localized: "Tool call"), toolStatus: status ?? .pending )) conversation.messages[conversation.messages.count - 1].toolDetails.merge(update) @@ -568,7 +586,7 @@ public final class AgentConnectionModel: ObservableObject { } var prompt = AgentPermissionPrompt( id: requestID, - title: tool?["title"] as? String ?? "Allow the Agent to continue?", + title: tool?["title"] as? String ?? String(localized: "Allow the Agent to continue?"), choices: options ) if let tool { @@ -609,7 +627,7 @@ public final class AgentConnectionModel: ObservableObject { conversation.isLoading = true conversations[sessionID] = conversation if !sendCommand(["kind": "loadSession", "token": token, "sessionId": sessionID]) { - requestFailed(token: token, sessionID: sessionID, message: errorMessage ?? "The Agent request failed.") + requestFailed(token: token, sessionID: sessionID, message: errorMessage ?? String(localized: "The Agent request failed.")) } } @@ -665,6 +683,7 @@ public final class AgentConnectionModel: ObservableObject { for id in conversations.keys { conversations[id]?.finishTurn(at: now()) conversations[id]?.contextUsage = nil + conversations[id]?.availableCommands = [] conversations[id]?.isResponding = false conversations[id]?.interruptPendingTools() conversations[id]?.isCancelling = false @@ -716,19 +735,25 @@ public final class AgentConnectionModel: ObservableObject { } } + private func buffer(_ text: String, role: AgentConversationMessage.Role, in sessionID: String) { + if let pending = pendingText[sessionID], pending.role != role { flushPendingText() } + pendingText[sessionID, default: (role, "")].text += text + scheduleFlush() + } + private func flushPendingText() { let pending = pendingText pendingText.removeAll() - for (sessionID, text) in pending where !text.isEmpty { - append(text, role: .agent, to: sessionID) + for (sessionID, chunk) in pending where !chunk.text.isEmpty { + append(chunk.text, role: chunk.role, to: sessionID) } } private func stopReasonMessage(_ reason: String?) -> String? { switch reason { - case "max_tokens": "The response stopped at the model's token limit." - case "max_turn_requests": "The Agent stopped after reaching its request limit for this turn." - case "refusal": "The Agent declined to continue." + case "max_tokens": String(localized: "The response stopped at the model's token limit.") + case "max_turn_requests": String(localized: "The Agent stopped after reaching its request limit for this turn.") + case "refusal": String(localized: "The Agent declined to continue.") default: nil } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift index 15de2e934..75c465516 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift @@ -14,7 +14,8 @@ public struct AgentSessionSummary: Identifiable, Equatable, Sendable { } public struct AgentConversationMessage: Identifiable, Equatable, Sendable { - public enum Role: Equatable, Sendable { case user, agent, tool } + /// `thought` holds the agent's streamed reasoning, kept apart from its reply. + public enum Role: Equatable, Sendable { case user, agent, thought, tool } public enum ToolStatus: String, Equatable, Sendable { case pending case inProgress = "in_progress" @@ -55,6 +56,10 @@ public struct AgentPermissionPrompt: Identifiable, Equatable, Sendable { public struct AgentConversation: Equatable, Sendable { public var messages: [AgentConversationMessage] = [] public var contextUsage: AgentContextUsage? + /// Latest complete plan from the agent; history replay restores it. + public var plan: AgentPlan? + /// Slash commands the agent currently advertises for this session. + public var availableCommands: [AgentCommand] = [] public var activeTurn: AgentTurnStatistics? /// Local statistics survive tab switches and disconnects, but are not fabricated /// when the Agent replays history without timing or usage records. diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift index 709ba8683..8ac223414 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift @@ -137,9 +137,14 @@ public struct AgentHistoryDocument: Sendable { switch message.role { case .user: role = String(localized: "You") case .agent: role = "Agent" + case .thought: role = String(localized: "Thinking") case .tool: role = String(localized: "Tool") } - var content = "## \(role)\n\n\(message.text)" + // Reasoning is exported as a quote so it stays distinct from the reply. + let body = message.role == .thought + ? message.text.components(separatedBy: .newlines).map { "> " + $0 }.joined(separator: "\n") + : message.text + var content = "## \(role)\n\n\(body)" if let status = message.toolStatus { content += "\n\nStatus: \(status.rawValue)" } for detail in [message.toolDetails.input, message.toolDetails.output].compactMap({ $0 }) { content += "\n\n\(detail)" diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift index 0f331848d..c3f882849 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift @@ -81,16 +81,17 @@ public struct AgentToolDetails: Equatable, Sendable { case "content": guard let block = item["content"] as? [String: Any] else { return nil } if let text = block["text"] as? String { - return Content(title: "Output", text: Self.bounded(text)) + return Content(title: String(localized: "Output"), text: Self.bounded(text)) } - return Content(title: "Content", text: block["type"] as? String ?? "Unsupported content") + return Content(title: String(localized: "Content"), + text: block["type"] as? String ?? String(localized: "Unsupported content")) case "diff": let old = item["oldText"] as? String ?? "" let new = item["newText"] as? String ?? "" - return Content(title: item["path"] as? String ?? "Diff", + return Content(title: item["path"] as? String ?? String(localized: "Diff"), text: Self.bounded("---\n" + old + "\n+++\n" + new)) case "terminal": - return Content(title: "Terminal", text: item["terminalId"] as? String ?? "") + return Content(title: String(localized: "Terminal"), text: item["terminalId"] as? String ?? "") default: return nil } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift new file mode 100644 index 000000000..d9442abed --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift @@ -0,0 +1,101 @@ +import Foundation + +/// The agent's current execution plan. ACP sends the complete plan on every +/// `plan` update, so a new value replaces the previous one. +public struct AgentPlan: Equatable, Sendable { + public struct Entry: Equatable, Sendable { + public enum Status: String, Equatable, Sendable { + case pending + case inProgress = "in_progress" + case completed + } + + public var content: String + /// Upstream priority label (`high`, `medium`, `low`); kept as reported. + public var priority: String? + public var status: Status + + public init(content: String, priority: String? = nil, status: Status) { + self.content = content + self.priority = priority + self.status = status + } + } + + public var entries: [Entry] + + public init(entries: [Entry]) { self.entries = entries } + + public var completedCount: Int { entries.filter { $0.status == .completed }.count } + public var isComplete: Bool { !entries.isEmpty && completedCount == entries.count } + /// The entry the agent is working on, or the next pending one. + public var currentEntry: Entry? { + entries.first { $0.status == .inProgress } ?? entries.first { $0.status == .pending } + } + + static let entryLimit = 100 + + /// An empty entry list is a valid update that clears the plan. + static func parse(_ update: [String: Any]) -> Self? { + guard let entries = update["entries"] as? [[String: Any]] else { return nil } + return Self(entries: entries.prefix(entryLimit).compactMap { entry in + guard let content = entry["content"] as? String, + let status = (entry["status"] as? String).flatMap(Entry.Status.init(rawValue:)) else { return nil } + return Entry(content: content, priority: entry["priority"] as? String, status: status) + }) + } +} + +/// A slash command the agent advertised for this session. Selecting one only +/// inserts `/name ` into the prompt; the agent interprets the sent text. +public struct AgentCommand: Identifiable, Equatable, Sendable { + public var name: String + public var description: String + /// Placeholder for the free-form input that follows the command name. + public var hint: String? + + public var id: String { name } + /// Codex lists skills as `$name` commands; they are mentioned as `$name`, not `/$name`. + public var isSkill: Bool { name.hasPrefix("$") } + /// Text the composer inserts to invoke the command. + public var invocation: String { isSkill ? name : "/" + name } + + public init(name: String, description: String, hint: String? = nil) { + self.name = name + self.description = description + self.hint = hint + } + + static let commandLimit = 200 + + static func parse(_ update: [String: Any]) -> [Self]? { + guard let commands = update["availableCommands"] as? [[String: Any]] else { return nil } + // A command name is its invocation and UI identity. Keep the first + // advertised definition when an upstream list repeats the same name. + var names = Set() + return commands.prefix(commandLimit).compactMap { command in + guard let name = command["name"] as? String, !name.isEmpty, + names.insert(name).inserted else { return nil } + let input = command["input"] as? [String: Any] + return Self(name: name, description: command["description"] as? String ?? "", + hint: (input?["hint"] as? String).flatMap { $0.isEmpty ? nil : $0 }) + } + } + + /// Commands matching the token being typed, or nil when the draft is not a + /// command prefix. `/` lists every command; `$` lists only skills, and only + /// when the agent advertises some, so a literal `$` is not intercepted. + public static func suggestions(for draft: String, in commands: [Self]) -> [Self]? { + guard let trigger = draft.first, trigger == "/" || trigger == "$" else { return nil } + let candidates = trigger == "$" ? commands.filter(\.isSkill) : commands + guard !candidates.isEmpty else { return nil } + let query = draft.dropFirst().lowercased() + guard !query.contains(where: \.isWhitespace) else { return nil } + func key(_ command: Self) -> String { + (command.isSkill ? String(command.name.dropFirst()) : command.name).lowercased() + } + let prefixed = candidates.filter { key($0).hasPrefix(query) } + let contained = candidates.filter { !key($0).hasPrefix(query) && key($0).contains(query) } + return prefixed + contained + } +} diff --git a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift index 451b897b7..a9f8a9080 100644 --- a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift @@ -309,6 +309,146 @@ struct AgentConversationFeatureModelTests { #expect(feature.sessions.first?.title == "Project overview") } + @Test + func interleavedReasoningAndReplyBecomeSeparateMessagesInArrivalOrder() throws { + let (feature, _) = try respondingFeature() + // Thought and reply chunks share one flush window; a role change must + // still end the previous segment instead of merging the texts. + try feature.receive(event("agentThoughtChunk")) + try feature.receive(event("agentThoughtChunk", ["update": ["sessionUpdate": "agent_thought_chunk", + "content": ["type": "text", "text": " Then test."]]])) + try feature.receive(event("agentMessageChunk")) + try feature.receive(event("toolCall")) + try feature.receive(event("agentThoughtChunk")) + try feature.receive(event("turnFinished")) + + let messages = try #require(feature.selectedConversation?.messages) + #expect(messages.map(\.role) == [.user, .thought, .agent, .tool, .thought]) + #expect(messages[1].text == "Read the manifest first. Then test.") + #expect(messages[2].text == "This project **builds** an IDE.") + #expect(messages[4].text == "Read the manifest first.") + let document = AgentHistoryDocument(id: "session-1", title: "Sample", messages: messages) + let markdown = AgentHistoryDocument.markdown([document]) + #expect(markdown.contains("> Read the manifest first. Then test.")) + #expect(markdown.contains("This project **builds** an IDE.")) + #expect(!markdown.contains("> This project")) + } + + @Test + func planUpdatesReplaceTheWholePlanAndAnEmptyPlanClearsIt() throws { + let (feature, _) = try respondingFeature() + try feature.receive(event("plan")) + let plan = try #require(feature.selectedConversation?.plan) + #expect(plan.entries.map(\.status) == [.completed, .inProgress, .pending]) + #expect(plan.entries.first?.priority == "high") + #expect(plan.completedCount == 1) + #expect(plan.currentEntry?.content == "Run the tests") + #expect(!plan.isComplete) + + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan", "entries": [ + ["content": "Summarize the results", "priority": "low", "status": "completed"], + ["content": "Unknown status", "priority": "low", "status": "blocked"] + ]]])) + let replaced = try #require(feature.selectedConversation?.plan) + #expect(replaced.entries.map(\.content) == ["Summarize the results"]) + #expect(replaced.isComplete) + #expect(replaced.currentEntry == nil) + + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan", "entries": [] as [Any]]])) + #expect(feature.selectedConversation?.plan == nil) + // A malformed update leaves the plan state unchanged rather than inventing one. + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan"]])) + #expect(feature.selectedConversation?.plan == nil) + } + + @Test + func advertisedCommandsArePerSessionAndFilterOnlyWhileTypingAName() throws { + let (feature, connection) = try respondingFeature() + try feature.receive(event("availableCommands")) + let commands = try #require(feature.selectedConversation?.availableCommands) + #expect(commands.map(\.name) == ["review", "compact"]) + #expect(commands[0].hint == "optional focus") + #expect(commands[1].hint == nil) + + #expect(AgentCommand.suggestions(for: "/", in: commands)?.map(\.name) == ["review", "compact"]) + #expect(AgentCommand.suggestions(for: "/CO", in: commands)?.map(\.name) == ["compact"]) + // Prefix matches rank before names that only contain the query. + #expect(AgentCommand.suggestions(for: "/e", in: commands)?.map(\.name) == ["review"]) + #expect(AgentCommand.suggestions(for: "/zz", in: commands)?.isEmpty == true) + #expect(AgentCommand.suggestions(for: "/review ", in: commands) == nil) + #expect(AgentCommand.suggestions(for: "review", in: commands) == nil) + #expect(AgentCommand.suggestions(for: "/", in: []) == nil) + #expect(commands.map(\.invocation) == ["/review", "/compact"]) + // A dollar sign is ordinary text unless the agent advertises skills. + #expect(AgentCommand.suggestions(for: "$", in: commands) == nil) + + // codex-acp lists skills as `$name` commands and ignores `/$name`, so a + // skill is inserted as a `$` mention and `$` filters to skills only. + let withSkills = commands + [AgentCommand(name: "$pdf", description: "Read PDF files"), + AgentCommand(name: "$spreadsheets", description: "Edit sheets")] + #expect(withSkills[2].isSkill && !withSkills[0].isSkill) + #expect(withSkills[2].invocation == "$pdf") + #expect(AgentCommand.suggestions(for: "$", in: withSkills)?.map(\.name) == ["$pdf", "$spreadsheets"]) + #expect(AgentCommand.suggestions(for: "$sp", in: withSkills)?.map(\.name) == ["$spreadsheets"]) + #expect(AgentCommand.suggestions(for: "/pd", in: withSkills)?.map(\.name) == ["$pdf"]) + #expect(AgentCommand.suggestions(for: "$pdf ", in: withSkills) == nil) + + // Another session has its own list; the command text is sent as typed. + feature.startNewConversation() + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "sessionId": "session-2"])) + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) + try feature.send("/review src") + #expect(connection.commands.last?["text"] as? String == "/review src") + try feature.receive(event("availableCommands", ["sessionId": "session-2", "update": [ + "sessionUpdate": "available_commands_update", "availableCommands": [] as [Any] + ]])) + #expect(feature.conversations["session-1"]?.availableCommands.count == 2) + } + + @Test + func duplicateAdvertisedCommandsKeepTheFirstDefinitionAndUniqueRowIdentities() throws { + let (feature, _) = try respondingFeature() + let advertised: [[String: Any]] = [ + ["name": "review", "description": "First definition", "input": ["hint": "focus"]], + ["name": "review", "description": "Repeated definition"], + ["name": "compact", "description": "Compact the conversation"] + ] + (0..<201).map { ["name": "command-\($0)"] } + try feature.receive(event("availableCommands", ["update": [ + "sessionUpdate": "available_commands_update", "availableCommands": advertised + ]])) + let commands = try #require(feature.selectedConversation?.availableCommands) + #expect(commands.count == 199, "Only the first 200 upstream entries are consumed") + #expect(Set(commands.map(\.id)).count == commands.count) + #expect(commands.prefix(2).map(\.name) == ["review", "compact"]) + #expect(commands[0].description == "First definition") + #expect(commands[0].hint == "focus") + #expect(AgentCommand.suggestions(for: "/re", in: commands)?.count == 1) + } + + @Test + func agentReportedModeChangeMovesOnlyTheMatchingModeSelector() throws { + let (feature, connection) = try connectedFeature() + feature.prepareConversation() + let configured = try #require(JSONSerialization.jsonObject(with: Data(event("sessionConfigured").utf8)) as? [String: Any]) + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "configOptions": configured["configOptions"] as Any])) + func current(_ category: String) -> String? { + feature.selectedConversation?.configOptions.first { $0.category == category }?.currentValue + } + #expect(current("mode") == "read-only") + let commandCount = connection.commands.count + try feature.receive(event("currentModeUpdate")) + #expect(current("mode") == "auto") + #expect(current("model") == "example-model") + // The agent already switched; echoing it back as a request would be wrong. + #expect(connection.commands.count == commandCount) + // A mode the selector does not offer is ignored rather than shown as a raw id. + try feature.receive(event("currentModeUpdate", ["update": ["sessionUpdate": "current_mode_update", + "currentModeId": "unlisted"]])) + #expect(current("mode") == "auto") + } + @Test func permissionChoicesAreAnsweredOrRejectedByCancel() throws { let (feature, connection) = try respondingFeature() @@ -403,11 +543,17 @@ struct AgentConversationFeatureModelTests { try feature.send("Explain this project") try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try feature.receive(event("availableCommands")) + #expect(feature.selectedConversation?.availableCommands.count == 2) + try feature.receive(event("availableCommands", ["sessionId": "session-2"])) + #expect(feature.conversations["session-2"]?.availableCommands.count == 2) + try feature.receive(event("stopped")) #expect(feature.connectionState == .failed("The Agent connection closed unexpectedly")) #expect(feature.selectedConversation?.isResponding == false) #expect(feature.selectedConversation?.isAttached == false) #expect(feature.selectedConversation?.messages.count == 1) + #expect(feature.conversations.values.allSatisfy { $0.availableCommands.isEmpty }) #expect(throws: AgentConversationError.notConnected) { try feature.send("again") } await feature.stop() @@ -416,7 +562,13 @@ struct AgentConversationFeatureModelTests { try feature.receive(event("ready")) try feature.send("again") #expect(transport.connections[1].commands.last?["kind"] as? String == "loadSession") + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) + try feature.receive(event("sessionLoaded", ["token": transport.connections[1].commands.last?["token"] as Any])) + try feature.receive(event("availableCommands", ["update": ["sessionUpdate": "available_commands_update", + "availableCommands": [["name": "new-process-command", "description": "Current capabilities"]]]])) + #expect(feature.selectedConversation?.availableCommands.map(\.name) == ["new-process-command"]) await feature.stop() + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) #expect(transport.connections[1].closeCount == 1) } diff --git a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift index ccde395e4..049aebb15 100644 --- a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift +++ b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift @@ -104,6 +104,228 @@ struct AgentConversationPresentationTests { #expect(AgentTranscriptItem.grouped(updated).map(\.id) == grouped.map(\.id)) } + @Test + func planReasoningAndCommandListsFitNarrowAndWidePanelsInBothAppearances() throws { + let plan = AgentPlan(entries: [ + .init(content: "Read the manifest and the build scripts of the sample project", priority: "high", status: .completed), + .init(content: "Run the tests", priority: "medium", status: .inProgress), + .init(content: "Summarize the results", priority: "low", status: .pending) + ]) + let commands = [AgentCommand(name: "review", description: "Review the current changes before committing", hint: "optional focus"), + AgentCommand(name: "compact", description: "Summarize the conversation to free context", hint: nil)] + for (name, scheme, width) in [("dark-narrow", ColorScheme.dark, 280.0), ("light-narrow", .light, 280.0), + ("dark-wide", .dark, 620.0), ("light-wide", .light, 620.0)] { + let host = NSHostingView(rootView: VStack(alignment: .leading, spacing: 10) { + AgentThoughtRow(text: "The manifest names the entry point, so read it first.", isStreaming: true, + expansion: .constant(AgentThoughtExpansion())) + AgentCommandSuggestionList(commands: commands, highlightedIndex: 0, onSelect: { _ in }) + AgentPlanView(plan: plan, isResponding: true) + }.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AgentPanelStyle.canvas).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 260), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.frame.size = NSSize(width: width, height: 260) + host.layoutSubtreeIfNeeded() + #expect(host.fittingSize.height <= 260, "Reasoning, commands and plan summary must fit a short panel") + if let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_STATISTICS_SCREENSHOTS"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent("guidance-\(name).png")) + } + } + } + + @Test + func reasoningSplitsToolTimelinesAndIsSearchableButNotCountedAsAMessage() throws { + let messages = [ + AgentConversationMessage(id: "list", role: .tool, text: "List files"), + AgentConversationMessage(id: "why", role: .thought, text: "The manifest names the entry point"), + AgentConversationMessage(id: "read", role: .tool, text: "Read file"), + AgentConversationMessage(id: "reply", role: .agent, text: "Done") + ] + let items = AgentTranscriptItem.grouped(messages) + #expect(items.map(\.id) == ["tools:list", "why", "tools:read", "reply"]) + #expect(items.filter { $0.matches("entry point") }.map(\.id) == ["why"]) + + var conversation = AgentConversation() + conversation.isAttached = true + conversation.messages = [AgentConversationMessage(role: .user, text: "Explain")] + messages + #expect(AgentHistoryPresentation.messageCount(conversation) == 2) + } + + @Test + func thoughtSearchRevealsAManuallyCollapsedMatchAndRestoresItsPreference() { + var expansion = AgentThoughtExpansion() + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + expansion.toggle(isStreaming: true, isSearching: false) + #expect(!expansion.isExpanded(isStreaming: true, isSearching: false)) + + // Search changes the effective disclosure without replacing the stored preference. + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + expansion.toggle(isStreaming: false, isSearching: true) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + + expansion.toggle(isStreaming: false, isSearching: false) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: false)) + } + + @Test + func thoughtStreamingCollapsesAutomaticallyButKeepsManualExpansion() { + var expansion = AgentThoughtExpansion() + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + expansion.toggle(isStreaming: false, isSearching: false) + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: false)) + } + + @Test + func commandKeysCompleteWithoutSendingAndSubmitFullInvocations() { + let commands = [AgentCommand(name: "review", description: "Review"), + AgentCommand(name: "compact", description: "Compact"), + AgentCommand(name: "$pdf", description: "Read PDFs")] + var completion = AgentCommandCompletion() + completion.draft = "/" + #expect(completion.handle(.up, commands: commands, isResponding: false) == .handled) + #expect(completion.highlightedIndex == 2) + #expect(completion.handle(.down, commands: commands, isResponding: false) == .handled) + #expect(completion.highlightedIndex == 0) + #expect(completion.handle(.down, commands: commands, isResponding: false) == .handled) + #expect(completion.handle(.tab, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "/compact ") + #expect(completion.suggestions(in: commands) == nil) + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + + completion.draft = "/re" + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "/review ") + completion.draft = "/review" + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + #expect(completion.draft == "/review") + completion.draft = "$pd" + #expect(completion.handle(.tab, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "$pdf ") + } + + @Test + func escapeDismissesSuggestionsBeforeCancellingAndEditingReopensThem() { + let commands = [AgentCommand(name: "review", description: "Review")] + var completion = AgentCommandCompletion() + completion.draft = "/" + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .handled) + #expect(completion.draft == "/") + #expect(completion.suggestions(in: commands) == nil) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .cancel) + #expect(completion.handle(.escape, commands: commands, isResponding: false) == .ignored) + completion.draft = "/r" + #expect(completion.suggestions(in: commands)?.count == 1) + completion.draft = "/" + #expect(completion.suggestions(in: commands)?.count == 1) + completion.draft = "/missing" + #expect(completion.suggestions(in: commands)?.isEmpty == true) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .handled) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .cancel) + } + + @Test + func unavailableCommandKeysLeaveOrdinaryTypingAndChangedListsUsable() { + let commands = [AgentCommand(name: "review", description: "Review"), + AgentCommand(name: "compact", description: "Compact")] + var completion = AgentCommandCompletion() + for draft in ["text", "$", "/missing", "/review argument"] { + completion.draft = draft + for key in [AgentCommandCompletion.Key.up, .down, .tab] { + #expect(completion.handle(key, commands: commands, isResponding: false) == .ignored) + #expect(completion.draft == draft) + } + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + } + completion.draft = "/" + #expect(completion.handle(.up, commands: commands, isResponding: false) == .handled) + // A fresh upstream list can be shorter while the same draft is focused. + #expect(completion.handle(.tab, commands: Array(commands.prefix(1)), isResponding: false) == .handled) + #expect(completion.draft == "/review ") + completion.draft = "/" + #expect(completion.handle(.tab, commands: [], isResponding: false) == .ignored) + } + + @Test + func activeThoughtKeepsOneThinkingLabelAndTheWaitingTimer() { + #expect(AgentThinkingRow().status == String(localized: "Thinking…")) + #expect(AgentThinkingRow(hasStreamingThought: true).status == String(localized: "Responding…")) + #expect(AgentThinkingRow(isCancelling: true, hasStreamingThought: true).status == String(localized: "Stopping…")) + } + + @Test + func commandSuggestionsKeepAWritingLineAtDefaultAndMinimumComposerHeights() throws { + let commands = (0..<200).map { + AgentCommand(name: "command-\($0)", description: "An upstream command with a long description", hint: nil) + } + let attachment = try AgentFileReference(url: URL(fileURLWithPath: "/example/project/README.md")) + for height in [400.0, 700.0] { + for width in [280.0, 620.0] { + for files in [[], [attachment]] { + for count in [0, 1, 40, 200] { + var editor: NSView? + var composer: NSView? + let host = NSHostingView(rootView: AgentConversationLayout { + Color.clear + } composer: { + AgentComposerContent(files: files, commands: Array(commands.prefix(count)), highlightedIndex: 0, + onSelect: { _ in }, onRemoveFile: { _ in }, onFocus: {}) { + Color.clear.frame(height: AgentComposerMetrics.contextHeight) + } editor: { + TextField("Message the Agent", text: .constant("/"), axis: .vertical) + .textFieldStyle(.plain).font(.system(size: 13)).lineLimit(1...) + .background(AgentDraftFrameProbe { editor = $0 }) + .padding(.horizontal, 8).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } toolbar: { + Color.clear.frame(height: AgentComposerMetrics.toolbarHeight) + } + .padding(.horizontal, 8).padding(.bottom, AgentComposerMetrics.bottomInset) + .background(AgentDraftFrameProbe { composer = $0 }) + }) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.frame.size = NSSize(width: width, height: height) + host.layoutSubtreeIfNeeded() + let input = try #require(editor) + let frame = host.convert(input.bounds, from: input) + let composerView = try #require(composer) + let pane = host.convert(composerView.bounds, from: composerView) + #expect(pane.height + AgentComposerMetrics.splitTopInset >= AgentComposerMetrics.minimumHeight(hasFiles: !files.isEmpty) - 0.5) + #expect(frame.height > 0, "The actual text field must survive \(count) commands and \(files.count) attachments") + #expect(pane.insetBy(dx: -0.5, dy: -0.5).contains(frame), "The writing line must stay inside the sized pane") + let writingScroll = try #require(enclosingScroll(of: input)) + #expect(writingScroll.bounds.height >= AgentComposerMetrics.writingLineHeight - 0.5) + if count > 0 { + let list = try #require(commandScroll(in: host, excluding: input)) + let listFrame = host.convert(list.bounds, from: list) + #expect(listFrame.height >= 26, "Suggestions must retain a complete, selectable row") + #expect(host.bounds.insetBy(dx: -0.5, dy: -0.5).contains(listFrame), + "The floating list must stay inside its conversation scope") + let isAboveInput = host.isFlipped ? listFrame.maxY <= frame.minY + 0.5 + : listFrame.minY >= frame.maxY - 0.5 + #expect(isAboveInput, "The floating list must not cover the writing line") + } + } + } + } + } + } + @Test func toolSearchKeepsTheOriginalGroupWhenEvidenceMatches() throws { var first = AgentConversationMessage(id: "list", role: .tool, text: "List files") @@ -266,4 +488,27 @@ struct AgentConversationPresentationTests { if let handle = view as? SplitHandleInteractionView { return handle } return view.subviews.lazy.compactMap { splitHandle(in: $0) }.first } + + private func enclosingScroll(of view: NSView) -> NSScrollView? { + if let scroll = view.superview as? NSScrollView { return scroll } + return view.superview.flatMap { enclosingScroll(of: $0) } + } + + private func commandScroll(in view: NSView, excluding editor: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView, scroll.hasVerticalScroller, !editor.isDescendant(of: scroll) { return scroll } + return view.subviews.lazy.compactMap { commandScroll(in: $0, excluding: editor) }.first + } +} + +/// Observe the actual native editor frame assigned by SwiftUI, without a run loop delay. +private struct AgentDraftFrameProbe: NSViewRepresentable { + let onCreate: (NSView) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + onCreate(view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} } diff --git a/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift new file mode 100644 index 000000000..d9a729026 --- /dev/null +++ b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift @@ -0,0 +1,195 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Thought transcript lifecycle", .serialized) +struct AgentThoughtTranscriptTests { + @Test + func manualExpansionSurvivesNonmatchingSearchRemovingAndRecreatingTheRow() async throws { + try await withTranscript { feature, host, window in + renderFrame(host) + let collapsed = try snapshot(host) + try record(host, name: "collapsed") + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "expanded thought") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + try record(host, name: "expanded") + + host.rootView = transcript(feature, search: "no matching thought") + try await renderUntil(host, expected: "filtered row") { try snapshot(host) != expanded && snapshot(host) != collapsed } + try record(host, name: "filtered") + // Visiting a new empty session disposes the real scroll subtree while search remains active. + // Returning must retain this session's choice even after SwiftUI's lazy row cache is gone. + let filtered = try snapshot(host) + feature.selectSession("empty") + try await renderUntil(host, expected: "empty conversation") { try snapshot(host) != filtered } + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "restored manual expansion") { try snapshot(host) == expanded } + try record(host, name: "restored") + } + } + + @Test + func matchingSearchRevealsACollapsedThoughtWithoutReplacingItsPreference() async throws { + try await withTranscript { feature, host, window in + renderFrame(host) + let collapsed = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "manual expansion") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "manual collapse") { try snapshot(host) == collapsed } + + host.rootView = transcript(feature, search: "manifest") + try await renderUntil(host, expected: "matching thought text") { try snapshot(host) == expanded } + try pressThought(in: host, window: window) + renderFrame(host) + #expect(try snapshot(host) == expanded, "Searching must keep the matched reasoning visible after a click") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "original manual collapse") { try snapshot(host) == collapsed } + } + } + + @Test + func switchingSessionsKeepsIndependentPreferencesAndClosingResetsThem() async throws { + try await withTranscript { feature, host, window in + seedThought(in: feature, sessionID: "second") + renderFrame(host) + let collapsed = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "first session expanded") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + + feature.selectSession("second") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "second session default collapse") { try snapshot(host) == collapsed } + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "first session retained expansion") { try snapshot(host) == expanded } + + feature.closeConversation("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "remaining session") { try snapshot(host) == collapsed } + // Reopening the session with replayed messages must use the fresh row's default. + seedThought(in: feature, sessionID: "first") + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "reopened session default") { try snapshot(host) == collapsed } + } + } + + private func withTranscript( + _ run: (AgentConnectionModel, NSHostingView, NSWindow) async throws -> Void + ) async throws { + let feature = AgentConnectionModel(transport: UnusedThoughtTransport()) + seedThought(in: feature, sessionID: "first") + feature.selectSession("first") + let host = NSHostingView(rootView: transcript(feature)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.contentView = nil; window.close() } + window.contentView = host + host.frame.size = NSSize(width: 360, height: 300) + window.orderFront(nil) + do { + try await run(feature, host, window) + await feature.stop() + } catch { + await feature.stop() + throw error + } + } + + private func transcript(_ feature: AgentConnectionModel, search: String = "") -> AnyView { + AnyView(AgentTranscriptView(feature: feature, agentName: "Fixture", agentVersion: nil, + agents: [], onSelectAgent: { _ in }, searchText: search) + .background(AgentPanelStyle.canvas).environment(\.colorScheme, .dark)) + } + + private func seedThought(in feature: AgentConnectionModel, sessionID: String) { + feature.receive(""" + {"kind":"update","sessionId":"\(sessionID)","update":{"sessionUpdate":"agent_thought_chunk",\ + "content":{"type":"text","text":"Find the manifest entry point"}}} + """) + feature.receive(""" + {"kind":"turnFinished","sessionId":"\(sessionID)"} + """) + } + + private func pressThought(in host: NSView, window: NSWindow) throws { + // The fixture's only thought is the first transcript row, inside the 12pt padding. + let point = NSPoint(x: 80, y: host.isFlipped ? 20 : host.bounds.height - 20) + let location = host.convert(point, to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, + clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)) + window.sendEvent(event) + } + } + + private func renderFrame(_ host: NSView) { + // Pump one native event without waiting: SwiftUI's lazy row disposal commits on the run loop. + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) + host.layoutSubtreeIfNeeded() + host.displayIfNeeded() + CATransaction.flush() + } + + // Compare complete frames from this one host, without font- or OS-specific golden images. + private func snapshot(_ host: NSView) throws -> Data { + let bitmap = try renderedBitmap(host) + let pixels = try #require(bitmap.bitmapData) + return Data(bytes: pixels, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } + + private func renderedBitmap(_ host: NSView) throws -> NSBitmapImageRep { + host.displayIfNeeded() + CATransaction.flush() + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(host.bounds.width), pixelsHigh: Int(host.bounds.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + let layer = try #require(host.layer) + context.cgContext.translateBy(x: 0, y: host.bounds.height) + context.cgContext.scaleBy(x: 1, y: -1) + layer.render(in: context.cgContext) + return bitmap + } + + private func record(_ host: NSView, name: String) throws { + guard let directory = ProcessInfo.processInfo.environment["LITHE_THOUGHT_SCREENSHOTS"] else { return } + let bitmap = try renderedBitmap(host) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("\(name).png")) + } + + /// Wait for observable native rendering, with no fixed delay or private SwiftUI state access. + private func renderUntil(_ host: NSView, expected: String, condition: () throws -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + renderFrame(host) + if try condition() { return } + await Task.yield() + } + #expect(try condition(), "Did not render \(expected) within two seconds") + throw ThoughtRenderError.timeout + } +} + +private enum ThoughtRenderError: Error { case timeout } + +@MainActor +private struct UnusedThoughtTransport: AgentConversationTransport { + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + throw AgentConversationError.notConnected + } +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 12839266a..d014152ca 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -325,6 +325,10 @@ fn serialized_events_match_the_shared_fixture() { "toolCallUpdate", "sessionInfo", "usageUpdate", + "agentThoughtChunk", + "plan", + "availableCommands", + "currentModeUpdate", ] { let update = &events[name]["update"]; let parsed: agent_client_protocol::schema::v1::SessionUpdate = diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 54a11d3ff..5339e48bd 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -389,7 +389,7 @@ while IFS=$'\t' read -r status first_path _; do rust_core=true macos_release=true ;; - scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/prepare-jdk.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) + scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/prepare-jdk.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-bun-install.ps1|scripts/windows-frontend-install.test.ts|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) windows=true ;; scripts/prepare-lithe-pr-review.mjs|scripts/test-prepare-lithe-pr-review.mjs|scripts/run-lithe-codex-with-timeout.sh|scripts/update-repo-charts.py) diff --git a/scripts/install-windows-frontend-dependencies.ps1 b/scripts/install-windows-frontend-dependencies.ps1 index fed03f1cc..e5c256068 100644 --- a/scripts/install-windows-frontend-dependencies.ps1 +++ b/scripts/install-windows-frontend-dependencies.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([ValidateRange(1, 1200)][int]$InstallTimeoutSeconds = 300) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot @@ -9,6 +9,27 @@ $expectedVersion = ([string]$package.packageManager) -replace '^bun@', '' $bunCache = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-cache")) $bunTemp = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-tmp")) $dependencyPaths = @("node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules") | ForEach-Object { Join-Path $root $_ } +$powerShell = (Get-Process -Id $PID).Path +$installWorker = Join-Path $PSScriptRoot "invoke-windows-bun-install.ps1" + +function Remove-InstallPath { + param([string]$Path) + + $deadline = [Diagnostics.Stopwatch]::StartNew() + while (Test-Path -LiteralPath $Path) { + try { + Remove-Item -Recurse -Force -LiteralPath $Path -ErrorAction Stop + return + } catch { + if ($deadline.Elapsed.TotalSeconds -ge 10) { + throw "Cannot clean installation data after releasing its process tree: $Path. $($_.Exception.Message)" + } + # Native termination and Windows filesystem filters can release + # locks just after the worker exits. This retry has a local deadline. + Start-Sleep -Milliseconds 100 + } + } +} function Write-CacheWarning { param([string]$Message, [string]$Title = "Bun cache fallback") @@ -44,7 +65,7 @@ $env:BUN_INSTALL_CACHE_DIR = $bunCache $env:BUN_TMPDIR = $bunTemp $env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX = "1" -if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } +Remove-InstallPath $bunTemp New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { @@ -58,20 +79,21 @@ if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $expectedVersion) { Push-Location $root try { - & bun install --frozen-lockfile + & $powerShell -NoProfile -ExecutionPolicy Bypass -File $installWorker -TimeoutSeconds $InstallTimeoutSeconds if ($LASTEXITCODE -ne 0) { if ($env:LITHE_BUN_CACHE_VERIFIED -eq "true") { Write-CacheWarning "The verified Bun cache could not complete installation. Clearing it and retrying with ordinary downloads." } else { Write-CacheWarning "The initial Bun install failed. Clearing partial data and retrying with ordinary downloads." } - if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } - if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } + $env:LITHE_BUN_CACHE_VERIFIED = "false" + Remove-InstallPath $bunCache + Remove-InstallPath $bunTemp foreach ($dependencyPath in $dependencyPaths) { - if (Test-Path -LiteralPath $dependencyPath) { Remove-Item -Recurse -Force -LiteralPath $dependencyPath } + Remove-InstallPath $dependencyPath } New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null - & bun install --frozen-lockfile --no-cache + & $powerShell -NoProfile -ExecutionPolicy Bypass -File $installWorker -NoCache -TimeoutSeconds $InstallTimeoutSeconds if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed after a clean retry." } @@ -95,7 +117,7 @@ try { } if (-not $cacheSealed) { Write-CacheWarning "The Bun download cache could not be sealed safely. Discarding it and continuing with the installed dependencies." - if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } + Remove-InstallPath $bunCache if ($null -ne $env:GITHUB_ENV) { "LITHE_BUN_CACHE_VERIFIED=false" >> $env:GITHUB_ENV } } } diff --git a/scripts/invoke-windows-bun-install.ps1 b/scripts/invoke-windows-bun-install.ps1 new file mode 100644 index 000000000..af896ebcf --- /dev/null +++ b/scripts/invoke-windows-bun-install.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [switch]$NoCache, + [ValidateRange(1, 1200)][int]$TimeoutSeconds = 300 +) + +$ErrorActionPreference = "Stop" +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "The Bun install worker requires Windows Job Objects." +} + +# Own the worker before Bun can spawn lifecycle scripts. The non-inheritable +# handle belongs to this worker process; Windows closes it at process exit and +# kills every remaining descendant, even when Bun has already exited on error. +# Do not dispose the handle in-process: the worker itself is a job member. +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Threading; + +public static class LitheBunInstallJob { + static Timer deadline; + const uint KillOnJobClose = 0x2000; + const int ExtendedLimitInformation = 9; + + [StructLayout(LayoutKind.Sequential)] + struct BasicLimits { + public long PerProcessUserTime, PerJobUserTime; + public uint LimitFlags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + struct IoCounters { + public ulong ReadOperations, WriteOperations, OtherOperations; + public ulong ReadBytes, WriteBytes, OtherBytes; + } + + [StructLayout(LayoutKind.Sequential)] + struct ExtendedLimits { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] + static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + public static IntPtr AttachWorker() { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + var limits = new ExtendedLimits(); + limits.Basic.LimitFlags = KillOnJobClose; + if (!SetInformationJobObject(job, ExtendedLimitInformation, ref limits, (uint)Marshal.SizeOf(limits)) || + !AssignProcessToJobObject(job, GetCurrentProcess())) { + int error = Marshal.GetLastWin32Error(); + CloseHandle(job); + throw new Win32Exception(error, "Cannot own the Bun installation process tree."); + } + return job; + } + + public static void StartDeadline(int seconds) { + deadline = new Timer(_ => { + Console.Error.WriteLine("Bun installation exceeded its " + seconds + " second deadline."); + Environment.Exit(124); + }, null, seconds * 1000, Timeout.Infinite); + } + + public static void FinishDeadline() { deadline.Dispose(); } +} +'@ + +$installJob = [LitheBunInstallJob]::AttachWorker() +[LitheBunInstallJob]::StartDeadline($TimeoutSeconds) +$arguments = @("install", "--frozen-lockfile") +if ($NoCache) { $arguments += "--no-cache" } +& bun @arguments +$installExitCode = $LASTEXITCODE +[LitheBunInstallJob]::FinishDeadline() +[GC]::KeepAlive($installJob) +exit $installExitCode diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 69d5700a1..b8910eda3 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -169,6 +169,8 @@ async function validatorArguments(resource, cachePath, targetRoot) { ]; } if (resource.validator === "bun") { + // This route validates sealed downloads only. Job-owned installation temp + // data and node_modules stay in the registry's non-reusable exclusions. const packagePath = await requiredFile(targetRoot, "package.json", resource.id); const packageManifest = JSON.parse(await fs.readFile(packagePath, "utf8")); const match = String(packageManifest.packageManager ?? "").match(/^bun@(.+)$/); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 77a2cbbde..d7366e43f 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,14 @@ function reuse(extraArguments = []) { } try { + await test("frontend install temp data and dependencies cannot cross worktrees", { timeout: 15000 }, () => { + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes("frontend-install-state")); + const refused = reuse(["--resource", "frontend-install-state"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /frontend-install-state.*node_modules.*bun-tmp.*cannot be reused/); + }); await test("Java launch files are never listed or copied between worktrees", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); diff --git a/scripts/windows-frontend-install.test.ts b/scripts/windows-frontend-install.test.ts new file mode 100644 index 000000000..5717cbc03 --- /dev/null +++ b/scripts/windows-frontend-install.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runProcess } from "../.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs"; + +const scripts = path.dirname(fileURLToPath(import.meta.url)); +const dependencyPaths = ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules"]; + +// A real child process keeps its current directory locked on Windows. IPC +// confirms that lock before the fake installer exits, without sleeps or polling. +const fakeBun = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const net = require("node:net"); +const root = process.env.LITHE_INSTALL_FIXTURE_ROOT; +if (process.argv.includes("--locker")) { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => process.send("locked")); +} else if (process.argv.includes("--version")) { + console.log("1.3.12"); +} else { + const retry = process.argv.includes("--no-cache"); + const mode = process.env.LITHE_INSTALL_FIXTURE_MODE; + fs.appendFileSync(path.join(root, "attempts.jsonl"), JSON.stringify(process.argv.slice(2)) + "\n"); + const directories = ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules"]; + if (retry && mode !== "permanent-failure" && mode !== "timeout") { + for (const directory of directories) { + if (fs.existsSync(path.join(root, directory, "partial"))) throw Error("Partial dependencies survived cleanup"); + fs.mkdirSync(path.join(root, directory), { recursive: true }); + } + fs.writeFileSync(path.join(root, "retry-succeeded"), "yes"); + } else { + for (const directory of directories) { + fs.mkdirSync(path.join(root, directory, "partial"), { recursive: true }); + } + const locked = path.join(root, "node_modules", "partial"); + const child = spawn(process.execPath, [__filename, "--locker"], { + cwd: locked, stdio: ["ignore", "ignore", "ignore", "ipc"] + }); + child.once("error", error => { throw error; }); + child.once("message", () => { + fs.appendFileSync(path.join(root, "owned-pids"), child.pid + "\n"); + if (mode !== "timeout") process.exit(mode === "success-with-child" ? 0 : 1); + }); + } +} +`; + +function isRunning(pid: number) { + try { process.kill(pid, 0); return true; } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + +async function ownedPids(root: string) { + try { + return (await fs.readFile(path.join(root, "owned-pids"), "utf8")).trim().split(/\s+/).map(Number); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +async function withInstaller(mode: string, check: (root: string, result: Awaited>) => Promise, timeoutSeconds = 300) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "lithe Windows install ")); + try { + await fs.mkdir(path.join(root, "scripts")); + await fs.mkdir(path.join(root, "windows/tauri"), { recursive: true }); + await fs.mkdir(path.join(root, "bin")); + await fs.mkdir(path.join(root, ".artifacts/bun-cache"), { recursive: true }); + for (const name of ["install-windows-frontend-dependencies.ps1", "invoke-windows-bun-install.ps1"]) { + await fs.copyFile(path.join(scripts, name), path.join(root, "scripts", name)); + } + await fs.writeFile(path.join(root, "windows/tauri/package.json"), JSON.stringify({ packageManager: "bun@1.3.12" })); + await fs.writeFile(path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), "old manifest"); + await fs.writeFile(path.join(root, ".artifacts/bun-cache/old-cache"), "old"); + await fs.writeFile(path.join(root, "bin/fake-bun.cjs"), fakeBun); + await fs.writeFile(path.join(root, "bin/bun.cmd"), '@echo off\r\nnode "%~dp0fake-bun.cjs" %*\r\n'); + // Cache validation is covered separately; here the sealer records whether + // the fallback invalidated its verified flag and allowed a new manifest. + await fs.writeFile(path.join(root, "scripts/verify-download-cache.mjs"), ` + import { writeFileSync } from "node:fs"; + import path from "node:path"; + const root = process.env.LITHE_INSTALL_FIXTURE_ROOT; + writeFileSync(path.join(root, "sealed-state"), process.env.LITHE_BUN_CACHE_VERIFIED); + writeFileSync(path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), "new manifest"); + `); + const result = await runProcess({ + command: "pwsh", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "scripts/install-windows-frontend-dependencies.ps1"), + "-InstallTimeoutSeconds", String(timeoutSeconds)], + cwd: root, + env: { + ...process.env, + PATH: path.join(root, "bin") + path.delimiter + process.env.PATH, + LITHE_INSTALL_FIXTURE_ROOT: root, + LITHE_INSTALL_FIXTURE_MODE: mode, + LITHE_BUN_CACHE_VERIFIED: "true", + GITHUB_ACTIONS: "false", + GITHUB_ENV: "", + }, + timeoutMs: 20000, + }); + expect(result.timedOut, result.stdout + result.stderr).toBe(false); + await check(root, result); + expect((await ownedPids(root)).filter(isRunning)).toEqual([]); + } finally { + // Cleanup remains owned even if a worker or assertion fails. + for (const pid of await ownedPids(root)) { + if (isRunning(pid)) { + await runProcess({ command: "taskkill.exe", args: ["/PID", String(pid), "/T", "/F"], timeoutMs: 5000 }); + } + } + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +describe.skipIf(process.platform !== "win32")("Windows dependency install lifecycle", () => { + test("failed installation releases locked descendants before its clean retry", async () => { + await withInstaller("recover", async (root, result) => { + expect(result.code, result.stdout + result.stderr).toBe(0); + const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(attempts).toEqual([["install", "--frozen-lockfile"], ["install", "--frozen-lockfile", "--no-cache"]]); + expect(await fs.readFile(path.join(root, "retry-succeeded"), "utf8")).toBe("yes"); + expect(await fs.readFile(path.join(root, "sealed-state"), "utf8")).toBe("false"); + for (const directory of dependencyPaths) expect(await fs.readdir(path.join(root, directory))).toEqual([]); + expect((await ownedPids(root)).length).toBe(1); + }); + }); + + test("permanent failure reports the retry error and releases both process trees", async () => { + await withInstaller("permanent-failure", async (root, result) => { + expect(result.code).not.toBe(0); + expect(result.stderr).toContain("failed after a clean retry"); + expect((await ownedPids(root)).length).toBe(2); + }); + }); + + test("successful installation releases leftover children without clearing a valid cache", async () => { + await withInstaller("success-with-child", async (root, result) => { + expect(result.code, result.stdout + result.stderr).toBe(0); + expect(await fs.readFile(path.join(root, ".artifacts/bun-cache/old-cache"), "utf8")).toBe("old"); + expect((await ownedPids(root)).length).toBe(1); + const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")).trim().split("\n"); + expect(attempts.length).toBe(1); + }); + }); + + test("a stalled installer reaches its local deadline and releases its descendants", async () => { + await withInstaller("timeout", async (root, result) => { + expect(result.code).not.toBe(0); + expect(result.stderr).toContain("exceeded its 3 second deadline"); + expect(result.stderr).toContain("failed after a clean retry"); + expect((await ownedPids(root)).length).toBe(2); + }, 3); + }); +}); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 0e25cd98c..37aead5e2 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -28,6 +28,13 @@ } ], "excludedResources": [ + { + "id": "frontend-install-state", + "reusable": false, + "locations": ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules", ".artifacts/bun-tmp"], + "identity": "Mutable installation state tied to one worktree, OS, architecture, Bun version and lifecycle attempt; no sealed reusable identity stamp", + "reason": "Each Windows install attempt owns its lifecycle processes in a worker Job Object. Release that tree before clearing partial dependencies or retrying. Never copy install temp data or node_modules across worktrees; only the sealed Bun download cache may be reused." + }, { "id": "jdt-maven-settings", "reusable": false, diff --git a/shared/fixtures/agent/acp-events-v1.json b/shared/fixtures/agent/acp-events-v1.json index 7cb57fbf6..70f7b2670 100644 --- a/shared/fixtures/agent/acp-events-v1.json +++ b/shared/fixtures/agent/acp-events-v1.json @@ -56,6 +56,26 @@ "sessionId": "session-1", "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "This project **builds** an IDE." } } }, + "agentThoughtChunk": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "Read the manifest first." } } + }, + "plan": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "plan", "entries": [{ "content": "Read the manifest", "priority": "high", "status": "completed" }, { "content": "Run the tests", "priority": "medium", "status": "in_progress" }, { "content": "Summarize the results", "priority": "low", "status": "pending" }] } + }, + "availableCommands": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "available_commands_update", "availableCommands": [{ "name": "review", "description": "Review the current changes", "input": { "hint": "optional focus" } }, { "name": "compact", "description": "Summarize the conversation to free context" }] } + }, + "currentModeUpdate": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "current_mode_update", "currentModeId": "auto" } + }, "toolCall": { "kind": "update", "sessionId": "session-1", diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index a3102611d..cb9c74d2c 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -420,6 +420,114 @@ "owner": "Agent", "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。" }, + { + "id": "agent-reasoning-display", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift", + "macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "shared/fixtures/agent/acp-events-v1.json", + "macos/Tests/LitheTests/AgentConversationPresentationTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", + "macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows Agent 对话 UI 尚未实现。" + }, + { + "id": "agent-plan-display", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 上报的执行计划(ACP plan)固定显示在活动统计栏上方,折叠时显示完成进度与当前步骤,展开查看每条状态;每次更新整体替换,空计划清除", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "shared/fixtures/agent/acp-events-v1.json" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:让真实 Agent(例如在计划模式或多步任务中)上报计划,确认计划栏显示“计划 已完成/总数”与当前步骤,展开后每条待办、进行中、已完成状态和图标正确;步骤推进时整体刷新而不重复;切换会话显示各自计划,加载历史会话后计划恢复;不上报计划的 Agent 不显示计划栏。在窄宽面板及深浅主题检查长步骤截断与滚动。Windows Agent 对话 UI 尚未实现。" + }, + { + "id": "agent-slash-commands", + "area": "AI", + "group": "Agent 对话", + "capability": "输入框以 / 开头(或以 $ 开头选择 Skill)时,按 Agent 为当前会话上报的命令列表(ACP available_commands_update)弹出补全,显示名称、说明与参数提示,选择后填入命令并按普通消息原样发送;长补全列表保留可见输入行,最小输入区中在上方浮出完整列表", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift", + "macos/Tests/LitheTests/AgentConversationPresentationTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift", + "macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:分别连接 Codex 与 Claude,输入 / 确认弹出的命令与 Agent 实际上报一致,不出现 Lithe 内置命令;Codex 上报的 $Skill 补全为 $名称 而不是 /$名称,输入 $ 只列 Skill;继续输入时按前缀优先过滤,无匹配时显示“没有匹配的命令”,输入空格后关闭;鼠标选择或 Return 补全为“/命令 ”,完整命令按 Return 直接发送并由 Agent 执行;macOS 14 及以上方向键和 Tab 可导航,macOS 13 仍可鼠标选择;Esc 先关闭补全、回复中再次 Esc 才停止;切换会话后显示各自命令列表;上游重复上报同名命令时只显示首条定义,断连后清空旧补全,重连加载后以新进程上报为准;在默认和最小输入区高度、有无附带文件、窄宽面板中输入 /,用长列表和空结果确认输入行可见可编辑,浮出的命令可鼠标选择,方向键选中最后一条时列表跟随滚动。Windows Agent 对话 UI 尚未实现。" + }, + { + "id": "agent-mode-sync", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 自行切换模式(ACP current_mode_update)时同步当前会话的权限模式选择器,仅接受上游配置中存在的模式,不回发设置请求", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "shared/fixtures/agent/acp-events-v1.json" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步当前会话中包含该模式 ID 的 mode 配置,模型与思考选项不改变,不向 Agent 回发 setConfigOption;未知模式 ID 不改变选择器且不显示原始 ID。切换会话确认各自模式独立,历史加载后以 Agent 回放为准。Windows Agent 对话 UI 尚未实现。" + }, { "id": "agent-turn-statistics", "area": "AI", From ba476d49cb72bda55e82ff28d0a962471f92dbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E6=B0=B4?= Date: Fri, 2 Oct 2026 20:58:38 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat(windows):=20=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=A0=8F/=E6=A0=87=E9=A2=98=E6=A0=8F/Run=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E6=9B=BF=E6=8D=A2=E4=B8=BA=20IDEA=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=8C=E5=B9=B6=E5=AF=B9=E9=BD=90=20Commit?= =?UTF-8?q?=20=E9=9D=A2=E6=9D=BF=E4=B8=8E=E5=85=B1=E7=94=A8=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=20(#1025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(windows): 共用 UI 组件补齐 IDEA 风格交互基础能力 - 滚动条:修复 ScrollArea 滑块带 flex-1 被拉满整条轨道的问题,改由 Base UI 按可视比例计算滑块长度(全局所有滚动区域受益) - 触控板:ScrollArea 的滚轮转发支持横向滑动,仅横向可滚动的容器才 处理,纵向列表行为不变 - 树行 SidebarTreeRow: - 新增 leadingAction 左侧操作位(位于展开箭头之后、图标之前), 渲染在行按钮外并按缩进定位,避免按钮嵌套交互元素 - 新增 variant="idea":16px 细线 chevron(IDEA expui 路径、 tree-chevron 颜色、悬停不变色),箭头/复选框/图标/文字 4px 间距, 图标与文字垂直居中 - 文字区增加 data-sidebar-tree-label 供横向滚动测量 - 复选框:支持 indeterminate 半选状态,显示短横 - 拖动会话 document-resize-session:新增 axis: "y",可驱动纵向高度 - 新增对应单测(横向滑动、纵向拖动) * feat(windows): 默认主题 Git 状态配色与表单控件 token 对齐 IDEA 配色取自 intellij-community expUI 明暗配色方案(FILESTATUS_* 与 *_LINES_COLOR): - modified / renamed:#0033B3 / #70AEFF;暂存修改为浅蓝派生 #2652BE / #85BAFF - added:#067D17 / #73BD79;untracked:#B23247 / #E88F89 - 新增可选 token git-file-deleted(删除文件灰 #6C707E / #6F737A); git-deleted 仍为红色,继续用于 diff 删除行与 -N 计数 - 新增可选 token git-gutter-added/modified/deleted,编辑器 gutter 使用 IDEA 浅色行标记(#7FC784/#88ADF7/#767A8A,暗色 #549159/#375FAD/#868A91) - 变更列表、提交文件树、文件树的状态颜色统一走 git-* token, 不再使用 text-info / 灰色 / 红色临时映射 - 新增 IDEA 表单控件 token:control-border、checkbox-border、 checkbox-background、tree-chevron - 以上新 token 在其他主题中未定义时,均回退到原有颜色 - rebase / patch / 历史改写对话框的警告文案与操作横幅由误用的 text-git-modified 改回 text-warning,避免随 modified 变蓝 * feat(windows): Commit 面板对齐 IntelliJ(提交区、变更列表、Amend 与视图选项) 面板与入口 - 侧栏 git 入口与面板标题由 Changes / Source Control 更名为 Commit(提交), 图标换为 IntelliJ commit 图标;中英文案、tooltip、aria 同步 - 移除面板内 Changes / History 标签栏与 git-commit-history 组件,提交历史 统一通过底部 Git Log 工具窗浏览;命令面板去掉 "Git: Show History" - 清理仅服务于标签的设置 rememberLastGitPanelMode / gitLastPanelMode / gitSidebarTabOrder,并加入 RETIRED_SETTINGS_KEYS 清理升级用户的残留键 - Git 数据控制器不再读取 `git log --all`;"与提交比较"选择器打开时才按需 加载最近 50 条提交 提交区(对齐 NonModalCommitPanel / CommitInputBorder / CommitLegendComponent) - 布局:顶部左侧 Amend、右侧已选文件图例(N 添加 / N 修改 / N 删除,未跟踪 计入添加并显示为“新增+未跟踪”,重命名计入修改,按状态着色);中间提交 说明框;底部 Commit 与 Commit and Push... 线框按钮,右侧推送/拉取计数、 AI 与设置 - 与变更列表之间只有一条分割线、无外框;拖动分割线或聚焦后按上/下方向键 调整说明框高度(变更列表至少 96px,说明框最小 48px),松手后持久化 - 说明框:占位文字 "Commit Message / 提交说明";4px 圆角、1px 控件边框, 聚焦 2px 强调色,出错时错误色 - 按钮不再因未选文件或说明为空而置灰;点击后按 IntelliJ CommitProgressPanel 文案提示(请选择要提交的文件 / 请填写提交说明 / 二者合并),修正后消失; 焦点在提交区内时 Commit 变为蓝色默认按钮 - Amend:勾选时读取当前 HEAD 的完整说明(标题 + 正文,新增 getHeadCommitMessage,不再取 `git log --all` 列表首条);空草稿自动载入、 不覆盖已输入内容、取消勾选且未编辑时恢复原草稿;HEAD 无提交时提示; Amend 模式下主按钮显示 Amend Commit,Commit and Push... 禁用(修正后 需要强制推送),多仓库确认对话框按钮同步 变更列表 - 分组改为 IntelliJ 的 "Changes / 更改" 与 "Unversioned Files / 未版本控制 的文件" 两个可展开树节点,节点名后显示灰色 "N 个文件" - 文件、文件夹、分组节点的包含复选框移到左侧(箭头之后、图标之前), IDEA 新 UI 样式,分组与文件夹支持三态;文件夹名后紧跟灰色文件数 - 去掉文件行末尾的暂存 +/- 按钮及其工具代码 - 长文件名不截断,改为横向滚动:按文字真实小数宽度判断截断并留 1px 余量, 面板从隐藏切回显示、侧栏改宽或字体加载完成后重新测量;状态刷新不再重置宽度 - 参考 IntelliJ ExpandableItemsHandler:名称超出可视右边缘时,悬停该行在 行上方弹出完整名称(含文件数或目录),移开、滚动或点击即消失 - 工具栏新增 View Options(眼睛图标),菜单直接列出 "Directory / 目录" 与 "Flat List / 平铺显示" 并勾选当前项,两次点击切换 其他 - 新增/更新单测:提交面板 Amend 与提示、拖动高度、分组节点与三态、复选框 位置、行宽测量、悬停提示判断、视图选项切换、数据控制器不读取历史 - 更新 platform-feature-matrix 的 git-status-commit、git-history 行,新增 git-status-colors-idea-alignment 行,并重新生成视图 * feat(windows): 活动栏、标题栏菜单与 Run 面板图标替换为 IDEA 图标 - 活动栏 Project / Git Log / Terminal / Run / Diagnostics 改用 IntelliJ expui toolwindows 图标 (project、vcs、terminal、run、problems),亮色与暗色各一套 - 标题栏紧凑菜单开关新增 MenuIcon(expui/general/menu),不影响其他使用 ListIcon 的位置 - 新增 RunToolWindowIcon / ProblemsToolWindowIcon,避免改动全局 25 处使用的 WarningIcon - Run 面板标题、空状态与命令面板统一使用 RunToolWindowIcon,移除自绘 RunIcon - generate-idea-icons.ts 支持映射项 destination 字段,用于终端图标这类 位于 platform/icons/src 之外(Jewel showcase 资源)的素材 - 更新 icons.test.tsx 的导出数量与双色渲染断言 * test(windows): svg-editor 测试结束后还原 locale-provider mock bun 的 mock.module 在同一进程内会泄漏到后续测试文件。svg-editor.test.tsx 把 @/i18n/locale-provider 替换成只含 useTranslation 的桩且不还原,导致全量运行时 Commit 面板等依赖真实 LocaleProvider 的测试拿到桩而失败。 在安装 happy-dom 之前保存真实模块,并在 afterAll 中还原。buffer.store 的 mock 无法同样处理(导入真实模块会触发 Tauri 窗口 API),保持不变。 * fix(windows): Amend 按实际暂存仓库读取 HEAD,提交说明分隔条从显示高度起算 - Amend 目标由有暂存文件的仓库决定,而不是活动仓库:单仓库参与时读取该仓库的 HEAD, 多仓库同时有暂存文件时禁用 Amend 并给出提示;目标仓库变化时撤销已加载的说明, 并在说明未被修改时恢复原草稿 - 提交说明编辑框最多显示视口的 60%,拖动与方向键调整改为从实际显示高度开始, 渲染与交互共用同一上限常量 - 补充 A 活动 / B 暂存、活动仓库无 HEAD、多仓库禁用、目标变化,以及窗口缩小后 拖动与键盘调整的回归测试 * fix(git): Amend 绑定到明确的仓库与 HEAD,由 Core 拒绝多仓库改写 Core 建立提交计划时会重新读取所有仓库的暂存状态,而界面只基于缓存的状态读取 并展示了某一个仓库的提交说明。若读取之后又有别的仓库被暂存(例如外部终端, 且关闭了自动刷新),一个全局 amend 会让两个仓库共用同一条说明并同时被改写。 - Core: amend 时有暂存更改的仓库最多一个,否则 invalid_request 且不写入 - Core: 新增可选 amendTarget{repositoryId, expectedHead},拒绝未知目标、 不一致的暂存仓库以及 HEAD 已移动(新提交或切分支)的请求;重试时目标 已提交则跳过 HEAD 检查 - Core: 约束只在 prepare 校验,不写入 Plan,避免 macOS 的计划模型丢弃未知 字段后 amend 悄悄变成普通提交;validate_session 兜底拒绝涉及多个暂存仓库的 amend 续接。缺省 amendTarget 时保持单仓库下限,macOS 无需改动 - Windows: getHeadCommitMessage 同时返回 HEAD hash;面板记录读取说明的仓库与 hash,提交与重试时转换为仓库标识发送,无法确认时提示重新加载 - 补充 Rust 单元与真实 Git 集成回归、前端面板与 API 测试 - 更新 rust-core-api 契约与工作区提交 Agent Note * fix(git): 重试沿用计划记录的 Amend 仓库,父仓库不再被误改写 子仓库 Amend 成功而父仓库提交被钩子拒绝后,父仓库已暂存了新的子模块引用。 重试时 Core 根据当前暂存区重新推断 Amend 目标,会把它当作唯一的暂存仓库并 改写其原 HEAD,用户确认的“新增父引用提交”变成了历史改写。 - Core: Plan 新增 amendRepositoryId,首次建立计划时确定;step 只 Amend 该 仓库,其余仓库(含只记录子模块引用的父仓库)做普通提交 - Core: 重试沿用 previous.plan 记录的仓库,指定不同 amendTarget 时拒绝; validate_session 拒绝指向未知仓库或非 Amend 计划的续接 - Core: 字段缺失(丢弃未知字段的客户端)时退回按暂存路径判断并限制为一个仓库, 行为与此前一致 - Windows: 重试不再根据组件 ref 推断目标,直接交还上一次计划 - 补充真实 Git 回归(子 Amend 成功、父钩子失败、恢复后重试)及单元、前端测试 - 更新 rust-core-api 契约与工作区提交 Agent Note * revert(windows): 移除 Commit 面板的 Amend 功能 Amend 在多仓库工作区里需要把“每个仓库的提交意图”做成 Swift 与 Rust 共享的 模型,当前 PR 内无法在 macOS 缺少构建验证的情况下安全完成,且反复引入回归。 本次先移除,Windows 提交面板恢复为只做普通提交。 - Windows: 删除 Amend 复选框、HEAD 说明读取、目标仓库推断、重试目标处理, 以及确认框的 Amend Commit 文案和相关 i18n 键 - Core: workspace_commit 及其测试、共享契约与 Agent Note 还原为与 preview 一致, macOS 的 amend 布尔字段保持不变,Swift 无需改动 - 测试: 删除全部 Amend 用例,新增“提交始终为普通提交且不显示 Amend”断言 - 矩阵: 去掉功能说明中的 Amend 描述并重新生成 - 保留与 Amend 无关的修复:提交说明分隔条按实际显示高度调整、图标替换、 svg-editor 测试的 locale mock 还原 * feat(agent): 显示 Agent 思考、计划与斜杠命令补全 (#1016) * feat(agent): 显示 Agent 思考、计划与斜杠命令补全 共享 Host 原本就透传 ACP SessionUpdate,但 macOS 只处理消息、工具、 标题、配置和用量,其余更新被丢弃。本次补齐四类标准更新: - agent_thought_chunk:独立的可折叠思考块,流式输出时展开、回复或 工具到达后收起;缓冲按角色切分,思考与回复不再混在一起;导出 Markdown 以引用块保留,历史消息数不计思考 - plan:计划栏固定在活动统计栏上方,显示进度与当前步骤;每次整体 替换,空计划清除 - available_commands_update:输入 / 弹出 Agent 上报的命令补全,前缀 优先;Codex 的 $Skill 补全为 $name(codex-acp 不解析 /$name), 输入 $ 只列 Skill;macOS 14+ 支持方向键与 Tab - current_mode_update:Agent 自行切换模式时同步权限模式选择器 另外把模型层的兜底文案、stopReason 提示和工具证据标题改为本地化, 补齐 zh-Hans 翻译。 fixture 新增四类事件样例并加入 Rust 往返测试;更新功能矩阵与 shared-acp Agent Note。真实 Agent 已在协议层确认 Codex/Claude 会上报 命令列表、Claude 会上报思考,界面层验证仍待完成。 * fix(agent): 修复命令补全布局和思考搜索展开 为输入区保留可编辑行,空间不足时在对话布局中显示可点击的命令浮层。搜索临时展开匹配思考块,清空后恢复手动选择。补充三项回归测试并同步功能矩阵与架构笔记。 * fix(ci): release Windows Bun install process trees before retry --------- Co-authored-by: Yao Jingxi <23722032@bjtu.edu.cn> --------- Co-authored-by: muchen <145200703+Mucheen@users.noreply.github.com> Co-authored-by: Yao Jingxi <23722032@bjtu.edu.cn> Co-authored-by: lick <2188718831@qq.com> --- .github/workflows/ci-windows.yml | 4 + docs/development/platform-parity-matrix.csv | 5 +- docs/development/platform-parity-matrix.md | 13 +- shared/platform-feature-matrix.json | 42 +- windows/tauri/scripts/generate-idea-icons.ts | 37 +- windows/tauri/scripts/idea-icon-mappings.json | 22 +- .../src/extensions/themes/builtin/lithe.json | 36 +- .../command-palette/constants/git-actions.tsx | 15 +- .../constants/view-actions.tsx | 4 +- .../editor/components/svg-editor.test.tsx | 6 + .../features/editor/styles/monaco-editor.css | 10 +- .../file-explorer/lib/file-tree-git-status.ts | 2 +- .../styles/file-explorer-tree.css | 6 + .../git/components/git-commit-history.tsx | 506 ------------------ .../git/components/git-commit-panel.test.tsx | 167 ++++++ .../git/components/git-commit-panel.tsx | 347 +++++++----- .../components/git-history-rewrite-dialog.tsx | 2 +- .../git/components/git-operation-banner.tsx | 2 +- .../git/components/git-patch-dialog.tsx | 2 +- .../git/components/git-rebase-dialog.tsx | 2 +- .../components/git-repository-empty-state.tsx | 3 - .../src/features/git/components/git-view.tsx | 247 ++++----- .../status/git-status-file-item.tsx | 95 +--- .../components/status/git-status-panel.tsx | 283 ++++++++-- .../status/git-status-row-width.test.ts | 41 ++ .../git-workspace-status-panel.test.tsx | 69 +++ .../use-git-status-expandable-hint.test.ts | 26 + .../status/use-git-status-expandable-hint.ts | 144 +++++ .../hooks/use-git-commit-area-resize.test.tsx | 237 ++++++++ .../git/hooks/use-git-commit-area-resize.ts | 175 ++++++ .../git/hooks/use-git-commit-picker.test.tsx | 95 ++++ .../git/hooks/use-git-commit-picker.ts | 52 ++ ...e-git-data-controller.integration.test.tsx | 93 +--- .../git/hooks/use-git-data-controller.ts | 36 +- .../git/hooks/use-git-history-mutations.ts | 5 - .../git-commit-panel-preferences.store.ts | 58 ++ .../git/utils/git-file-stage-action.test.ts | 41 -- .../git/utils/git-file-stage-action.ts | 33 -- .../git/utils/git-file-status-visuals.test.ts | 10 +- .../git/utils/git-file-status-visuals.ts | 13 +- .../features/git/utils/idea-control-styles.ts | 10 + .../components/sidebar/main-sidebar.tsx | 12 +- .../sidebar/sidebar-pane-selector.tsx | 18 +- .../src/features/run/components/run-icon.tsx | 30 -- .../src/features/run/components/run-pane.tsx | 7 +- .../settings/components/tabs/git-settings.tsx | 18 - .../settings/config/default-settings.ts | 3 - .../settings/lib/settings-migrations.ts | 7 +- .../settings/lib/settings-normalization.ts | 15 - .../features/settings/types/settings.types.ts | 3 - .../sidebar/components/sidebar-tree.tsx | 85 ++- .../window/components/title-bar/title-bar.tsx | 3 +- windows/tauri/src/i18n/locale.test.ts | 7 +- windows/tauri/src/i18n/locale.ts | 83 ++- windows/tauri/src/styles/theme.css | 8 + windows/tauri/src/ui/checkbox.tsx | 12 +- windows/tauri/src/ui/icons.test.tsx | 8 +- windows/tauri/src/ui/icons.tsx | 9 + .../src/ui/icons/idea-assets.generated.ts | 19 +- .../src/ui/icons/idea/expui/general/menu.svg | 6 + .../ui/icons/idea/expui/general/menu_dark.svg | 6 + .../icons/idea/expui/toolwindows/problems.svg | 6 + .../idea/expui/toolwindows/problems_dark.svg | 6 + .../icons/idea/expui/toolwindows/project.svg | 4 + .../idea/expui/toolwindows/project_dark.svg | 4 + .../ui/icons/idea/expui/toolwindows/run.svg | 4 + .../icons/idea/expui/toolwindows/run_dark.svg | 4 + .../icons/idea/expui/toolwindows/terminal.svg | 6 + .../idea/expui/toolwindows/terminal_dark.svg | 6 + .../ui/icons/idea/expui/toolwindows/vcs.svg | 7 + .../icons/idea/expui/toolwindows/vcs_dark.svg | 7 + windows/tauri/src/ui/scroll-area.tsx | 4 +- .../src/ui/scroll-container-wheel.test.ts | 16 + .../tauri/src/ui/scroll-container-wheel.ts | 31 +- .../src/utils/document-resize-session.test.ts | 20 +- .../src/utils/document-resize-session.ts | 12 +- 76 files changed, 2153 insertions(+), 1339 deletions(-) delete mode 100644 windows/tauri/src/features/git/components/git-commit-history.tsx create mode 100644 windows/tauri/src/features/git/components/git-commit-panel.test.tsx create mode 100644 windows/tauri/src/features/git/components/status/git-status-row-width.test.ts create mode 100644 windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.test.ts create mode 100644 windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.ts create mode 100644 windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx create mode 100644 windows/tauri/src/features/git/hooks/use-git-commit-area-resize.ts create mode 100644 windows/tauri/src/features/git/hooks/use-git-commit-picker.test.tsx create mode 100644 windows/tauri/src/features/git/hooks/use-git-commit-picker.ts create mode 100644 windows/tauri/src/features/git/stores/git-commit-panel-preferences.store.ts delete mode 100644 windows/tauri/src/features/git/utils/git-file-stage-action.test.ts delete mode 100644 windows/tauri/src/features/git/utils/git-file-stage-action.ts create mode 100644 windows/tauri/src/features/git/utils/idea-control-styles.ts create mode 100644 windows/tauri/src/ui/icons/idea/expui/general/menu.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/general/menu_dark.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/problems.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/problems_dark.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/project.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/project_dark.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/run.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/run_dark.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/terminal.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/terminal_dark.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/vcs.svg create mode 100644 windows/tauri/src/ui/icons/idea/expui/toolwindows/vcs_dark.svg diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index e98877102..ae3937e26 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -301,6 +301,10 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-log-layout.json -- src/features/git/hooks/use-git-log-column-resize.test.tsx src/features/git/components/log/git-log-date-cell.test.tsx src/features/git/components/log/git-commit-file-tree.test.ts src/features/git/utils/git-log-columns.test.ts src/features/git/utils/git-log-date.test.ts src/features/git/utils/multi-file-diff.test.ts src/utils/document-resize-session.test.ts src/features/layout/components/plugin-activity-rail.test.ts src/features/layout/components/workbench-panel-frame.test.ts src/features/layout/config/item-order.test.ts src/features/keymaps/defaults/default-keymaps.git-log.test.ts src/features/terminal/stores/terminal.store.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Commit panel layout and history picker + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-commit-panel.json -- src/features/git/hooks/use-git-commit-picker.test.tsx src/features/git/hooks/use-git-commit-area-resize.test.tsx src/features/git/components/git-commit-panel.test.tsx + - name: Test workspace Git commit adapters and staging shell: pwsh run: | diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index b3f5219bb..049654711 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -39,11 +39,12 @@ search-quick-open,搜索,搜索与导航,快速打开文件与符号,已实现, search-project,搜索,搜索与导航,项目范围文本搜索,已实现,待验证,已实现,待验证,Search,使用同一项目和查询 fixture 对比命中路径、行号、列号和结果排序。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search search-global,搜索,搜索与导航,全局命令与设置搜索,已实现,待验证,已实现,待验证,Search,使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。,,macos/Sources/Lithe/Views/Search,windows/tauri/src/features/global-search; windows/tauri/src/features/command-palette search-replace,搜索,搜索与导航,项目替换与取消,已实现,待验证,已实现,待验证,Search,执行替换预览、确认替换和取消操作,确认未保存文件与异常文件不会被静默覆盖。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search -git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md; windows/tauri/src/features/git/api/git-repository-path.ts; rust/lithe-core/tests/git_path_roundtrip.rs; shared/fixtures/git/windows-paths.json +git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 Windows 另验证提交区(对齐 IntelliJ 非模态提交面板):顶部右侧为已选文件图例(“N 添加 N 修改 N 删除”,未跟踪计入添加并显示为“新增+未跟踪”,重命名计入修改,各段按文件状态着色,未选择文件时不显示);中间为提交说明编辑框(占位文字为“Commit Message/提交说明”,不带省略号;4px 圆角,常态 1px 控件边框,聚焦 2px 强调色边框,失败时错误色边框);底部左侧为 IDEA 线框样式的 Commit 与 Commit and Push... 按钮(28px 高、4px 圆角、1px 边框),右侧为 AI 生成与设置按钮。提交区与变更列表之间只有一条分割线、无外框,拖动分割线(或聚焦后按上/下方向键)可调整提交说明编辑框高度:向上拖动变高,变更列表至少保留 96px,编辑框最小 48px,松开后保存并在重启后恢复,拖动过程中不写入持久化。提交按钮与 Commit and Push... 在未选文件或说明为空时不置灰:点击后在按钮上方显示 IDEA 文案提示(“请选择要提交的文件” / “请填写提交说明” / 二者合并),修正对应项后提示自动消失;焦点位于提交区(如编辑说明)时 Commit 变为蓝色默认按钮。变更列表按 IDEA 分为“更改”(Changes)与“未版本控制的文件”(Unversioned Files)两个可展开节点,节点名后显示灰色“N 个文件”,复选框为三态(部分包含时显示短横);文件、文件夹、分组节点的包含复选框位于展开箭头之后、图标之前,箭头为 IDEA 细线 chevron(16px、1px 描边,亮色 #818594、暗色 #B4B8BF,悬停不变色),图标与文字在行内垂直居中,箭头/复选框/图标/文字间距 4px,文件夹名后紧跟灰色文件数;长文件名不再截断,变更列表出现横向滚动条。 变更列表工具栏有 IDEA 式“视图选项”(眼睛图标),菜单中直接列出“目录 / 平铺显示”并勾选当前项,两次点击即可切换(平铺时文件名后附灰色目录),选择持久化;文件行末尾不再显示暂存 +/- 按钮;列表很长时纵向/横向滚动条滑块按可视比例显示长度,笔记本触控板左右滑动可横向滚动;面板从隐藏切回显示、侧栏改宽或字体加载完成后,长文件名仍完整显示而不被截断;名称超出面板可视右边缘时,鼠标悬停该行会像 IDEA 一样在行上方弹出完整名称(含文件数或目录),移开、滚动或点击即消失。 Windows 补充验证:先拉高提交说明,再缩小窗口或侧栏可用高度、触发按钮换行,确认变更列表仍保留 96px;恢复空间后恢复原高度偏好,隐藏时不覆盖偏好,继续拖动从实际高度开始。提交比较选择器查询失败时显示错误和重试;重试成功且结果为空才显示无提交;关闭、切换仓库或工作区后迟到结果不得覆盖当前状态。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md; windows/tauri/src/features/git/api/git-repository-path.ts; rust/lithe-core/tests/git_path_roundtrip.rs; shared/fixtures/git/windows-paths.json; windows/tauri/src/features/git/hooks/use-git-commit-area-resize.ts; windows/tauri/src/features/git/stores/git-commit-panel-preferences.store.ts; windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/sidebar/components/sidebar-tree.tsx; windows/tauri/src/features/git/utils/idea-control-styles.ts; windows/tauri/src/features/git/hooks/use-git-commit-picker.ts; windows/tauri/src/features/git/hooks/use-git-commit-picker.test.tsx; windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx +git-status-colors-idea-alignment,版本控制,Git,Windows 默认主题 Git 状态配色与 IDEA 对齐(修改/重命名蓝、新增绿、删除灰、未跟踪暖红、gutter 浅色),平台专属,不适用,已实现,待验证,Git,在 Windows 打开包含已修改、已暂存修改、新增、删除、重命名、未跟踪文件与 .gitignore 忽略项的仓库,在 Lithe 明暗主题下确认:文件树、Commit 面板变更列表与 Git Log 提交文件树中已修改和重命名为蓝色(暂存修改为浅蓝)、新增为绿色、删除为灰色、未跟踪为暖红、忽略文件仍为置灰半透明;编辑器 gutter 修改/新增/删除标记为 IDEA 浅蓝/浅绿/灰色;diff 删除行与 -N 计数仍为红色;切换到未定义新 token 的其他内置主题时颜色回退正常;rebase/patch/历史改写对话框的警告文案与操作横幅保持琥珀警示色。,对齐 IntelliJ IDEA 新 UI 的 FILESTATUS 与 *_LINES_COLOR(来源 intellij-community expUI_lightScheme/expUI_darkScheme):modified 与 renamed #0033B3/#70AEFF、added #067D17/#73BD79、deleted 文件 #6C707E/#6F737A(新增可选 token git-file-deleted)、untracked #B23247/#E88F89;编辑器 gutter 使用可选 token git-gutter-added/modified/deleted(#7FC784/#88ADF7/#767A8A,暗色 #549159/#375FAD/#868A91)。git-deleted 保持红色,继续用于 diff 删除行与删除计数;git-modified-staged 为蓝色浅化派生。其他主题未定义新 token 时回退到 git-deleted/git-added/git-modified。原误用 text-git-modified 作为警告色的 rebase/patch/历史改写对话框与操作横幅改回 text-warning。变更列表与提交文件树不再使用 text-info/灰色/红色临时映射,统一走 git-* token。macOS 项目树配色体系不同(modified 用 accent),不属于本行对齐范围。,macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift,windows/tauri/src/extensions/themes/builtin/lithe.json; windows/tauri/src/styles/theme.css; windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts; windows/tauri/src/features/editor/styles/monaco-editor.css; windows/tauri/src/features/git/components/git-operation-banner.tsx; windows/tauri/src/features/git/utils/git-file-status-visuals.ts git-multi-repository-change-groups,版本控制,Git,多仓库变更折叠分组,已实现,待验证,已实现,待验证,Git,在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift; macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift; macos/Sources/LitheGitModule/Models/GitModels.swift; rust/lithe-core/src/git/mod.rs,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json git-branches-remotes,版本控制,Git,分支、标签与远程,已实现,待验证,已实现,待验证,Git,创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer -git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx; windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx +git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx; windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 2609cfe43..c4bb025e1 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:130 -- macOS:实现:✅ 115 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 4 已验证,🔍 114 待验证,— 12 不适用 -- Windows:实现:✅ 102 已实现,🟡 12 部分实现,❌ 14 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 118 待验证,— 12 不适用 +- 功能项:131 +- macOS:实现:✅ 115 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 6 平台专属;验证:✔️ 4 已验证,🔍 114 待验证,— 13 不适用 +- Windows:实现:✅ 103 已实现,🟡 12 部分实现,❌ 14 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 119 待验证,— 12 不适用 ## 实现状态定义 @@ -121,15 +121,16 @@
-版本控制 · 11 个能力点 +版本控制 · 12 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md`、`windows/tauri/src/features/git/api/git-repository-path.ts`、`rust/lithe-core/tests/git_path_roundtrip.rs`、`shared/fixtures/git/windows-paths.json` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 | | +| Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md`、`windows/tauri/src/features/git/api/git-repository-path.ts`、`rust/lithe-core/tests/git_path_roundtrip.rs`、`shared/fixtures/git/windows-paths.json`、`windows/tauri/src/features/git/hooks/use-git-commit-area-resize.ts`、`windows/tauri/src/features/git/stores/git-commit-panel-preferences.store.ts`、`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/sidebar/components/sidebar-tree.tsx`、`windows/tauri/src/features/git/utils/idea-control-styles.ts`、`windows/tauri/src/features/git/hooks/use-git-commit-picker.ts`、`windows/tauri/src/features/git/hooks/use-git-commit-picker.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 Windows 另验证提交区(对齐 IntelliJ 非模态提交面板):顶部右侧为已选文件图例(“N 添加 N 修改 N 删除”,未跟踪计入添加并显示为“新增+未跟踪”,重命名计入修改,各段按文件状态着色,未选择文件时不显示);中间为提交说明编辑框(占位文字为“Commit Message/提交说明”,不带省略号;4px 圆角,常态 1px 控件边框,聚焦 2px 强调色边框,失败时错误色边框);底部左侧为 IDEA 线框样式的 Commit 与 Commit and Push... 按钮(28px 高、4px 圆角、1px 边框),右侧为 AI 生成与设置按钮。提交区与变更列表之间只有一条分割线、无外框,拖动分割线(或聚焦后按上/下方向键)可调整提交说明编辑框高度:向上拖动变高,变更列表至少保留 96px,编辑框最小 48px,松开后保存并在重启后恢复,拖动过程中不写入持久化。提交按钮与 Commit and Push... 在未选文件或说明为空时不置灰:点击后在按钮上方显示 IDEA 文案提示(“请选择要提交的文件” / “请填写提交说明” / 二者合并),修正对应项后提示自动消失;焦点位于提交区(如编辑说明)时 Commit 变为蓝色默认按钮。变更列表按 IDEA 分为“更改”(Changes)与“未版本控制的文件”(Unversioned Files)两个可展开节点,节点名后显示灰色“N 个文件”,复选框为三态(部分包含时显示短横);文件、文件夹、分组节点的包含复选框位于展开箭头之后、图标之前,箭头为 IDEA 细线 chevron(16px、1px 描边,亮色 #818594、暗色 #B4B8BF,悬停不变色),图标与文字在行内垂直居中,箭头/复选框/图标/文字间距 4px,文件夹名后紧跟灰色文件数;长文件名不再截断,变更列表出现横向滚动条。 变更列表工具栏有 IDEA 式“视图选项”(眼睛图标),菜单中直接列出“目录 / 平铺显示”并勾选当前项,两次点击即可切换(平铺时文件名后附灰色目录),选择持久化;文件行末尾不再显示暂存 +/- 按钮;列表很长时纵向/横向滚动条滑块按可视比例显示长度,笔记本触控板左右滑动可横向滚动;面板从隐藏切回显示、侧栏改宽或字体加载完成后,长文件名仍完整显示而不被截断;名称超出面板可视右边缘时,鼠标悬停该行会像 IDEA 一样在行上方弹出完整名称(含文件数或目录),移开、滚动或点击即消失。 Windows 补充验证:先拉高提交说明,再缩小窗口或侧栏可用高度、触发按钮换行,确认变更列表仍保留 96px;恢复空间后恢复原高度偏好,隐藏时不覆盖偏好,继续拖动从实际高度开始。提交比较选择器查询失败时显示错误和重试;重试成功且结果为空才显示无提交;关闭、切换仓库或工作区后迟到结果不得覆盖当前状态。 | | +| Git | **Windows 默认主题 Git 状态配色与 IDEA 对齐(修改/重命名蓝、新增绿、删除灰、未跟踪暖红、gutter 浅色)**
git-status-colors-idea-alignment | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions/themes/builtin/lithe.json`、`windows/tauri/src/styles/theme.css`、`windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts`、`windows/tauri/src/features/editor/styles/monaco-editor.css`、`windows/tauri/src/features/git/components/git-operation-banner.tsx`、`windows/tauri/src/features/git/utils/git-file-status-visuals.ts` | Git | 在 Windows 打开包含已修改、已暂存修改、新增、删除、重命名、未跟踪文件与 .gitignore 忽略项的仓库,在 Lithe 明暗主题下确认:文件树、Commit 面板变更列表与 Git Log 提交文件树中已修改和重命名为蓝色(暂存修改为浅蓝)、新增为绿色、删除为灰色、未跟踪为暖红、忽略文件仍为置灰半透明;编辑器 gutter 修改/新增/删除标记为 IDEA 浅蓝/浅绿/灰色;diff 删除行与 -N 计数仍为红色;切换到未定义新 token 的其他内置主题时颜色回退正常;rebase/patch/历史改写对话框的警告文案与操作横幅保持琥珀警示色。 | 对齐 IntelliJ IDEA 新 UI 的 FILESTATUS 与 *_LINES_COLOR(来源 intellij-community expUI_lightScheme/expUI_darkScheme):modified 与 renamed #0033B3/#70AEFF、added #067D17/#73BD79、deleted 文件 #6C707E/#6F737A(新增可选 token git-file-deleted)、untracked #B23247/#E88F89;编辑器 gutter 使用可选 token git-gutter-added/modified/deleted(#7FC784/#88ADF7/#767A8A,暗色 #549159/#375FAD/#868A91)。git-deleted 保持红色,继续用于 diff 删除行与删除计数;git-modified-staged 为蓝色浅化派生。其他主题未定义新 token 时回退到 git-deleted/git-added/git-modified。原误用 text-git-modified 作为警告色的 rebase/patch/历史改写对话框与操作横幅改回 text-warning。变更列表与提交文件树不再使用 text-info/灰色/红色临时映射,统一走 git-* token。macOS 项目树配色体系不同(modified 用 accent),不属于本行对齐范围。 | | Git | **多仓库变更折叠分组**
git-multi-repository-change-groups | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift`、`macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift`、`macos/Sources/LitheGitModule/Models/GitModels.swift`、`rust/lithe-core/src/git/mod.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | Git | 在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | | Git | **分支、标签与远程**
git-branches-remotes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 | | | Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 | | -| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/components/log/git-commit-details.tsx`、`windows/tauri/src/features/git/components/log/git-commit-details.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | | +| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/components/log/git-commit-details.tsx`、`windows/tauri/src/features/git/components/log/git-commit-details.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。 | | | Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | | Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index cb9c74d2c..3943a9fef 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1106,13 +1106,49 @@ "shared/contracts/application-boundary.md", "windows/tauri/src/features/git/api/git-repository-path.ts", "rust/lithe-core/tests/git_path_roundtrip.rs", - "shared/fixtures/git/windows-paths.json" + "shared/fixtures/git/windows-paths.json", + "windows/tauri/src/features/git/hooks/use-git-commit-area-resize.ts", + "windows/tauri/src/features/git/stores/git-commit-panel-preferences.store.ts", + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/sidebar/components/sidebar-tree.tsx", + "windows/tauri/src/features/git/utils/idea-control-styles.ts", + "windows/tauri/src/features/git/hooks/use-git-commit-picker.ts", + "windows/tauri/src/features/git/hooks/use-git-commit-picker.test.tsx", + "windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Git", + "verification": "修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 Windows 另验证提交区(对齐 IntelliJ 非模态提交面板):顶部右侧为已选文件图例(“N 添加 N 修改 N 删除”,未跟踪计入添加并显示为“新增+未跟踪”,重命名计入修改,各段按文件状态着色,未选择文件时不显示);中间为提交说明编辑框(占位文字为“Commit Message/提交说明”,不带省略号;4px 圆角,常态 1px 控件边框,聚焦 2px 强调色边框,失败时错误色边框);底部左侧为 IDEA 线框样式的 Commit 与 Commit and Push... 按钮(28px 高、4px 圆角、1px 边框),右侧为 AI 生成与设置按钮。提交区与变更列表之间只有一条分割线、无外框,拖动分割线(或聚焦后按上/下方向键)可调整提交说明编辑框高度:向上拖动变高,变更列表至少保留 96px,编辑框最小 48px,松开后保存并在重启后恢复,拖动过程中不写入持久化。提交按钮与 Commit and Push... 在未选文件或说明为空时不置灰:点击后在按钮上方显示 IDEA 文案提示(“请选择要提交的文件” / “请填写提交说明” / 二者合并),修正对应项后提示自动消失;焦点位于提交区(如编辑说明)时 Commit 变为蓝色默认按钮。变更列表按 IDEA 分为“更改”(Changes)与“未版本控制的文件”(Unversioned Files)两个可展开节点,节点名后显示灰色“N 个文件”,复选框为三态(部分包含时显示短横);文件、文件夹、分组节点的包含复选框位于展开箭头之后、图标之前,箭头为 IDEA 细线 chevron(16px、1px 描边,亮色 #818594、暗色 #B4B8BF,悬停不变色),图标与文字在行内垂直居中,箭头/复选框/图标/文字间距 4px,文件夹名后紧跟灰色文件数;长文件名不再截断,变更列表出现横向滚动条。 变更列表工具栏有 IDEA 式“视图选项”(眼睛图标),菜单中直接列出“目录 / 平铺显示”并勾选当前项,两次点击即可切换(平铺时文件名后附灰色目录),选择持久化;文件行末尾不再显示暂存 +/- 按钮;列表很长时纵向/横向滚动条滑块按可视比例显示长度,笔记本触控板左右滑动可横向滚动;面板从隐藏切回显示、侧栏改宽或字体加载完成后,长文件名仍完整显示而不被截断;名称超出面板可视右边缘时,鼠标悬停该行会像 IDEA 一样在行上方弹出完整名称(含文件数或目录),移开、滚动或点击即消失。 Windows 补充验证:先拉高提交说明,再缩小窗口或侧栏可用高度、触发按钮换行,确认变更列表仍保留 96px;恢复空间后恢复原高度偏好,隐藏时不覆盖偏好,继续拖动从实际高度开始。提交比较选择器查询失败时显示错误和重试;重试成功且结果为空才显示无提交;关闭、切换仓库或工作区后迟到结果不得覆盖当前状态。" + }, + { + "id": "git-status-colors-idea-alignment", + "area": "版本控制", + "group": "Git", + "capability": "Windows 默认主题 Git 状态配色与 IDEA 对齐(修改/重命名蓝、新增绿、删除灰、未跟踪暖红、gutter 浅色)", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift" + ], + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable" + }, + "windows": { + "evidence": [ + "windows/tauri/src/extensions/themes/builtin/lithe.json", + "windows/tauri/src/styles/theme.css", + "windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts", + "windows/tauri/src/features/editor/styles/monaco-editor.css", + "windows/tauri/src/features/git/components/git-operation-banner.tsx", + "windows/tauri/src/features/git/utils/git-file-status-visuals.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。" + "notes": "对齐 IntelliJ IDEA 新 UI 的 FILESTATUS 与 *_LINES_COLOR(来源 intellij-community expUI_lightScheme/expUI_darkScheme):modified 与 renamed #0033B3/#70AEFF、added #067D17/#73BD79、deleted 文件 #6C707E/#6F737A(新增可选 token git-file-deleted)、untracked #B23247/#E88F89;编辑器 gutter 使用可选 token git-gutter-added/modified/deleted(#7FC784/#88ADF7/#767A8A,暗色 #549159/#375FAD/#868A91)。git-deleted 保持红色,继续用于 diff 删除行与删除计数;git-modified-staged 为蓝色浅化派生。其他主题未定义新 token 时回退到 git-deleted/git-added/git-modified。原误用 text-git-modified 作为警告色的 rebase/patch/历史改写对话框与操作横幅改回 text-warning。变更列表与提交文件树不再使用 text-info/灰色/红色临时映射,统一走 git-* token。macOS 项目树配色体系不同(modified 用 accent),不属于本行对齐范围。", + "verification": "在 Windows 打开包含已修改、已暂存修改、新增、删除、重命名、未跟踪文件与 .gitignore 忽略项的仓库,在 Lithe 明暗主题下确认:文件树、Commit 面板变更列表与 Git Log 提交文件树中已修改和重命名为蓝色(暂存修改为浅蓝)、新增为绿色、删除为灰色、未跟踪为暖红、忽略文件仍为置灰半透明;编辑器 gutter 修改/新增/删除标记为 IDEA 浅蓝/浅绿/灰色;diff 删除行与 -N 计数仍为红色;切换到未定义新 token 的其他内置主题时颜色回退正常;rebase/patch/历史改写对话框的警告文案与操作横幅保持琥珀警示色。" }, { "id": "git-multi-repository-change-groups", @@ -1224,7 +1260,7 @@ "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。" }, { "id": "git-rebase-stash", diff --git a/windows/tauri/scripts/generate-idea-icons.ts b/windows/tauri/scripts/generate-idea-icons.ts index b04ed0434..5520d4263 100644 --- a/windows/tauri/scripts/generate-idea-icons.ts +++ b/windows/tauri/scripts/generate-idea-icons.ts @@ -18,6 +18,12 @@ import { dirname, join, resolve } from "node:path"; interface MappingEntry { icon: string; source: string | null; + /** + * Repo-relative path under src/ui/icons/idea to copy to, for assets that + * live outside platform/icons/src (for example the terminal glyph that only + * ships in the Jewel showcase resources). Defaults to `source`. + */ + destination?: string; note?: string; } @@ -65,11 +71,21 @@ const iconsTsx = readFileSync(ICONS_TSX_PATH, "utf8"); interface PlannedIcon { icon: string; - source: string; + /** Light/dark paths relative to --source. */ + lightSource: string; + darkSource: string | null; + /** Light/dark paths relative to src/ui/icons/idea. */ light: string; dark: string | null; } +/** [destination relative to ASSETS_DIR, source relative to --source] per variant. */ +function assetPairs(p: PlannedIcon): Array<[string, string]> { + const pairs: Array<[string, string]> = [[p.light, p.lightSource]]; + if (p.dark && p.darkSource) pairs.push([p.dark, p.darkSource]); + return pairs; +} + const planned: PlannedIcon[] = []; const missingSources: string[] = []; const unknownExports: string[] = []; @@ -94,11 +110,14 @@ for (const entry of mapping.icons) { } const darkRel = entry.source.replace(/\.svg$/, "_dark.svg"); const darkAbs = join(sourceRoot, darkRel); + const hasDark = existsSync(darkAbs); + const destination = entry.destination ?? entry.source; planned.push({ icon: entry.icon, - source: entry.source, - light: entry.source, - dark: existsSync(darkAbs) ? darkRel : null, + lightSource: entry.source, + darkSource: hasDark ? darkRel : null, + light: destination, + dark: hasDark ? destination.replace(/\.svg$/, "_dark.svg") : null, }); } @@ -139,12 +158,11 @@ if (check) { problems.push("src/ui/icons/idea-assets.generated.ts is out of date; rerun the generator"); } for (const p of planned) { - const pairs = p.dark ? [p.light, p.dark] : [p.light]; - for (const rel of pairs) { + for (const [rel, src] of assetPairs(p)) { const copied = join(ASSETS_DIR, rel); if (!existsSync(copied)) { problems.push(`missing copied asset: src/ui/icons/idea/${rel}`); - } else if (sha(readFileSync(copied)) !== sha(readFileSync(join(sourceRoot, rel)))) { + } else if (sha(readFileSync(copied)) !== sha(readFileSync(join(sourceRoot, src)))) { problems.push(`copied asset differs from source: src/ui/icons/idea/${rel}`); } } @@ -158,11 +176,10 @@ if (check) { } for (const p of planned) { - const pairs = p.dark ? [p.light, p.dark] : [p.light]; - for (const rel of pairs) { + for (const [rel, src] of assetPairs(p)) { const dest = join(ASSETS_DIR, rel); mkdirSync(dirname(dest), { recursive: true }); - writeFileSync(dest, readFileSync(join(sourceRoot, rel))); + writeFileSync(dest, readFileSync(join(sourceRoot, src))); } } writeFileSync(GENERATED_TS_PATH, generated); diff --git a/windows/tauri/scripts/idea-icon-mappings.json b/windows/tauri/scripts/idea-icon-mappings.json index 33263c57d..48c6564e4 100644 --- a/windows/tauri/scripts/idea-icon-mappings.json +++ b/windows/tauri/scripts/idea-icon-mappings.json @@ -332,7 +332,7 @@ }, { "icon": "FilesIcon", - "source": "expui/general/listFiles.svg" + "source": "expui/toolwindows/project.svg" }, { "icon": "FlipHorizontalIcon", @@ -396,8 +396,7 @@ }, { "icon": "GitGraphIcon", - "source": null, - "note": "no confident IntelliJ platform asset; lucide fallback" + "source": "expui/toolwindows/vcs.svg" }, { "icon": "GitMergeIcon", @@ -529,6 +528,10 @@ "source": null, "note": "no confident IntelliJ platform asset; lucide fallback" }, + { + "icon": "MenuIcon", + "source": "expui/general/menu.svg" + }, { "icon": "MicrophoneIcon", "source": null, @@ -642,6 +645,10 @@ "icon": "PlusIcon", "source": "expui/general/add.svg" }, + { + "icon": "ProblemsToolWindowIcon", + "source": "expui/toolwindows/problems.svg" + }, { "icon": "PulseIcon", "source": null, @@ -693,6 +700,10 @@ "source": null, "note": "no confident IntelliJ platform asset; lucide fallback" }, + { + "icon": "RunToolWindowIcon", + "source": "expui/toolwindows/run.svg" + }, { "icon": "ScissorsIcon", "source": "expui/general/cut.svg" @@ -780,8 +791,9 @@ }, { "icon": "TerminalWindowIcon", - "source": null, - "note": "no confident IntelliJ platform asset; lucide fallback" + "source": "../../jewel/samples/showcase/src/main/resources/icons/terminal.svg", + "destination": "expui/toolwindows/terminal.svg", + "note": "IDEA terminal tool window glyph; only present under Jewel showcase resources in the community checkout" }, { "icon": "TextAaIcon", diff --git a/windows/tauri/src/extensions/themes/builtin/lithe.json b/windows/tauri/src/extensions/themes/builtin/lithe.json index f340924ac..f1cb555b7 100644 --- a/windows/tauri/src/extensions/themes/builtin/lithe.json +++ b/windows/tauri/src/extensions/themes/builtin/lithe.json @@ -15,6 +15,10 @@ "muted-foreground": "#4f5965", "subtle-foreground": "#68717d", "border": "#dfe1e5", + "control-border": "#c9ccd6", + "checkbox-border": "#a8adbd", + "checkbox-background": "#ffffff", + "tree-chevron": "#818594", "accent": "#edf3ff", "selected": "#d4e2ff", "selection": "rgba(53, 116, 240, 0.2)", @@ -26,12 +30,16 @@ "success": "#27864f", "warning": "#a86400", "info": "#3574f0", - "git-modified": "#a86400", - "git-modified-staged": "#bd7411", - "git-added": "#27864f", + "git-modified": "#0033b3", + "git-modified-staged": "#2652be", + "git-added": "#067d17", "git-deleted": "#cf3f4f", - "git-untracked": "#0877c1", - "git-renamed": "#7656a8", + "git-file-deleted": "#6c707e", + "git-untracked": "#b23247", + "git-renamed": "#0033b3", + "git-gutter-added": "#7fc784", + "git-gutter-modified": "#88adf7", + "git-gutter-deleted": "#767a8a", "terminal-black": "#1f2328", "terminal-red": "#cf3f4f", "terminal-green": "#27864f", @@ -82,6 +90,10 @@ "muted-foreground": "#b4b8bf", "subtle-foreground": "#8b929e", "border": "#43454a", + "control-border": "#4e5157", + "checkbox-border": "#5a5d63", + "checkbox-background": "#2b2d30", + "tree-chevron": "#b4b8bf", "accent": "#393b40", "selected": "#2e436e", "selection": "#214283", @@ -93,12 +105,16 @@ "success": "#57965c", "warning": "#d6ae58", "info": "#548af7", - "git-modified": "#d9a441", - "git-modified-staged": "#e5b75e", - "git-added": "#4cc38a", + "git-modified": "#70aeff", + "git-modified-staged": "#85baff", + "git-added": "#73bd79", "git-deleted": "#f16d75", - "git-untracked": "#58a6e7", - "git-renamed": "#c8a2f4", + "git-file-deleted": "#6f737a", + "git-untracked": "#e88f89", + "git-renamed": "#70aeff", + "git-gutter-added": "#549159", + "git-gutter-modified": "#375fad", + "git-gutter-deleted": "#868a91", "terminal-black": "#0f1012", "terminal-red": "#f16d75", "terminal-green": "#4cc38a", diff --git a/windows/tauri/src/features/command-palette/constants/git-actions.tsx b/windows/tauri/src/features/command-palette/constants/git-actions.tsx index 0e88b453f..e8c71e817 100644 --- a/windows/tauri/src/features/command-palette/constants/git-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/git-actions.tsx @@ -1,7 +1,6 @@ import { ArrowUpIcon as ArrowUp, ArchiveIcon as Archive, - ClockCounterClockwiseIcon as ClockCounterClockwise, FolderOpenIcon as FolderOpen, GitBranchIcon as GitBranch, GitCommitIcon as GitCommit, @@ -154,15 +153,11 @@ export const createGitActions = (params: GitActionsParams): Action[] => { icon: , category: "Git", commandId: "workbench.showSourceControl", - action: () => openGitAction({ type: "show-tab", tab: "changes" }), - }, - { - id: "git-show-history", - label: "Git: Show History", - description: "Open commit history", - icon: , - category: "Git", - action: () => openGitAction({ type: "show-tab", tab: "history" }), + action: () => { + setIsSidebarVisible(true); + setActiveView("git"); + onClose(); + }, }, { id: "git-open-log", diff --git a/windows/tauri/src/features/command-palette/constants/view-actions.tsx b/windows/tauri/src/features/command-palette/constants/view-actions.tsx index b3cb53439..28973395c 100644 --- a/windows/tauri/src/features/command-palette/constants/view-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/view-actions.tsx @@ -1,4 +1,3 @@ -import { RunIcon } from "@/features/run/components/run-icon"; import { WarningCircleIcon as AlertCircle, ColumnsIcon as Columns, @@ -9,6 +8,7 @@ import { SidebarSimpleIcon as PanelBottom, SidebarSimpleIcon as PanelLeft, ArrowCounterClockwiseIcon as RotateCcw, + RunToolWindowIcon, MagnifyingGlassIcon as Search, TerminalWindowIcon as Terminal, MagnifyingGlassPlusIcon as ZoomIn, @@ -109,7 +109,7 @@ export const createViewActions = (params: ViewActionsParams): Action[] => { label: isBottomPaneVisible && bottomPaneActiveTab === "run" ? "View: Hide Run" : "View: Show Run", description: "Toggle the Run tool window", - icon: , + icon: , category: "View", commandId: "workbench.toggleRun", action: () => { diff --git a/windows/tauri/src/features/editor/components/svg-editor.test.tsx b/windows/tauri/src/features/editor/components/svg-editor.test.tsx index 50cd022e9..cd418e821 100644 --- a/windows/tauri/src/features/editor/components/svg-editor.test.tsx +++ b/windows/tauri/src/features/editor/components/svg-editor.test.tsx @@ -6,6 +6,11 @@ import { installHappyDom } from "@/test-utils/happy-dom"; import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { isImageFile, isKnownTextFile } from "@/features/file-system/controllers/file-utils"; +// Bun module mocks outlive this file, so keep a copy of the real locale module and put it +// back in afterAll; otherwise later files in the same run render with this stub. Import it +// before the DOM is installed because its store graph calls the Tauri window API when a +// window exists. The buffer store cannot be captured this way for the same reason. +const realLocaleProviderModule = { ...(await import("@/i18n/locale-provider")) }; const restoreDom = installHappyDom(); const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; @@ -69,6 +74,7 @@ afterEach(async () => { store.setState({ buffers: [buffer] }); }); afterAll(() => { + mock.module("@/i18n/locale-provider", () => realLocaleProviderModule); actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct; globalThis.ResizeObserver = previousResizeObserver; globalThis.DOMRect = previousDOMRect; diff --git a/windows/tauri/src/features/editor/styles/monaco-editor.css b/windows/tauri/src/features/editor/styles/monaco-editor.css index 3fd46e5df..cd0807a5f 100644 --- a/windows/tauri/src/features/editor/styles/monaco-editor.css +++ b/windows/tauri/src/features/editor/styles/monaco-editor.css @@ -517,7 +517,9 @@ display: none !important; } -/* Git change markers in the line-decoration gutter (#681). */ +/* Git change markers in the line-decoration gutter (#681). The optional + git-gutter-* tokens carry IntelliJ's lighter *_LINES_COLOR values; themes + without them reuse the file-status colors. */ .monaco-editor-shell .monaco-editor .lithe-git-gutter { width: 3px !important; margin-left: 3px; @@ -525,11 +527,11 @@ } .monaco-editor-shell .monaco-editor .lithe-git-gutter-added { - background: var(--git-added); + background: var(--git-gutter-added, var(--git-added)); } .monaco-editor-shell .monaco-editor .lithe-git-gutter-modified { - background: var(--git-modified); + background: var(--git-gutter-modified, var(--git-modified)); } /* A deletion owns no line; draw a wedge at the top of the following line. */ @@ -538,7 +540,7 @@ height: 0 !important; border-top: 4px solid transparent; border-bottom: 4px solid transparent; - border-left: 6px solid var(--git-deleted); + border-left: 6px solid var(--git-gutter-deleted, var(--git-deleted)); margin-top: -4px; } diff --git a/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts b/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts index 3d157c61c..91d7910c0 100644 --- a/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts +++ b/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts @@ -37,7 +37,7 @@ export function getFileTreeGitStatusDecoration( case "added": return { colorClassName: "text-git-added", label: "Added" }; case "deleted": - return { colorClassName: "text-git-deleted", label: "Deleted" }; + return { colorClassName: "text-git-file-deleted", label: "Deleted" }; case "untracked": return { colorClassName: "text-git-untracked", label: "Untracked" }; case "renamed": diff --git a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css index 6201a714b..36c36b9de 100644 --- a/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css +++ b/windows/tauri/src/features/file-explorer/styles/file-explorer-tree.css @@ -123,6 +123,12 @@ height: 12px; } +/* IntelliJ chevrons are drawn at their native 16px with a 1px stroke. */ +.file-tree-container .file-tree-row [data-sidebar-tree-disclosure][data-variant="idea"] svg { + width: 16px; + height: 16px; +} + .file-tree-container .file-tree-node-icon { width: var(--file-tree-icon-size) !important; height: var(--file-tree-icon-size) !important; diff --git a/windows/tauri/src/features/git/components/git-commit-history.tsx b/windows/tauri/src/features/git/components/git-commit-history.tsx deleted file mode 100644 index dd1a86c31..000000000 --- a/windows/tauri/src/features/git/components/git-commit-history.tsx +++ /dev/null @@ -1,506 +0,0 @@ -import { showGitRebaseDialog } from "../services/git-rebase-dialog-service"; -import { - ArrowCounterClockwiseIcon as Reset, - FunnelIcon as Funnel, - GitCommitIcon as CherryPick, - GitMergeIcon as Squash, - PencilIcon as Edit, - TrashIcon as Trash, -} from "@/ui/icons"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type React from "react"; -import { writeSidebarResourceDragData } from "@/features/sidebar/utils/sidebar-resource-drag"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - Dropdown, - useDropdownMenu, - type MenuItem, -} from "@/ui/dropdown"; -import { Spinner } from "@/ui/spinner"; -import { Avatar } from "@/ui/avatar"; -import { EmptyState } from "@/ui/empty"; -import { SidebarHeaderIconButton, SidebarHeader, SidebarSearchPopover } from "@/ui/sidebar"; -import { formatRelativeDate } from "@/utils/date"; -import { matchesSearchQuery } from "@/utils/search-match"; -import { cn } from "@/utils/cn"; -import { useTranslation } from "@/i18n/locale-provider"; -import type { GitCommit } from "../types/git.types"; -import { useGitStore } from "../stores/git.store"; -import { getGitAuthorAvatarUrl } from "../utils/git-author-avatar"; -import { useGitHistoryMutations } from "../hooks/use-git-history-mutations"; -import { isGitHeadCommit } from "../utils/git-history-message"; -import { showGitPatchDialog } from "../services/git-patch-dialog-service"; -import { - isContiguousGitHistorySelection, - resolveGitHistoryContextSelection, - selectedCommitsInHistoryOrder, - updateGitHistorySelection, -} from "../utils/git-history-selection"; - -interface GitCommitHistoryProps { - onViewCommitDiff?: (commitHash: string, filePath?: string) => void; - repoPath?: string; - ahead?: number; - behind?: number; -} - -interface CommitItemProps { - commit: GitCommit; - onSelect: (event: React.MouseEvent, commit: GitCommit) => void; - onContextMenu: (event: React.MouseEvent, commit: GitCommit) => void; - isSelected: boolean; - syncState: "local" | "pushed"; - repoPath?: string; -} - -type HistorySearchScope = "all" | "message" | "author" | "hash"; - -const HISTORY_SEARCH_SCOPE_KEYS: Record = { - all: "git.historyFilterAll", - message: "git.historyFilterMessage", - author: "git.historyFilterAuthor", - hash: "git.historyFilterHash", -}; - -function getCommitSearchFields(commit: GitCommit, scope: HistorySearchScope) { - if (scope === "message") return [commit.message, commit.description ?? ""]; - if (scope === "author") return [commit.author, commit.email ?? ""]; - if (scope === "hash") return [commit.hash, commit.hash.substring(0, 7)]; - - return [ - commit.message, - commit.description ?? "", - commit.author, - commit.email ?? "", - commit.hash, - commit.hash.substring(0, 7), - ]; -} - -const CommitItem = memo( - ({ - commit, - onSelect, - onContextMenu, - isSelected, - syncState, - repoPath, - }: CommitItemProps) => { - const shortHash = commit.hash.substring(0, 7); - const avatarUrl = getGitAuthorAvatarUrl(commit); - - return ( -
- -
- ); - }, -); - -const GitCommitHistory = ({ - onViewCommitDiff, - repoPath, - ahead = 0, - behind = 0, -}: GitCommitHistoryProps) => { - const { t } = useTranslation(); - const commits = useGitStore((state) => state.commits); - const hasMoreCommits = useGitStore((state) => state.hasMoreCommits); - const isLoadingMoreCommits = useGitStore((state) => state.isLoadingMoreCommits); - const actions = useGitStore((state) => state.actions); - const scrollContainerRef = useRef(null); - const lastScrollTop = useRef(0); - const scrollSetupTimeoutRef = useRef(null); - const scrollSetupRafRef = useRef(null); - const selectionAnchorRef = useRef(null); - const contextMenu = useDropdownMenu<{ commitHashes: string[] }>(); - const [selectedCommitHashes, setSelectedCommitHashes] = useState>(new Set()); - const [historySearchQuery, setHistorySearchQuery] = useState(""); - const [historySearchScope, setHistorySearchScope] = useState("all"); - const clearHistorySelection = useCallback(() => { - setSelectedCommitHashes(new Set()); - selectionAnchorRef.current = null; - }, []); - const { - isMutatingHistory, - historyDialog, - undoCommit, - editMessage, - removeCommit, - squashSelectedCommits, - resetBranchToCommit, - cherryPickSelectedCommit, - } = useGitHistoryMutations({ repoPath, onCompleted: clearHistorySelection }); - - const filteredCommits = useMemo(() => { - const query = historySearchQuery.trim(); - if (!query) return commits; - - return commits.filter((commit) => - matchesSearchQuery(query, getCommitSearchFields(commit, historySearchScope)), - ); - }, [commits, historySearchQuery, historySearchScope]); - - const commitSyncStateByHash = useMemo(() => { - const syncState = new Map(); - commits.forEach((commit, index) => { - syncState.set(commit.hash, index < ahead ? "local" : "pushed"); - }); - return syncState; - }, [ahead, commits]); - - useEffect(() => { - const availableHashes = new Set(commits.map((commit) => commit.hash)); - setSelectedCommitHashes((current) => { - const next = new Set([...current].filter((hash) => availableHashes.has(hash))); - return next.size === current.size ? current : next; - }); - if (selectionAnchorRef.current && !availableHashes.has(selectionAnchorRef.current)) { - selectionAnchorRef.current = null; - } - }, [commits]); - - const handleCommitSelect = useCallback( - (event: React.MouseEvent, commit: GitCommit) => { - const result = updateGitHistorySelection( - filteredCommits.map((candidate) => candidate.hash), - selectedCommitHashes, - commit.hash, - selectionAnchorRef.current, - { - additive: event.ctrlKey || event.metaKey, - range: event.shiftKey, - }, - ); - setSelectedCommitHashes(result.selected); - selectionAnchorRef.current = result.anchor; - if (!(event.ctrlKey || event.metaKey || event.shiftKey)) { - onViewCommitDiff?.(commit.hash); - } - }, - [filteredCommits, onViewCommitDiff, selectedCommitHashes], - ); - - const handleCommitContextMenu = useCallback( - (event: React.MouseEvent, commit: GitCommit) => { - const next = resolveGitHistoryContextSelection(selectedCommitHashes, commit.hash); - setSelectedCommitHashes(next); - if (!selectedCommitHashes.has(commit.hash)) { - selectionAnchorRef.current = commit.hash; - } - contextMenu.open(event, { commitHashes: [...next] }); - }, - [contextMenu, selectedCommitHashes], - ); - - const contextMenuCommits = useMemo( - () => selectedCommitsInHistoryOrder(commits, new Set(contextMenu.data?.commitHashes ?? [])), - [commits, contextMenu.data], - ); - const contextSelection = useMemo( - () => new Set(contextMenuCommits.map((commit) => commit.hash)), - [contextMenuCommits], - ); - const contextSelectionIsContiguous = isContiguousGitHistorySelection(commits, contextSelection); - - const contextMenuItems: MenuItem[] = (() => { - if (contextMenuCommits.length > 1) { - return [ - { - id: "squash-commits", - label: contextSelectionIsContiguous ? t("git.squashCommits") : t("git.historyReview.contiguousRequired"), - icon: , - disabled: isMutatingHistory || !contextSelectionIsContiguous, - onClick: () => void squashSelectedCommits(contextMenuCommits), - }, - { - id: "create-patch", - label: t("git.patch.create"), - disabled: isMutatingHistory || contextMenuCommits.length !== 2, - onClick: () => { if (repoPath) void showGitPatchDialog(repoPath, { mode: "export", commits: contextMenuCommits }); }, - }, - ]; - } - - const commit = contextMenuCommits[0]; - if (!commit) return []; - return [ - { - id: "undo-commit", - label: isGitHeadCommit(commit) ? t("git.undoCommit") : t("git.historyReview.headRequired"), - icon: , - disabled: isMutatingHistory || !isGitHeadCommit(commit), - onClick: () => undoCommit(commit), - }, - { - id: "interactive-rebase", - label: t("git.rebasePlan.fromHere"), - disabled: isMutatingHistory, - onClick: () => { if (repoPath) showGitRebaseDialog(repoPath, commit.hash); }, - }, - { - id: "edit-commit-message", - label: t("git.editCommitMessage"), - icon: , - disabled: isMutatingHistory, - onClick: () => void editMessage(commit), - }, - { - id: "delete-commit", - label: t("git.deleteCommit"), - icon: , - className: "text-destructive", - disabled: isMutatingHistory, - onClick: () => void removeCommit(commit), - }, - { - id: "reset-to-commit", - label: t("git.resetToCommit"), - icon: , - disabled: isMutatingHistory, - onClick: () => void resetBranchToCommit(commit), - }, - { - id: "cherry-pick-commit", - label: t("git.cherryPickCommit"), - icon: , - disabled: isMutatingHistory || commits[0]?.hash === commit.hash, - onClick: () => void cherryPickSelectedCommit(commit), - }, - ]; - })(); - - const hasHistoryRows = commits.length > 0; - const hasHistoryFilter = historySearchScope !== "all"; - - useEffect(() => { - if (!repoPath) return; - - let scrollHandler: (() => void) | null = null; - let isListenerAttached = false; - - const handleScroll = () => { - const container = scrollContainerRef.current; - if (!container) return; - - const { scrollTop, scrollHeight, clientHeight } = container; - const isScrollingDown = scrollTop > lastScrollTop.current; - lastScrollTop.current = scrollTop; - - const scrollPercent = (scrollTop + clientHeight) / scrollHeight; - - if (isScrollingDown && scrollPercent >= 0.8) { - if (hasMoreCommits && !isLoadingMoreCommits) { - actions.loadMoreCommits(repoPath); - } - } - }; - - const setupScrollListener = () => { - const container = scrollContainerRef.current; - if (!container || isListenerAttached) return false; - - if (container.scrollHeight > container.clientHeight && hasMoreCommits) { - container.addEventListener("scroll", handleScroll); - isListenerAttached = true; - scrollHandler = handleScroll; - return true; - } - return false; - }; - - const removeScrollListener = () => { - const container = scrollContainerRef.current; - if (container && isListenerAttached && scrollHandler) { - container.removeEventListener("scroll", scrollHandler); - isListenerAttached = false; - scrollHandler = null; - } - }; - - if (commits.length === 0) { - lastScrollTop.current = 0; - } - - if (!setupScrollListener()) { - if (scrollSetupRafRef.current) { - cancelAnimationFrame(scrollSetupRafRef.current); - } - scrollSetupRafRef.current = requestAnimationFrame(() => { - if (!setupScrollListener()) { - if (scrollSetupTimeoutRef.current) { - clearTimeout(scrollSetupTimeoutRef.current); - } - scrollSetupTimeoutRef.current = setTimeout(() => { - setupScrollListener(); - scrollSetupTimeoutRef.current = null; - }, 100); - } - scrollSetupRafRef.current = null; - }); - } - - return () => { - if (scrollSetupRafRef.current) { - cancelAnimationFrame(scrollSetupRafRef.current); - scrollSetupRafRef.current = null; - } - if (scrollSetupTimeoutRef.current) { - clearTimeout(scrollSetupTimeoutRef.current); - scrollSetupTimeoutRef.current = null; - } - removeScrollListener(); - }; - }, [commits.length, hasMoreCommits, isLoadingMoreCommits, repoPath, actions]); - - return ( -
- {historyDialog} - - - - - } - > - - - - setHistorySearchScope(scope as HistorySearchScope)} - > - {(Object.keys(HISTORY_SEARCH_SCOPE_KEYS) as HistorySearchScope[]).map((scope) => ( - - {t(HISTORY_SEARCH_SCOPE_KEYS[scope])} - - ))} - - - - - - {(ahead > 0 || behind > 0) && ( -
- {ahead > 0 ? ( -
- {ahead}{" "} - {t("git.localCommitsNotPushed", { count: ahead, plural: ahead !== 1 ? "s" : "" })} -
- ) : null} - {behind > 0 ? ( -
- {behind}{" "} - {t("git.remoteCommitsNotPulled", { count: behind, plural: behind !== 1 ? "s" : "" })} -
- ) : null} -
- )} - -
- {!hasHistoryRows ? ( - - ) : filteredCommits.length === 0 ? ( - - ) : ( - <> - {filteredCommits.map((commit) => ( - - ))} - - {isLoadingMoreCommits && ( -
- -
- )} - - {!hasMoreCommits && commits.length > 0 && ( -
- {t("git.historyEnd")} -
- )} - - )} -
- - -
- ); -}; - -export default GitCommitHistory; diff --git a/windows/tauri/src/features/git/components/git-commit-panel.test.tsx b/windows/tauri/src/features/git/components/git-commit-panel.test.tsx new file mode 100644 index 000000000..ff51077c4 --- /dev/null +++ b/windows/tauri/src/features/git/components/git-commit-panel.test.tsx @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { workspaceRuntimeRegistry as registry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store"; +import GitCommitPanel from "./git-commit-panel"; +import type { GitFile } from "../types/git.types"; + +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousAct: boolean | undefined; +let currentMessage = ""; +let selectedFiles: GitFile[] = []; + +beforeEach(() => { + registry.resetForTests(); + registry.activateWorkspace({ id: "A", name: "A" }, "ready"); + restoreDom = installHappyDom(); + previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + currentMessage = ""; +}); + +afterEach(async () => { + await act(async () => root.unmount()); + // bun runs test files in one process: restore the shared workspace stores so the + // panel's draft owner does not leak into later files (pull dialog and + // workspace-commit assertions read them). + const commitStore = useWorkspaceCommitStore.getStore("A").getState(); + commitStore.setDraftOwner(null); + commitStore.workflow.setState({ busy: false, session: null, review: null, error: null }); + container.remove(); + restoreDom(); + registry.resetForTests(); + if (previousAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct; +}); + +const stagedFile: GitFile = { + path: "src/hello.ts", + repositoryPath: "C:/workspace/A", + repositoryRelativePath: "src/hello.ts", + status: "modified", + staged: true, + canToggleStaging: true, +}; + +interface RenderOptions { + message?: string; + files?: GitFile[]; +} + +// Mirror the real owner: commit message updates re-render the panel with the +// next draft. This is also the exact callback the textarea onChange invokes. +const applyMessage = (message: string) => { + currentMessage = message; + rerender(); +}; + +const rerender = () => { + root.render( + + + , + ); +}; + +const renderPanel = async (options: RenderOptions = {}) => { + currentMessage = options.message ?? ""; + selectedFiles = options.files ?? [stagedFile]; + await act(async () => rerender()); +}; + +const buttonByText = (text: string) => + Array.from(container.querySelectorAll("button")).find((button) => button.textContent === text); +const commitButton = () => buttonByText("Commit"); + +test("the legend counts untracked as added and renames as modified, like IntelliJ", async () => { + const file = (path: string, status: GitFile["status"]): GitFile => ({ + ...stagedFile, + path, + repositoryRelativePath: path, + status, + }); + await renderPanel({ + files: [ + file("a.ts", "added"), + file("b.ts", "untracked"), + file("c.ts", "modified"), + file("d.ts", "renamed"), + file("e.ts", "deleted"), + ], + }); + const legend = container.querySelector('[data-testid="git-commit-legend"]'); + expect(Array.from(legend?.children ?? []).map((chunk) => chunk.textContent)).toEqual([ + "1+1 added", + "2 modified", + "1 deleted", + ]); +}); + +test("the legend is hidden when no file is selected", async () => { + await renderPanel({ files: [] }); + expect(container.querySelector('[data-testid="git-commit-legend"]')).toBeNull(); +}); + +test("a commit starts a regular commit, never an amend", async () => { + await renderPanel({ message: "Plain commit" }); + const workflow = useWorkspaceCommitStore.getStore("A").getState().workflow; + const prepare = spyOn(workflow, "prepare").mockResolvedValue(undefined); + try { + await act(async () => commitButton()!.click()); + expect(prepare).toHaveBeenCalledTimes(1); + const request = prepare.mock.calls[0][0]; + expect(request.amend).toBe(false); + expect(request.message).toBe("Plain commit"); + expect(container.querySelector('[aria-label="Amend"]')).toBeNull(); + } finally { + prepare.mockRestore(); + } +}); + +test("Commit stays enabled and explains what is missing, like IntelliJ", async () => { + const workflow = useWorkspaceCommitStore.getStore("A").getState().workflow; + const prepare = spyOn(workflow, "prepare").mockResolvedValue(undefined); + try { + await renderPanel({ files: [] }); + expect(commitButton()?.disabled).toBe(false); + expect(buttonByText("Commit and Push...")?.disabled).toBe(false); + + await act(async () => commitButton()!.click()); + const hint = () => container.querySelector('[data-testid="git-commit-hint"]')?.textContent; + expect(hint()).toBe("Select files to commit and specify commit message"); + expect(prepare).not.toHaveBeenCalled(); + + // Typing a message clears that half of the hint; the missing selection remains. + await act(async () => applyMessage("Ready")); + expect(hint()).toBe("Select files to commit"); + } finally { + prepare.mockRestore(); + } +}); + +test("Commit with files but no message asks for a commit message", async () => { + await renderPanel(); + await act(async () => commitButton()!.click()); + expect(container.querySelector('[data-testid="git-commit-hint"]')?.textContent).toBe( + "Specify commit message", + ); + await act(async () => applyMessage("Done")); + expect(container.querySelector('[data-testid="git-commit-hint"]')).toBeNull(); +}); diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index 81864fb35..db8f0a7a7 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -1,13 +1,12 @@ import { ArrowDownIcon as ArrowDown, ArrowUpIcon as ArrowUp, - CaretDownIcon as ChevronDown, WarningCircleIcon as AlertCircle, SparkleIcon as Sparkles, GearSixIcon as SettingsIcon, } from "@/ui/icons"; import type React from "react"; -import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store"; import { workspaceCommitBindings } from "../utils/git-workspace-commit-bindings"; import { GitWorkspaceCommitReview } from "./git-workspace-commit-review"; @@ -16,11 +15,10 @@ import type { TranslationKey } from "@/i18n/locale"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; -import { ButtonGroup, ButtonGroupSeparator } from "@/ui/button-group"; -import { Dropdown, type MenuItem } from "@/ui/dropdown"; import { SidebarComposerBody } from "@/ui/sidebar"; import Textarea from "@/ui/textarea"; import { cn } from "@/utils/cn"; +import { IDEA_BUTTON_CLASS_NAME } from "../utils/idea-control-styles"; import { commitAIError, collectCommitFiles, @@ -37,6 +35,7 @@ import { useWorkspaceStoreScopeId, } from "@/features/workspace/stores/create-workspace-scoped-store"; import type { GitFile } from "../types/git.types"; +import { COMMIT_MESSAGE_MAX_VIEWPORT_RATIO } from "../hooks/use-git-commit-area-resize"; interface GitCommitPanelProps { selectedFiles: GitFile[]; @@ -55,8 +54,55 @@ interface GitCommitPanelProps { focusRequest?: number; } -const COMMIT_TEXTAREA_MIN_HEIGHT = 64; -const COMMIT_TEXTAREA_MAX_HEIGHT = 128; + +// IntelliJ CommitLegendComponent: "N added N modified N deleted", each part +// tinted with its file-status color. Untracked files count as added (shown as +// "new+unversioned") and renames count as modified, as in ChangeInfoCalculator. +interface CommitLegendChunk { + id: "added" | "modified" | "deleted"; + value: string; + labelKey: "git.changeAdded" | "git.changeModified" | "git.changeDeleted"; + className: string; +} + +function buildCommitLegend(files: GitFile[]): CommitLegendChunk[] { + let added = 0; + let untracked = 0; + let modified = 0; + let deleted = 0; + for (const file of files) { + if (file.status === "added") added += 1; + else if (file.status === "untracked") untracked += 1; + else if (file.status === "deleted") deleted += 1; + else modified += 1; + } + const chunks: CommitLegendChunk[] = []; + if (added > 0 || untracked > 0) { + chunks.push({ + id: "added", + value: added > 0 && untracked > 0 ? `${added}+${untracked}` : String(added || untracked), + labelKey: "git.changeAdded", + className: "text-git-added", + }); + } + if (modified > 0) { + chunks.push({ + id: "modified", + value: String(modified), + labelKey: "git.changeModified", + className: "text-git-modified", + }); + } + if (deleted > 0) { + chunks.push({ + id: "deleted", + value: String(deleted), + labelKey: "git.changeDeleted", + className: "text-git-file-deleted", + }); + } + return chunks; +} const GitCommitPanel = ({ selectedFiles, @@ -99,10 +145,13 @@ const GitCommitPanel = ({ const isCurrentWorkspace = workspaceReady && workspaceId === activeWorkspaceId; const setDraftOwner = useWorkspaceCommitStore((state) => state.setDraftOwner); const [isGenerating, setIsGenerating] = useState(false); - const [isCommitActionMenuOpen, setIsCommitActionMenuOpen] = useState(false); const [remoteAction, setRemoteAction] = useState<"push" | null>(null); const [error, setError] = useState(null); - const commitMenuAnchorRef = useRef(null); + // IntelliJ CommitProgressPanel: the buttons stay enabled and a click with nothing + // to commit explains what is missing until the selection or message changes. + const [commitHint, setCommitHint] = useState<{ noChanges: boolean; noMessage: boolean } | null>( + null, + ); const commitTextareaRef = useRef(null); const selectedFilesCount = selectedFiles.length; @@ -111,20 +160,6 @@ const GitCommitPanel = ({ globalThis.requestAnimationFrame?.(() => commitTextareaRef.current?.focus()); }, [focusRequest]); - useLayoutEffect(() => { - const textarea = commitTextareaRef.current; - if (!textarea) return; - - textarea.style.height = "auto"; - const nextHeight = Math.min( - COMMIT_TEXTAREA_MAX_HEIGHT, - Math.max(COMMIT_TEXTAREA_MIN_HEIGHT, textarea.scrollHeight), - ); - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = - textarea.scrollHeight > COMMIT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"; - }, [commitMessage]); - const handleGenerateCommitMessage = async () => { if (!repoPath || selectedFilesCount === 0 || generationRef.current || !aiSettings.enabled) return; @@ -165,11 +200,15 @@ const GitCommitPanel = ({ (batch.session && !batch.session.succeeded) ) return; - if (selectedFilesCount === 0) { - setError(t("git.selectFilesToCommit")); + const noChanges = selectedFilesCount === 0; + const noMessage = !commitMessage.trim(); + if (noChanges || noMessage) { + setCommitHint({ noChanges, noMessage }); + if (noMessage && !noChanges) commitTextareaRef.current?.focus(); return; } - if (!repoPath || !commitMessage.trim()) return; + if (!repoPath) return; + setCommitHint(null); setDraftOwner(repoPath); setError(null); await workflow.prepare({ @@ -215,37 +254,74 @@ const GitCommitPanel = ({ } }; + // Missing files or message no longer grey the buttons out (see commitHint); only + // states where a click cannot start a commit at all still disable them. const isCommitDisabled = !isCurrentWorkspace || isStaging || - selectedFilesCount === 0 || - !commitMessage.trim() || Boolean(batch.review) || Boolean(batch.session && !batch.session.succeeded) || isCommitting || isGenerating; const isGenerateDisabled = selectedFilesCount === 0 || isGenerating || isCommitting || !aiSettings.enabled; - const hasRemoteChanges = ahead > 0 || behind > 0; const isRemoteActionLoading = remoteAction !== null; const composerButtonClassName = "h-6 rounded-md border-transparent bg-transparent px-1.5 ui-text-sm leading-none text-subtle-foreground shadow-none hover:bg-accent/80 hover:text-foreground focus-visible:ring-1 focus-visible:ring-border-strong/35 [&_svg]:size-3"; - const commitActionItems: MenuItem[] = [ - { - id: "commit-and-push", - label: t("git.commitAndPush"), - icon: , - disabled: isCommitDisabled || isRemoteActionLoading || isPulling, - onClick: () => { - setIsCommitActionMenuOpen(false); - void handleCommit(true); - }, - }, - ]; + const isCommitAndPushDisabled = isCommitDisabled || isRemoteActionLoading || isPulling; + + const hasNoChanges = selectedFilesCount === 0; + const hasNoMessage = !commitMessage.trim(); + // Drop each part of the hint as soon as the user fixes it, like clearError(). + const visibleCommitHint = + commitHint && ((commitHint.noChanges && hasNoChanges) || (commitHint.noMessage && hasNoMessage)) + ? { + noChanges: commitHint.noChanges && hasNoChanges, + noMessage: commitHint.noMessage && hasNoMessage, + } + : null; + const commitHintText = visibleCommitHint + ? t( + visibleCommitHint.noChanges && visibleCommitHint.noMessage + ? "git.selectFilesAndSpecifyCommitMessage" + : visibleCommitHint.noChanges + ? "git.selectFilesToCommit" + : "git.specifyCommitMessage", + ) + : null; + const hasError = Boolean(error || batch.error || visibleCommitHint?.noMessage); + const commitLegend = useMemo(() => buildCommitLegend(selectedFiles), [selectedFiles]); return ( - <> - + // IntelliJ NonModalCommitPanel order: status row (legend), commit message, then + // commit actions with the options button pushed right. +
+ {commitLegend.length > 0 ? ( +
+ + {commitLegend.map((chunk) => ( + + {chunk.value} {t(chunk.labelKey)} + + ))} + +
+ ) : null} + + {(error || batch.error) && (
-
-
- - {selectedFilesCount > 0 - ? t(selectedFilesCount === 1 ? "git.fileSelected" : "git.filesSelected", { - count: selectedFilesCount, - }) - : t("git.noFilesSelected")} - - - {hasRemoteChanges && ( -
- {ahead > 0 && ( - - )} + {commitHintText ? ( +
+ + {commitHintText} +
+ ) : null} - {behind > 0 && ( - - )} -
+
+
+ data-commit-default-button="" + > + {isCommitting ? t("git.committing") : t("git.commit")} + + -
- +
+ {ahead > 0 && ( + + )} + {behind > 0 && ( + + )} {isGenerating ? ( )} - - - - - - - setIsCommitActionMenuOpen(false)} - items={commitActionItems} - className="min-w-37.5" - /> +
- +
); }; diff --git a/windows/tauri/src/features/git/components/git-history-rewrite-dialog.tsx b/windows/tauri/src/features/git/components/git-history-rewrite-dialog.tsx index 149763841..a966be59e 100644 --- a/windows/tauri/src/features/git/components/git-history-rewrite-dialog.tsx +++ b/windows/tauri/src/features/git/components/git-history-rewrite-dialog.tsx @@ -286,7 +286,7 @@ export function GitHistoryRewriteDialog({ {result.warnings?.map((warning, index) => (

{warning.message} {warning.details ? `\n${warning.details}` : ""} diff --git a/windows/tauri/src/features/git/components/git-operation-banner.tsx b/windows/tauri/src/features/git/components/git-operation-banner.tsx index d2bd74369..0638a4535 100644 --- a/windows/tauri/src/features/git/components/git-operation-banner.tsx +++ b/windows/tauri/src/features/git/components/git-operation-banner.tsx @@ -74,7 +74,7 @@ const GitOperationBanner = ({ repoPath }: GitOperationBannerProps) => { data-testid="git-operation-banner" >

- + {inProgressTitles[operation.kind]} {operation.reference ? ( diff --git a/windows/tauri/src/features/git/components/git-patch-dialog.tsx b/windows/tauri/src/features/git/components/git-patch-dialog.tsx index 597ede985..56eb30149 100644 --- a/windows/tauri/src/features/git/components/git-patch-dialog.tsx +++ b/windows/tauri/src/features/git/components/git-patch-dialog.tsx @@ -501,7 +501,7 @@ function PatchApplyDialog({ {warnings.map((warning, index) => (

{warning.message} {warning.details ? `\n${warning.details}` : ""} diff --git a/windows/tauri/src/features/git/components/git-rebase-dialog.tsx b/windows/tauri/src/features/git/components/git-rebase-dialog.tsx index 04b5e36e2..51eca0e80 100644 --- a/windows/tauri/src/features/git/components/git-rebase-dialog.tsx +++ b/windows/tauri/src/features/git/components/git-rebase-dialog.tsx @@ -473,7 +473,7 @@ function GitRebaseDialog({

) : null} {command?.warnings?.map((warning, index) => ( -

+

{warning.message} {warning.details ? `\n${warning.details}` : ""}

diff --git a/windows/tauri/src/features/git/components/git-repository-empty-state.tsx b/windows/tauri/src/features/git/components/git-repository-empty-state.tsx index 8bf455c98..318f9c709 100644 --- a/windows/tauri/src/features/git/components/git-repository-empty-state.tsx +++ b/windows/tauri/src/features/git/components/git-repository-empty-state.tsx @@ -37,9 +37,6 @@ export function GitRepositoryEmptyState({ const ui = useUIState.getState(); ui.setIsSidebarVisible(true); ui.setActiveView("git"); - window.dispatchEvent(new CustomEvent("lithe:git-palette-action", { - detail: { type: "show-tab", tab: "changes" }, - })); } useEffect(() => { diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index d2d715ce7..491558d76 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -22,8 +22,6 @@ import { SidebarFooter, SidebarHeaderIconButton, SidebarPanel, - SidebarTabPanels, - SidebarTabBar, SidebarTitleBar, } from "@/ui/sidebar"; import { toast } from "sonner"; @@ -31,10 +29,16 @@ import { formatRelativeDate } from "@/utils/date"; import { joinPath } from "@/utils/path-helpers"; import { matchesSearchQuery } from "@/utils/search-match"; import { getBranches } from "../api/git-branches-api"; +import { useGitCommitPicker } from "../hooks/use-git-commit-picker"; +import { + useActiveWorkspaceId, + useWorkspaceStoreScopeId, +} from "@/features/workspace/stores/create-workspace-scoped-store"; import { clearRepositoryDiscoveryCache, resolveRepositoryPath } from "../api/git-repo-api"; import { getRemotes } from "../api/git-remotes-api"; import { applyStash, dropStash, popStash } from "../api/git-stash-api"; import { getGitStatus, initRepository } from "../api/git-status-api"; +import { useGitCommitAreaResize } from "../hooks/use-git-commit-area-resize"; import { useGitDataController } from "../hooks/use-git-data-controller"; import { useGitDiffActions } from "../hooks/use-git-diff-actions"; import { useGitPullWorkflow } from "../hooks/use-git-pull-workflow"; @@ -50,7 +54,6 @@ import { import type { GitActionsMenuAnchorRect } from "../utils/git-actions-menu-position"; import { getStashDisplayTitle, getStashPositionLabel } from "../utils/git-stash-format"; import GitActionsMenu from "./git-actions-menu"; -import GitCommitHistory from "./git-commit-history"; import GitCommitPanel from "./git-commit-panel"; import GitCommandSurface from "./git-command-surface"; import GitRemoteManager from "./git-remote-manager"; @@ -65,12 +68,10 @@ interface GitViewProps { isActive?: boolean; } -type GitSidebarTab = "changes" | "history"; const GIT_VIEW_BRANCH_MANAGER_EVENT = "lithe:open-branch-manager"; type GitPaletteAction = | { type: "select-repository" } - | { type: "show-tab"; tab: GitSidebarTab } | { type: "manage-branches"; tab?: "branches" | "worktrees" | "repositories" } | { type: "show-branch-diff" } | { type: "manage-remotes" } @@ -85,14 +86,14 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const isLoadingGitData = useGitStore((state) => state.isLoadingGitData); const isRefreshing = useGitStore((state) => state.isRefreshing); const actions = useGitStore((state) => state.actions); - const commits = useGitStore((state) => state.commits); + const activeWorkspaceId = useActiveWorkspaceId(); + const scopedWorkspaceId = useWorkspaceStoreScopeId(); const branches = useGitStore((state) => state.branches); const stashes = useGitStore((state) => state.stashes); const { syncWorkspaceRepositories, setManualRepository } = useRepositoryStore.use.actions(); const { activeRepoPath, hasLoadError, - hasHistoryLoadError, refresh: handleManualRefresh, refreshWorkingTree, } = useGitDataController({ @@ -131,14 +132,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const [showRemoteManager, setShowRemoteManager] = useState(false); const [showTagManager, setShowTagManager] = useState(false); const showUntrackedFiles = useSettingsStore((state) => state.settings.showUntrackedFiles); - const rememberLastGitPanelMode = useSettingsStore( - (state) => state.settings.rememberLastGitPanelMode, - ); - const gitLastPanelMode = useSettingsStore((state) => state.settings.gitLastPanelMode); - const gitSidebarTabOrder = useSettingsStore((state) => state.settings.gitSidebarTabOrder); const openDiffOnClick = useSettingsStore((state) => state.settings.openDiffOnClick); - const updateSetting = useSettingsStore((state) => state.actions.updateSetting); - const [activeTab, setActiveTab] = useState("changes"); const [isStaging, setIsStaging] = useState(false); const [commitFocusRequest, setCommitFocusRequest] = useState(0); const repositoryPaths = useRepositoryStore((state) => state.availableRepoPaths); @@ -158,6 +152,12 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { [actions, activeRepoPath], ); const [showCommitDiffList, setShowCommitDiffList] = useState(false); + const commitPicker = useGitCommitPicker( + activeRepoPath, + scopedWorkspaceId ?? activeWorkspaceId, + showCommitDiffList, + ); + const commits = commitPicker.commits; const [commitDiffSearchQuery, setCommitDiffSearchQuery] = useState(""); const [showBranchDiffList, setShowBranchDiffList] = useState(false); const [branchDiffSearchQuery, setBranchDiffSearchQuery] = useState(""); @@ -193,6 +193,9 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { workingTreeDiffEntriesByScope: nextWorkingTreeDiffEntriesByScope, }; }, [gitStatus?.files, showUntrackedFiles]); + const changesAreaRef = useRef(null); + const commitAreaRef = useRef(null); + const commitAreaResize = useGitCommitAreaResize(commitAreaRef, changesAreaRef); const commitByHash = useMemo(() => { return new Map(commits.map((commit) => [commit.hash, commit] as const)); }, [commits]); @@ -306,18 +309,6 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { setRepoSelectionError(null); }, [repoPath]); - useEffect(() => { - if (!rememberLastGitPanelMode) return; - setActiveTab(gitLastPanelMode); - }, [rememberLastGitPanelMode, gitLastPanelMode]); - - useEffect(() => { - if (!rememberLastGitPanelMode) return; - if (gitLastPanelMode !== activeTab) { - void updateSetting("gitLastPanelMode", activeTab); - } - }, [activeTab, rememberLastGitPanelMode, gitLastPanelMode, updateSetting]); - const handleOpenBranchManager = useCallback( (tab: "branches" | "worktrees" | "repositories" = "branches") => { window.dispatchEvent(new CustomEvent(GIT_VIEW_BRANCH_MANAGER_EVENT, { detail: { tab } })); @@ -355,11 +346,6 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { return; } - if (detail.type === "show-tab") { - setActiveTab(detail.tab); - return; - } - if (detail.type === "manage-branches") { handleOpenBranchManager(detail.tab); return; @@ -576,6 +562,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { () => branches.filter((branch) => branch !== gitStatus?.branch), [branches, gitStatus?.branch], ); + const filteredBranchDiffBranches = useMemo(() => { const query = branchDiffSearchQuery.trim().toLowerCase(); if (!query) { @@ -585,34 +572,11 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { return branchDiffBranches.filter((branch) => matchesSearchQuery(query, [branch])); }, [branchDiffBranches, branchDiffSearchQuery]); - const gitTabOrder: GitSidebarTab[] = ["changes", "history"]; - const gitTabs: Array<{ - id: GitSidebarTab; - label: string; - }> = [...gitSidebarTabOrder] - .filter((id): id is GitSidebarTab => id === "changes" || id === "history") - .sort((a, b) => gitTabOrder.indexOf(a) - gitTabOrder.indexOf(b)) - .map((id) => { - const tabMap: Record = { - changes: { - id: "changes", - label: t("workbench.changes"), - }, - history: { - id: "history", - label: t("git.history"), - }, - }; - - return tabMap[id]; - }) - .filter(Boolean); - if (!activeRepoPath) { return ( <> - + {renderActionsButton()} {repoPath ? ( @@ -644,7 +608,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { return ( <> - + {renderActionsButton()} @@ -654,13 +618,13 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { ); } - const showLoadError = hasLoadError || (activeTab === "history" && hasHistoryLoadError); + const showLoadError = hasLoadError; const loadError = (
- {t(hasLoadError ? "git.statusLoadFailed" : "git.historyLoadFailed")} + {t("git.statusLoadFailed")} +
+ ) : commitPicker.status === "loading" ? ( + + ) : filteredDiffCommits.length === 0 ? ( {commitDiffSearchQuery.trim() ? t("git.noMatchingCommits") : t("git.noCommits")} diff --git a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx index 2b0ad2a78..b9c002375 100644 --- a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx +++ b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx @@ -3,23 +3,16 @@ import { ThemedFileIcon } from "@/extensions/icon-themes/components/themed-file- import { writeSidebarResourceDragData } from "@/features/sidebar/utils/sidebar-resource-drag"; import { useTranslation } from "@/i18n/locale-provider"; import { Checkbox } from "@/ui/checkbox"; -import { Button } from "@/ui/button"; -import { MinusIcon as Minus, PlusIcon as Plus } from "@/ui/icons"; -import { Spinner } from "@/ui/spinner"; import { SidebarTreeRow } from "@/features/sidebar/components/sidebar-tree"; import { FILE_TREE_BASE_INDENT } from "@/features/file-explorer/lib/file-tree-row"; import { cn } from "@/utils/cn"; import type { GitFile } from "../../types/git.types"; import { getWorkingTreeStatusColorClassName } from "../../utils/git-file-status-visuals"; +import { IDEA_CHECKBOX_CLASS_NAME } from "../../utils/idea-control-styles"; import { getGitFileRepositoryPath, getGitFileRepositoryRelativePath, } from "../../utils/git-status-selection"; -import { - activateGitFileStageAction, - getGitFileStageActionState, - prepareGitFileStageAction, -} from "../../utils/git-file-stage-action"; interface GitFileItemProps { file: GitFile; @@ -38,52 +31,6 @@ interface GitFileItemProps { reserveDisclosureSpace?: boolean; className?: string; repoPath?: string; - onStagedChange?: (staged: boolean) => void; - stagePending?: boolean; -} - -interface GitFileStageActionProps { - staged: boolean; - label: string; - disabled?: boolean; - pending?: boolean; - onStagedChange: (staged: boolean) => void; -} - -export function GitFileStageAction({ - staged, - label, - disabled, - pending = false, - onStagedChange, -}: GitFileStageActionProps) { - const actionState = getGitFileStageActionState(staged, pending, disabled); - - return ( - - ); } export const GitFileItem = ({ @@ -103,8 +50,6 @@ export const GitFileItem = ({ reserveDisclosureSpace = false, className, repoPath, - onStagedChange, - stagePending = false, }: GitFileItemProps) => { const { t } = useTranslation(); const pathParts = getGitFileRepositoryRelativePath(file).split("/"); @@ -120,6 +65,7 @@ export const GitFileItem = ({ baseIndent={FILE_TREE_BASE_INDENT} showGuides={showIndentGuides} active={active} + variant="idea" containerClassName="group/git-status-row" className={cn("h-full overflow-clip py-0.5", className)} style={rowHeight ? { height: rowHeight } : undefined} @@ -137,30 +83,19 @@ export const GitFileItem = ({ /> ) : null } - action={ -
- {onStagedChange ? ( - - ) : null} - -
+ // IntelliJ places the include-in-commit checkbox before the file icon. + leadingAction={ + } draggable={!!dragRepoPath} onDragStart={(event) => { diff --git a/windows/tauri/src/features/git/components/status/git-status-panel.tsx b/windows/tauri/src/features/git/components/status/git-status-panel.tsx index fb928254f..f0feb2a9f 100644 --- a/windows/tauri/src/features/git/components/status/git-status-panel.tsx +++ b/windows/tauri/src/features/git/components/status/git-status-panel.tsx @@ -3,6 +3,8 @@ import { ArrowCounterClockwiseIcon as RotateCcw, CaretDownIcon as CaretDown, CaretRightIcon as CaretRight, + CheckIcon as Check, + EyeIcon as Eye, FolderOpenIcon as FolderOpen, GitCommitIcon as GitCommit, GitDiffIcon as GitDiff, @@ -13,7 +15,7 @@ import { } from "@/ui/icons"; import { useVirtualizer } from "@tanstack/react-virtual"; import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { ThemedFileIcon } from "@/extensions/icon-themes/components/themed-file-icon"; import { useFileTreePresentation } from "@/features/file-explorer/hooks/use-file-tree-presentation"; @@ -66,6 +68,12 @@ import { } from "../../utils/git-status-selection"; import { StashMessageModal } from "../stash/git-stash-modal"; import { GitFileItem } from "./git-status-file-item"; +import { IDEA_CHECKBOX_CLASS_NAME } from "../../utils/idea-control-styles"; +import { + isLabelTruncated, + measureLabelNaturalWidth, + useGitStatusExpandableHint, +} from "./use-git-status-expandable-hint"; import { showGitPatchDialog } from "../../services/git-patch-dialog-service"; interface GitStatusPanelProps { @@ -136,8 +144,34 @@ type GitStatusVirtualRow = }; const GIT_STATUS_SECTION_HEADER_HEIGHT = 32; -const GIT_STATUS_SECTION_CONTENT_GAP = 2; -const GIT_STATUS_SECTION_GAP = 8; +// IntelliJ lists "Changes" and "Unversioned Files" as top-level tree nodes, so their +// files sit one level deeper than the node. +const GIT_STATUS_SECTION_CHILD_DEPTH = 1; +/** + * Width a rendered row needs to show its name and count without truncation: the row + * minus the flexible label area, plus the label parts' natural widths, rounded up with a + * pixel of slack so fractional text widths never end in an ellipsis. `truncated` tells + * whether the row is clipped right now. + */ +export function measureGitStatusRowWidth(row: HTMLElement): { width: number; truncated: boolean } { + const label = row.querySelector("[data-sidebar-tree-label]"); + if (!label) return { width: row.offsetWidth, truncated: false }; + return { + width: Math.ceil(row.offsetWidth - label.clientWidth + measureLabelNaturalWidth(label)) + 1, + truncated: isLabelTruncated(label), + }; +} + +/** Include-in-commit state of a group: all, none, or some of its eligible files. */ +function getCommitInclusionState(files: readonly GitFile[]) { + const eligible = files.filter((file) => file.canToggleStaging !== false); + const included = eligible.filter((file) => file.staged).length; + return { + eligible, + checked: eligible.length > 0 && included === eligible.length, + indeterminate: included > 0 && included < eligible.length, + }; +} const GIT_STATUS_TREE_OVERSCAN = 12; const getFileEntryId = (filePath: string) => `file:${filePath}`; @@ -232,6 +266,9 @@ const GitStatusPanel = ({ const statusViewportRef = useRef(null); const [isLoading, setIsLoading] = useState(false); const [isDiffMenuOpen, setIsDiffMenuOpen] = useState(false); + const [isViewOptionsOpen, setIsViewOptionsOpen] = useState(false); + const viewOptionsAnchorRef = useRef(null); + const updateSetting = useSettingsStore((state) => state.actions.updateSetting); const [optimisticStageMap, setOptimisticStageMap] = useState>({}); const stagingOperationsRef = useRef(new Map>()); const [stagePendingPaths, setStagePendingPaths] = useState>(new Set()); @@ -394,19 +431,11 @@ const GitStatusPanel = ({ groupedFiles: Record, ) => { if (fileCount === 0) return; - if (rows.length > 0) { - rows.push({ kind: "spacer", key: `${section}:section-gap`, size: GIT_STATUS_SECTION_GAP }); - } rows.push({ kind: "section", key: `${section}:header`, section, count: fileCount }); if (collapsedSections.has(section)) return; - rows.push({ - kind: "spacer", - key: `${section}:content-gap`, - size: GIT_STATUS_SECTION_CONTENT_GAP, - }); if (gitChangesFolderView && tree) { - for (const node of tree.nodes) appendTreeNode(node, 0, section); + for (const node of tree.nodes) appendTreeNode(node, GIT_STATUS_SECTION_CHILD_DEPTH, section); return; } @@ -417,9 +446,9 @@ const GitStatusPanel = ({ key: `${section}:${status}:${file.staged ? "staged" : "unstaged"}:${file.path}`, section, file, - depth: 0, + depth: GIT_STATUS_SECTION_CHILD_DEPTH, showDirectory: true, - reserveDisclosureSpace: false, + reserveDisclosureSpace: true, }); } } @@ -446,13 +475,58 @@ const GitStatusPanel = ({ sections, ]); + // IntelliJ's changes tree scrolls horizontally instead of truncating long names. Rows + // are virtualized and absolutely positioned, so the tree takes the widest width any + // rendered row has needed; it restarts from the viewport width only when the layout + // itself changes, so routine status refreshes do not briefly re-truncate every name. + const [treeContentWidth, setTreeContentWidth] = useState(0); + const hasVisibleFiles = visibleFiles.length > 0; + const measureTreeContentWidth = useCallback(() => { + const viewport = statusViewportRef.current; + if (!viewport) return; + // Only rows that are actually clipped widen the tree, so a tree that fits keeps + // following the viewport width when the sidebar is resized. + let widest = 0; + for (const row of viewport.querySelectorAll("[data-git-status-row-index]")) { + const { width, truncated } = measureGitStatusRowWidth(row); + // A row that is already as wide as it claims to need cannot be helped by growing + // further; skipping it keeps a mis-measured row from widening the tree forever. + if (truncated && width > row.offsetWidth + 1) widest = Math.max(widest, width); + } + if (widest > 0) setTreeContentWidth((current) => (widest > current ? widest : current)); + }, []); + useLayoutEffect(() => { + setTreeContentWidth(0); + }, [repoPath, gitChangesFolderView, fileTreePresentation.compactFolders]); + useLayoutEffect(() => { + measureTreeContentWidth(); + }); + // Text widths also change without a React render: the panel was hidden (every width + // reads 0) and is shown again, the sidebar is resized, or the UI font finishes loading. + useEffect(() => { + const viewport = statusViewportRef.current; + const fonts = globalThis.document?.fonts; + const observer = + viewport && typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => measureTreeContentWidth()) + : null; + if (viewport) observer?.observe(viewport); + fonts?.addEventListener?.("loadingdone", measureTreeContentWidth); + return () => { + observer?.disconnect(); + fonts?.removeEventListener?.("loadingdone", measureTreeContentWidth); + }; + // The viewport only exists while there are files to list. + }, [measureTreeContentWidth, hasVisibleFiles]); + // Rows still cut off by the viewport edge show their full label on hover, like IntelliJ. + useGitStatusExpandableHint(statusViewportRef, hasVisibleFiles); + const statusVirtualizer = useVirtualizer({ count: statusRows.length, getScrollElement: () => statusViewportRef.current, estimateSize: (index) => { const row = statusRows[index]; - if (row?.kind === "section" || row?.kind === "repository") - return GIT_STATUS_SECTION_HEADER_HEIGHT; + if (row?.kind === "repository") return GIT_STATUS_SECTION_HEADER_HEIGHT; if (row?.kind === "spacer") return row.size; return fileTreePresentation.rowHeight; }, @@ -814,18 +888,55 @@ const GitStatusPanel = ({ onCollapsedSectionsChange(next); }; - const renderSectionHeader = (section: StatusSection, title: string, count: number) => ( - toggleSectionCollapsed(section)} - className="h-full" - style={{ height: "100%" }} - > - {title} - - ); + const renderSectionNode = (section: StatusSection) => { + const sectionData = sectionById.get(section); + if (!sectionData) return null; + const title = t(sectionData.kind === "tracked" ? "git.changesNode" : "git.unversionedFilesNode"); + const inclusion = getCommitInclusionState(sectionData.files); + const count = sectionData.files.length; + const expanded = !collapsedSections.has(section); + return ( + toggleSectionCollapsed(section)} + onDoubleClick={() => toggleSectionCollapsed(section)} + variant="idea" + label={{title}} + // IntelliJ appends the grey "N files" count right after the node name. + description={t("git.diffFileCount", { count, plural: count === 1 ? "" : "s" })} + leadingAction={ + + handleSetCommitPathsSelected( + inclusion.eligible.map((file) => file.path), + // A mixed group includes everything, like IntelliJ's tri-state node. + inclusion.indeterminate ? true : checked, + ) + } + disabled={ + isLoading || + inclusion.eligible.length === 0 || + sectionData.files.some((file) => stagePendingPaths.has(file.path)) + } + aria-label={t( + inclusion.checked ? "git.excludeFolderFromCommit" : "git.includeFolderInCommit", + { name: title }, + )} + /> + } + className="h-full py-0.5" + style={{ height: fileTreePresentation.rowHeight }} + data-git-status-section={sectionData.kind} + /> + ); + }; const focusStatusRow = (index: number) => { statusVirtualizer.scrollToIndex(index, { align: "auto" }); @@ -841,7 +952,7 @@ const GitStatusPanel = ({ const findFocusableStatusRow = (start: number, step: -1 | 1) => { for (let index = start; index >= 0 && index < statusRows.length; index += step) { const row = statusRows[index]; - if (row?.kind === "folder" || row?.kind === "file") return index; + if (row?.kind === "folder" || row?.kind === "file" || row?.kind === "section") return index; } return -1; }; @@ -852,7 +963,28 @@ const GitStatusPanel = ({ const rowElement = target.closest("[data-git-status-row-index]"); const rowIndex = Number(rowElement?.dataset.gitStatusRowIndex); const row = statusRows[rowIndex]; - if (!Number.isInteger(rowIndex) || (row?.kind !== "folder" && row?.kind !== "file")) return; + if (!Number.isInteger(rowIndex) || !row) return; + if (row.kind === "section") { + const collapsed = collapsedSections.has(row.section); + if ((event.key === "ArrowRight" && collapsed) || (event.key === "ArrowLeft" && !collapsed)) { + event.preventDefault(); + toggleSectionCollapsed(row.section); + return; + } + const sectionTarget = + event.key === "ArrowDown" + ? findFocusableStatusRow(rowIndex + 1, 1) + : event.key === "ArrowUp" + ? findFocusableStatusRow(rowIndex - 1, -1) + : event.key === "ArrowRight" + ? findFocusableStatusRow(rowIndex + 1, 1) + : -1; + if (sectionTarget < 0) return; + event.preventDefault(); + focusStatusRow(sectionTarget); + return; + } + if (row.kind !== "folder" && row.kind !== "file") return; let targetIndex = -1; if (event.key === "ArrowDown") { @@ -888,6 +1020,11 @@ const GitStatusPanel = ({ } for (let index = rowIndex - 1; index >= 0; index -= 1) { const candidate = statusRows[index]; + // The section node is the parent of its top-level files and folders. + if (candidate?.kind === "section" && candidate.section === row.section) { + targetIndex = index; + break; + } if ( candidate && (candidate.kind === "folder" || candidate.kind === "file") && @@ -925,6 +1062,7 @@ const GitStatusPanel = ({ {row.repoPath} void handleSetFilesStaged(eligible, staged)} disabled={ @@ -939,13 +1077,7 @@ const GitStatusPanel = ({
); } - if (row.kind === "section") { - return renderSectionHeader( - row.section, - t(sectionById.get(row.section)?.kind === "tracked" ? "git.tracked" : "git.untracked"), - row.count, - ); - } + if (row.kind === "section") return renderSectionNode(row.section); if (row.kind === "file") { const entry = entryById.get(getFileEntryId(row.file.path)); @@ -966,8 +1098,6 @@ const GitStatusPanel = ({ disabled={ isLoading || row.file.canToggleStaging === false || stagePendingPaths.has(row.file.path) } - onStagedChange={(staged) => void handleSetFilesStaged([row.file], staged)} - stagePending={stagePendingPaths.has(row.file.path)} showDirectory={row.showDirectory} showFileIcon={fileTreePresentation.showIcons} showIndentGuides={fileTreePresentation.showIndentGuides} @@ -985,9 +1115,8 @@ const GitStatusPanel = ({ const entry = entryById.get(getFolderEntryId(row.section, row.branch.path)); if (!folderState || !entry) return null; const isCollapsed = collapsedFolders.has(`${row.section}:${row.branch.path}`); - const isChecked = folderState.descendantFilePaths.every( - (filePath) => displayFileByPath.get(filePath)?.staged, - ); + const folderInclusion = getCommitInclusionState(entry.files); + const isChecked = folderInclusion.checked; return ( toggleFolderCollapsed(row.section, row.branch.path)} onContextMenu={(event) => handleContextMenu(event, entry)} + variant="idea" label={row.label} - trailing={ - - {t("git.diffFileCount", { - count: entry.files.length, - plural: entry.files.length === 1 ? "" : "s", - })} - - } + description={t("git.diffFileCount", { + count: entry.files.length, + plural: entry.files.length === 1 ? "" : "s", + })} className="h-full py-0.5" style={{ height: fileTreePresentation.rowHeight }} leading={ @@ -1027,11 +1153,16 @@ const GitStatusPanel = ({ /> ) : null } - action={ + leadingAction={ - handleSetCommitPathsSelected(folderState.descendantFilePaths, checked) + handleSetCommitPathsSelected( + folderState.descendantFilePaths, + folderInclusion.indeterminate ? true : checked, + ) } disabled={ isLoading || @@ -1233,6 +1364,50 @@ const GitStatusPanel = ({ />
+ {/* IntelliJ's View Options: switch between the directory tree and a flat list. */} +
+ setIsViewOptionsOpen((open) => !open)} + active={isViewOptionsOpen} + tooltip={t("git.viewOptions")} + tooltipSide="bottom" + aria-label={t("git.viewOptions")} + aria-haspopup="menu" + aria-expanded={isViewOptionsOpen} + > + + +
+ setIsViewOptionsOpen(false)} + className="min-w-37.5" + // One click picks the layout: both choices sit at the top level instead of + // behind IntelliJ's Group By submenu, with a check on the active one. + items={( + [ + { id: "group-by-directory", folderView: true, label: t("git.groupByDirectory") }, + { id: "flat-list", folderView: false, label: t("git.flatList") }, + ] as const + ).map((option) => ({ + id: option.id, + label: option.label, + icon: + gitChangesFolderView === option.folderView ? ( + + ) : ( + + ), + onClick: () => { + setIsViewOptionsOpen(false); + if (gitChangesFolderView !== option.folderView) { + void updateSetting("gitChangesFolderView", option.folderView); + } + }, + }))} + /> {unstagedFiles.length > 0 && ( 0 ? treeContentWidth : undefined, } as React.CSSProperties } > diff --git a/windows/tauri/src/features/git/components/status/git-status-row-width.test.ts b/windows/tauri/src/features/git/components/status/git-status-row-width.test.ts new file mode 100644 index 000000000..682b95f20 --- /dev/null +++ b/windows/tauri/src/features/git/components/status/git-status-row-width.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { measureGitStatusRowWidth } from "./git-status-panel"; + +let restoreDom: () => void; +beforeEach(() => { + restoreDom = installHappyDom(); +}); +afterEach(() => restoreDom()); + +// happy-dom has no layout engine, so each element reports fixed box metrics. +const sized = (element: T, metrics: Record): T => { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, { configurable: true, value }); + } + return element; +}; + +const buildRow = (nameWidth: number, countWidth: number) => { + const row = sized(document.createElement("div"), { offsetWidth: 200 }); + const label = sized(document.createElement("span"), { clientWidth: 120 }); + label.dataset.sidebarTreeLabel = ""; + // The name gets what is left of the 120px label after the count and the 6px gap. + const nameSpace = Math.min(nameWidth, 120 - 6 - countWidth); + label.append( + sized(document.createElement("span"), { scrollWidth: nameWidth, clientWidth: nameSpace }), + sized(document.createElement("span"), { scrollWidth: countWidth, clientWidth: countWidth }), + ); + row.append(label); + return row; +}; + +test("a clipped name widens the row by the hidden part of its name and count", () => { + // Fixed chrome is 200 - 120 = 80px; the name and count need 150 + 6 + 40 = 196px, + // plus one pixel of slack against fractional text widths. + expect(measureGitStatusRowWidth(buildRow(150, 40))).toEqual({ width: 277, truncated: true }); +}); + +test("a row whose label fits needs no more than its current width", () => { + expect(measureGitStatusRowWidth(buildRow(60, 54))).toEqual({ width: 201, truncated: false }); +}); diff --git a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx index 0c21afcb8..419abdf72 100644 --- a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx +++ b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx @@ -93,3 +93,72 @@ test("keeps the repository header with one changed root and excludes dirty-only await act(async () => headerCheckbox!.click()); expect(staging).toHaveBeenCalledWith("C:/workspace/B", ["src/hello.ts"], true); }); + +test("groups files under IntelliJ's Changes and Unversioned Files nodes", async () => { + await render([file("A", "tracked.ts"), { ...file("A", "new.ts"), status: "untracked" }]); + const changes = container.querySelector('[data-git-status-section="tracked"]'); + const unversioned = container.querySelector('[data-git-status-section="untracked"]'); + expect(changes?.textContent).toContain("Changes"); + expect(changes?.textContent).toContain("1 file"); + expect(unversioned?.textContent).toContain("Unversioned Files"); + expect(container.textContent).not.toContain("Tracked"); + expect(container.textContent).not.toContain("Untracked"); +}); + +test("a group node checkbox includes all of its files and shows a mixed state", async () => { + await render([file("A", "one.ts"), { ...file("A", "two.ts"), staged: true }]); + const groupCheckbox = container.querySelector( + '[aria-label="Include folder Changes in commit"]', + ); + expect(groupCheckbox?.hasAttribute("data-indeterminate")).toBe(true); + await act(async () => groupCheckbox!.click()); + expect(staging).toHaveBeenCalledWith("C:/workspace/A", ["src/one.ts", "src/two.ts"], true); +}); + +test("the include-in-commit checkbox sits before the file name, not after it", async () => { + await render([file("A", "left.ts")]); + const checkbox = container.querySelector('[aria-label="Include left.ts in commit"]'); + const row = checkbox?.closest("[data-sidebar-tree-row]"); + const label = Array.from(row?.querySelectorAll("span") ?? []).find( + (span) => span.textContent === "left.ts", + ); + expect(row).not.toBeNull(); + expect(label).not.toBeUndefined(); + // The checkbox overlays a reserved slot placed ahead of the icon and name. + expect(row!.querySelector("[data-sidebar-tree-leading-slot]")).not.toBeNull(); + expect( + checkbox!.compareDocumentPosition(row!.querySelector("[role=treeitem]")!) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); +}); + +test("View Options switches between the directory tree and a flat list in two clicks", async () => { + const settings = await import("@/features/settings/stores/settings.store"); + const updateSetting = spyOn( + settings.useSettingsStore.getState().actions, + "updateSetting", + ).mockResolvedValue(undefined as never); + // The dropdown positions itself with ResizeObserver, which happy-dom lacks. + const globals = globalThis as { ResizeObserver?: unknown }; + const previousResizeObserver = globals.ResizeObserver; + globals.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + }; + try { + await render([file("A", "flat.ts")]); + const current = settings.useSettingsStore.getState().settings.gitChangesFolderView; + const viewOptions = container.querySelector('[aria-label="View Options"]'); + await act(async () => viewOptions!.click()); + const target = Array.from(document.querySelectorAll("[role=menuitem]")).find( + (item) => item.textContent?.includes(current ? "Flat List" : "Directory"), + ); + expect(target).not.toBeUndefined(); + await act(async () => target!.click()); + expect(updateSetting).toHaveBeenCalledWith("gitChangesFolderView", !current); + } finally { + updateSetting.mockRestore(); + globals.ResizeObserver = previousResizeObserver; + } +}); diff --git a/windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.test.ts b/windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.test.ts new file mode 100644 index 000000000..9dc491811 --- /dev/null +++ b/windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.test.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { isLabelCutOff, measureLabelNaturalWidth } from "./use-git-status-expandable-hint"; + +let restoreDom: () => void; +beforeEach(() => { + restoreDom = installHappyDom(); +}); +afterEach(() => restoreDom()); + +test("a label reaching past the visible viewport edge is cut off", () => { + expect(isLabelCutOff(100, 180, 260)).toBe(true); + expect(isLabelCutOff(100, 160, 260)).toBe(false); + // Sub-pixel rounding at the exact edge does not count as cut off. + expect(isLabelCutOff(100, 160.5, 260)).toBe(false); +}); + +test("the natural label width adds every part at full text width plus the gaps", () => { + const label = document.createElement("span"); + for (const width of [150, 40]) { + const part = document.createElement("span"); + Object.defineProperty(part, "scrollWidth", { configurable: true, value: width }); + label.append(part); + } + expect(measureLabelNaturalWidth(label)).toBe(196); +}); diff --git a/windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.ts b/windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.ts new file mode 100644 index 000000000..5412679b7 --- /dev/null +++ b/windows/tauri/src/features/git/components/status/use-git-status-expandable-hint.ts @@ -0,0 +1,144 @@ +import { useEffect } from "react"; + +/** Gap between label parts (name, count or directory), matching SidebarTreeRow's gap-1.5. */ +const LABEL_PART_GAP = 6; +/** Horizontal padding of the hint around the copied label. */ +const HINT_PADDING_X = 4; + +/** + * Fractional width of a label part's text. `scrollWidth` is rounded to whole pixels, so a + * name overflowing by a fraction of a pixel still gets an ellipsis while reading as + * fitting; a range around the text reports the real laid-out width. Environments without + * layout (tests) fall back to `scrollWidth`. + */ +function measureTextWidth(part: HTMLElement): number { + const range = part.ownerDocument.createRange?.(); + range?.selectNodeContents(part); + const textWidth = range?.getBoundingClientRect().width ?? 0; + // Never fall back to scrollWidth when real layout exists: a stretched flex part reports + // its box width there, which grows with the tree and would keep widening it. + return textWidth > 0 ? textWidth : part.scrollWidth; +} + +/** + * Layout snaps boxes to 1/64px while a text range is fractional, so differences below + * this are measurement noise rather than truncation. + */ +const TRUNCATION_TOLERANCE = 0.05; + +/** Natural width of a tree row label: every part at full text width plus the gaps between. */ +export function measureLabelNaturalWidth(label: HTMLElement): number { + const parts = Array.from(label.children) as HTMLElement[]; + return ( + parts.reduce((total, part) => total + measureTextWidth(part), 0) + + LABEL_PART_GAP * Math.max(0, parts.length - 1) + ); +} + +/** Whether any label part is narrower than its text, i.e. truncated or ellipsized. */ +export function isLabelTruncated(label: HTMLElement): boolean { + return (Array.from(label.children) as HTMLElement[]).some((part) => { + const available = part.getBoundingClientRect().width || part.clientWidth; + return ( + measureTextWidth(part) > available + TRUNCATION_TOLERANCE || + part.scrollWidth > part.clientWidth + ); + }); +} + +/** Whether a label starting at `labelLeft` runs past the visible right edge of its viewport. */ +export function isLabelCutOff(labelLeft: number, naturalWidth: number, visibleRight: number) { + return labelLeft + naturalWidth > visibleRight + 1; +} + +/** + * IntelliJ's ExpandableItemsHandler for the changes tree: hovering a row whose name or + * count is cut off by the viewport edge (or ellipsized) shows the whole label in a hint + * drawn over the row and past the panel edge. The hint is a static copy of the label, + * positioned with fixed coordinates and ignoring the pointer, so it never steals hover, + * clicks or drags from the row; it hides on leave, scroll, wheel and press. + */ +export function useGitStatusExpandableHint( + viewportRef: React.RefObject, + enabled: boolean, +) { + useEffect(() => { + const viewport = viewportRef.current; + if (!enabled || !viewport) return; + + const hint = document.createElement("div"); + hint.dataset.gitStatusExpandableHint = ""; + hint.setAttribute("aria-hidden", "true"); + hint.className = + "pointer-events-none fixed z-50 flex items-center whitespace-nowrap rounded-[4px] border border-control-border bg-background text-foreground shadow-md"; + hint.style.display = "none"; + hint.style.paddingInline = `${HINT_PADDING_X}px`; + document.body.append(hint); + + let currentRow: HTMLElement | null = null; + const hide = () => { + currentRow = null; + hint.style.display = "none"; + hint.replaceChildren(); + }; + + const show = (row: HTMLElement) => { + const label = row.querySelector("[data-sidebar-tree-label]"); + if (!label) return hide(); + const viewportRect = viewport.getBoundingClientRect(); + const labelRect = label.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + const naturalWidth = measureLabelNaturalWidth(label); + const truncated = isLabelTruncated(label); + // clientWidth excludes the overlay scrollbar gutter, so the cut-off edge is exact. + const visibleRight = viewportRect.left + viewport.clientWidth; + if (!truncated && !isLabelCutOff(labelRect.left, naturalWidth, visibleRight)) { + return hide(); + } + + const copy = label.cloneNode(true) as HTMLElement; + copy.removeAttribute("data-sidebar-tree-label"); + copy.style.overflow = "visible"; + copy.style.flex = "none"; + for (const part of Array.from(copy.querySelectorAll("*"))) { + part.style.overflow = "visible"; + part.style.textOverflow = "clip"; + part.style.flex = "none"; + part.style.maxWidth = "none"; + } + const font = getComputedStyle(label); + hint.style.fontSize = font.fontSize; + hint.style.fontFamily = font.fontFamily; + hint.style.left = `${labelRect.left - HINT_PADDING_X - 1}px`; + hint.style.top = `${rowRect.top}px`; + hint.style.height = `${rowRect.height}px`; + hint.replaceChildren(copy); + hint.style.display = "flex"; + currentRow = row; + }; + + const handlePointerOver = (event: PointerEvent) => { + const row = (event.target as Element | null)?.closest?.( + "[data-git-status-row-index]", + ); + if (!row || !viewport.contains(row)) return hide(); + if (row !== currentRow) show(row); + }; + + viewport.addEventListener("pointerover", handlePointerOver); + viewport.addEventListener("pointerleave", hide); + viewport.addEventListener("pointerdown", hide); + viewport.addEventListener("scroll", hide, { passive: true }); + viewport.addEventListener("wheel", hide, { passive: true }); + window.addEventListener("blur", hide); + return () => { + viewport.removeEventListener("pointerover", handlePointerOver); + viewport.removeEventListener("pointerleave", hide); + viewport.removeEventListener("pointerdown", hide); + viewport.removeEventListener("scroll", hide); + viewport.removeEventListener("wheel", hide); + window.removeEventListener("blur", hide); + hint.remove(); + }; + }, [enabled, viewportRef]); +} diff --git a/windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx b/windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx new file mode 100644 index 000000000..d1e33a7bc --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-commit-area-resize.test.tsx @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { + COMMIT_MESSAGE_DEFAULT_HEIGHT, + COMMIT_MESSAGE_MAX_HEIGHT, + COMMIT_MESSAGE_MIN_HEIGHT, + useGitCommitPanelPreferencesStore, +} from "../stores/git-commit-panel-preferences.store"; +import { + COMMIT_MESSAGE_HEIGHT_CSS_VARIABLE, + GIT_CHANGES_MIN_HEIGHT, + useGitCommitAreaResize, +} from "./use-git-commit-area-resize"; + +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousAct: boolean | undefined; +let frames: FrameRequestCallback[] = []; +const frameScheduler = { + scheduleFrame: (callback: FrameRequestCallback) => frames.push(callback), + cancelFrame: () => undefined, +}; +let changesHeight = 300; +let previousInnerHeight: number; +let previousObserver: typeof ResizeObserver; +let paneHeight = 0; +let editorHeight = 0; +let chromeHeight = 80; +const observers = new Set<() => void>(); + +function Probe() { + const commitAreaRef = useRef(null); + const changesRef = useRef(null); + const resize = useGitCommitAreaResize(commitAreaRef, changesRef, frameScheduler); + return ( +
{ + if (element) element.getBoundingClientRect = () => ({ height: paneHeight }) as DOMRect; + }} + > +
{ + changesRef.current = element; + if (element) { + element.getBoundingClientRect = () => ({ height: changesHeight }) as DOMRect; + } + }} + /> +
{ + commitAreaRef.current = element; + if (element) + element.getBoundingClientRect = () => + ({ height: editorHeight + chromeHeight }) as DOMRect; + }} + data-testid="commit-area" + style={resize.commitAreaStyle} + > +