diff --git a/AGENTS.md b/AGENTS.md index 8cc80e16..946ce60c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,11 +176,6 @@ additional hardening beyond the actual internal-network threat model: blocking page state, or an error with an immediate local recovery action. - Do not show the same event both inline and as an application notification. Preserve user input and actionable error context when an operation fails. -- Treat renderer bundle budgets as regression guardrails, not UX targets. Do - not lazy-load one lightweight peer tab solely to satisfy a byte ceiling. - When a genuinely heavy view needs deferred loading, preserve peer - interaction semantics, render a non-empty layout-stable local fallback, - and test its first interaction rather than only its eventual appearance. ## Commit Messages and Release Notes diff --git a/UI-DESIGN.md b/UI-DESIGN.md index b8c857a0..baa5f133 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -282,6 +282,9 @@ - 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。 - 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 +- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整, + 并记住用户选择;分隔条使用 `separator` 语义,支持方向键、Home 和 End。小于 + `900px` 时继续使用固定宽度抽屉,不显示拖拽分隔条。 - 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量; 展开后的每个 Task 子项左侧显示名称,模式、计划和共享状态标记收在右侧,与父会话的 更新时间位置一致;展开层级只到 Task,不展示 Job、Subjob 或 Run。 @@ -311,17 +314,29 @@ 助手工作栏是应用级右侧工具容器,不归属于聊天页面,也不根据当前页面、项目或 Runtime 自动增删入口。产品契约见 -[通用助手工作栏与执行空间 PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。 +[通用助手工作栏与执行空间 PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md), +动态 Tab、“+”和终端细节见 +[助手工作栏多终端页签 PRD](./docs/prd/assistant-experience/workbar-terminal-tabs-prd.md)。 -- 默认固定提供任务中心、工作区、浏览器和成果四个标准栏目,不根据当前页面或 - Runtime 能力自动增删入口。 +- 升级后默认打开任务中心、工作区、浏览器和成果四个单实例应用,不根据当前页面或 Runtime + 能力自动增删稳定应用目录入口。 +- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。 + “+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。 +- 应用注册表声明单实例或多实例。单实例重复选择时聚焦已有 Tab;终端从首期开始支持 + 多实例,每次选择创建独立 Tab 和 Shell Session。 - Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容, 也不得把 Job/Run 提升为可导航 UI 对象。 - 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。 - 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 - 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 -- 四个栏目使用稳定标签并保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 +- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 + 实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。 +- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少 + 两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或 + `focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。 +- 选择“终端”后直接绑定当前项目,由项目执行空间自动决定本机或托管 SSH;不再显示第二层 + 目标选择器。终端面板内部不重复提供关闭会话按钮,关闭统一由 Tab 入口完成。 - 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目; 记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。 - 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。 @@ -627,6 +642,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。 - 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。 - 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。 +- 左侧笔记与待办列表在双栏布局中允许在 `180px` 至 `360px` 范围内通过指针或键盘调整, + 也可从页面标题区隐藏和恢复;宽度与显示状态均记住用户选择。分隔条保留 `separator` + 语义及方向键、Home、End 操作,窄窗口堆叠布局不显示拖拽分隔条。 - 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。 - 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。 - 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。 diff --git a/docs/README.md b/docs/README.md index 4cd5212d..d398ed53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先 | Task 与 Job | [Task 与 Job 总览](./prd/task-and-job/README.md) | | Smart Heartbeat | [智能心跳 PRD](./prd/smart-heartbeat/smart-heartbeat-prd.md) | | 助手工作栏 | [通用助手工作栏与执行空间 PRD](./prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md) | +| 工作栏终端 | [助手工作栏多终端页签 PRD](./prd/assistant-experience/workbar-terminal-tabs-prd.md) | | 会话监督 | [会话监督 PRD](./prd/supervision/conversation-supervision-prd.md) | | 记忆 | [分区记忆 PRD](./prd/memory/partitioned-memory-prd.md) | | 并行实验 | [并行实验工作台 PRD](./prd/experiments/parallel-experiments-prd.md) | @@ -35,9 +36,8 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先 - [全双工实时语音交互设计](./architecture/full-duplex-voice-design.md) - [DeepSeek Harness Runtime 设计](./architecture/deepseek-harness-runtime-design.md) - [应用内反馈系统设计与对接](./architecture/in-app-feedback-integration-design.md) -- [SSH 远程主机与 GoodBuddy Agent 稳定终态设计](./architecture/remote-host-and-goodbuddy-agent-design.md) +- [SSH 远程主机与 GoodBuddy Agent 实现说明](./architecture/remote-host-and-goodbuddy-agent-design.md) - [SSH Host 远程环境准备与控制面直连设计](./architecture/remote-host-environment-provisioning-design.md) -- [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md) - [长期助手路线图](./roadmap/long-term-assistant-roadmap.md) - [产品、性能与体验综合改进计划](./roadmap/product-performance-experience-improvement-plan.md) - [电脑控制实施状态](./status/computer-control-implementation-status.md) diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index d22a10dd..9b25a20b 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -5,10 +5,10 @@ | 项目 | 内容 | | --- | --- | | 状态 | 设计中 | -| 版本 | 0.5 | -| 日期 | 2026-08-28 | +| 版本 | 0.6 | +| 日期 | 2026-08-31 | | 适用产品 | GoodBuddy 桌面端 | -| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[SSH 远程主机与 GoodBuddy Agent 稳定终态设计](../../architecture/remote-host-and-goodbuddy-agent-design.md) | +| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[SSH 远程主机与 GoodBuddy Agent 实现说明](../../architecture/remote-host-and-goodbuddy-agent-design.md) | | 相关能力 | [会话监督](../supervision/conversation-supervision-prd.md)、[自动化平台](../../architecture/automation-platform-architecture.md)、[长期助手路线图](../../roadmap/long-term-assistant-roadmap.md) | ## 1. 背景 @@ -104,7 +104,8 @@ Runtime - 监督默认选择当前会话,用户可以改选其他 Conversation、Task 或实验对象。 - Runtime 默认跟随当前会话或 Task;执行事件可以查看,但 Job/Run 不作为独立选择对象。 -- 终端默认使用当前项目执行空间,用户可以新建本机或远程终端。 +- 终端创建时固定到当前项目执行空间,由项目配置自动决定本机或托管 SSH;没有当前项目时 + 使用本机 Home。 - 工作区默认显示当前项目目录,用户可以打开其他本机目录或远程目录。 - 成果默认使用当前范围,用户可以切换到项目、全局或其他允许范围。 - 下一次请求的附件和知识库由当前 Composer 展示与管理,不作为工作栏能力。历史执行上下文 @@ -134,8 +135,8 @@ Runtime - 当前 Runtime 不支持后台 Job 时,Runtime 能力仍可发现;打开后说明当前可监督的内容。 - 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,Git 区域显示不可用原因。 - 没有活动进程时,进程面板提供创建终端或启动 Runtime 的入口。 -- 没有项目时,终端和工作区允许用户选择本机目录或远程主机,但必须先取得有界临时 - Execution Space Grant,不能把可选路径直接传给文件、Git、Process 或 Runtime。 +- 没有项目时,终端使用当前用户本机 Home;工作区等允许选择路径的能力仍必须先取得有界 + 临时 Execution Space Grant,不能把可选路径直接传给文件、Git、Process 或 Runtime。 - 监督未启用时,监督面板提供目标、模式和“开始监督”,而不是隐藏能力。 不得渲染成排没有解释的禁用按钮,也不得把“进程连通”描述为已经支持完整原生监督。 @@ -201,22 +202,26 @@ Runtime ### 6.2 能力目录与面板实例 -能力目录不等于同时打开八个面板。推荐使用工作栏内的纵向能力导航,并在旁边或停靠区域管理 -用户已经打开的面板实例: +能力目录不等于同时打开八个面板。当前产品采用“已打开实例 Tab + 持久新增按钮”的工作栏 +模型:Tab 行只显示已打开实例,行末“+”始终可见;点击后在工作栏内容区打开稳定能力目录, +用户再创建或激活应用实例。详细交互以 +[助手工作栏多终端页签 PRD](./workbar-terminal-tabs-prd.md) 为准。 - 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。 -- 目录使用与其交互模型匹配的列表、工具栏或菜单语义;单实例切换使用 `tablist`、`tab`、 - `tabpanel`,多实例停靠区使用有名称的区域和明确面板标题。 +- 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。 +- 应用注册表声明 `single` 或 `multiple`。重复选择单实例应用时聚焦已有实例;选择多实例 + 应用时每次创建新实例。 - 支持方向键、Home、End、Enter、Space、关闭面板和正确焦点恢复。 - 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。 - 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭实例后能力仍可从目录重新打开。 - 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。 - 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。 -当前聊天右栏过渡实现保留 Task Center、工作区、浏览器和成果四个横向页签时,必须 -单行横向滚动,不能自动隐藏或缩写到不可辨认。Task Center 继续作为 Task 的现有入口; -审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和完整配置统一归属 -“智能心跳”菜单入口。当前阶段不新增独立自动化中心。 +从当前聊天右栏迁移时,Task Center、工作区、浏览器和成果作为默认打开的单实例应用保留; +终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动, +不能自动隐藏或缩写到不可辨认,“+”不能随滚动消失。Task Center 继续作为 Task 的现有 +入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和 +完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。 ### 6.3 工作栏尺寸 @@ -311,10 +316,12 @@ Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness ### 7.4 终端 -终端面板允许用户主动创建和管理本机或 SSH 终端: +终端面板允许用户通过工作栏“+”主动创建和管理多个本机或 SSH 终端。详细产品行为、首期 +直接 SSH PTY 技术选择、生命周期和验收以 +[助手工作栏多终端页签 PRD](./workbar-terminal-tabs-prd.md) 为准。 - 新建、重命名、切换、关闭和重新连接终端。 -- 选择执行空间、工作目录和 Shell。 +- 创建时自动绑定当前项目执行空间、权威工作目录和平台默认 Shell,不显示重复目标选择。 - 显示本机或远程主机、目录、Shell 和连接状态。 - 支持复制、粘贴、搜索、清屏、滚动和调整终端尺寸。 - 支持将终端切换到右侧、底部或独立窗口。 @@ -815,7 +822,8 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ### 阶段 2:监督、终端与受管进程 - 普通会话手动监督和右栏评论流。 -- 本机 PTY 终端。 +- 工作栏持久“+”、应用注册表和单/多实例 Tab。 +- 本机 PTY 与直接 SSH PTY 多终端。 - 受管进程注册、日志和终止。 - 自动回复后监督、节流和独立预算。 - 用户选择终端停靠位置。 @@ -921,6 +929,8 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ## 18. 相关文档的职责 - 本文是助手工作栏稳定能力目录、用户面板实例、范围控制和执行空间的产品总契约。 +- [助手工作栏多终端页签 PRD](./workbar-terminal-tabs-prd.md) 定义持久“+”、应用实例策略、 + 动态 Tab 以及本机与直接 SSH 多终端。 - [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 定义 Task、Conversation、 Job、Subjob、Run 与 Subagent。 - [Task Center PRD](../task-and-job/task-center-prd.md) 定义应用级 Task 索引。 diff --git a/docs/prd/assistant-experience/workbar-terminal-tabs-prd.md b/docs/prd/assistant-experience/workbar-terminal-tabs-prd.md new file mode 100644 index 00000000..f0ef82d5 --- /dev/null +++ b/docs/prd/assistant-experience/workbar-terminal-tabs-prd.md @@ -0,0 +1,582 @@ +# 助手工作栏多终端页签 PRD + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 状态 | 已实施,跨平台验证中 | +| 版本 | 1.1 | +| 日期 | 2026-08-31 | +| 适用产品 | GoodBuddy 桌面端 | +| 界面归属 | [通用助手工作栏与执行空间](./assistant-workbar-and-execution-spaces-prd.md) | +| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) | +| 远程架构 | [SSH 远程主机与 GoodBuddy Agent 实现说明](../../architecture/remote-host-and-goodbuddy-agent-design.md)、[SSH Host 远程环境准备与控制面直连设计](../../architecture/remote-host-environment-provisioning-design.md) | + +## 1. 文档职责 + +本文细化助手工作栏中的以下行为: + +- 已打开应用以同级 Tab 呈现。 +- Tab 行始终提供“+”入口,用户按需打开应用。 +- 应用通过统一注册表声明单实例或多实例策略。 +- “终端”作为首个多实例应用,自动使用当前项目的本机或托管 SSH 执行空间。 +- 本机与远程交互式终端的生命周期、状态、性能和验收要求。 + +[通用助手工作栏与执行空间 PRD](./assistant-workbar-and-execution-spaces-prd.md) +继续负责工作栏的应用级位置、稳定能力目录、执行空间、范围绑定和总体产品边界。本文不重新 +定义 Host Key、凭据、Agent 安装、Ask/Execute Runtime 或项目持久化契约。 + +## 2. 背景与当前问题 + +当前工作栏在聊天页面右侧固定显示“任务中心、工作区、浏览器、成果”四个 Tab。用户不能: + +- 通过统一入口按需打开新的工作栏应用。 +- 关闭并重新打开一个工作栏应用。 +- 为支持多实例的应用创建第二个独立实例。 +- 在 GoodBuddy 内使用本机或远程 Host 的交互式 Shell。 + +当前代码已经具备工作栏右侧回流、宽度调整、Tab 键盘导航、SSH Host Key 固定、加密凭据和 +SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 Session Manager 或终端模拟器。 +`node-pty` 当前只作为其他 Runtime 的间接依赖被打包,不能视为已经存在用户终端能力。 + +## 3. 产品定义 + +### 3.1 工作栏应用 + +工作栏应用是可以在助手工作栏中打开的一个能力,例如任务中心、工作区、浏览器、成果或 +终端。每项应用通过统一注册表声明: + +- 稳定应用 ID。 +- 用户可见名称、图标和说明。 +- `single` 或 `multiple` 实例策略。 +- 创建实例时可用的默认上下文。 +- 当前不可用时的原因和修复入口。 + +目录中的应用可发现性与实例是否已经打开相互独立。应用不可用时保留入口并说明原因,不以 +隐藏代替状态。 + +### 3.2 工作栏 Tab 实例 + +工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只 +显示已经打开的实例,不把全部能力同时渲染为 Tab。 + +### 3.3 终端实例 + +每个终端 Tab 对应一个独立交互式 Shell Session: + +- 本机终端由桌面 Main 进程管理本机 PTY。 +- 远程终端通过现有、已验证的 SSH Host 打开直接 SSH PTY。 +- 不同终端拥有独立 Shell、工作目录、输入、输出、尺寸和结束状态。 +- 创建后固定到当时选择的执行位置,切换项目不会迁移已经运行的终端。 + +直接 SSH PTY 是用户操作的远程终端,不是连接期临时 GoodBuddy Agent,也不作为 Runtime +stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或桌面重启的进程恢复。 + +## 4. 已确认的产品决策 + +1. 工作栏 Tab 行末尾始终显示“+”按钮。 +2. “+”是普通命令按钮,不是一个 `tab`。 +3. 点击“+”在工作栏内容区显示“新建工作栏应用”选择页;取消后返回原活动 Tab。 +4. 选择应用后创建或激活实例,并关闭临时选择页。 +5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。 +6. 多实例应用每次选择都创建新实例。 +7. 终端从首期开始就是多实例应用。 +8. 任务中心、工作区、浏览器和成果首期保持单实例;后续只需修改应用注册表策略即可开放 + 多实例,不重写 Tab 壳层。 +9. 现有四个应用在升级后仍按当前默认布局打开,避免用户入口突然消失。 +10. 用户关闭、切换或重排应用 Tab 后,“+”仍保持可见。 +11. 选择“终端”后不再显示第二层目标选择器。存在当前项目时直接绑定该项目,由 Main + 根据项目执行空间自动打开本机或托管 SSH Shell;没有当前项目时打开本机 Home Shell。 +12. 远程首期使用直接 SSH PTY。SSH 断线后终端进入“已中断”,重新连接会创建一个新的 + Shell,不伪装为恢复旧进程。 +13. 工作栏收起、切换主页面或切换 Tab 不终止终端;显式关闭终端 Tab 才终止对应 Shell。 +14. 应用重启不自动启动 Shell。恢复的终端 Tab 显示“会话已结束”,由用户显式重新连接。 +15. 终端是用户交互表面。Ask/Execute 继续约束 Agent Runtime,不把用户键盘操作误判为 + Agent 工具调用;Agent、自动化和模型不得未经明确用户操作向终端注入输入。 +16. 工作栏继续按上位 PRD 提升为应用级容器;切换聊天、知识、魔法笔记或活动记录等主页面 + 不销毁 Tab 实例或终端 Session。 + +## 5. 目标 + +### 5.1 用户目标 + +- 在工作栏中随时通过“+”发现并打开应用。 +- 同时打开多个本机或远程终端,并通过 Tab 快速切换。 +- 清楚知道每个终端运行在本机还是哪个远程 Host、当前目录和连接状态。 +- 在终端断线、退出或目标失效后获得准确状态和明确恢复操作。 +- 使用键盘完成打开应用、切换 Tab、操作终端和关闭实例。 + +### 5.2 产品目标 + +- 用一个可扩展的应用注册表统一单实例与多实例行为。 +- 把现有固定四 Tab 迁移为用户实例,而不回归现有任务中心、工作区、浏览器和成果能力。 +- 建立真实的 Main/Preload/Renderer 终端生产链路,而不是用普通文本框模拟终端。 +- 复用现有 SSH Host Key、凭据和连接池,不建立第二套 Host 管理。 +- 保持终端输入输出有序、响应流畅、内存有界,并可靠清理本机和远程资源。 + +## 6. 非目标 + +- 不把 GoodBuddy 改造成完整 IDE。 +- 不唤起 Windows Terminal、Terminal.app 等外部终端窗口代替内嵌终端。 +- 不扫描或导入用户全部 SSH config、目录、Shell profile 或命令历史。 +- 不支持任意 `ProxyCommand`、端口转发或 SSH Agent Forwarding。 +- 不在打开远程终端时安装、更新或修复 GoodBuddy Agent。 +- 不把 Agent 工具调用、Runtime 日志或自动化输出伪装成用户终端。 +- 不允许 Renderer 直接访问 PTY、ChildProcess、SSH Client、凭据或 Node API。 +- 首期不提供终端协作、录制、云同步、Shell Session 复用、tmux 管理或跨设备恢复。 +- 首期不实现底部停靠和独立窗口;本文保留兼容模型,后续按工作栏总 PRD 扩展。 +- 首期不通过 GoodBuddy Agent 恢复远程终端,也不为终端复制 Runtime transcript、 + `outcome_unknown` 或模型调用恢复协议。 + +## 7. 信息架构与“+”交互 + +### 7.1 Tab 行 + +```text +┌──────────────────────────────────────────────┐ +│ 任务中心 │ 工作区 │ 终端 1 │ 终端 2 │ + │ +├──────────────────────────────────────────────┤ +│ 当前活动实例内容 │ +└──────────────────────────────────────────────┘ +``` + +- 已打开实例位于一个语义化 `tablist` 中。 +- 每个实例使用独立 `tab` 和 `tabpanel`。 +- “+”固定在 Tab 行末尾,但位于 `tablist` 之外。 +- Tab 过多时实例区域单行横向滚动,“+”保持可见;不换行、不自动隐藏实例。 +- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、 + 工作目录和 Shell。用户可以重命名终端。 +- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。 +- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现; + 粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。 + +### 7.2 新建应用选择页 + +点击“+”后: + +1. 保留当前实例及其运行状态。 +2. 在工作栏内容区显示“新建工作栏应用”。 +3. 列出稳定应用目录,每项显示名称、说明、单/多实例提示和当前可用状态。 +4. 初始焦点进入第一个可用应用;Escape 或“取消”返回原活动实例和“+”按钮。 +5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab。 +6. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户 + 重复选择本机或远程目标。 + +选择页是“+”触发的临时界面,不进入实例顺序,不持久化,也不占用一个普通 Tab。 + +### 7.3 首期应用注册表 + +| 应用 | 实例策略 | 升级后默认状态 | 再次从“+”选择 | +| --- | --- | --- | --- | +| 任务中心 | `single` | 打开 | 聚焦已有 Tab | +| 工作区 | `single` | 打开 | 聚焦已有 Tab | +| 浏览器 | `single` | 打开 | 聚焦已有 Tab | +| 成果 | `single` | 打开 | 聚焦已有 Tab | +| 终端 | `multiple` | 不打开 | 每次创建新 Tab | + +实例策略是应用注册数据,不能分散硬编码在多个点击处理器中。 + +### 7.4 Tab 操作 + +- 点击或使用方向键切换实例不会暂停或终止后台实例。 +- 支持左右方向键、Home、End 和 roving `tabindex`。 +- 关闭非活动 Tab 后焦点保持在当前活动 Tab。 +- 关闭活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。 +- 关闭最后一个实例后显示明确空状态和“打开应用”,Tab 行仍保留“+”。 +- 关闭正在运行的终端需要确认“关闭终端将结束 Shell 及其子进程”;取消是初始焦点。 +- 已退出或已中断的终端可以直接关闭,不重复确认。 + +## 8. 终端创建流程 + +### 8.1 自动目标解析 + +- 当前项目是本地项目时,创建目标为公开 Project ID,Main 从权威项目记录解析本机根目录。 +- 当前项目是托管 SSH 项目时,创建目标仍为公开 Project ID,Main 再解析项目 Host、 + 固定 Host Key、凭据和远端根目录。 +- 没有当前项目时使用本机目标和当前用户 Home。 +- Host 未验证、凭据不可用或不支持交互式 Shell 时,终端 Tab 就地显示失败原因和恢复入口, + 不静默切换到本机或其他 Host。 + +### 8.2 本机终端 + +- 当前项目是本地项目时,默认工作目录为项目根目录。 +- 没有本地项目目标时,默认工作目录为当前用户 Home。 +- Windows 优先使用可用的 PowerShell 7,其次 Windows PowerShell,最后使用系统 + `COMSPEC`。 +- macOS 和 Linux 优先使用当前用户有效的 `$SHELL`,不可用时使用平台常规 Shell fallback。 +- 首期自动选择默认 Shell,不提供任意可执行文件路径输入。 +- Main 只向 Shell 传递正常交互所需的筛选环境,不注入模型 API Key、GoodBuddy 内部 + Token 或其他产品凭据。 + +### 8.3 远程终端 + +- 当前项目是托管 SSH 项目时,自动使用项目 Host 和远端项目根目录。 +- Main 复用现有 Host store、完整 Host Key 固定、认证方式和 SSH connection pool。 +- 远端通过 SSH PTY 打开账号的登录 Shell,并支持输入、ANSI 输出和窗口尺寸同步。 +- 活动终端持有专用 Channel lease,不得被连接池空闲回收。 +- 不把密码、私钥、认证头或 GoodBuddy 模型凭据发送给 Renderer 或写入终端环境。 +- Host 地址、用户、凭据或 Host Key generation 变化时,旧 Session 进入“已中断”,新建 + Session 必须使用 Host store 中的当前身份。 + +### 8.4 创建结果 + +创建成功后: + +- 新 Tab 插入当前活动 Tab 右侧并立即激活。 +- 焦点进入终端输入区域。 +- 状态栏显示“本机”或 Host 名称、工作目录、Shell 和连接状态。 +- 终端尺寸使用内容区计算出的首个有效 `cols/rows`,不是固定默认值覆盖实际布局。 + +创建失败时在终端 Tab 保留错误上下文并提供重试;不得留下不可回收 Session 或显示为已经连接。 + +## 9. 终端交互与状态 + +### 9.1 基础交互 + +首期支持: + +- 真实 TTY 输入与 ANSI 输出。 +- 中文输入法、组合字符和常规键盘快捷键。 +- 复制、粘贴、全选、搜索、清屏和滚动。 +- 工作栏宽度或窗口尺寸变化后的 PTY resize。 +- 终端重命名。 +- 显式关闭、退出后重新打开和远程中断后重新连接。 + +浏览器原生页面快捷键与终端按键冲突时,终端聚焦期间优先交给终端;应用级保留快捷键必须 +有明确规则和可访问说明。 + +### 9.2 状态模型 + +```ts +type TerminalSessionState = + | 'starting' + | 'running' + | 'exited' + | 'interrupted' + | 'closing' + | 'failed' +``` + +- `starting`:正在创建本机 PTY 或 SSH Channel。 +- `running`:允许输入和 resize。 +- `exited`:Shell 正常或带退出码结束,显示退出码或信号。 +- `interrupted`:远程连接、Host identity 或桌面生命周期导致会话失效。 +- `closing`:已拒绝新输入,正在回收进程或 Channel。 +- `failed`:Session 未成功开始或发生不可恢复错误。 + +高频输出不逐行进入 live region。只播报连接成功、连接失败、已中断和 Shell 已退出等重要 +状态变化。 + +### 9.3 重新连接 + +- 本机或远程 Session 结束后,“重新打开”在同一 Tab 中创建全新的 Shell Session。 +- 远程断线后的按钮文案为“重新连接并新建 Shell”,不能暗示恢复旧进程。 +- 新 Session 使用 Host store 和项目中的当前有效配置,不复用旧 Host identity。 +- 重新连接前清空旧终端画面,并保留可见说明“这是新的 Shell 会话”。 + +## 10. 实例、Session 与持久化 + +工作栏实例与运行 Session 分离: + +```ts +type WorkbarAppDefinition = { + id: 'tasks' | 'workspace' | 'browser' | 'results' | 'terminal' + instancePolicy: 'single' | 'multiple' +} + +type WorkbarTabInstance = { + id: string + appId: WorkbarAppDefinition['id'] + title: string + targetRef?: { + type: 'project' | 'local' + id?: string + } +} +``` + +- 持久化工作栏打开状态、Tab 顺序、活动实例、应用 ID、自定义标题、公开目标引用和宽度比例。 +- 不持久化终端正文、未执行输入、凭据、环境变量、PTY PID、SSH Channel 或派生 Host + identity。 +- Main 创建终端后返回新的不透明 Session ID;Renderer 不能选择 PID 或 Channel。 +- 应用重启恢复终端 Tab 描述,但状态为“会话已结束”,不自动建立 SSH 连接或启动本机 + Shell。 +- 运行期间切换项目不会改变已经创建的终端目标。用户需要新目标时通过“+”创建新终端。 + +## 11. 生命周期与关闭语义 + +| 用户或系统动作 | Tab | Shell Session | +| --- | --- | --- | +| 切换 Tab | 保留 | 继续运行 | +| 收起工作栏 | 保留 | 继续运行,停止非必要高频 UI 工作 | +| 切换主页面 | 保留 | 继续运行 | +| 切换项目 | 保留并显示原目标 | 继续在原目标运行 | +| 关闭运行中的终端 Tab 并确认 | 删除 | 结束 Shell 和子进程 | +| Shell 自行退出 | 保留 | 标记 `exited` | +| SSH 断线 | 保留 | 标记 `interrupted` | +| 删除或修改目标 Host | 保留 | 关闭旧 Channel 并标记 `interrupted` | +| Renderer 销毁或应用退出 | 下次恢复描述 | 在总关闭截止时间内回收 | + +关闭操作必须安全处理竞态。Main 停止接受新输入,关闭 stdin/Channel,先正常结束,再在既有 +应用关闭截止时间内升级清理;一个卡死终端不能无限阻塞应用退出。关闭后的迟到 ACK 视为已 +完成,input、resize 或重复 close 返回 Session 不存在,不操作其他 Session。 + +## 12. 技术生产路径 + +### 12.1 Shared + +新增独立终端契约,至少覆盖: + +- `create`:目标引用、初始 `cols/rows`。 +- `write`:Session ID 和有界输入。 +- `resize`:Session ID、`cols/rows`。 +- `close`:Session ID。 +- `snapshot`:目标、状态、Shell、目录和终态。 +- `event`:带 Session ID 和单调序号的输出、状态、退出与错误事件。 + +所有 Schema 使用 Zod 严格校验,并限制 ID、标题、输入、尺寸、事件块、会话数和缓冲字节。 + +### 12.2 Main + +新增统一 `TerminalSessionManager`: + +- Session 按所属 `webContents` 和不透明 ID 隔离。 +- 将现有间接 `node-pty` 提升为 GoodBuddy 直接生产依赖,并用于启动平台 Shell。 +- 远程使用现有 `SshConnectionPool` 打开交互式 PTY Channel。 +- 统一处理 write、resize、close、exit、连接中断和应用退出。 +- 终端输出只发送给所属窗口。 +- 复用现有子进程树终止思路,但针对 PTY 验证 Windows 与 POSIX 的实际回收行为。 + +### 12.3 Preload 与 IPC + +- `DesktopApi` 只暴露显式 `terminal.create/write/resize/close/getSnapshot/onEvent`。 +- Main Handler 对每个调用执行可信 sender 检查和共享 Schema parse。 +- Preload 管理事件订阅释放,不暴露 `ipcRenderer`、PTY、SSH Client 或 Node API。 + +### 12.4 Renderer + +- 使用 `@xterm/xterm` 6 和官方 fit/search addon 处理 ANSI、光标、选择、IME、搜索、 + 滚动和可访问输入,不自行实现终端转义状态机。 +- 终端首次打开时按需加载;支持 WebGL2 时使用官方 WebGL renderer,初始化失败或 Context + 丢失时释放 WebGL addon 并回退到 xterm 默认 renderer。 +- xterm 使用解析后的跨平台系统等宽字体栈,不能把未解析的 CSS `var(...)` 交给 Canvas。 +- Tab 壳层只管理实例与焦点,不持有 PTY 或 SSH 对象。 +- `ResizeObserver` 只在有效非零布局后发送去重后的 `cols/rows`。 +- 组件卸载时退订画面事件;只有显式关闭终端才调用 Session close。 +- 终端样式复用全局语义颜色、边框、焦点和字体令牌,不创建另一套工作台设计系统。 + +### 12.5 输出顺序与背压 + +- 输出按 Session 分区并携带单调序号。 +- Main 合并小块输出,限制单事件大小、待发送条目和总字节。 +- Renderer 确认已经消费的序号;慢消费者达到上限时暂停 PTY/SSH stream,消费后恢复。 +- 不通过丢弃中间输出维持界面响应,因为丢失字节会破坏终端状态。 +- Renderer 只保留有界滚动历史;清屏不改变 Main Session 生命周期。 + +## 13. 权限与边界 + +- 用户终端以当前本机账号或所选 SSH 账号的正常权限运行,不增加额外虚构的 trust tier。 +- Ask/Execute 是 Agent Runtime 模式,不自动改变用户直接操作终端的 OS 权限。 +- UI 必须持续将其标注为“用户终端”,避免被理解为 Ask Runtime 的只读输出。 +- Agent、模型、监督器和自动化不能调用终端 write,也不能把指令注入已有 Session。 +- 终端输出不会自动进入对话上下文、模型 Prompt、成果或活动记录。 +- Renderer 只能引用项目和 Session 的公开 ID;Main 从权威 store 解析目录、Host、凭据和 + 当前 Host identity。 +- 不接受 Renderer 提交任意 Shell 可执行路径、SSH 命令、PID、环境变量集合或原始凭据。 +- 保持 Electron `contextIsolation`、sandbox 和禁用 Renderer Node integration。 + +## 14. 错误与反馈 + +- 创建失败、断线、Shell 退出和目标失效保留在终端面板,提供就地重试。 +- 不与全局通知重复显示同一终端错误。 +- 非局部错误,例如无法保存工作栏布局,可以进入应用通知。 +- 错误文案说明发生了什么、当前 Shell 是否仍在运行、用户可以做什么。 +- Host Key 变化沿用现有 Host 管理流程;终端只显示“主机身份已变化,请重新验证”,不在 + 终端内复制凭据或 Host Key 修改流程。 +- 目标不可用时保留 Tab 名称和目标,不静默切换到本机或其他 Host。 + +## 15. 性能与资源要求 + +- 工作栏切换和终端输入在正常负载下保持即时反馈,持续输出不能阻塞主界面导航。 +- 同一窗口的活动终端数量必须有明确上限;达到上限时阻止新建并说明先关闭不用的终端。 +- 每个 Session 的输出队列、Renderer 滚动历史和 IPC 单事件均有硬上限。 +- 工作栏不可见时可以降低渲染刷新频率,但不能丢失终端协议字节或暂停 Shell 本身。 +- 应用退出复用现有总清理截止时间,不因终端新增无界等待。 +- 生产构建必须实际加载本机 PTY 原生模块;不能只验证文件存在。 +- 远程终端不能使 SSH connection pool 的空闲项、Channel 或监听器泄漏。 + +具体数值在共享契约和性能测试中确定,以现有 IPC、窗口和关闭预算为上限,不在多个进程中 +维护不同常量。 + +## 16. 无障碍与响应式 + +- Tab 使用 `tablist/tab/tabpanel`;“+”使用带可访问名称“打开工作栏应用”的普通按钮。 +- 应用选择页使用可键盘操作的列表或菜单语义,不使用 `SegmentedControl`;终端没有重复的 + 目标选择页。 +- 每个终端 Tab 的可访问名称包含自定义名称或位置,多个终端不能都只读作“终端”。 +- Tab 关闭后焦点移动到确定的相邻 Tab;取消创建后焦点返回“+”。 +- 工作栏收起后焦点返回唯一工作栏开关,不增加焦点陷阱或遮罩。 +- 终端高频输出不逐行播报;只播报连接、退出、失败和中断。 +- 200% 文字缩放时,目标、Shell、目录、状态和关闭操作仍可访问。 +- Tab 单行横向滚动,终端内容随工作栏尺寸变化并同步 PTY,不把 Tab 换成下拉框。 +- 深色和浅色主题都使用设计系统语义令牌,并保持终端文本、选择和光标可辨。 + +## 17. 实施顺序与多代理工作包 + +功能必须以完整的本机和远程用户流程交付。以下是可并行的开发工作包,不表示可以发布只有 +Schema、假数据终端或不可用入口的中间产品。 + +### 17.1 依赖顺序 + +```text +契约冻结 + ├─ 工作栏实例壳层 + ├─ 本机 PTY 后端 + ├─ SSH PTY 后端 + └─ 终端 Renderer + ↓ +单一集成负责人完成 Main/Preload/App 接线 + ↓ +完整验证、真实 Host 验收与文档同步 +``` + +### 17.2 文件所有权 + +| 工作包 | 主要职责 | 建议文件所有权 | 不应修改 | +| --- | --- | --- | --- | +| 契约与注册表 | Workbar/Terminal Schema、状态、测试 | 新建 `src/shared/workbar-contracts.ts`、`terminal-contracts.ts` 及测试 | `App.tsx`、`ipc.ts`、Preload | +| 工作栏壳层 | 动态实例 Tab、“+”、选择页、单/多实例和焦点 | 新建工作栏组件及其测试 | PTY、SSH、Main IPC | +| 本机终端后端 | 本机 PTY、输入、resize、退出和进程树清理 | 新建 `src/main/terminal/` 本机文件及测试 | Renderer、远程 Agent | +| SSH 终端后端 | SSH PTY、lease、resize、断线和 Channel 清理 | 新建 `src/main/terminal/` 远程文件及聚焦测试 | 工作栏 UI、Agent 协议 | +| 终端 Renderer | 模拟器、IME、搜索、复制粘贴、状态栏和 resize | 新建终端组件、样式和测试 | Main、SSH、中心 IPC | +| 集成 | 依赖、IPC channel、DesktopApi、Preload、Main 注册、`App` 接线与关闭顺序 | `package.json`、lock、`ipc-channels.ts`、`contracts.ts`、`preload/index.ts`、`main/ipc.ts`、`App.tsx` | 不重写各包已验收内部逻辑 | +| 验收与文档 | E2E、跨平台构建检查、真实 Host、最终文档 | 测试与相关文档 | 不用假实现替换生产路径 | + +中心文件只能由集成负责人修改,避免多个代理同时编辑 `App.tsx`、`main/ipc.ts`、 +`shared/contracts.ts`、`preload/index.ts`、`package.json` 和 lockfile。 + +### 17.3 并行开发门槛 + +契约负责人先冻结: + +- 应用注册和实例策略。 +- Terminal request、snapshot、event 和 error code。 +- Session 所有权和关闭语义。 +- 输出序号、ACK 与容量常量。 + +其他代理基于冻结契约工作。接口偏差由集成负责人统一修正,不由多个代理分别修改共享中心 +文件。 + +## 18. 验收标准 + +### 18.1 工作栏应用与实例 + +- [ ] Tab 行末始终显示“+”,包括没有任何打开实例时。 +- [ ] 点击“+”显示稳定应用目录,取消后返回原实例并恢复焦点。 +- [ ] 选择已打开单实例应用只聚焦已有 Tab。 +- [x] 连续选择终端会创建多个互相独立的终端 Tab。 +- [ ] 关闭、切换和重排实例后,“+”仍保持可用。 +- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。 +- [x] 关闭活动 Tab 后激活正确的相邻 Tab 并恢复焦点。 +- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停 + 设备上出现,且不引发布局位移。 +- [ ] 升级后现有任务中心、工作区、浏览器和成果仍按默认布局打开且行为不回归。 +- [ ] 在所有主要页面都能打开工作栏;切换主要页面不会销毁 Tab 实例或终端 Session。 + +### 18.2 本机终端 + +- [x] 从“+ → 终端”直接打开当前本机项目的真实交互式 Shell,工作目录为项目根。 +- [ ] 没有项目时从用户 Home 打开本机 Shell。 +- [ ] `pwd` / `Get-Location`、交互输入、ANSI 颜色、中文 IME、复制和粘贴工作正常。 +- [ ] 调整工作栏和窗口尺寸会更新 PTY `cols/rows`,全屏 TTY 程序正确重绘。 +- [ ] 多个本机终端的输入、输出、目录和退出状态互不串线。 +- [ ] Shell 有活动子进程时关闭 Tab,确认后回收完整进程树;取消后 Session 继续运行。 +- [ ] 退出后的 Tab 显示退出码,并可在同一 Tab 显式创建新 Shell。 + +### 18.3 远程终端 + +- [ ] 从“+ → 终端”直接为当前托管 SSH 项目打开真实 SSH PTY。 +- [ ] 终端持续显示 Host、远端目录、Shell 和连接状态。 +- [ ] 使用现有固定 Host Key 和已存凭据,Renderer、日志和命令参数不出现凭据。 +- [ ] 输入、ANSI、中文、复制粘贴和 resize 在真实 Host 上工作正常。 +- [ ] 活动远程终端不会被 SSH connection pool 空闲回收。 +- [ ] SSH 断线立即标记“已中断”并拒绝继续输入,不显示为仍在运行。 +- [ ] “重新连接并新建 Shell”明确创建新 Session,不宣称恢复旧远端进程。 +- [ ] Host 删除、地址/用户/凭据或 Host Key generation 变化会中断旧 Session;新 Session + 使用当前 Host identity。 + +### 18.4 生命周期、性能和边界 + +- [ ] 收起工作栏、切换主页面、切换 Tab 或切换项目不会终止已有终端。 +- [ ] 终端不会随项目切换静默迁移执行位置。 +- [ ] 应用重启只恢复终端 Tab 描述,不自动连接或启动 Shell。 +- [ ] 输出顺序在持续大输出下保持正确,Main 和 Renderer 内存保持有界,主界面仍可操作。 +- [ ] 慢 Renderer 触发暂停和恢复,不丢弃中间终端字节。 +- [ ] Renderer 不获得 raw PTY、ChildProcess、SSH Client、PID、环境变量或凭据。 +- [ ] Agent、模型和自动化不能向已有用户终端注入输入。 +- [ ] 窗口销毁和应用退出在既有关闭截止时间内回收本机进程、SSH Channel 和监听器。 +- [ ] 重复 close、迟到 input/resize 和退出竞态不会操作其他 Session 或导致进程泄漏。 + +### 18.5 设计、无障碍和跨平台 + +- [ ] 仅使用键盘可以打开应用、创建终端、切换和关闭 Tab、复制、粘贴及重新连接。 +- [ ] Tab、“+”、选择页和关闭确认使用正确语义并有可见焦点。 +- [ ] 终端输出不逐行进入 live region,关键状态变化可被辅助技术感知。 +- [ ] 浅色、深色、200% 文字缩放和窄窗口下可以完成核心流程。 +- [ ] Windows、macOS、Linux x64 和 Linux arm64 的正式构建实际启动本机 PTY。 +- [ ] 远程链路在共享 Linux x64 真实 Host 上完成创建、输入、resize、断线、重连、关闭和 + 应用退出验证;若 Host 不可达,远程终端不得标记为完成。 + +## 19. 验证计划 + +### 19.1 自动验证 + +- 共享 Zod Schema 和边界值测试。 +- 单/多实例注册策略、Tab 顺序、关闭焦点和“+”选择页组件测试。 +- 本机 PTY Session Manager 的输入、resize、退出、幂等关闭和进程树测试。 +- SSH PTY 的 Host identity、lease、断线、resize 和关闭测试。 +- Main trusted sender、Session 所有权、IPC 输入边界和事件路由测试。 +- Terminal Renderer 的 IME、键盘、ResizeObserver、订阅释放和状态测试。 +- `npm test` +- `npm run typecheck` +- `npm run lint` +- `npm run build` + +### 19.2 真实路径验证 + +- 在当前开发平台通过产品 UI 完成本机终端全流程。 +- 在共享 Linux x64 Host 通过现有 Host 配置完成远程终端全流程。 +- 真实运行需要 TTY 的程序,验证 ANSI、光标和 resize,不只运行非交互命令。 +- 持续输出并主动减慢 Renderer 消费,验证背压和内存上限。 +- 分别在本机和远程启动带子进程的命令,关闭 Tab 和应用后核对无 GoodBuddy 所属遗留资源。 +- 生产构建在各受支持平台实际加载 PTY 原生模块,不以单元测试或包内文件存在代替。 + +### 19.3 当前开发验证记录 + +2026-09-01 已通过生产 `SshConnectionPool` 和 `createSshTerminalSession` 在共享 Linux x64 +Host 完成直接 SSH PTY 验证:使用已有固定 Host Key 和测试凭据,以远端项目目录启动登录 +Shell,验证交互输入、ANSI、UTF-8 中文、`80×24` 到 `101×37` resize、连接销毁后进入 +`interrupted`、重新连接创建新 Shell,以及 Session close 和 connection pool dispose +回收远端 Shell。验证只创建一个 +`$HOME/.goodbuddy/terminal-smoke-` 临时目录,结束后已删除并确认无遗留 Shell 进程。 + +同日使用最新生产构建完成工作栏视觉检查:活动 Tab 使用连续内容表面、顶部强调线和应用 +图标;关闭按钮默认不可见,悬停后以 overlay 显示且 Tab 宽度不变;横向滚动继续可用但不 +显示滚动条,“+”保持独立可见。 + +本记录不替代尚未勾选的完整产品 UI、剪贴板、macOS、Linux arm64 和各平台本机 PTY 验收。 + +本 PRD 不改变 Agent 协议,因此首期远程终端无需 Agent package 更新。若实现期间改为 +Agent-owned PTY,必须先修订本文的远程生命周期和恢复语义,并按 Agent 开发规则在共享 +Linux x64 Host 验证当前源码或候选包。 + +## 20. 相关文档职责 + +- [通用助手工作栏与执行空间 PRD](./assistant-workbar-and-execution-spaces-prd.md): + 工作栏应用级位置、稳定能力目录、执行空间和总体范围。 +- 本文:持久“+”、应用实例策略、工作栏动态 Tab、自动项目目标、本机与直接 SSH 多终端。 +- [统一界面设计系统](../../../UI-DESIGN.md):Tab、菜单、布局、主题、焦点和无障碍规则。 +- [SSH 远程主机与 GoodBuddy Agent 实现说明](../../architecture/remote-host-and-goodbuddy-agent-design.md): + Host Key、凭据、连接池、Agent 和远程项目现状。 +- [SSH Host 远程环境准备与控制面直连设计](../../architecture/remote-host-environment-provisioning-design.md): + Host 保存、环境准备和项目使用之间的职责。 diff --git a/package-lock.json b/package-lock.json index 45cc244a..5b91a80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,10 @@ "@napi-rs/canvas": "1.0.3", "@opencode-ai/sdk": "^1.18.9", "@wecom/aibot-node-sdk": "^1.0.6", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-search": "0.16.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "cross-spawn": "^7.0.6", "dingtalk-stream": "^2.1.6-beta.1", "dompurify": "^3.4.13", @@ -50,6 +54,7 @@ "koffi": "3.1.4", "lucide-react": "^1.27.0", "mermaid": "^11.16.1", + "node-pty": "^1.2.0-beta.15", "npm": "11.19.0", "onnxruntime-web": "^1.23.2", "pdfjs-dist": "^6.2.108", @@ -5873,6 +5878,33 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", diff --git a/package.json b/package.json index 6dae6908..35ea2af3 100644 --- a/package.json +++ b/package.json @@ -288,6 +288,10 @@ "@napi-rs/canvas": "1.0.3", "@opencode-ai/sdk": "^1.18.9", "@wecom/aibot-node-sdk": "^1.0.6", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-search": "0.16.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "cross-spawn": "^7.0.6", "dingtalk-stream": "^2.1.6-beta.1", "dompurify": "^3.4.13", @@ -299,6 +303,7 @@ "koffi": "3.1.4", "lucide-react": "^1.27.0", "mermaid": "^11.16.1", + "node-pty": "^1.2.0-beta.15", "npm": "11.19.0", "onnxruntime-web": "^1.23.2", "pdfjs-dist": "^6.2.108", diff --git a/src/main/agent/process-environment.ts b/src/main/agent/process-environment.ts index a6c255c8..06409ecd 100644 --- a/src/main/agent/process-environment.ts +++ b/src/main/agent/process-environment.ts @@ -1,4 +1,4 @@ -const runtimeProviderEnvironmentNames = [ +export const runtimeProviderEnvironmentNames = [ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', diff --git a/src/main/index.ts b/src/main/index.ts index a0446f11..d3a836de 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -156,6 +156,7 @@ import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from './desktop-diagnostics' +import { TerminalSessionManager } from './terminal/terminal-session-manager' const legacyDefaultShortcut = defaultGlobalShortcutSettings.accelerator @@ -215,6 +216,7 @@ const embeddingBrokers = new Set() let stopRuntimeReconfiguration: (() => Promise) | undefined let dshExtensionInstaller: DshNpmExtensionInstaller | undefined let remoteAgentServices: RemoteAgentServices | undefined +let terminalSessionManager: TerminalSessionManager | undefined let managedRemoteExecutionServices: | ManagedRemoteExecutionServices | undefined @@ -1302,6 +1304,25 @@ if (hasSingleInstanceLock) { await Promise.all(invalidations) } ) + terminalSessionManager = new TerminalSessionManager({ + database: startupAssistantDatabase, + executionSpaceResolver, + targetResolver: startupRemoteAgentServices.targetResolver, + sshPool: startupRemoteAgentServices.sshPool, + remoteEnabled: async () => + (await applicationSettingsStore.get()).remoteProjectsEnabled, + deliverEvent: (ownerWebContentsId, event) => { + const targetWindow = mainWindow + if ( + targetWindow && + !targetWindow.isDestroyed() && + targetWindow.webContents.id === ownerWebContentsId && + !targetWindow.webContents.isDestroyed() + ) { + targetWindow.webContents.send(ipcChannels.terminalEvent, event) + } + } + }) removeIpcHandlers = registerIpcHandlers( mainWindow, runtime, @@ -1359,7 +1380,8 @@ if (hasSingleInstanceLock) { setCurrentEmbeddingConnection, remoteEnvironmentUpdateService, agentPackageManager, - startupRemoteAgentServices.connectionManager + startupRemoteAgentServices.connectionManager, + terminalSessionManager ) removeFeedbackIpcHandler = registerFeedbackIpcHandler( mainWindow, @@ -1473,6 +1495,7 @@ app.on('before-quit', (event) => { () => documentOcrModelManager?.dispose(), () => documentOcrBroker?.dispose() ], + [() => terminalSessionManager?.dispose()], [() => managedRemoteExecutionServices?.dispose()], [() => remoteAgentServices?.dispose()], [() => knowledgeGateway?.dispose()], diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 07fd1f86..c7fb140d 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -55,6 +55,196 @@ describe('embedding IPC boundary', () => { }) }) +describe('terminal IPC boundary', () => { + afterEach(() => { + electronMocks.handlers.clear() + vi.clearAllMocks() + }) + + it('validates terminal requests and routes them through the trusted renderer owner', async () => { + const webContents = { + id: 73, + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const snapshot = { + sessionId: '00000000-0000-4000-8000-000000000073', + target: { type: 'local' as const }, + title: 'PowerShell', + state: 'running' as const, + cwd: 'C:\\Workspace', + cols: 120, + rows: 36, + lastSequence: 0 + } + const terminalSessionManager = { + create: vi.fn(async () => snapshot), + enableEventDelivery: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + snapshot: vi.fn(() => snapshot), + acknowledge: vi.fn(), + close: vi.fn(async () => ({ + ...snapshot, + state: 'closed' as const + })), + closeOwner: vi.fn(async () => undefined) + } + const args: Parameters = [ + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + {} as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + { + queueDueSchedules: vi.fn(() => []), + listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []) + } as never, + { clear: vi.fn() } as never, + {} as never, + vi.fn(async () => {}), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + terminalSessionManager as never + ] + const dispose = registerIpcHandlers(...args) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + + await expect( + electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, { + target: { type: 'local' }, + cols: 120, + rows: 36 + }) + ).resolves.toEqual(snapshot) + expect(terminalSessionManager.create).toHaveBeenCalledWith(73, { + target: { type: 'local' }, + cols: 120, + rows: 36 + }) + await vi.waitFor(() => + expect( + terminalSessionManager.enableEventDelivery + ).toHaveBeenCalledWith(73, snapshot.sessionId) + ) + + electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, { + sessionId: snapshot.sessionId, + data: 'echo ready\r' + }) + electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, { + sessionId: snapshot.sessionId, + cols: 100, + rows: 28 + }) + electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, { + sessionId: snapshot.sessionId, + sequence: 4 + }) + expect( + electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.( + event, + { sessionId: snapshot.sessionId } + ) + ).toEqual(snapshot) + await expect( + electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, { + sessionId: snapshot.sessionId + }) + ).resolves.toMatchObject({ state: 'closed' }) + + expect(terminalSessionManager.write).toHaveBeenCalledWith( + 73, + snapshot.sessionId, + 'echo ready\r' + ) + expect(terminalSessionManager.resize).toHaveBeenCalledWith( + 73, + snapshot.sessionId, + { cols: 100, rows: 28 } + ) + expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith( + 73, + snapshot.sessionId, + 4 + ) + expect(terminalSessionManager.snapshot).toHaveBeenCalledWith( + 73, + snapshot.sessionId + ) + expect(terminalSessionManager.close).toHaveBeenCalledWith( + 73, + snapshot.sessionId + ) + + expect(() => + electronMocks.handlers.get(ipcChannels.terminalWrite)?.( + { + sender: { ...webContents, id: 99 }, + senderFrame: webContents.mainFrame + }, + { + sessionId: snapshot.sessionId, + data: 'untrusted' + } + ) + ).toThrow('拒绝来自未知窗口的 IPC 请求') + expect(() => + electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, { + sessionId: snapshot.sessionId, + cols: 0, + rows: 28 + }) + ).toThrow() + + await dispose() + expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73) + }) +}) + type InvokeHandler = (event: unknown, input?: unknown) => unknown type KnowledgeGrantMock = ( requestId: string, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e0ca9b2e..39de491d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -83,6 +83,14 @@ import { knowledgeTaskItemSchema } from '../shared/knowledge-task-contracts' import { ipcChannels } from '../shared/ipc-channels' +import { + terminalAckRequestSchema, + terminalCloseRequestSchema, + terminalCreateRequestSchema, + terminalResizeRequestSchema, + terminalSnapshotRequestSchema, + terminalWriteRequestSchema +} from '../shared/terminal-contracts' import { browserProfileCreateInputSchema, browserProfileRenameInputSchema, @@ -119,6 +127,7 @@ import { agentPackageInventorySchema } from '../shared/agent-package-contracts' import { assertTrustedSender } from './trusted-ipc-sender' +import type { TerminalSessionManager } from './terminal/terminal-session-manager' import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts' import { speechModelActionInputSchema, @@ -1088,7 +1097,8 @@ export function registerIpcHandlers( remoteAgentConnectionManager?: Pick< RemoteAgentConnectionManager, 'getHostConnectionState' | 'onHostConnectionStateChange' - > + >, + terminalSessionManager?: TerminalSessionManager ): () => Promise { type ActiveRequestLease = { controller: AbortController @@ -1097,6 +1107,12 @@ export function registerIpcHandlers( release(): void } const activeRequests = new Map() + const requireTerminalSessionManager = (): TerminalSessionManager => { + if (!terminalSessionManager) { + throw new Error('终端服务不可用') + } + return terminalSessionManager + } const activeRequestConversations = new Map< string, ActiveRequestLease @@ -1224,6 +1240,7 @@ export function registerIpcHandlers( (channel) => channel !== ipcChannels.agentEvent && channel !== ipcChannels.browserState && + channel !== ipcChannels.terminalEvent && channel !== ipcChannels.conversationNew && channel !== ipcChannels.settingsOpen && channel !== ipcChannels.versionCheckResult && @@ -3347,6 +3364,92 @@ export function registerIpcHandlers( ]) }) + registerHandler( + ipcChannels.terminalCreate, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const manager = requireTerminalSessionManager() + const snapshot = await manager.create( + event.sender.id, + terminalCreateRequestSchema.parse(input) + ) + setImmediate(() => { + if ( + !window.isDestroyed() && + event.sender === window.webContents && + !event.sender.isDestroyed() + ) { + manager.enableEventDelivery( + event.sender.id, + snapshot.sessionId + ) + } + }) + return snapshot + } + ) + + registerHandler(ipcChannels.terminalWrite, (event, input: unknown) => { + assertTrustedSender(event, window) + const manager = requireTerminalSessionManager() + const request = terminalWriteRequestSchema.parse(input) + manager.write( + event.sender.id, + request.sessionId, + request.data + ) + }) + + registerHandler(ipcChannels.terminalResize, (event, input: unknown) => { + assertTrustedSender(event, window) + const manager = requireTerminalSessionManager() + const request = terminalResizeRequestSchema.parse(input) + manager.resize(event.sender.id, request.sessionId, { + cols: request.cols, + rows: request.rows + }) + }) + + registerHandler( + ipcChannels.terminalSnapshot, + (event, input: unknown) => { + assertTrustedSender(event, window) + const manager = requireTerminalSessionManager() + const request = terminalSnapshotRequestSchema.parse(input) + return manager.snapshot( + event.sender.id, + request.sessionId + ) + } + ) + + registerHandler( + ipcChannels.terminalAck, + (event, input: unknown) => { + assertTrustedSender(event, window) + const manager = requireTerminalSessionManager() + const request = terminalAckRequestSchema.parse(input) + manager.acknowledge( + event.sender.id, + request.sessionId, + request.sequence + ) + } + ) + + registerHandler( + ipcChannels.terminalClose, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const manager = requireTerminalSessionManager() + const request = terminalCloseRequestSchema.parse(input) + return manager.close( + event.sender.id, + request.sessionId + ) + } + ) + registerHandler( ipcChannels.browserInteract, async (event, input: unknown) => { @@ -7769,6 +7872,7 @@ export function registerIpcHandlers( approvalBroker.clear() goodbuddyConfigService?.clear() pendingGoodBuddyConfigReload = false + await terminalSessionManager?.closeOwner(window.webContents.id) await requestRendererPersistence() await waitForRendererQuiescence() for (const channel of channels) { diff --git a/src/main/ssh/ssh-connection-pool.test.ts b/src/main/ssh/ssh-connection-pool.test.ts index 5f360208..70e39010 100644 --- a/src/main/ssh/ssh-connection-pool.test.ts +++ b/src/main/ssh/ssh-connection-pool.test.ts @@ -59,6 +59,8 @@ function createConnection(identity: SshPoolConnectionIdentity) { return { identity, isUsable: vi.fn(() => true), + onDisconnect: vi.fn(() => () => undefined), + openTerminalShell: vi.fn(), openAgentAttach: vi.fn(), runAgentDoctor: vi.fn(), runAgentBootstrapProbe: vi.fn(), @@ -403,6 +405,13 @@ describe("authenticated ssh2 connection", () => { callback(undefined, channel); }, ), + shell: vi.fn( + ( + _window: unknown, + _options: unknown, + callback: (error: Error | undefined, stream: typeof channel) => void, + ) => callback(undefined, channel), + ), sftp: vi.fn(), }); const target = createTarget(); @@ -425,6 +434,7 @@ describe("authenticated ssh2 connection", () => { ); await connection.openAgentAttach(verifyAgentInstallationId("agent-v1")); + await connection.openTerminalShell({ cols: 132, rows: 43 }); await expect( connection.probeRemotePackageBootstrap({ operationId: "00000000-0000-4000-8000-000000000301", @@ -470,6 +480,17 @@ describe("authenticated ssh2 connection", () => { }, expect.any(Function), ); + expect(client.shell).toHaveBeenCalledWith( + { + term: "xterm-256color", + cols: 132, + rows: 43, + width: 0, + height: 0, + }, + { env: {}, x11: false }, + expect.any(Function), + ); expect(connection).not.toHaveProperty("exec"); }); @@ -496,6 +517,7 @@ describe("authenticated ssh2 connection", () => { end: vi.fn(), destroy: vi.fn(), exec: vi.fn(), + shell: vi.fn(), sftp: vi.fn(), }); const target = createTarget(); @@ -530,6 +552,7 @@ describe("authenticated ssh2 connection", () => { end: vi.fn(), destroy: vi.fn(), exec: vi.fn(), + shell: vi.fn(), sftp: vi.fn(), }); const controller = new AbortController(); @@ -569,6 +592,7 @@ describe("authenticated ssh2 connection", () => { }, 0); }, ), + shell: vi.fn(), sftp: vi.fn(), }); const target = createTarget(); @@ -616,6 +640,7 @@ describe("authenticated ssh2 connection", () => { }, 0); }, ), + shell: vi.fn(), sftp: vi.fn(), }); const target = createTarget(); @@ -673,6 +698,7 @@ describe("authenticated ssh2 connection", () => { }, 0); }, ), + shell: vi.fn(), sftp: vi.fn(), }); const target = createTarget(); @@ -747,6 +773,7 @@ describe("authenticated ssh2 connection", () => { }, 0); }, ), + shell: vi.fn(), sftp: vi.fn(), }); const target = createTarget(); diff --git a/src/main/ssh/ssh-connection-pool.ts b/src/main/ssh/ssh-connection-pool.ts index 57da35a2..0a92d061 100644 --- a/src/main/ssh/ssh-connection-pool.ts +++ b/src/main/ssh/ssh-connection-pool.ts @@ -65,9 +65,20 @@ export type AgentDiagnosticResult = { stderr: string; }; +export type SshTerminalShellOptions = { + cols: number; + rows: number; + term?: string; +}; + export interface AuthenticatedSshConnection { readonly identity: SshPoolConnectionIdentity; isUsable(): boolean; + onDisconnect(listener: (error?: Error) => void): () => void; + openTerminalShell( + options: SshTerminalShellOptions, + signal?: AbortSignal, + ): Promise; openAgentAttach( installationId: VerifiedAgentInstallationId, signal?: AbortSignal, @@ -128,19 +139,29 @@ export interface AuthenticatedSshConnection { export interface SshConnectionLease extends Omit< AuthenticatedSshConnection, | "dispose" + | "onDisconnect" + | "openTerminalShell" | "createRemotePackageUploadStaging" | "prepareUploadedRemotePackageBootstrap" > { release(): void; } +export interface SshTerminalConnectionLease extends SshConnectionLease { + onDisconnect(listener: (error?: Error) => void): () => void; + openTerminalShell( + options: SshTerminalShellOptions, + signal?: AbortSignal, + ): Promise; +} + export interface SshRemotePackageBootstrapLease extends SshRemotePackageBootstrapExecutor { readonly identity: SshPoolConnectionIdentity; isUsable(): boolean; release(): void; } -type InternalSshConnectionLease = SshConnectionLease & +type InternalSshConnectionLease = SshTerminalConnectionLease & Pick< AuthenticatedSshConnection, | "createRemotePackageUploadStaging" @@ -149,7 +170,7 @@ type InternalSshConnectionLease = SshConnectionLease & type ClientLike = Pick< Client, - "connect" | "end" | "destroy" | "exec" | "sftp" | "on" | "once" + "connect" | "end" | "destroy" | "exec" | "shell" | "sftp" | "on" | "once" >; type SshConnectionDependencies = { @@ -285,19 +306,26 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection { private usable = true; private disposed = false; private agentBootstrapProbe: AgentBootstrapProbeResult | undefined; + private readonly disconnectListeners = new Set<(error?: Error) => void>(); private constructor( readonly identity: SshPoolConnectionIdentity, private readonly client: ClientLike, private readonly controlPlanePackageInstaller: Buffer, ) { - const disconnected = (): void => { + const disconnected = (error?: Error): void => { + if (!this.usable) { + return; + } this.usable = false; this.agentBootstrapProbe = undefined; + for (const listener of [...this.disconnectListeners]) { + listener(error); + } }; this.client.on("close", disconnected); this.client.on("end", disconnected); - this.client.on("error", disconnected); + this.client.on("error", (error: Error) => disconnected(error)); } static connect( @@ -390,6 +418,70 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection { return this.usable; } + onDisconnect(listener: (error?: Error) => void): () => void { + if (!this.usable) { + queueMicrotask(() => listener()); + return () => undefined; + } + this.disconnectListeners.add(listener); + return () => { + this.disconnectListeners.delete(listener); + }; + } + + openTerminalShell( + options: SshTerminalShellOptions, + signal?: AbortSignal, + ): Promise { + this.assertUsable(); + if ( + !Number.isSafeInteger(options.cols) || + options.cols <= 0 || + !Number.isSafeInteger(options.rows) || + options.rows <= 0 + ) { + return Promise.reject(new Error("SSH PTY 尺寸无效")); + } + if (signal?.aborted) { + return Promise.reject(createAbortError(signal)); + } + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: unknown, channel?: ClientChannel): void => { + if (settled) { + channel?.destroy(); + return; + } + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + if (error || !channel) { + reject(error ?? new Error("无法打开 SSH 终端通道")); + } else { + resolve(channel); + } + }; + const abort = (): void => { + finish(createAbortError(signal)); + }; + const timeout = setTimeout(() => { + finish(new Error("SSH 终端通道打开超时")); + }, CHANNEL_OPEN_TIMEOUT_MS); + signal?.addEventListener("abort", abort, { once: true }); + this.client.shell( + { + term: options.term ?? "xterm-256color", + cols: options.cols, + rows: options.rows, + width: 0, + height: 0, + }, + { env: {}, x11: false }, + (error, channel) => finish(error, channel), + ); + }); + } + async openAgentAttach( installationId: VerifiedAgentInstallationId, signal?: AbortSignal, @@ -584,6 +676,10 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection { this.disposed = true; this.usable = false; this.agentBootstrapProbe = undefined; + for (const listener of [...this.disconnectListeners]) { + listener(); + } + this.disconnectListeners.clear(); this.client.end(); this.client.destroy(); } @@ -881,6 +977,13 @@ export class SshConnectionPool { }; } + async acquireTerminal( + target: SshConnectionPoolTarget, + signal?: AbortSignal, + ): Promise { + return (await this.acquire(target, signal)) as SshTerminalConnectionLease; + } + disposeHost(hostId: string): void { for (const entry of this.entries.values()) { if (entry.identity.hostId === hostId) { @@ -912,6 +1015,14 @@ export class SshConnectionPool { const lease: InternalSshConnectionLease = { identity: connection.identity, isUsable: () => !released && connection.isUsable(), + onDisconnect: (listener) => { + assertActive(); + return connection.onDisconnect(listener); + }, + openTerminalShell: (options, signal) => { + assertActive(); + return connection.openTerminalShell(options, signal); + }, openAgentAttach: (installationId, signal) => { assertActive(); return connection.openAgentAttach(installationId, signal); diff --git a/src/main/terminal/local-terminal-session.test.ts b/src/main/terminal/local-terminal-session.test.ts new file mode 100644 index 00000000..f44d9e44 --- /dev/null +++ b/src/main/terminal/local-terminal-session.test.ts @@ -0,0 +1,318 @@ +import type { IDisposable, IPty } from 'node-pty' +import { describe, expect, it, vi } from 'vitest' +import { + TERMINAL_LIMITS, + type TerminalEvent +} from '../../shared/terminal-contracts' +import { + LocalTerminalSession, + buildLocalTerminalEnvironment, + resolveLocalTerminalLaunch, + resolveLocalTerminalShell +} from './local-terminal-session' + +class FakePty implements IPty { + readonly pid = 4321 + readonly cols = 80 + readonly rows = 24 + readonly process = 'fake-shell' + handleFlowControl = false + readonly write = vi.fn() + readonly resize = vi.fn() + readonly clear = vi.fn() + readonly kill = vi.fn() + readonly pause = vi.fn() + readonly resume = vi.fn() + private dataListeners = new Set<(data: string) => void>() + private exitListeners = new Set< + (event: { exitCode: number; signal?: number }) => void + >() + + readonly onData = ( + listener: (data: string) => void + ): IDisposable => { + this.dataListeners.add(listener) + return { + dispose: () => this.dataListeners.delete(listener) + } + } + + readonly onExit = ( + listener: (event: { + exitCode: number + signal?: number + }) => void + ): IDisposable => { + this.exitListeners.add(listener) + return { + dispose: () => this.exitListeners.delete(listener) + } + } + + emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data) + } + } + + emitExit(exitCode: number, signal?: number): void { + for (const listener of [...this.exitListeners]) { + listener({ exitCode, signal }) + } + } +} + +const baseOptions = { + sessionId: '5d8f47a2-c681-4c26-95a2-2ed1a086fc78', + target: { type: 'local' } as const, + targetLabel: '本机', + title: '终端 · 本机 1', + size: { cols: 80, rows: 24 } +} + +function sessionDependencies( + pty: FakePty, + overrides: Record = {} +) { + return { + platform: 'linux' as const, + environment: { + SHELL: '/bin/bash', + PATH: '/bin', + SAFE_VALUE: 'visible' + }, + executableExists: async (path: string) => path === '/bin/bash', + directoryExists: async () => true, + homeDirectory: () => '/home/tester', + spawn: vi.fn(() => pty), + terminate: vi.fn(async () => { + pty.emitExit(0) + }), + ...overrides + } +} + +describe('local terminal launch resolution', () => { + it('prefers pwsh, then PowerShell, then COMSPEC on Windows', async () => { + const available = new Set(['C:\\bin\\powershell.EXE']) + const shell = await resolveLocalTerminalShell({ + platform: 'win32', + environment: { + Path: 'C:\\bin', + PATHEXT: '.EXE', + COMSPEC: 'C:\\Windows\\cmd.exe' + }, + executableExists: async (path) => available.has(path) + }) + + expect(shell).toEqual({ + shell: 'C:\\bin\\powershell.EXE', + shellLabel: 'powershell.EXE' + }) + }) + + it('uses a valid POSIX SHELL and falls back to home for invalid cwd', async () => { + const launch = await resolveLocalTerminalLaunch('/missing', { + platform: 'linux', + environment: { + SHELL: '/usr/bin/fish', + PATH: '/usr/bin', + LANG: 'zh_CN.UTF-8', + GOODBUDDY_MODEL_API_KEY: 'secret', + ANTHROPIC_API_KEY: 'secret' + }, + executableExists: async (path) => path === '/usr/bin/fish', + directoryExists: async (path) => path === '/home/tester', + homeDirectory: () => '/home/tester' + }) + + expect(launch.cwd).toBe('/home/tester') + expect(launch.shell).toBe('/usr/bin/fish') + expect(launch.env).toMatchObject({ + SHELL: '/usr/bin/fish', + LANG: 'zh_CN.UTF-8' + }) + expect(launch.env).not.toHaveProperty('GOODBUDDY_MODEL_API_KEY') + expect(launch.env).not.toHaveProperty('ANTHROPIC_API_KEY') + }) + + it('filters product and model credentials without dropping normal variables', () => { + expect( + buildLocalTerminalEnvironment({ + PATH: '/bin', + CUSTOM_TOKEN: 'user-owned', + FACTORY_API_KEY: 'internal', + OPENAI_API_KEY: 'model' + }) + ).toEqual({ + PATH: '/bin', + CUSTOM_TOKEN: 'user-owned' + }) + }) +}) + +describe('LocalTerminalSession', () => { + it('spawns the PTY and preserves ordered state, output, exit events', async () => { + const pty = new FakePty() + const dependencies = sessionDependencies(pty) + const session = await LocalTerminalSession.create({ + ...baseOptions, + dependencies + }) + const events: TerminalEvent[] = [] + session.onEvent((event) => events.push(event)) + + pty.emitData('你好') + pty.emitExit(7, 15) + + expect(dependencies.spawn).toHaveBeenCalledWith( + '/bin/bash', + [], + expect.objectContaining({ + cols: 80, + rows: 24, + cwd: '/home/tester', + name: 'xterm-256color' + }) + ) + expect(events.map((event) => event.type)).toEqual([ + 'state', + 'state', + 'output', + 'exit', + 'state' + ]) + expect(events.map((event) => event.sequence)).toEqual([ + 1, 2, 3, 4, 5 + ]) + expect(session.snapshot()).toMatchObject({ + state: 'exited', + exit: { exitCode: 7, signal: '15' }, + lastSequence: 5 + }) + }) + + it('forwards writes and deduplicates resize while running', async () => { + const pty = new FakePty() + const session = await LocalTerminalSession.create({ + ...baseOptions, + dependencies: sessionDependencies(pty) + }) + + expect(session.write('echo ok\r')).toBe(true) + expect(session.resize({ cols: 80, rows: 24 })).toBe(true) + expect(session.resize({ cols: 120, rows: 40 })).toBe(true) + + expect(pty.write).toHaveBeenCalledWith('echo ok\r') + expect(pty.resize).toHaveBeenCalledTimes(1) + expect(pty.resize).toHaveBeenCalledWith(120, 40) + expect(session.snapshot().size).toEqual({ + cols: 120, + rows: 40 + }) + }) + + it('splits output events at the UTF-8 boundary', async () => { + const pty = new FakePty() + const session = await LocalTerminalSession.create({ + ...baseOptions, + dependencies: sessionDependencies(pty) + }) + const output: string[] = [] + session.onEvent((event) => { + if (event.type === 'output') { + output.push(event.data) + } + }) + const data = + 'a'.repeat(TERMINAL_LIMITS.maximumEventBytes - 1) + '你' + + pty.emitData(data) + + expect(output.join('')).toBe(data) + expect( + output.every( + (chunk) => + Buffer.byteLength(chunk) <= + TERMINAL_LIMITS.maximumEventBytes + ) + ).toBe(true) + }) + + it('pauses at the pending-event boundary and resumes after ACK', async () => { + const pty = new FakePty() + const session = await LocalTerminalSession.create({ + ...baseOptions, + dependencies: sessionDependencies(pty) + }) + let lastSequence = 0 + session.onEvent((event) => { + lastSequence = event.sequence + }) + + for ( + let index = 0; + index < TERMINAL_LIMITS.maximumPendingEvents; + index += 1 + ) { + pty.emitData('x') + if (pty.pause.mock.calls.length > 0) { + break + } + } + + expect(pty.pause).toHaveBeenCalledTimes(1) + session.acknowledge(lastSequence) + expect(pty.resume).toHaveBeenCalledTimes(1) + }) + + it('reports launch failures with stable failed state', async () => { + const session = await LocalTerminalSession.create({ + ...baseOptions, + dependencies: { + platform: 'linux', + environment: { SHELL: '/missing' }, + executableExists: async () => false, + directoryExists: async () => true, + homeDirectory: () => '/home/tester' + } + }) + const events: TerminalEvent[] = [] + session.onEvent((event) => events.push(event)) + + expect(session.snapshot()).toMatchObject({ + state: 'failed', + error: { code: 'launch-failed', retryable: true } + }) + expect(events.map((event) => event.type)).toEqual([ + 'state', + 'error', + 'state' + ]) + expect(session.write('ignored')).toBe(false) + }) + + it('closes once, rejects late operations, and keeps a stable exit', async () => { + const pty = new FakePty() + const dependencies = sessionDependencies(pty) + const session = await LocalTerminalSession.create({ + ...baseOptions, + dependencies + }) + + const firstClose = session.close() + const secondClose = session.close() + expect(secondClose).toBe(firstClose) + await firstClose + + expect(dependencies.terminate).toHaveBeenCalledTimes(1) + expect(session.write('late')).toBe(false) + expect(session.resize({ cols: 90, rows: 30 })).toBe(false) + expect(session.snapshot()).toMatchObject({ + state: 'exited', + exit: { exitCode: 0, signal: null } + }) + await session.close() + expect(dependencies.terminate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/terminal/local-terminal-session.ts b/src/main/terminal/local-terminal-session.ts new file mode 100644 index 00000000..1065c3ae --- /dev/null +++ b/src/main/terminal/local-terminal-session.ts @@ -0,0 +1,672 @@ +import { randomUUID } from 'node:crypto' +import { constants as fileSystemConstants } from 'node:fs' +import { access, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { posix, win32 } from 'node:path' +import * as nodePty from 'node-pty' +import type { IDisposable, IPty } from 'node-pty' +import { + TERMINAL_LIMITS, + type TerminalError, + type TerminalEvent, + type TerminalExit, + type TerminalSize, + type TerminalSnapshot, + type TerminalTarget +} from '../../shared/terminal-contracts' +import { requestProcessTreeTermination } from '../agent/child-process-termination' +import { runtimeProviderEnvironmentNames } from '../agent/process-environment' + +export type LocalTerminalTarget = Extract< + TerminalTarget, + { type: 'local' | 'project' } +> + +export type LocalTerminalLaunch = { + cwd: string + shell: string + shellLabel: string + env: NodeJS.ProcessEnv +} + +export type LocalPtySpawn = ( + file: string, + args: string[], + options: nodePty.IPtyForkOptions +) => IPty + +export type LocalTerminalDependencies = { + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv + homeDirectory?: () => string + executableExists?: (path: string) => Promise + directoryExists?: (path: string) => Promise + spawn?: LocalPtySpawn + terminate?: (pty: IPty, platform: NodeJS.Platform) => Promise +} + +export type LocalTerminalSessionOptions = { + sessionId?: string + target: LocalTerminalTarget + targetLabel: string + title: string + size: TerminalSize + projectDirectory?: string + dependencies?: LocalTerminalDependencies +} + +export type LocalTerminalEventListener = (event: TerminalEvent) => void +type UnsequencedTerminalEvent = T extends TerminalEvent + ? Omit + : never +type LocalTerminalEvent = UnsequencedTerminalEvent + +const CONTROL_EVENT_RESERVE = 2 +const PAUSE_PENDING_EVENT_COUNT = + TERMINAL_LIMITS.maximumPendingEvents - CONTROL_EVENT_RESERVE +const RESUME_PENDING_EVENT_COUNT = Math.floor( + PAUSE_PENDING_EVENT_COUNT / 2 +) +const CLOSE_WAIT_MS = 1_500 +const FORCE_WAIT_MS = 250 + +const credentialEnvironmentNames = new Set([ + ...runtimeProviderEnvironmentNames, + 'DEEPSEEK_API_KEY', + 'DASHSCOPE_API_KEY', + 'TOGETHER_API_KEY', + 'FIREWORKS_API_KEY', + 'PERPLEXITY_API_KEY', + 'CEREBRAS_API_KEY', + 'CLAUDE_CODE_OAUTH_TOKEN' +]) + +function isCredentialEnvironmentName(name: string): boolean { + const upperName = name.toUpperCase() + return ( + upperName.startsWith('GOODBUDDY_') || + upperName.startsWith('FACTORY_') || + credentialEnvironmentNames.has(upperName) + ) +} + +/** + * Keep the user's normal interactive environment while removing credentials + * inherited by Electron Main for GoodBuddy and model-provider operation. + */ +export function buildLocalTerminalEnvironment( + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {} + for (const [name, value] of Object.entries(source)) { + if (value !== undefined && !isCredentialEnvironmentName(name)) { + environment[name] = value + } + } + return environment +} + +async function defaultExecutableExists(path: string): Promise { + try { + await access( + path, + process.platform === 'win32' + ? fileSystemConstants.F_OK + : fileSystemConstants.X_OK + ) + return true + } catch { + return false + } +} + +async function defaultDirectoryExists(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + +async function findExecutableOnPath( + name: string, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + exists: (path: string) => Promise +): Promise { + const pathApi = platform === 'win32' ? win32 : posix + if (pathApi.isAbsolute(name)) { + return (await exists(name)) ? name : undefined + } + const pathValue = + environment.PATH ?? environment.Path ?? environment.path ?? '' + const extensions = + platform === 'win32' + ? (environment.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .filter(Boolean) + : [''] + const pathDelimiter = platform === 'win32' ? ';' : ':' + for (const directory of pathValue.split(pathDelimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = pathApi.join(directory, `${name}${extension}`) + if (await exists(candidate)) { + return candidate + } + } + } + return undefined +} + +export async function resolveLocalTerminalShell( + dependencies: LocalTerminalDependencies = {} +): Promise<{ shell: string; shellLabel: string }> { + const platform = dependencies.platform ?? process.platform + const environment = dependencies.environment ?? process.env + const exists = dependencies.executableExists ?? defaultExecutableExists + let shell: string | undefined + + if (platform === 'win32') { + shell = + (await findExecutableOnPath( + 'pwsh', + environment, + platform, + exists + )) ?? + (await findExecutableOnPath( + 'powershell', + environment, + platform, + exists + )) + if (!shell) { + const comspec = environment.COMSPEC?.trim() + shell = comspec + ? await findExecutableOnPath( + comspec, + environment, + platform, + exists + ) + : undefined + } + } else { + const configuredShell = environment.SHELL?.trim() + if ( + configuredShell && + posix.isAbsolute(configuredShell) && + (await exists(configuredShell)) + ) { + shell = configuredShell + } else { + const fallbacks = + platform === 'darwin' + ? ['/bin/zsh', '/bin/bash', '/bin/sh'] + : ['/bin/bash', '/bin/sh'] + for (const candidate of fallbacks) { + if (await exists(candidate)) { + shell = candidate + break + } + } + } + } + + if (!shell) { + throw new Error('No supported interactive shell is available') + } + return { + shell, + shellLabel: + platform === 'win32' + ? win32.basename(shell) + : posix.basename(shell) + } +} + +export async function resolveLocalTerminalWorkingDirectory( + projectDirectory: string | undefined, + dependencies: LocalTerminalDependencies = {} +): Promise { + const isDirectory = + dependencies.directoryExists ?? defaultDirectoryExists + const home = (dependencies.homeDirectory ?? homedir)() + if ( + projectDirectory?.trim() && + (await isDirectory(projectDirectory)) + ) { + return projectDirectory + } + if (await isDirectory(home)) { + return home + } + throw new Error('No valid local terminal working directory is available') +} + +export async function resolveLocalTerminalLaunch( + projectDirectory: string | undefined, + dependencies: LocalTerminalDependencies = {} +): Promise { + const [shell, cwd] = await Promise.all([ + resolveLocalTerminalShell(dependencies), + resolveLocalTerminalWorkingDirectory( + projectDirectory, + dependencies + ) + ]) + return { + ...shell, + cwd, + env: buildLocalTerminalEnvironment( + dependencies.environment ?? process.env + ) + } +} + +function splitBoundedOutput(data: string): string[] { + const encoded = Buffer.from(data) + const chunks: string[] = [] + let offset = 0 + while (offset < encoded.byteLength) { + let end = Math.min( + offset + TERMINAL_LIMITS.maximumEventBytes, + encoded.byteLength + ) + if (end < encoded.byteLength) { + while ( + end > offset && + (encoded[end]! & 0xc0) === 0x80 + ) { + end -= 1 + } + } + if (end === offset) { + end = Math.min(offset + 1, encoded.byteLength) + } + chunks.push(encoded.subarray(offset, end).toString('utf8')) + offset = end + } + return chunks +} + +async function waitUntil( + predicate: () => boolean, + milliseconds: number +): Promise { + if (predicate()) { + return + } + await new Promise((resolve) => { + const finish = (): void => { + clearInterval(interval) + clearTimeout(timeout) + resolve() + } + const interval = setInterval(() => { + if (predicate()) { + finish() + } + }, 10) + const timeout = setTimeout(finish, milliseconds) + interval.unref?.() + timeout.unref?.() + }) +} + +async function defaultTerminate( + pty: IPty, + platform: NodeJS.Platform +): Promise { + let exited = false + const exitSubscription = pty.onExit(() => { + exited = true + }) + requestProcessTreeTermination( + { + pid: pty.pid, + killed: false, + exitCode: null, + kill: (signal) => + platform === 'win32' ? pty.kill() : pty.kill(signal) + }, + { + platform, + processGroup: platform !== 'win32', + waitMs: CLOSE_WAIT_MS + } + ) + await waitUntil(() => exited, CLOSE_WAIT_MS) + if (!exited) { + requestProcessTreeTermination( + { + pid: pty.pid, + killed: false, + exitCode: null, + kill: () => + platform === 'win32' + ? pty.kill() + : pty.kill('SIGKILL') + }, + { + platform, + processGroup: platform !== 'win32', + signal: 'SIGKILL', + waitMs: FORCE_WAIT_MS + } + ) + await waitUntil(() => exited, FORCE_WAIT_MS) + } + exitSubscription.dispose() +} + +export class LocalTerminalSession { + static async create( + options: LocalTerminalSessionOptions + ): Promise { + const session = new LocalTerminalSession(options) + await session.start() + return session + } + + readonly sessionId: string + private readonly options: LocalTerminalSessionOptions + private readonly dependencies: LocalTerminalDependencies + private readonly listeners = new Set() + private readonly pendingEvents: TerminalEvent[] = [] + private readonly subscriptions: IDisposable[] = [] + private pty: IPty | undefined + private state: TerminalSnapshot['state'] = 'starting' + private launch: LocalTerminalLaunch | undefined + private size: TerminalSize + private lastSequence = 0 + private pendingOutputBytes = 0 + private paused = false + private exit: TerminalExit | null = null + private error: TerminalError | null = null + private closePromise: Promise | undefined + + private constructor(options: LocalTerminalSessionOptions) { + this.options = options + this.dependencies = options.dependencies ?? {} + this.sessionId = options.sessionId ?? randomUUID() + this.size = { ...options.size } + this.emit({ type: 'state', state: 'starting' }) + } + + private async start(): Promise { + try { + this.launch = await resolveLocalTerminalLaunch( + this.options.target.type === 'project' + ? this.options.projectDirectory + : undefined, + this.dependencies + ) + const spawn = this.dependencies.spawn ?? nodePty.spawn + this.pty = spawn(this.launch.shell, [], { + name: 'xterm-256color', + cols: this.size.cols, + rows: this.size.rows, + cwd: this.launch.cwd, + env: this.launch.env + }) + this.subscriptions.push( + this.pty.onData((data) => this.handleOutput(data)), + this.pty.onExit((result) => + this.handleExit(result.exitCode, result.signal) + ) + ) + this.state = 'running' + this.emit({ type: 'state', state: 'running' }) + } catch (cause) { + this.fail( + 'launch-failed', + cause instanceof Error + ? cause.message + : 'Failed to launch the local terminal' + ) + } + } + + snapshot(): TerminalSnapshot { + const launch = this.launch + return { + sessionId: this.sessionId, + target: this.options.target, + targetLabel: this.options.targetLabel, + title: this.options.title, + state: this.state, + shell: launch?.shellLabel ?? '未知', + workingDirectory: + launch?.cwd ?? + this.options.projectDirectory ?? + (this.dependencies.homeDirectory ?? homedir)(), + size: { ...this.size }, + lastSequence: this.lastSequence, + exit: this.exit, + error: this.error + } + } + + onEvent(listener: LocalTerminalEventListener): () => void { + this.listeners.add(listener) + for (const event of this.pendingEvents) { + listener(event) + } + return () => { + this.listeners.delete(listener) + } + } + + write(data: string): boolean { + if (this.state !== 'running' || !this.pty) { + return false + } + this.pty.write(data) + return true + } + + resize(size: TerminalSize): boolean { + if (this.state !== 'running' || !this.pty) { + return false + } + if ( + this.size.cols === size.cols && + this.size.rows === size.rows + ) { + return true + } + this.pty.resize(size.cols, size.rows) + this.size = { ...size } + return true + } + + acknowledge(sequence: number): void { + while ( + this.pendingEvents[0] && + this.pendingEvents[0].sequence <= sequence + ) { + const event = this.pendingEvents.shift() + if (event?.type === 'output') { + this.pendingOutputBytes -= Buffer.byteLength(event.data) + } + } + if ( + this.paused && + this.pendingEvents.length <= RESUME_PENDING_EVENT_COUNT && + this.pendingOutputBytes <= + TERMINAL_LIMITS.maximumBufferedOutputBytes / 2 + ) { + this.paused = false + this.pty?.resume() + } + } + + close(): Promise { + if (this.closePromise) { + return this.closePromise + } + this.closePromise = this.closeInternal() + return this.closePromise + } + + private async closeInternal(): Promise { + if ( + this.state === 'exited' || + this.state === 'interrupted' || + (this.state === 'failed' && !this.pty) + ) { + return this.snapshot() + } + const preserveFailedState = this.state === 'failed' + if (!preserveFailedState) { + this.state = 'closing' + this.emit({ type: 'state', state: 'closing' }) + } + const pty = this.pty + if (pty) { + try { + await ( + this.dependencies.terminate ?? defaultTerminate + )(pty, this.dependencies.platform ?? process.platform) + } catch (cause) { + this.error = { + code: 'internal-error', + message: this.boundErrorMessage(cause), + retryable: false + } + this.emit({ type: 'error', error: this.error }) + } finally { + if (preserveFailedState) { + this.disposeSubscriptions() + } + } + } + if (this.state === 'closing') { + this.handleExit(null, 'SIGTERM') + } + return this.snapshot() + } + + private handleOutput(data: string): void { + if (this.state !== 'running' || data.length === 0) { + return + } + for (const chunk of splitBoundedOutput(data)) { + if ( + this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT || + this.pendingOutputBytes + Buffer.byteLength(chunk) > + TERMINAL_LIMITS.maximumBufferedOutputBytes + ) { + this.fail( + 'output-limit-exceeded', + 'Terminal output exceeded the bounded Main process buffer' + ) + void this.close() + return + } + this.pendingOutputBytes += Buffer.byteLength(chunk) + this.emit({ type: 'output', data: chunk }) + this.updateBackpressure() + } + } + + private updateBackpressure(): void { + if ( + !this.paused && + (this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT || + this.pendingOutputBytes >= + TERMINAL_LIMITS.maximumBufferedOutputBytes) + ) { + this.paused = true + this.pty?.pause() + } + } + + private handleExit( + exitCode: number | null, + signal?: number | string + ): void { + if (this.state === 'exited' || this.state === 'failed') { + return + } + this.disposeSubscriptions() + this.exit = { + exitCode: + typeof exitCode === 'number' && + Number.isSafeInteger(exitCode) + ? exitCode + : null, + signal: + signal === undefined || signal === null + ? null + : String(signal) + } + if (this.exit.exitCode === null && this.exit.signal === null) { + this.exit.signal = 'unknown' + } + this.state = 'exited' + this.emit({ type: 'exit', exit: this.exit }) + this.emit({ type: 'state', state: 'exited' }) + } + + private fail( + code: TerminalError['code'], + message: string + ): void { + if (this.state === 'failed' || this.state === 'exited') { + return + } + this.error = { + code, + message: Buffer.from(message) + .subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes) + .toString('utf8') || 'Local terminal failed', + retryable: code === 'launch-failed' + } + this.state = 'failed' + this.emit({ type: 'error', error: this.error }) + this.emit({ type: 'state', state: 'failed' }) + } + + private emit( + event: LocalTerminalEvent + ): void { + if (this.lastSequence >= Number.MAX_SAFE_INTEGER) { + this.state = 'failed' + this.error = { + code: 'internal-error', + message: 'Terminal event sequence was exhausted', + retryable: false + } + return + } + const sequenced = { + ...event, + sessionId: this.sessionId, + sequence: ++this.lastSequence + } as TerminalEvent + this.pendingEvents.push(sequenced) + for (const listener of this.listeners) { + listener(sequenced) + } + } + + private boundErrorMessage(cause: unknown): string { + const message = + cause instanceof Error + ? cause.message + : 'Failed to close the local terminal' + return ( + Buffer.from(message) + .subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes) + .toString('utf8') || 'Failed to close the local terminal' + ) + } + + private disposeSubscriptions(): void { + for (const subscription of this.subscriptions.splice(0)) { + subscription.dispose() + } + } +} diff --git a/src/main/terminal/ssh-terminal-session.test.ts b/src/main/terminal/ssh-terminal-session.test.ts new file mode 100644 index 00000000..f860d589 --- /dev/null +++ b/src/main/terminal/ssh-terminal-session.test.ts @@ -0,0 +1,271 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { + AuthenticatedSshConnection, + SshConnectionPoolTarget, + SshPoolConnectionIdentity, + SshTerminalShellOptions, +} from "../ssh/ssh-connection-pool"; +import { SshConnectionPool } from "../ssh/ssh-connection-pool"; +import { + createSshTerminalSession, + sshTerminalInternals, +} from "./ssh-terminal-session"; + +const sessionId = "00000000-0000-4000-8000-000000000501"; +const hostId = "00000000-0000-4000-8000-000000000502"; +const projectId = "00000000-0000-4000-8000-000000000503"; + +function createTarget(): SshConnectionPoolTarget { + return { + host: { + id: hostId, + name: "Build host", + hostname: "build.example.com", + port: 22, + username: "builder", + authentication: "password", + password: "private password", + hostKey: { + algorithm: "ssh-ed25519", + publicKeyBase64: "pinned", + fingerprintSha256: `SHA256:${"A".repeat(43)}`, + generation: 3, + }, + }, + hostRevision: 7, + hostKeyGeneration: 3, + }; +} + +function createChannel() { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + write: vi.fn(() => true), + setWindow: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + close: vi.fn(), + destroy: vi.fn(), + }); +} + +function createHarness(idleTimeoutMs = 60_000) { + const channel = createChannel(); + let usable = true; + const disconnectListeners = new Set<(error?: Error) => void>(); + let shellOptions: SshTerminalShellOptions | undefined; + const identity: SshPoolConnectionIdentity = { + hostId, + hostRevision: 7, + hostKeyGeneration: 3, + authenticationIdentity: "a".repeat(64), + }; + const connection = { + identity, + isUsable: vi.fn(() => usable), + onDisconnect: vi.fn((listener: (error?: Error) => void) => { + disconnectListeners.add(listener); + return () => disconnectListeners.delete(listener); + }), + openTerminalShell: vi.fn( + async (options: SshTerminalShellOptions) => { + shellOptions = options; + return channel as never; + }, + ), + openAgentAttach: vi.fn(), + runAgentDoctor: vi.fn(), + runAgentBootstrapProbe: vi.fn(), + probeRemotePackageBootstrap: vi.fn(), + createRemotePackageUploadStaging: vi.fn(), + prepareRemotePackageBootstrap: vi.fn(), + prepareUploadedRemotePackageBootstrap: vi.fn(), + commitRemotePackageBootstrap: vi.fn(), + cleanupRemotePackageBootstrap: vi.fn(), + runAgentLifecycleAction: vi.fn(), + runAgentRuntimeAction: vi.fn(), + openStagedSftp: vi.fn(), + listDirectories: vi.fn(), + dispose: vi.fn(() => { + usable = false; + }), + } satisfies AuthenticatedSshConnection; + const pool = new SshConnectionPool(async () => connection, { + idleTimeoutMs, + }); + const events: Array<{ + type: string; + sequence: number; + [key: string]: unknown; + }> = []; + + return { + channel, + connection, + pool, + events, + shellOptions: () => shellOptions, + disconnect(error?: Error) { + usable = false; + for (const listener of [...disconnectListeners]) { + listener(error); + } + }, + }; +} + +async function openSession(harness: ReturnType) { + return createSshTerminalSession(harness.pool, { + sessionId, + target: { type: "project", projectId }, + targetLabel: "Build host", + title: "终端 · Build host", + shell: "/bin/bash", + workingDirectory: "/srv/team's app", + size: { cols: 120, rows: 40 }, + poolTarget: createTarget(), + onEvent: (event) => harness.events.push(event), + }); +} + +describe("SSH terminal session", () => { + it("holds a pool lease and opens a login shell with the requested PTY and quoted cwd", async () => { + vi.useFakeTimers(); + try { + const harness = createHarness(100); + const session = await openSession(harness); + + expect(harness.shellOptions()).toEqual({ + cols: 120, + rows: 40, + term: "xterm-256color", + }); + expect(harness.channel.write).toHaveBeenCalledWith( + "cd -- '/srv/team'\"'\"'s app' || exit\r", + ); + await vi.advanceTimersByTimeAsync(1_000); + expect(harness.connection.dispose).not.toHaveBeenCalled(); + + expect(session.close()).toMatchObject({ + state: "exited", + exit: { exitCode: null, signal: "SIGHUP" }, + }); + expect(session.close()).toMatchObject({ state: "exited" }); + expect(harness.channel.close).toHaveBeenCalledOnce(); + expect(harness.channel.destroy).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(100); + expect(harness.connection.dispose).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("forwards writes and resizes while running and returns an authoritative snapshot", async () => { + const harness = createHarness(); + const session = await openSession(harness); + + session.write("printf '你好'\\r"); + session.resize({ cols: 90, rows: 28 }); + + expect(harness.channel.write).toHaveBeenLastCalledWith( + "printf '你好'\\r", + ); + expect(harness.channel.setWindow).toHaveBeenCalledWith(28, 90, 0, 0); + expect(session.snapshot()).toMatchObject({ + sessionId, + state: "running", + shell: "/bin/bash", + workingDirectory: "/srv/team's app", + size: { cols: 90, rows: 28 }, + lastSequence: 1, + }); + session.close(); + }); + + it("emits ordered bounded output and pauses until acknowledged", async () => { + const harness = createHarness(); + const session = await openSession(harness); + + for (let index = 0; index < 255; index += 1) { + harness.channel.emit("data", `chunk-${index}`); + } + + expect(harness.events).toHaveLength(256); + expect(harness.events.map((event) => event.sequence)).toEqual( + Array.from({ length: 256 }, (_, index) => index + 1), + ); + expect(harness.channel.pause).toHaveBeenCalledOnce(); + session.acknowledge(256); + expect(harness.channel.resume).toHaveBeenCalledOnce(); + session.close(); + }); + + it("marks a disconnected SSH transport interrupted immediately and rejects late I/O", async () => { + const harness = createHarness(); + const session = await openSession(harness); + harness.disconnect(new Error("socket reset")); + + expect(session.snapshot()).toMatchObject({ + state: "interrupted", + error: { + code: "interrupted", + message: "socket reset", + retryable: true, + }, + }); + expect(harness.events.at(-1)).toMatchObject({ + type: "state", + state: "interrupted", + }); + const writes = harness.channel.write.mock.calls.length; + session.write("late"); + session.resize({ cols: 80, rows: 24 }); + expect(harness.channel.write).toHaveBeenCalledTimes(writes); + expect(harness.channel.setWindow).not.toHaveBeenCalled(); + session.close(); + }); + + it("records normal shell exit before releasing the channel lease", async () => { + const harness = createHarness(); + const session = await openSession(harness); + harness.channel.emit("exit", 7); + harness.channel.emit("close"); + + expect(session.snapshot()).toMatchObject({ + state: "exited", + exit: { exitCode: 7, signal: null }, + }); + expect(harness.events.slice(-2)).toMatchObject([ + { type: "exit", exit: { exitCode: 7, signal: null } }, + { type: "state", state: "exited" }, + ]); + session.close(); + }); + + it("strictly quotes POSIX paths and rejects line-based command injection", () => { + expect(sshTerminalInternals.quotePosixShellArgument("a'b")).toBe( + "'a'\"'\"'b'", + ); + expect(() => + sshTerminalInternals.quotePosixShellArgument("/srv/app\nwhoami"), + ).toThrow("无效字符"); + }); + + it("opens a Host-home shell without injecting a cwd command", async () => { + const harness = createHarness(); + const session = await createSshTerminalSession(harness.pool, { + sessionId, + target: { type: "project", projectId }, + targetLabel: "Build host", + title: "终端 · Build host", + size: { cols: 80, rows: 24 }, + poolTarget: createTarget(), + onEvent: (event) => harness.events.push(event), + }); + + expect(harness.channel.write).not.toHaveBeenCalled(); + expect(session.snapshot().workingDirectory).toBe("~"); + session.close(); + }); +}); diff --git a/src/main/terminal/ssh-terminal-session.ts b/src/main/terminal/ssh-terminal-session.ts new file mode 100644 index 00000000..2a17c5bd --- /dev/null +++ b/src/main/terminal/ssh-terminal-session.ts @@ -0,0 +1,316 @@ +import { StringDecoder } from "node:string_decoder"; +import type { ClientChannel } from "ssh2"; +import { + TERMINAL_LIMITS, + terminalSizeSchema, + type TerminalError, + type TerminalEvent, + type TerminalExit, + type TerminalSessionId, + type TerminalSize, + type TerminalSnapshot, + type TerminalTarget, +} from "../../shared/terminal-contracts"; +import { + type SshConnectionPool, + type SshConnectionPoolTarget, + type SshTerminalConnectionLease, +} from "../ssh/ssh-connection-pool"; + +const SSH_TERMINAL_TERM = "xterm-256color"; + +export type SshTerminalSessionOptions = { + sessionId: TerminalSessionId; + target: TerminalTarget; + targetLabel: string; + title: string; + shell?: string; + workingDirectory?: string; + size: TerminalSize; + poolTarget: SshConnectionPoolTarget; + onEvent: (event: TerminalEvent) => void; + signal?: AbortSignal; +}; + +export type SshTerminalSession = { + write(data: string): void; + resize(size: TerminalSize): void; + acknowledge(sequence: number): void; + snapshot(): TerminalSnapshot; + close(): TerminalSnapshot; +}; + +type RunningSession = { + lease: SshTerminalConnectionLease; + channel: ClientChannel; + removeDisconnectListener: () => void; +}; + +type TerminalEventPayload = + | { type: "output"; data: string } + | { type: "state"; state: TerminalSnapshot["state"] } + | { type: "exit"; exit: TerminalExit } + | { type: "error"; error: TerminalError }; + +function quotePosixShellArgument(value: string): string { + if (value.includes("\0") || value.includes("\r") || value.includes("\n")) { + throw new Error("远程工作目录包含无效字符"); + } + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function eventByteLength(event: TerminalEvent): number { + switch (event.type) { + case "output": + return Buffer.byteLength(event.data); + case "error": + return Buffer.byteLength(event.error.message) + 64; + default: + return 64; + } +} + +function splitUtf8(text: string, maximumBytes: number): string[] { + const chunks: string[] = []; + let chunk = ""; + let chunkBytes = 0; + for (const character of text) { + const characterBytes = Buffer.byteLength(character); + if (chunk && chunkBytes + characterBytes > maximumBytes) { + chunks.push(chunk); + chunk = ""; + chunkBytes = 0; + } + chunk += character; + chunkBytes += characterBytes; + } + if (chunk) { + chunks.push(chunk); + } + return chunks; +} + +export async function createSshTerminalSession( + pool: SshConnectionPool, + options: SshTerminalSessionOptions, +): Promise { + const initialSize = terminalSizeSchema.parse(options.size); + const workingDirectoryCommand = options.workingDirectory + ? `cd -- ${quotePosixShellArgument(options.workingDirectory)} || exit\r` + : undefined; + const lease = await pool.acquireTerminal(options.poolTarget, options.signal); + let channel: ClientChannel; + try { + channel = await lease.openTerminalShell( + { + cols: initialSize.cols, + rows: initialSize.rows, + term: SSH_TERMINAL_TERM, + }, + options.signal, + ); + } catch (error) { + lease.release(); + throw error; + } + + let state: TerminalSnapshot["state"] = "running"; + let size = initialSize; + let sequence = 0; + let exit: TerminalExit | null = null; + let error: TerminalError | null = null; + let closed = false; + let paused = false; + let pendingBytes = 0; + const pendingEvents: Array<{ event: TerminalEvent; bytes: number }> = []; + const decoder = new StringDecoder("utf8"); + let running: RunningSession | undefined; + + const emit = (event: TerminalEventPayload): void => { + sequence += 1; + const sequenced = { + ...event, + sessionId: options.sessionId, + sequence, + } as TerminalEvent; + const bytes = eventByteLength(sequenced); + pendingEvents.push({ event: sequenced, bytes }); + pendingBytes += bytes; + options.onEvent(sequenced); + if ( + !paused && + (pendingEvents.length >= TERMINAL_LIMITS.maximumPendingEvents || + pendingBytes >= + TERMINAL_LIMITS.maximumBufferedOutputBytes - + TERMINAL_LIMITS.maximumEventBytes) + ) { + paused = true; + channel.pause(); + } + }; + + const release = (): void => { + const active = running; + running = undefined; + active?.removeDisconnectListener(); + active?.lease.release(); + }; + + const interrupt = (disconnectError?: Error): void => { + if (closed || state !== "running") { + return; + } + state = "interrupted"; + error = { + code: "interrupted", + message: disconnectError?.message || "SSH 连接已中断", + retryable: true, + }; + emit({ type: "error", error }); + emit({ type: "state", state }); + release(); + channel.destroy(); + }; + + const removeDisconnectListener = lease.onDisconnect(interrupt); + running = { lease, channel, removeDisconnectListener }; + + channel.on("data", (chunk: Buffer | string) => { + if (closed || state !== "running") { + return; + } + const text = + typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk)); + for (const data of splitUtf8( + text, + TERMINAL_LIMITS.maximumEventBytes, + )) { + emit({ type: "output", data }); + } + }); + channel.once( + "exit", + (code: number | null, signal?: string) => { + if (closed || state !== "running") { + return; + } + exit = + typeof code === "number" + ? { exitCode: code, signal: null } + : { exitCode: null, signal: signal || "UNKNOWN" }; + }, + ); + channel.once("error", (channelError: Error) => { + interrupt(channelError); + }); + channel.once("close", () => { + if (closed || state !== "running") { + release(); + return; + } + const finalText = decoder.end(); + if (finalText) { + for (const data of splitUtf8( + finalText, + TERMINAL_LIMITS.maximumEventBytes, + )) { + emit({ type: "output", data }); + } + } + if (!lease.isUsable()) { + interrupt(); + return; + } + state = "exited"; + exit ??= { exitCode: 0, signal: null }; + emit({ type: "exit", exit }); + emit({ type: "state", state }); + release(); + }); + + if (workingDirectoryCommand) { + channel.write(workingDirectoryCommand); + } + emit({ type: "state", state }); + + const currentSnapshot = (): TerminalSnapshot => ({ + sessionId: options.sessionId, + target: options.target, + targetLabel: options.targetLabel, + title: options.title, + state, + shell: options.shell ?? "login shell", + workingDirectory: options.workingDirectory || "~", + size, + lastSequence: sequence, + exit, + error, + }); + + return { + write(data) { + if (closed || state !== "running") { + return; + } + channel.write(data); + }, + resize(nextSize) { + if (closed || state !== "running") { + return; + } + size = terminalSizeSchema.parse(nextSize); + channel.setWindow(size.rows, size.cols, 0, 0); + }, + acknowledge(acknowledgedSequence) { + if ( + !Number.isSafeInteger(acknowledgedSequence) || + acknowledgedSequence < 0 + ) { + throw new Error("终端输出确认序号无效"); + } + while ( + pendingEvents[0] && + pendingEvents[0].event.sequence <= acknowledgedSequence + ) { + pendingBytes -= pendingEvents.shift()?.bytes ?? 0; + } + if ( + paused && + pendingEvents.length < TERMINAL_LIMITS.maximumPendingEvents && + pendingBytes < + TERMINAL_LIMITS.maximumBufferedOutputBytes - + TERMINAL_LIMITS.maximumEventBytes + ) { + paused = false; + channel.resume(); + } + }, + snapshot() { + return currentSnapshot(); + }, + close() { + if (closed) { + return currentSnapshot(); + } + closed = true; + if (state === "running") { + state = "closing"; + emit({ type: "state", state }); + exit = { exitCode: null, signal: "SIGHUP" }; + } + release(); + channel.close(); + channel.destroy(); + if (state === "closing") { + state = "exited"; + emit({ type: "exit", exit: exit! }); + emit({ type: "state", state }); + } + return currentSnapshot(); + }, + }; +} + +export const sshTerminalInternals = { + quotePosixShellArgument, +}; diff --git a/src/main/terminal/terminal-session-manager.test.ts b/src/main/terminal/terminal-session-manager.test.ts new file mode 100644 index 00000000..7734456d --- /dev/null +++ b/src/main/terminal/terminal-session-manager.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it, vi } from 'vitest' +import { + TERMINAL_LIMITS, + type TerminalEvent, + type TerminalSnapshot, + type TerminalTarget +} from '../../shared/terminal-contracts' +import { + TerminalSessionManager, + TerminalSessionManagerError, + type ManagedTerminalSession, + type TerminalSessionManagerDependencies +} from './terminal-session-manager' + +const projectId = '00000000-0000-4000-8000-000000000601' +const hostId = '00000000-0000-4000-8000-000000000602' + +function snapshot( + sessionId: string, + target: TerminalTarget, + workingDirectory = '/home/tester' +): TerminalSnapshot { + return { + sessionId, + target, + targetLabel: target.type === 'local' ? '本机' : 'Build host', + title: '终端', + state: 'running', + shell: 'bash', + workingDirectory, + size: { cols: 80, rows: 24 }, + lastSequence: 1, + exit: null, + error: null + } +} + +function fakeSession( + value: TerminalSnapshot, + initialEvents: TerminalEvent[] = [] +): ManagedTerminalSession & { + emit(event: TerminalEvent): void + close: ReturnType + write: ReturnType + resize: ReturnType +} { + let current = value + const listeners = new Set<(event: TerminalEvent) => void>() + return { + snapshot: () => current, + write: vi.fn(() => true), + resize: vi.fn(() => true), + acknowledge: vi.fn(), + close: vi.fn(async () => { + current = { ...current, state: 'closing' } + return current + }), + onEvent(listener) { + listeners.add(listener) + initialEvents.forEach(listener) + return () => listeners.delete(listener) + }, + emit(event) { + for (const listener of listeners) { + listener(event) + } + } + } +} + +function harness( + overrides: Partial = {} +) { + let nextId = 700 + const delivered: Array<{ ownerId: number; event: TerminalEvent }> = [] + const localSessions: ReturnType[] = [] + const sshSessions: ReturnType[] = [] + const createLocalSession = vi.fn(async (options) => { + const event = { + sessionId: options.sessionId!, + sequence: 1, + type: 'output' as const, + data: 'prompt' + } + const session = fakeSession( + snapshot( + options.sessionId!, + options.target, + options.projectDirectory ?? '/home/tester' + ), + [event] + ) + localSessions.push(session) + return session + }) + const createSshSession = vi.fn(async (_pool, options) => { + options.onEvent({ + sessionId: options.sessionId, + sequence: 1, + type: 'output', + data: 'remote prompt' + }) + const session = fakeSession( + snapshot( + options.sessionId, + options.target, + options.workingDirectory + ) + ) + sshSessions.push(session) + return session + }) + const dependencies = { + database: { + getProject: vi.fn(() => ({ + id: projectId, + name: 'Workspace', + rootPath: 'legacy', + executionSpace: { kind: 'local', rootPath: '/authoritative/root' } + })) + }, + executionSpaceResolver: { + resolveProject: vi.fn((project: { + executionSpace: { kind: 'local'; rootPath: string } + }) => ({ + kind: 'local' as const, + rootPath: project.executionSpace.rootPath, + cacheIdentity: 'local', + routeIdentity: 'local', + workspaceAccess: {} + })) + }, + targetResolver: { + resolve: vi.fn(async () => ({ + host: { id: hostId, name: 'Build host' }, + hostRevision: 2, + hostKeyGeneration: 3 + })) + }, + sshPool: {} as never, + remoteEnabled: vi.fn(() => true), + deliverEvent: vi.fn((ownerId, event) => { + delivered.push({ ownerId, event }) + }), + createLocalSession, + createSshSession, + createSessionId: () => + `00000000-0000-4000-8000-${String(nextId++).padStart(12, '0')}`, + ...overrides + } as unknown as TerminalSessionManagerDependencies + return { + manager: new TerminalSessionManager(dependencies), + dependencies, + delivered, + localSessions, + sshSessions, + createLocalSession, + createSshSession + } +} + +describe('TerminalSessionManager routing and delivery', () => { + it('routes Home local and authoritative local project roots', async () => { + const test = harness() + const local = await test.manager.create(1, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + const project = await test.manager.create(1, { + target: { type: 'project', projectId }, + cols: 90, + rows: 30 + }) + + expect(local.workingDirectory).toBe('/home/tester') + expect(project.workingDirectory).toBe('/authoritative/root') + expect(test.createLocalSession.mock.calls[0]?.[0].projectDirectory) + .toBeUndefined() + expect(test.createLocalSession.mock.calls[1]?.[0].projectDirectory) + .toBe('/authoritative/root') + }) + + it('routes SSH projects to their authoritative remote root', async () => { + const test = harness() + vi.mocked(test.dependencies.executionSpaceResolver.resolveProject) + .mockReturnValue({ + kind: 'ssh', + hostId, + remoteRootPath: '/srv/project', + cacheIdentity: 'ssh', + routeIdentity: 'ssh', + workspaceAccess: {} as never + }) + + const remote = await test.manager.create(1, { + target: { type: 'project', projectId }, + cols: 80, + rows: 24 + }) + + expect(test.createSshSession.mock.calls[0]?.[1].workingDirectory) + .toBe('/srv/project') + expect(remote.workingDirectory).toBe('/srv/project') + expect(test.dependencies.targetResolver.resolve).toHaveBeenCalledWith(hostId) + }) + + it('gates all remote targets', async () => { + const test = harness({ remoteEnabled: () => false }) + vi.mocked(test.dependencies.executionSpaceResolver.resolveProject) + .mockReturnValue({ + kind: 'ssh', + hostId, + remoteRootPath: '/srv/project', + cacheIdentity: 'ssh', + routeIdentity: 'ssh', + workspaceAccess: {} as never + }) + await expect(test.manager.create(1, { + target: { type: 'project', projectId }, + cols: 80, + rows: 24 + })).rejects.toMatchObject({ code: 'target-unavailable' }) + expect(test.createSshSession).not.toHaveBeenCalled() + }) + + it('delays startup events until delivery is explicitly enabled', async () => { + const test = harness() + const created = await test.manager.create(7, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + expect(test.delivered).toEqual([]) + + test.manager.enableEventDelivery(7, created.sessionId) + expect(test.delivered).toEqual([ + { + ownerId: 7, + event: expect.objectContaining({ data: 'prompt', sequence: 1 }) + } + ]) + }) +}) + +describe('TerminalSessionManager ownership and lifecycle', () => { + it('revalidates ownership for every session operation', async () => { + const test = harness() + const created = await test.manager.create(11, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + const calls = [ + () => test.manager.write(12, created.sessionId, 'x'), + () => test.manager.resize(12, created.sessionId, { cols: 90, rows: 30 }), + () => test.manager.snapshot(12, created.sessionId), + () => test.manager.acknowledge(12, created.sessionId, 1), + () => test.manager.close(12, created.sessionId), + () => test.manager.enableEventDelivery(12, created.sessionId) + ] + for (const call of calls) { + expect(call).toThrowError( + expect.objectContaining({ code: 'session-not-found' }) + ) + } + expect(test.localSessions[0]?.write).not.toHaveBeenCalled() + }) + + it('enforces the per-window limit without affecting another owner', async () => { + const test = harness() + for (let index = 0; + index < TERMINAL_LIMITS.maximumSessionsPerWindow; + index += 1) { + await test.manager.create(1, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + } + await expect(test.manager.create(1, { + target: { type: 'local' }, + cols: 80, + rows: 24 + })).rejects.toBeInstanceOf(TerminalSessionManagerError) + await expect(test.manager.create(2, { + target: { type: 'local' }, + cols: 80, + rows: 24 + })).resolves.toBeDefined() + }) + + it('releases closed records and ignores late delivery ACKs', async () => { + const test = harness() + const created = await test.manager.create(1, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + const first = await test.manager.close(1, created.sessionId) + + expect(first.state).toBe('closing') + expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1) + expect(() => + test.manager.acknowledge(1, created.sessionId, 1) + ).not.toThrow() + expect(() => test.manager.close(1, created.sessionId)) + .toThrowError(expect.objectContaining({ code: 'session-not-found' })) + expect(() => test.manager.write(1, created.sessionId, 'late')) + .toThrowError(expect.objectContaining({ code: 'session-not-found' })) + expect(() => test.manager.resize( + 1, + created.sessionId, + { cols: 100, rows: 40 } + )).toThrowError(expect.objectContaining({ + code: 'session-not-found' + })) + + await expect(test.manager.create(1, { + target: { type: 'local' }, + cols: 80, + rows: 24 + })).resolves.toBeDefined() + }) + + it('closes only one owner and disposes all sessions idempotently', async () => { + const test = harness() + const first = await test.manager.create(1, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + const second = await test.manager.create(2, { + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + + await test.manager.closeOwner(1) + expect(() => test.manager.snapshot(1, first.sessionId)) + .toThrowError(expect.objectContaining({ code: 'session-not-found' })) + expect(test.manager.snapshot(2, second.sessionId).state).toBe('running') + + await Promise.all([test.manager.dispose(), test.manager.dispose()]) + expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1) + expect(test.localSessions[1]?.close).toHaveBeenCalledTimes(1) + await expect(test.manager.create(2, { + target: { type: 'local' }, + cols: 80, + rows: 24 + })).rejects.toMatchObject({ code: 'internal-error' }) + }) +}) diff --git a/src/main/terminal/terminal-session-manager.ts b/src/main/terminal/terminal-session-manager.ts new file mode 100644 index 00000000..5530a924 --- /dev/null +++ b/src/main/terminal/terminal-session-manager.ts @@ -0,0 +1,479 @@ +import { randomUUID } from 'node:crypto' +import { + TERMINAL_LIMITS, + type TerminalCreateRequest, + type TerminalErrorCode, + type TerminalEvent, + type TerminalSize, + type TerminalSnapshot +} from '../../shared/terminal-contracts' +import type { AssistantDatabase } from '../assistant/assistant-database' +import { + type ExecutionSpaceDescriptor, + type ExecutionSpaceResolver +} from '../execution-space/execution-space-resolver' +import type { RemoteAgentTargetResolver } from '../remote-agent/remote-agent-connection-manager' +import type { + SshConnectionPool, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' +import { + LocalTerminalSession, + type LocalTerminalSessionOptions +} from './local-terminal-session' +import { + createSshTerminalSession, + type SshTerminalSession, + type SshTerminalSessionOptions +} from './ssh-terminal-session' + +export class TerminalSessionManagerError extends Error { + constructor( + readonly code: TerminalErrorCode, + message: string + ) { + super(message) + this.name = 'TerminalSessionManagerError' + } +} + +export type ManagedTerminalSession = { + snapshot(): TerminalSnapshot + write(data: string): boolean | void + resize(size: TerminalSize): boolean | void + acknowledge(sequence: number): void + close(): void | TerminalSnapshot | Promise + onEvent?: (listener: (event: TerminalEvent) => void) => () => void +} + +export type TerminalSessionManagerDependencies = { + database: Pick + executionSpaceResolver: Pick + targetResolver: RemoteAgentTargetResolver + sshPool: SshConnectionPool + remoteEnabled: () => boolean | Promise + deliverEvent: (ownerWebContentsId: number, event: TerminalEvent) => void + createLocalSession?: ( + options: LocalTerminalSessionOptions + ) => Promise + createSshSession?: ( + pool: SshConnectionPool, + options: SshTerminalSessionOptions + ) => Promise + createSessionId?: () => string +} + +type SessionRecord = { + ownerId: number + sessionId: string + deliveryEnabled: boolean + delayedEvents: TerminalEvent[] + closing: boolean + session?: ManagedTerminalSession + ready: Promise + closePromise?: Promise + removeEventListener?: () => void +} + +type ResolvedLaunch = + | { + kind: 'local' + targetLabel: string + projectDirectory?: string + } + | { + kind: 'ssh' + targetLabel: string + workingDirectory: string + poolTarget: SshConnectionPoolTarget + } + +/** + * Owns ephemeral terminal sessions. Session identifiers are generated in Main + * and every operation is scoped to the creating WebContents identifier. + */ +export class TerminalSessionManager { + private readonly records = new Map() + private disposePromise: Promise | undefined + private disposed = false + + constructor( + private readonly dependencies: TerminalSessionManagerDependencies + ) {} + + async create( + ownerWebContentsId: number, + request: TerminalCreateRequest + ): Promise { + this.assertOwnerId(ownerWebContentsId) + if (this.disposed) { + throw new TerminalSessionManagerError( + 'internal-error', + '终端会话管理器已关闭' + ) + } + if (this.ownerSessionCount(ownerWebContentsId) >= + TERMINAL_LIMITS.maximumSessionsPerWindow) { + throw new TerminalSessionManagerError( + 'session-limit-reached', + `每个窗口最多可同时打开 ${TERMINAL_LIMITS.maximumSessionsPerWindow} 个终端` + ) + } + + const sessionId = + this.dependencies.createSessionId?.() ?? randomUUID() + const record: SessionRecord = { + ownerId: ownerWebContentsId, + sessionId, + deliveryEnabled: false, + delayedEvents: [], + closing: false, + ready: Promise.resolve(undefined as never) + } + this.records.set(sessionId, record) + record.ready = this.createSession(record, request) + + try { + const session = await record.ready + if (record.closing) { + return await this.closeRecord(record) + } + return session.snapshot() + } catch (error) { + if (this.records.get(sessionId) === record) { + this.records.delete(sessionId) + } + throw error + } + } + + /** + * Enables event forwarding after the create response has crossed IPC. Events + * produced while a PTY/channel was starting are retained and delivered first. + */ + enableEventDelivery( + ownerWebContentsId: number, + sessionId: string + ): void { + const record = this.requireOwned(ownerWebContentsId, sessionId) + if (record.deliveryEnabled) { + return + } + record.deliveryEnabled = true + for (const event of record.delayedEvents.splice(0)) { + this.deliver(record, event) + } + } + + write( + ownerWebContentsId: number, + sessionId: string, + data: string + ): boolean { + const record = this.requireOwned(ownerWebContentsId, sessionId) + const session = this.requireRunning(record) + return session.write(data) !== false + } + + resize( + ownerWebContentsId: number, + sessionId: string, + size: TerminalSize + ): boolean { + const record = this.requireOwned(ownerWebContentsId, sessionId) + const session = this.requireRunning(record) + return session.resize(size) !== false + } + + snapshot( + ownerWebContentsId: number, + sessionId: string + ): TerminalSnapshot { + const record = this.requireOwned(ownerWebContentsId, sessionId) + if (!record.session) { + throw new TerminalSessionManagerError( + 'session-not-running', + '终端会话仍在启动' + ) + } + return record.session.snapshot() + } + + acknowledge( + ownerWebContentsId: number, + sessionId: string, + sequence: number + ): void { + this.assertOwnerId(ownerWebContentsId) + const record = this.records.get(sessionId) + if (!record) { + // ACKs already queued in the renderer may arrive after a successful + // close. They are delivery bookkeeping, so treating them as complete is + // both safe and necessary for a race-free close path. + return + } + if (record.ownerId !== ownerWebContentsId) { + throw new TerminalSessionManagerError( + 'session-not-found', + '终端会话不存在' + ) + } + if (!record.session || record.closing) { + return + } + record.session.acknowledge(sequence) + } + + close( + ownerWebContentsId: number, + sessionId: string + ): Promise { + const record = this.requireOwned(ownerWebContentsId, sessionId) + return this.closeRecord(record) + } + + async closeOwner(ownerWebContentsId: number): Promise { + this.assertOwnerId(ownerWebContentsId) + const records = [...this.records.values()].filter( + (record) => record.ownerId === ownerWebContentsId + ) + await Promise.allSettled(records.map((record) => this.closeRecord(record))) + for (const record of records) { + record.removeEventListener?.() + if (this.records.get(record.sessionId) === record) { + this.records.delete(record.sessionId) + } + } + } + + dispose(): Promise { + this.disposePromise ??= this.disposeInternal() + return this.disposePromise + } + + private async disposeInternal(): Promise { + this.disposed = true + const records = [...this.records.values()] + await Promise.allSettled(records.map((record) => this.closeRecord(record))) + for (const record of records) { + record.removeEventListener?.() + } + this.records.clear() + } + + private async createSession( + record: SessionRecord, + request: TerminalCreateRequest + ): Promise { + const launch = await this.resolveLaunch(request) + const common = { + sessionId: record.sessionId, + target: request.target, + targetLabel: launch.targetLabel, + title: `终端 · ${launch.targetLabel}`, + size: { cols: request.cols, rows: request.rows } + } + let session: ManagedTerminalSession + if (launch.kind === 'local') { + session = await ( + this.dependencies.createLocalSession ?? + ((options) => LocalTerminalSession.create(options)) + )({ + ...common, + target: request.target as Extract< + TerminalCreateRequest['target'], + { type: 'local' | 'project' } + >, + projectDirectory: launch.projectDirectory + }) + record.session = session + record.removeEventListener = session.onEvent?.((event) => + this.handleEvent(record, event) + ) + } else { + session = await ( + this.dependencies.createSshSession ?? + ((pool, options) => + createSshTerminalSession(pool, options) as Promise) + )(this.dependencies.sshPool, { + ...common, + workingDirectory: launch.workingDirectory, + poolTarget: launch.poolTarget, + onEvent: (event) => this.handleEvent(record, event) + }) + record.session = session + } + return session + } + + private async resolveLaunch( + request: TerminalCreateRequest + ): Promise { + if (request.target.type === 'local') { + return { kind: 'local', targetLabel: '本机' } + } + + let project: ReturnType + try { + project = this.dependencies.database.getProject( + request.target.projectId + ) + } catch (error) { + throw this.resolutionError('target-not-found', error, '项目不存在') + } + let executionSpace: ExecutionSpaceDescriptor + try { + executionSpace = + this.dependencies.executionSpaceResolver.resolveProject(project) + } catch (error) { + throw this.resolutionError( + 'target-unavailable', + error, + '项目执行空间不可用' + ) + } + if (executionSpace.kind === 'local') { + return { + kind: 'local', + targetLabel: project.name, + projectDirectory: executionSpace.rootPath + } + } + await this.assertRemoteEnabled() + const poolTarget = await this.resolveTarget(executionSpace.hostId) + return { + kind: 'ssh', + targetLabel: poolTarget.host.name, + workingDirectory: executionSpace.remoteRootPath, + poolTarget + } + } + + private async resolveTarget( + hostId: string + ): Promise { + try { + return await this.dependencies.targetResolver.resolve(hostId) + } catch (error) { + throw this.resolutionError( + 'target-unavailable', + error, + 'SSH 主机不可用' + ) + } + } + + private async assertRemoteEnabled(): Promise { + if (!(await this.dependencies.remoteEnabled())) { + throw new TerminalSessionManagerError( + 'target-unavailable', + '远程终端当前未启用' + ) + } + } + + private handleEvent(record: SessionRecord, event: TerminalEvent): void { + if (this.records.get(record.sessionId) !== record) { + return + } + if (!record.deliveryEnabled) { + record.delayedEvents.push(event) + return + } + this.deliver(record, event) + } + + private deliver(record: SessionRecord, event: TerminalEvent): void { + try { + this.dependencies.deliverEvent(record.ownerId, event) + } catch { + // A destroyed renderer is cleaned up by closeOwner; it must not break + // PTY/SSH event handling while that close is being scheduled. + } + } + + private requireRunning(record: SessionRecord): ManagedTerminalSession { + const session = record.session + if ( + !session || + record.closing || + session.snapshot().state !== 'running' + ) { + throw new TerminalSessionManagerError( + 'session-not-running', + '终端会话未在运行' + ) + } + return session + } + + private closeRecord(record: SessionRecord): Promise { + record.closing = true + record.closePromise ??= (async () => { + try { + const session = await record.ready + const closed = await session.close() + const snapshot = + closed && typeof closed === 'object' && 'sessionId' in closed + ? closed + : session.snapshot() + record.removeEventListener?.() + record.removeEventListener = undefined + record.delayedEvents.length = 0 + if (this.records.get(record.sessionId) === record) { + this.records.delete(record.sessionId) + } + return snapshot + } catch (error) { + record.closing = false + record.closePromise = undefined + throw error + } + })() + return record.closePromise + } + + private requireOwned( + ownerWebContentsId: number, + sessionId: string + ): SessionRecord { + this.assertOwnerId(ownerWebContentsId) + const record = this.records.get(sessionId) + if (!record || record.ownerId !== ownerWebContentsId) { + throw new TerminalSessionManagerError( + 'session-not-found', + '终端会话不存在' + ) + } + return record + } + + private ownerSessionCount(ownerId: number): number { + let count = 0 + for (const record of this.records.values()) { + if (record.ownerId === ownerId && !record.closing) { + count += 1 + } + } + return count + } + + private assertOwnerId(ownerId: number): void { + if (!Number.isSafeInteger(ownerId) || ownerId < 0) { + throw new TypeError('无效的终端窗口标识') + } + } + + private resolutionError( + code: TerminalErrorCode, + cause: unknown, + fallback: string + ): TerminalSessionManagerError { + return new TerminalSessionManagerError( + code, + cause instanceof Error && cause.message + ? cause.message + : fallback + ) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8c263b82..97b35290 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -166,6 +166,16 @@ import type { RemoteProjectRecoverySnapshot, RemoteProjectRecoveryState } from '../shared/remote-project-recovery-contracts' +import type { + TerminalAckRequest, + TerminalCloseRequest, + TerminalCreateRequest, + TerminalEvent, + TerminalResizeRequest, + TerminalSnapshot, + TerminalSnapshotRequest, + TerminalWriteRequest +} from '../shared/terminal-contracts' const desktopApi: DesktopApi = { app: { @@ -302,6 +312,41 @@ const desktopApi: DesktopApi = { ipcRenderer.removeListener(ipcChannels.browserState, handler) } }, + terminal: { + create: (request: TerminalCreateRequest) => + ipcRenderer.invoke( + ipcChannels.terminalCreate, + request + ) as Promise, + write: async (request: TerminalWriteRequest) => { + await ipcRenderer.invoke(ipcChannels.terminalWrite, request) + }, + resize: async (request: TerminalResizeRequest) => { + await ipcRenderer.invoke(ipcChannels.terminalResize, request) + }, + close: (request: TerminalCloseRequest) => + ipcRenderer.invoke( + ipcChannels.terminalClose, + request + ) as Promise, + getSnapshot: (request: TerminalSnapshotRequest) => + ipcRenderer.invoke( + ipcChannels.terminalSnapshot, + request + ) as Promise, + ack: async (request: TerminalAckRequest) => { + await ipcRenderer.invoke(ipcChannels.terminalAck, request) + }, + onEvent: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: TerminalEvent + ): void => listener(payload) + ipcRenderer.on(ipcChannels.terminalEvent, handler) + return () => + ipcRenderer.removeListener(ipcChannels.terminalEvent, handler) + } + }, settings: { getRuntime: () => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 8107a5e1..a8bfa95e 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -228,6 +228,28 @@ describe('sandboxed preload', () => { ) }) + it('exposes only bounded terminal session operations', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + const terminal = + source.match( + /terminal: \{(?[\s\S]*?)\r?\n {2}\},\r?\n {2}settings:/u + )?.groups?.body ?? '' + expect(terminal).toContain('create:') + expect(terminal).toContain('write:') + expect(terminal).toContain('resize:') + expect(terminal).toContain('close:') + expect(terminal).toContain('getSnapshot:') + expect(terminal).toContain('ack:') + expect(terminal).toContain('onEvent:') + expect(terminal).toContain('ipcRenderer.removeListener(') + expect(terminal).not.toMatch( + /\b(?:shell|cwd|command|environment|password|privateKey|pid)\b/u + ) + }) + it('exposes narrow awaited remote project activation and save operations', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 7482c35b..5aecf662 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -18,6 +18,9 @@ import type { } from '../../shared/contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts' import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' +import type { + TerminalSnapshot +} from '../../shared/terminal-contracts' import { loadBrandingPreferences, saveBrandingPreferences @@ -99,6 +102,12 @@ vi.mock('./ActivityPanel', async (importOriginal) => { return importOriginal() }) +vi.mock('./TerminalPanel', () => ({ + TerminalPanel: ({ title }: { title?: string }) => ( +
+ ) +})) + import App from './App' import { loadActivityRecords } from './activity-store' import { changeUiLocale } from './i18n' @@ -155,6 +164,20 @@ const project = { updatedAt: '2026-07-31T00:00:00.000Z' } +const terminalSnapshot: TerminalSnapshot = { + sessionId: '00000000-0000-4000-8000-000000000901', + target: { type: 'local' }, + targetLabel: '本机', + title: '终端 1', + state: 'running', + shell: 'PowerShell 7', + workingDirectory: 'C:\\Users\\test', + size: { cols: 80, rows: 24 }, + lastSequence: 0, + exit: null, + error: null +} + const api: DesktopApi = { app: { getInfo: vi.fn(async () => ({ @@ -231,6 +254,23 @@ const api: DesktopApi = { } }) }, + terminal: { + create: vi.fn(async (request) => ({ + ...terminalSnapshot, + target: request.target, + size: { cols: request.cols, rows: request.rows } + })), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + close: vi.fn(async () => ({ + ...terminalSnapshot, + state: 'exited' as const, + exit: { exitCode: 0, signal: null } + })), + getSnapshot: vi.fn(async () => terminalSnapshot), + ack: vi.fn(async () => undefined), + onEvent: vi.fn(() => () => {}) + }, speech: { transcribe: vi.fn(async () => ({ text: '本地语音结果' })), cancel: vi.fn(async () => true) @@ -5462,6 +5502,35 @@ describe('App', () => { ).toBeInTheDocument() }) + it('opens multiple current-project terminals without a second target picker', async () => { + render() + + fireEvent.click(screen.getByLabelText('切换助手工作栏')) + const add = await screen.findByRole('button', { + name: '打开工作栏应用' + }) + for (let count = 1; count <= 2; count += 1) { + fireEvent.click(add) + fireEvent.click( + await screen.findByRole('button', { + name: /打开当前项目的用户终端/u + }) + ) + expect( + await screen.findByRole('tab', { + name: `终端 ${count}` + }) + ).toHaveAttribute('aria-selected', 'true') + } + + expect( + screen.getByRole('button', { name: '打开工作栏应用' }) + ).toBeVisible() + expect( + screen.getAllByRole('tab', { name: /^终端 \d+$/u }) + ).toHaveLength(2) + }) + it('opens workspace entries from their row actions', async () => { vi.mocked(api.workspace.listDirectory).mockResolvedValue({ path: '', @@ -5683,7 +5752,7 @@ describe('App', () => { ).not.toBeInTheDocument() expect( screen.queryByLabelText('切换助手工作栏') - ).not.toBeInTheDocument() + ).toBeInTheDocument() await waitFor(() => expect(api.usage.getTokenSummary).toHaveBeenCalledOnce() ) @@ -9567,7 +9636,7 @@ describe('App', () => { ).toBeInTheDocument() expect( screen.queryByLabelText('切换助手工作栏') - ).not.toBeInTheDocument() + ).toBeInTheDocument() }) it('excludes ignored heartbeat suggestions from the navigation badge', async () => { @@ -9907,6 +9976,88 @@ describe('App', () => { } }) + it('resizes and remembers the docked primary sidebar width', () => { + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 1200 + }) + const { container, unmount } = render() + + try { + const sidebar = + container.querySelector('.sidebar') + const separator = screen.getByRole('separator', { + name: '调整主侧栏宽度' + }) + const setPointerCapture = vi.fn() + const releasePointerCapture = vi.fn() + Object.defineProperties(separator, { + setPointerCapture: { value: setPointerCapture }, + hasPointerCapture: { value: () => true }, + releasePointerCapture: { value: releasePointerCapture } + }) + + expect( + sidebar?.style.getPropertyValue('--primary-sidebar-width') + ).toBe('278px') + expect(separator).toHaveAttribute('aria-valuemin', '220') + expect(separator).toHaveAttribute('aria-valuemax', '420') + + fireEvent.pointerDown(separator, { + button: 0, + clientX: 320, + pointerId: 17 + }) + fireEvent.pointerMove(separator, { + clientX: 400, + pointerId: 17 + }) + + expect(setPointerCapture).toHaveBeenCalledWith(17) + expect(sidebar).toHaveClass('sidebar--resizing') + expect( + sidebar?.style.getPropertyValue('--primary-sidebar-width') + ).toBe('400px') + + fireEvent.pointerUp(separator, { pointerId: 17 }) + expect(releasePointerCapture).toHaveBeenCalledWith(17) + expect(sidebar).not.toHaveClass('sidebar--resizing') + expect( + localStorage.getItem('goodbuddy.primary-sidebar-width.v1') + ).toBe('400') + + fireEvent.keyDown(separator, { key: 'Home' }) + expect( + sidebar?.style.getPropertyValue('--primary-sidebar-width') + ).toBe('220px') + + fireEvent.keyDown(separator, { key: 'ArrowRight' }) + expect( + sidebar?.style.getPropertyValue('--primary-sidebar-width') + ).toBe('236px') + + fireEvent.keyDown(separator, { key: 'End' }) + expect( + sidebar?.style.getPropertyValue('--primary-sidebar-width') + ).toBe('420px') + expect(separator).toHaveAttribute('aria-valuenow', '420') + + unmount() + const persistedRender = render() + expect( + persistedRender.container + .querySelector('.sidebar') + ?.style.getPropertyValue('--primary-sidebar-width') + ).toBe('420px') + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalWidth + }) + } + }) + it('docks the assistant workbar without obscuring a narrow workspace', async () => { const originalWidth = window.innerWidth const { container } = render() @@ -9994,7 +10145,7 @@ describe('App', () => { ) expect( screen.queryByLabelText('切换助手工作栏') - ).not.toBeInTheDocument() + ).toBeInTheDocument() } finally { delete api.updates } @@ -10206,7 +10357,7 @@ describe('App', () => { ).toBeInTheDocument() expect( screen.queryByLabelText('切换助手工作栏') - ).not.toBeInTheDocument() + ).toBeInTheDocument() expect( screen.queryByRole('button', { name: /^专家角色:/u }) ).not.toBeInTheDocument() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f3ec3e5d..0e34a02d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -558,9 +558,66 @@ const storageKey = 'goodbuddy.conversations.v1' const activeProjectStorageKey = 'goodbuddy.active-project.v1' +const primarySidebarWidthStorageKey = + 'goodbuddy.primary-sidebar-width.v1' +const defaultPrimarySidebarWidth = 278 +const compactPrimarySidebarWidth = 236 +const minimumPrimarySidebarWidth = 220 +const maximumPrimarySidebarWidth = 420 +const minimumPrimaryWorkspaceWidth = 480 +const primarySidebarKeyboardResizeStep = 16 + const maxMessageContentLength = 1_000_000 const maxMessageBlocks = 500 +function getPrimarySidebarWidthLimits(viewportWidth: number): { + minimum: number + maximum: number +} { + const availableWidth = Math.max(0, Math.floor(viewportWidth)) + return { + minimum: minimumPrimarySidebarWidth, + maximum: Math.max( + minimumPrimarySidebarWidth, + Math.min( + maximumPrimarySidebarWidth, + availableWidth - minimumPrimaryWorkspaceWidth + ) + ) + } +} + +function clampPrimarySidebarWidth( + width: number, + viewportWidth: number +): number { + const limits = getPrimarySidebarWidthLimits(viewportWidth) + return Math.min( + limits.maximum, + Math.max(limits.minimum, Math.round(width)) + ) +} + +function loadPrimarySidebarWidth(): number { + const fallback = + window.innerWidth <= 1020 + ? compactPrimarySidebarWidth + : defaultPrimarySidebarWidth + try { + const persistedWidth = Number( + localStorage.getItem(primarySidebarWidthStorageKey) + ) + return clampPrimarySidebarWidth( + Number.isFinite(persistedWidth) && persistedWidth > 0 + ? persistedWidth + : fallback, + window.innerWidth + ) + } catch { + return clampPrimarySidebarWidth(fallback, window.innerWidth) + } +} + function appendMessageContentBlock( blocks: ConversationMessageBlock[] | undefined, type: 'text' | 'reasoning', @@ -2298,6 +2355,11 @@ function App(): React.JSX.Element { const [sidebarOpen, setSidebarOpen] = useState( () => window.innerWidth >= 900 ) + const [primarySidebarWidth, setPrimarySidebarWidth] = useState( + loadPrimarySidebarWidth + ) + const [primarySidebarResizing, setPrimarySidebarResizing] = + useState(false) const [assistantSidebarOpen, setAssistantSidebarOpen] = useState( () => window.innerWidth >= 1280 ) @@ -2647,6 +2709,10 @@ function App(): React.JSX.Element { >({}) const sidebarRef = useRef(null) const sidebarToggleRef = useRef(null) + const livePrimarySidebarWidthRef = useRef(primarySidebarWidth) + const primarySidebarResizePointerIdRef = useRef( + undefined + ) const assistantSidebarToggleRef = useRef(null) const conversationActionTriggerRefs = useRef( new Map() @@ -2673,6 +2739,71 @@ function App(): React.JSX.Element { setSidebarOpen(false) requestAnimationFrame(() => sidebarToggleRef.current?.focus()) }, []) + const resizePrimarySidebarFromClientX = useCallback( + (clientX: number, commit: boolean): void => { + const width = clampPrimarySidebarWidth( + clientX, + window.innerWidth + ) + livePrimarySidebarWidthRef.current = width + if (commit) { + setPrimarySidebarWidth(width) + return + } + sidebarRef.current?.style.setProperty( + '--primary-sidebar-width', + `${width}px` + ) + }, + [] + ) + const finishPrimarySidebarResize = useCallback( + (event: React.PointerEvent): void => { + if ( + primarySidebarResizePointerIdRef.current !== event.pointerId + ) { + return + } + primarySidebarResizePointerIdRef.current = undefined + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + setPrimarySidebarWidth(livePrimarySidebarWidthRef.current) + setPrimarySidebarResizing(false) + }, + [] + ) + const resizePrimarySidebarWithKeyboard = useCallback( + (event: React.KeyboardEvent): void => { + if (narrowWindow || !sidebarOpen) { + return + } + const limits = getPrimarySidebarWidthLimits(window.innerWidth) + const nextWidth = + event.key === 'Home' + ? limits.minimum + : event.key === 'End' + ? limits.maximum + : event.key === 'ArrowLeft' + ? primarySidebarWidth - + primarySidebarKeyboardResizeStep + : event.key === 'ArrowRight' + ? primarySidebarWidth + + primarySidebarKeyboardResizeStep + : undefined + if (nextWidth === undefined) { + return + } + event.preventDefault() + const width = clampPrimarySidebarWidth( + nextWidth, + window.innerWidth + ) + livePrimarySidebarWidthRef.current = width + setPrimarySidebarWidth(width) + }, + [narrowWindow, primarySidebarWidth, sidebarOpen] + ) const navigateFromSidebar = useCallback( (nextView: WorkspaceView, trigger: HTMLElement): void => { if (viewRef.current !== 'settings' && nextView === 'settings') { @@ -2788,12 +2919,30 @@ function App(): React.JSX.Element { return () => window.clearInterval(interval) }, [activeId, assistantTasks, knowledgeOperationCount, view]) + useEffect(() => { + livePrimarySidebarWidthRef.current = primarySidebarWidth + try { + localStorage.setItem( + primarySidebarWidthStorageKey, + String(primarySidebarWidth) + ) + } catch { + // The current width remains usable when browser storage is unavailable. + } + }, [primarySidebarWidth]) + useEffect(() => { const collapseSidebarAtNarrowWidth = (): void => { const narrow = window.innerWidth < 900 setNarrowWindow(narrow) if (narrow) { setSidebarOpen(false) + setPrimarySidebarResizing(false) + primarySidebarResizePointerIdRef.current = undefined + } else { + setPrimarySidebarWidth((current) => + clampPrimarySidebarWidth(current, window.innerWidth) + ) } } window.addEventListener('resize', collapseSidebarAtNarrowWidth) @@ -8033,6 +8182,9 @@ function App(): React.JSX.Element { ]) const mainSidebarOpen = narrowWindow && sidebarOpen + const canResizePrimarySidebar = sidebarOpen && !narrowWindow + const primarySidebarWidthLimits = + getPrimarySidebarWidthLimits(window.innerWidth) const backgroundIsolated = mainSidebarOpen const runtimeState = runtimeSwitching || @@ -8060,7 +8212,17 @@ function App(): React.JSX.Element { } aria-hidden={!sidebarOpen} aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined} - className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'} + className={ + sidebarOpen + ? `sidebar${ + primarySidebarResizing && + canResizePrimarySidebar + ? ' sidebar--resizing' + : '' + }` + : 'sidebar sidebar--closed' + } + id="primary-sidebar" inert={!sidebarOpen} onKeyDown={(event) => { if (mainSidebarOpen) { @@ -8069,6 +8231,11 @@ function App(): React.JSX.Element { }} ref={sidebarRef} role={narrowWindow && sidebarOpen ? 'dialog' : undefined} + style={ + { + '--primary-sidebar-width': `${primarySidebarWidth}px` + } as React.CSSProperties + } > + {sidebarOpen && ( +
{ + if ( + primarySidebarResizePointerIdRef.current === + event.pointerId + ) { + primarySidebarResizePointerIdRef.current = undefined + setPrimarySidebarWidth( + livePrimarySidebarWidthRef.current + ) + setPrimarySidebarResizing(false) + } + }} + onPointerCancel={finishPrimarySidebarResize} + onPointerDown={(event) => { + if ( + event.button !== 0 || + !canResizePrimarySidebar + ) { + return + } + event.preventDefault() + primarySidebarResizePointerIdRef.current = event.pointerId + event.currentTarget.setPointerCapture(event.pointerId) + resizePrimarySidebarFromClientX(event.clientX, true) + setPrimarySidebarResizing(true) + }} + onPointerMove={(event) => { + if ( + primarySidebarResizePointerIdRef.current !== + event.pointerId + ) { + return + } + if (!canResizePrimarySidebar) { + finishPrimarySidebarResize(event) + return + } + event.preventDefault() + resizePrimarySidebarFromClientX(event.clientX, false) + }} + onPointerUp={finishPrimarySidebarResize} + role="separator" + tabIndex={canResizePrimarySidebar ? 0 : -1} + /> + )} {sidebarOpen && ( - )} +
openCustomTaskDialog('current')} schedules={assistantSchedules} selectedTaskId={selectedAssistantTaskId} @@ -10682,9 +10907,7 @@ function App(): React.JSX.Element { onOpenWorkspaceEntry={openWorkspaceEntry} onRefreshChanges={refreshWorkspaceChanges} onTabChange={setAssistantSidebarTab} - open={ - assistantSidebarOpen && view === 'chat' - } + open={assistantSidebarOpen} restoreFocusRef={assistantSidebarToggleRef} tab={assistantSidebarTab} workspaceChanges={workspaceChanges} diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index 38898372..83fc7169 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -198,6 +198,7 @@ const getApplicationSettings = vi.fn<() => Promise>(async ( const onNotify = vi.fn() beforeEach(() => { + localStorage.clear() analysisEventListener = undefined getApplicationSettings.mockResolvedValue({ checkUpdatesOnStartup: false, @@ -528,6 +529,155 @@ describe('MagicNotesWorkspace', () => { expect(pane).toBeVisible() }) + it('hides, restores, and remembers the left list pane', async () => { + const firstRender = render( + + ) + + const pane = await screen.findByLabelText('笔记列表') + const hideButton = screen.getByRole('button', { + name: '隐藏左侧列表' + }) + expect(hideButton).toHaveAttribute('aria-expanded', 'true') + expect(pane).toBeVisible() + + fireEvent.click(hideButton) + expect(pane).not.toBeVisible() + expect( + screen.queryByRole('separator', { + name: '调整笔记列表与编辑区宽度' + }) + ).not.toBeInTheDocument() + expect( + JSON.parse( + localStorage.getItem('goodbuddy.magic-notes-layout.v1') ?? '{}' + ) + ).toMatchObject({ listPaneOpen: false }) + + firstRender.unmount() + render() + const restoredPane = await screen.findByLabelText('笔记列表') + expect(restoredPane).not.toBeVisible() + + const showButton = screen.getByRole('button', { + name: '显示左侧列表' + }) + expect(showButton).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(showButton) + expect(restoredPane).toBeVisible() + expect( + screen.getByRole('separator', { + name: '调整笔记列表与编辑区宽度' + }) + ).toBeInTheDocument() + }) + + it('resizes the left list pane with pointer and keyboard controls', async () => { + const originalInnerWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 1200 + }) + render() + + try { + await screen.findByText('记录正文') + const separator = screen.getByRole('separator', { + name: '调整笔记列表与编辑区宽度' + }) + const layout = + separator.closest('.magic-notes-layout') as HTMLElement + const setPointerCapture = vi.fn() + const releasePointerCapture = vi.fn() + Object.defineProperties(separator, { + setPointerCapture: { value: setPointerCapture }, + hasPointerCapture: { value: () => true }, + releasePointerCapture: { value: releasePointerCapture } + }) + + expect(separator).toHaveAttribute('aria-valuenow', '220') + expect(separator).toHaveAttribute('aria-valuemin', '180') + expect(separator).toHaveAttribute('aria-valuemax', '360') + + fireEvent.pointerDown(separator, { + button: 0, + clientX: 220, + pointerId: 13 + }) + fireEvent.pointerMove(separator, { + clientX: 350, + pointerId: 13 + }) + + expect(setPointerCapture).toHaveBeenCalledWith(13) + expect(layout).toHaveClass('magic-notes-layout--resizing') + expect(layout).toHaveClass('magic-notes-layout--list-resizing') + expect( + layout.style.getPropertyValue('--magic-notes-list-width') + ).toBe('350px') + + fireEvent.pointerUp(separator, { pointerId: 13 }) + expect(releasePointerCapture).toHaveBeenCalledWith(13) + expect(layout).not.toHaveClass('magic-notes-layout--resizing') + expect( + JSON.parse( + localStorage.getItem( + 'goodbuddy.magic-notes-layout.v1' + ) ?? '{}' + ) + ).toMatchObject({ listPaneWidth: 350 }) + + fireEvent.keyDown(separator, { key: 'Home' }) + expect( + layout.style.getPropertyValue('--magic-notes-list-width') + ).toBe('180px') + + fireEvent.keyDown(separator, { key: 'ArrowRight' }) + expect( + layout.style.getPropertyValue('--magic-notes-list-width') + ).toBe('196px') + + fireEvent.keyDown(separator, { key: 'End' }) + expect( + layout.style.getPropertyValue('--magic-notes-list-width') + ).toBe('360px') + expect(separator).toHaveAttribute('aria-valuenow', '360') + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth + }) + } + }) + + it('disables horizontal pane resizing in the narrow stacked layout', async () => { + const originalInnerWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 700 + }) + render() + + try { + await screen.findByText('记录正文') + const listSeparator = screen.getByRole('separator', { + name: '调整笔记列表与编辑区宽度' + }) + expect(listSeparator).toHaveAttribute('aria-disabled', 'true') + expect(listSeparator).toHaveAttribute('tabindex', '-1') + const aiSeparator = screen.getByRole('separator', { + name: '调整编辑区与 AI 评论宽度' + }) + expect(aiSeparator).toHaveAttribute('aria-disabled', 'true') + expect(aiSeparator).toHaveAttribute('tabindex', '-1') + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth + }) + } + }) + it('shows a concise empty state before a note has AI comments', async () => { let resolveDetail: | ((value: MagicNoteDetail) => void) diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index eedd6268..d3bad1da 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -8,6 +8,8 @@ import { FolderTree, Lightbulb, ListTodo, + PanelLeftClose, + PanelLeftOpen, PanelRightClose, PanelRightOpen, Pin, @@ -73,23 +75,107 @@ type DraftSwitchTarget = const defaultAiPaneWidth = 280 const minimumAiPaneWidth = 240 const maximumAiPaneWidth = 520 -const magicNotesListPaneWidth = 220 +const defaultMagicNotesListPaneWidth = 220 +const minimumMagicNotesListPaneWidth = 180 +const maximumMagicNotesListPaneWidth = 360 const minimumMagicNotesEditorWidth = 300 const magicNotesResizeHandleWidth = 9 -const aiPaneKeyboardResizeStep = 16 +const magicNotesPaneKeyboardResizeStep = 16 +const magicNotesLayoutStorageKey = + 'goodbuddy.magic-notes-layout.v1' -function getAiPaneWidthLimits(layoutWidth: number): { +type MagicNotesLayoutPreferences = { + listPaneOpen: boolean + listPaneWidth: number + aiPaneOpen: boolean + aiPaneWidth: number +} + +function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences { + const defaults = { + listPaneOpen: true, + listPaneWidth: defaultMagicNotesListPaneWidth, + aiPaneOpen: true, + aiPaneWidth: defaultAiPaneWidth + } + try { + const value = localStorage.getItem(magicNotesLayoutStorageKey) + if (!value || value.length > 10_000) { + return defaults + } + const parsed = JSON.parse(value) as Partial< + MagicNotesLayoutPreferences + > + return { + listPaneOpen: parsed.listPaneOpen !== false, + listPaneWidth: + typeof parsed.listPaneWidth === 'number' && + Number.isFinite(parsed.listPaneWidth) + ? Math.min( + maximumMagicNotesListPaneWidth, + Math.max( + minimumMagicNotesListPaneWidth, + parsed.listPaneWidth + ) + ) + : defaults.listPaneWidth, + aiPaneOpen: parsed.aiPaneOpen !== false, + aiPaneWidth: + typeof parsed.aiPaneWidth === 'number' && + Number.isFinite(parsed.aiPaneWidth) + ? Math.min( + maximumAiPaneWidth, + Math.max(minimumAiPaneWidth, parsed.aiPaneWidth) + ) + : defaults.aiPaneWidth + } + } catch { + return defaults + } +} + +function persistMagicNotesLayoutPreferences( + preferences: MagicNotesLayoutPreferences +): void { + try { + localStorage.setItem( + magicNotesLayoutStorageKey, + JSON.stringify(preferences) + ) + } catch { + // The in-memory layout remains usable when browser storage is unavailable. + } +} + +type MagicNotesPaneWidthLimits = { minimum: number maximum: number -} { +} + +function getMagicNotesPaneWidthLimits({ + adjacentPaneOpen, + adjacentPaneWidth, + layoutWidth, + maximum, + minimum +}: { + adjacentPaneOpen: boolean + adjacentPaneWidth: number + layoutWidth: number + maximum: number + minimum: number +}): MagicNotesPaneWidthLimits { + const reservedAdjacentWidth = adjacentPaneOpen + ? adjacentPaneWidth + magicNotesResizeHandleWidth + : 0 return { - minimum: minimumAiPaneWidth, + minimum, maximum: Math.max( - minimumAiPaneWidth, + minimum, Math.min( - maximumAiPaneWidth, + maximum, layoutWidth - - magicNotesListPaneWidth - + reservedAdjacentWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth ) @@ -97,9 +183,73 @@ function getAiPaneWidthLimits(layoutWidth: number): { } } -function clampAiPaneWidth(width: number, layoutWidth: number): number { - const limits = getAiPaneWidthLimits(layoutWidth) - return Math.min(limits.maximum, Math.max(limits.minimum, width)) +function clampMagicNotesPaneWidth( + width: number, + limits: MagicNotesPaneWidthLimits +): number { + return Math.round( + Math.min(limits.maximum, Math.max(limits.minimum, width)) + ) +} + +function getAiPaneWidthLimits( + layoutWidth: number, + listPaneWidth: number, + listPaneOpen: boolean +): MagicNotesPaneWidthLimits { + return getMagicNotesPaneWidthLimits({ + adjacentPaneOpen: listPaneOpen, + adjacentPaneWidth: listPaneWidth, + layoutWidth, + maximum: maximumAiPaneWidth, + minimum: minimumAiPaneWidth + }) +} + +function clampAiPaneWidth( + width: number, + layoutWidth: number, + listPaneWidth: number, + listPaneOpen: boolean +): number { + return clampMagicNotesPaneWidth( + width, + getAiPaneWidthLimits( + layoutWidth, + listPaneWidth, + listPaneOpen + ) + ) +} + +function getListPaneWidthLimits( + layoutWidth: number, + aiPaneWidth: number, + aiPaneOpen: boolean +): MagicNotesPaneWidthLimits { + return getMagicNotesPaneWidthLimits({ + adjacentPaneOpen: aiPaneOpen, + adjacentPaneWidth: aiPaneWidth, + layoutWidth, + maximum: maximumMagicNotesListPaneWidth, + minimum: minimumMagicNotesListPaneWidth + }) +} + +function clampListPaneWidth( + width: number, + layoutWidth: number, + aiPaneWidth: number, + aiPaneOpen: boolean +): number { + return clampMagicNotesPaneWidth( + width, + getListPaneWidthLimits( + layoutWidth, + aiPaneWidth, + aiPaneOpen + ) + ) } function noteSummary(note: MagicNoteDetail): MagicNoteSummary { @@ -349,8 +499,23 @@ export function MagicNotesWorkspace({ const [composerKey, setComposerKey] = useState(0) const [editingEntry, setEditingEntry] = useState() const [deletingEntryId, setDeletingEntryId] = useState('') - const [aiPaneOpen, setAiPaneOpen] = useState(true) - const [aiPaneWidth, setAiPaneWidth] = useState(defaultAiPaneWidth) + const initialLayoutPreferences = useMemo( + () => loadMagicNotesLayoutPreferences(), + [] + ) + const [listPaneOpen, setListPaneOpen] = useState( + initialLayoutPreferences.listPaneOpen + ) + const [listPaneWidth, setListPaneWidth] = useState( + initialLayoutPreferences.listPaneWidth + ) + const [listPaneResizing, setListPaneResizing] = useState(false) + const [aiPaneOpen, setAiPaneOpen] = useState( + initialLayoutPreferences.aiPaneOpen + ) + const [aiPaneWidth, setAiPaneWidth] = useState( + initialLayoutPreferences.aiPaneWidth + ) const [aiPaneResizing, setAiPaneResizing] = useState(false) const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState( window.innerWidth @@ -392,7 +557,9 @@ export function MagicNotesWorkspace({ const draftAnalysisContextRef = useRef(0) const lastDraftAnalysisStartedAtRef = useRef(0) const magicNotesLayoutRef = useRef(null) - const liveAiPaneWidthRef = useRef(defaultAiPaneWidth) + const liveListPaneWidthRef = useRef(listPaneWidth) + const listResizePointerIdRef = useRef(undefined) + const liveAiPaneWidthRef = useRef(aiPaneWidth) const aiResizePointerIdRef = useRef(undefined) const composerRef = useRef(null) const continueEditingRef = useRef(null) @@ -426,12 +593,14 @@ export function MagicNotesWorkspace({ ) const getLayoutBounds = useCallback((): { + left: number width: number right: number } => { const bounds = magicNotesLayoutRef.current?.getBoundingClientRect() const width = bounds?.width || window.innerWidth return { + left: bounds?.left ?? 0, width, right: bounds?.right || width } @@ -442,7 +611,9 @@ export function MagicNotesWorkspace({ const bounds = getLayoutBounds() const width = clampAiPaneWidth( bounds.right - clientX, - bounds.width + bounds.width, + liveListPaneWidthRef.current, + listPaneOpen ) liveAiPaneWidthRef.current = width if (commit) { @@ -454,7 +625,7 @@ export function MagicNotesWorkspace({ `${width}px` ) }, - [getLayoutBounds] + [getLayoutBounds, listPaneOpen] ) const finishAiPaneResize = useCallback( @@ -475,26 +646,106 @@ export function MagicNotesWorkspace({ const resizeAiPaneWithKeyboard = useCallback( (event: React.KeyboardEvent): void => { const bounds = getLayoutBounds() - const limits = getAiPaneWidthLimits(bounds.width) + const limits = getAiPaneWidthLimits( + bounds.width, + listPaneWidth, + listPaneOpen + ) const nextWidth = event.key === 'Home' ? limits.minimum : event.key === 'End' ? limits.maximum : event.key === 'ArrowLeft' - ? aiPaneWidth + aiPaneKeyboardResizeStep + ? aiPaneWidth + magicNotesPaneKeyboardResizeStep : event.key === 'ArrowRight' - ? aiPaneWidth - aiPaneKeyboardResizeStep + ? aiPaneWidth - magicNotesPaneKeyboardResizeStep : undefined if (nextWidth === undefined) { return } event.preventDefault() - const width = clampAiPaneWidth(nextWidth, bounds.width) + const width = clampAiPaneWidth( + nextWidth, + bounds.width, + listPaneWidth, + listPaneOpen + ) liveAiPaneWidthRef.current = width setAiPaneWidth(width) }, - [aiPaneWidth, getLayoutBounds] + [aiPaneWidth, getLayoutBounds, listPaneOpen, listPaneWidth] + ) + + const resizeListPaneFromClientX = useCallback( + (clientX: number, commit: boolean): void => { + const bounds = getLayoutBounds() + const width = clampListPaneWidth( + clientX - bounds.left, + bounds.width, + liveAiPaneWidthRef.current, + aiPaneOpen + ) + liveListPaneWidthRef.current = width + if (commit) { + setListPaneWidth(width) + return + } + magicNotesLayoutRef.current?.style.setProperty( + '--magic-notes-list-width', + `${width}px` + ) + }, + [aiPaneOpen, getLayoutBounds] + ) + + const finishListPaneResize = useCallback( + (event: React.PointerEvent): void => { + if (listResizePointerIdRef.current !== event.pointerId) { + return + } + listResizePointerIdRef.current = undefined + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + setListPaneWidth(liveListPaneWidthRef.current) + setListPaneResizing(false) + }, + [] + ) + + const resizeListPaneWithKeyboard = useCallback( + (event: React.KeyboardEvent): void => { + const bounds = getLayoutBounds() + const limits = getListPaneWidthLimits( + bounds.width, + aiPaneWidth, + aiPaneOpen + ) + const nextWidth = + event.key === 'Home' + ? limits.minimum + : event.key === 'End' + ? limits.maximum + : event.key === 'ArrowLeft' + ? listPaneWidth - magicNotesPaneKeyboardResizeStep + : event.key === 'ArrowRight' + ? listPaneWidth + magicNotesPaneKeyboardResizeStep + : undefined + if (nextWidth === undefined) { + return + } + event.preventDefault() + const width = clampListPaneWidth( + nextWidth, + bounds.width, + aiPaneWidth, + aiPaneOpen + ) + liveListPaneWidthRef.current = width + setListPaneWidth(width) + }, + [aiPaneOpen, aiPaneWidth, getLayoutBounds, listPaneWidth] ) useEffect( @@ -512,33 +763,62 @@ export function MagicNotesWorkspace({ [] ) + useEffect(() => { + persistMagicNotesLayoutPreferences({ + listPaneOpen, + listPaneWidth, + aiPaneOpen, + aiPaneWidth + }) + }, [aiPaneOpen, aiPaneWidth, listPaneOpen, listPaneWidth]) + useEffect(() => { const layout = magicNotesLayoutRef.current const updateLayoutWidth = (): void => { const width = layout?.getBoundingClientRect().width || window.innerWidth setMagicNotesLayoutWidth(width) + if (width > 720) { + setListPaneWidth((current) => { + const next = clampListPaneWidth( + current, + width, + liveAiPaneWidthRef.current, + aiPaneOpen + ) + liveListPaneWidthRef.current = next + return next + }) + } if (width > 800) { setAiPaneWidth((current) => { - const next = clampAiPaneWidth(current, width) + const next = clampAiPaneWidth( + current, + width, + liveListPaneWidthRef.current, + listPaneOpen + ) liveAiPaneWidthRef.current = next return next }) } } updateLayoutWidth() - window.addEventListener('resize', updateLayoutWidth) const observer = layout && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateLayoutWidth) : undefined if (layout && observer) { observer.observe(layout) + } else { + window.addEventListener('resize', updateLayoutWidth) } return () => { - window.removeEventListener('resize', updateLayoutWidth) + if (!observer) { + window.removeEventListener('resize', updateLayoutWidth) + } observer?.disconnect() } - }, []) + }, [aiPaneOpen, listPaneOpen]) const notifyError = useCallback( (error: unknown): void => @@ -1136,8 +1416,24 @@ export function MagicNotesWorkspace({ .reverse(), [detail] ) - const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth) - const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800 + const listPaneWidthLimits = getListPaneWidthLimits( + magicNotesLayoutWidth, + aiPaneWidth, + aiPaneOpen + ) + const aiPaneWidthLimits = getAiPaneWidthLimits( + magicNotesLayoutWidth, + listPaneWidth, + listPaneOpen + ) + const canResizeListPane = + listPaneOpen && + magicNotesLayoutWidth > 720 && + listPaneWidthLimits.maximum > listPaneWidthLimits.minimum + const canResizeAiPane = + aiPaneOpen && + magicNotesLayoutWidth > 800 && + aiPaneWidthLimits.maximum > aiPaneWidthLimits.minimum const submitCreateNote = async (): Promise => { const title = newTitle.trim() @@ -1419,6 +1715,24 @@ export function MagicNotesWorkspace({ + - ))} - - -
+ + instance.appId === 'tasks' && approvals.length > 0 ? ( + + {approvals.length} + + ) : null + } + renderPanel={(instance) => ( +
{actionError ? (

{actionError}

) : null} - {tab === 'tasks' && ( + {instance.appId === 'terminal' && instance.targetRef ? ( + + {t('sidebar.terminal.loading')} +
+ } + > + + setWorkbarInstances((current) => + current.map((candidate) => + candidate.id === instance.id + ? { ...candidate, title } + : candidate + ) + ) + } + onSessionChange={(snapshot) => { + setTerminalSessionIds((current) => + current[instance.id] === snapshot.sessionId + ? current + : { + ...current, + [instance.id]: snapshot.sessionId + } + ) + setTerminalSnapshots((current) => ({ + ...current, + [instance.id]: snapshot + })) + }} + sessionId={terminalSessionIds[instance.id]} + target={instance.targetRef} + title={instance.title} + /> + + ) : null} + {instance.appId === 'tasks' && (

@@ -812,7 +1162,7 @@ export function RightAssistantSidebar({

)} - {tab === 'workspace' && ( + {instance.appId === 'workspace' && ( currentWorkspacePreview ? (
@@ -1043,7 +1393,7 @@ export function RightAssistantSidebar({ ) )} - {tab === 'browser' && ( + {instance.appId === 'browser' && (
@@ -1149,7 +1499,54 @@ export function RightAssistantSidebar({ )}
)} - + + )} + /> + {terminalCloseConfirmation ? ( +
+
{ + if ( + event.key === 'Escape' && + !terminalCloseConfirmation.closing + ) { + event.preventDefault() + cancelTerminalClose() + } + }} + role="alertdialog" + > +

{t('sidebar.terminal.closeDialog.title')}

+

{t('sidebar.terminal.closeDialog.description')}

+ {terminalCloseConfirmation.error ? ( +

{terminalCloseConfirmation.error}

+ ) : null} +
+ + +
+
+
+ ) : null} ) } diff --git a/src/renderer/src/TerminalPanel.test.tsx b/src/renderer/src/TerminalPanel.test.tsx new file mode 100644 index 00000000..977699ec --- /dev/null +++ b/src/renderer/src/TerminalPanel.test.tsx @@ -0,0 +1,458 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalEvent, + TerminalSnapshot +} from '../../shared/terminal-contracts' +import { + TerminalPanel, + resolveTerminalFontFamily, + type TerminalAdapter, + type TerminalEmulator, + type TerminalFactory +} from './TerminalPanel' + +vi.mock('@xterm/xterm', () => ({ Terminal: class MockTerminal {} })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: class MockFitAddon {} })) +vi.mock('@xterm/addon-search', () => ({ + SearchAddon: class MockSearchAddon {} +})) + +const sessionId = '00000000-0000-4000-8000-000000000201' + +function snapshot( + overrides: Partial = {} +): TerminalSnapshot { + return { + sessionId, + target: { type: 'local' }, + targetLabel: '本机', + title: '终端 · 本机 1', + state: 'running', + shell: 'PowerShell 7', + workingDirectory: 'D:\\workspace', + size: { cols: 80, rows: 24 }, + lastSequence: 0, + exit: null, + error: null, + ...overrides + } +} + +type EmulatorHarness = { + factory: TerminalFactory + terminal: TerminalEmulator + emitInput: (data: string) => void + write: ReturnType + dispose: ReturnType + fit: ReturnType +} + +function emulatorHarness(): EmulatorHarness { + let inputListener: ((data: string) => void) | undefined + const write = vi.fn((_: string, callback?: () => void) => callback?.()) + const dispose = vi.fn() + const fit = vi.fn() + const terminal: TerminalEmulator = { + cols: 80, + rows: 24, + open: vi.fn(), + onData: vi.fn((listener: (data: string) => void) => { + inputListener = listener + return { dispose: vi.fn() } + }), + write, + clear: vi.fn(), + selectAll: vi.fn(), + getSelection: vi.fn(() => 'selected output'), + focus: vi.fn(), + dispose + } + return { + terminal, + write, + dispose, + fit, + emitInput: (data) => inputListener?.(data), + factory: () => ({ + terminal, + fitAddon: { fit }, + searchAddon: { findNext: vi.fn(() => true) } + }) + } +} + +type AdapterHarness = { + adapter: TerminalAdapter + emit: (event: TerminalEvent) => void +} + +function adapterHarness( + createResult: TerminalSnapshot = snapshot() +): AdapterHarness { + let eventListener: ((event: TerminalEvent) => void) | undefined + const adapter: TerminalAdapter = { + create: vi.fn(async () => createResult), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + close: vi.fn(async () => + snapshot({ state: 'exited', exit: { exitCode: 0, signal: null } }) + ), + getSnapshot: vi.fn(async () => createResult), + ack: vi.fn(async () => undefined), + subscribe: vi.fn((listener) => { + eventListener = listener + return vi.fn() + }) + } + return { + adapter, + emit: (event) => eventListener?.(event) + } +} + +type ResizeObserverHarness = { + trigger: (width?: number, height?: number) => void + disconnect: ReturnType +} + +let resizeObserver: ResizeObserverHarness + +beforeEach(() => { + const disconnect = vi.fn() + class MockResizeObserver { + private readonly callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + resizeObserver = { + disconnect, + trigger: (width = 800, height = 480) => + this.callback( + [ + { + contentRect: { + width, + height + } as DOMRectReadOnly + } as ResizeObserverEntry + ], + this as unknown as ResizeObserver + ) + } + } + + observe(): void {} + unobserve(): void {} + disconnect = disconnect + } + vi.stubGlobal('ResizeObserver', MockResizeObserver) +}) + +afterEach(() => { + cleanup() + document.documentElement.style.removeProperty('--font-family-mono') + vi.unstubAllGlobals() +}) + +describe('TerminalPanel', () => { + it('creates only after the first valid fitted size and forwards input', async () => { + const backend = adapterHarness() + const emulator = emulatorHarness() + render( + + ) + + resizeObserver.trigger(0, 0) + expect(backend.adapter.create).not.toHaveBeenCalled() + resizeObserver.trigger() + + await waitFor(() => + expect(backend.adapter.create).toHaveBeenCalledWith({ + target: { type: 'local' }, + cols: 80, + rows: 24 + }) + ) + expect(screen.getByText('PowerShell 7')).toBeInTheDocument() + + emulator.emitInput('Get-Location\r') + await waitFor(() => + expect(backend.adapter.write).toHaveBeenCalledWith({ + sessionId, + data: 'Get-Location\r' + }) + ) + }) + + it('deduplicates resize, consumes ordered events, and ACKs output', async () => { + const backend = adapterHarness() + const emulator = emulatorHarness() + render( + + ) + resizeObserver.trigger() + await waitFor(() => + expect(backend.adapter.create).toHaveBeenCalledOnce() + ) + + resizeObserver.trigger(700, 400) + resizeObserver.trigger(600, 300) + expect(backend.adapter.resize).not.toHaveBeenCalled() + + backend.emit({ + sessionId, + sequence: 1, + type: 'output', + data: 'hello' + }) + backend.emit({ + sessionId, + sequence: 1, + type: 'output', + data: 'duplicate' + }) + await waitFor(() => + expect(emulator.write).toHaveBeenCalledWith( + 'hello', + expect.any(Function) + ) + ) + expect(emulator.write).not.toHaveBeenCalledWith( + 'duplicate', + expect.anything() + ) + Object.defineProperty(emulator.terminal, 'cols', { value: 100 }) + Object.defineProperty(emulator.terminal, 'rows', { value: 30 }) + resizeObserver.trigger() + resizeObserver.trigger() + await waitFor(() => + expect(backend.adapter.resize).toHaveBeenCalledOnce() + ) + expect(backend.adapter.resize).toHaveBeenCalledWith({ + sessionId, + cols: 100, + rows: 30 + }) + + backend.emit({ + sessionId, + sequence: 2, + type: 'state', + state: 'interrupted' + }) + await waitFor(() => + expect(backend.adapter.ack).toHaveBeenLastCalledWith({ + sessionId, + sequence: 2 + }) + ) + expect(screen.getAllByText('连接已中断')).not.toHaveLength(0) + }) + + it('unsubscribes and destroys the emulator without closing the session', async () => { + const backend = adapterHarness() + const emulator = emulatorHarness() + const unsubscribe = vi.fn() + vi.mocked(backend.adapter.subscribe).mockImplementation((listener) => { + void listener + return unsubscribe + }) + const view = render( + + ) + await waitFor(() => + expect(backend.adapter.getSnapshot).toHaveBeenCalledWith({ + sessionId + }) + ) + + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(emulator.dispose).toHaveBeenCalledOnce() + expect(resizeObserver.disconnect).toHaveBeenCalledOnce() + expect(backend.adapter.close).not.toHaveBeenCalled() + }) + + it('leaves session closing to the workbar tab action', async () => { + const backend = adapterHarness() + const emulator = emulatorHarness() + render( + + ) + await screen.findByText('PowerShell 7') + + expect( + screen.queryByRole('button', { name: '关闭终端会话' }) + ).not.toBeInTheDocument() + expect(backend.adapter.close).not.toHaveBeenCalled() + }) + + it('renders and ACKs startup events included in the create snapshot sequence', async () => { + const backend = adapterHarness(snapshot({ lastSequence: 2 })) + const emulator = emulatorHarness() + render( + + ) + resizeObserver.trigger() + await waitFor(() => + expect(backend.adapter.create).toHaveBeenCalledOnce() + ) + + backend.emit({ + sessionId, + sequence: 1, + type: 'output', + data: 'startup prompt' + }) + backend.emit({ + sessionId, + sequence: 2, + type: 'state', + state: 'running' + }) + + await waitFor(() => + expect(emulator.write).toHaveBeenCalledWith( + 'startup prompt', + expect.any(Function) + ) + ) + expect(backend.adapter.ack).toHaveBeenLastCalledWith({ + sessionId, + sequence: 2 + }) + }) + + it('keeps one emulator when callback identities change', async () => { + const backend = adapterHarness() + const emulator = emulatorHarness() + const factory = vi.fn(emulator.factory) + const firstCallback = vi.fn() + const view = render( + + ) + resizeObserver.trigger() + await waitFor(() => + expect(backend.adapter.create).toHaveBeenCalledOnce() + ) + + const latestCallback = vi.fn() + view.rerender( + + ) + backend.emit({ + sessionId, + sequence: 1, + type: 'state', + state: 'interrupted' + }) + + await waitFor(() => + expect(latestCallback).toHaveBeenCalledWith( + expect.objectContaining({ state: 'interrupted' }) + ) + ) + expect(factory).toHaveBeenCalledOnce() + expect(emulator.dispose).not.toHaveBeenCalled() + }) + + it('closes a session whose create result arrives after unmount', async () => { + let resolveCreate: + | ((value: TerminalSnapshot) => void) + | undefined + const backend = adapterHarness() + vi.mocked(backend.adapter.create).mockImplementation( + () => + new Promise((resolve) => { + resolveCreate = resolve + }) + ) + const emulator = emulatorHarness() + const view = render( + + ) + resizeObserver.trigger() + await waitFor(() => + expect(backend.adapter.create).toHaveBeenCalledOnce() + ) + + view.unmount() + resolveCreate?.(snapshot()) + await waitFor(() => + expect(backend.adapter.close).toHaveBeenCalledWith({ sessionId }) + ) + }) + + it('uses the resolved cross-platform monospace font stack', () => { + document.documentElement.style.setProperty( + '--font-family-mono', + '"Test Mono", monospace' + ) + expect(resolveTerminalFontFamily()).toBe( + '"Test Mono", monospace' + ) + }) + + it('keeps creation failures visible and allows retrying', async () => { + const backend = adapterHarness() + vi.mocked(backend.adapter.create) + .mockRejectedValueOnce(new Error('Shell 启动失败')) + .mockResolvedValueOnce(snapshot()) + const emulator = emulatorHarness() + render( + + ) + resizeObserver.trigger() + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Shell 启动失败' + ) + expect(screen.getAllByText('连接失败')).not.toHaveLength(0) + fireEvent.click(screen.getByRole('button', { name: '重试' })) + await waitFor(() => + expect(backend.adapter.create).toHaveBeenCalledTimes(2) + ) + expect(await screen.findAllByText('已连接')).not.toHaveLength(0) + }) +}) diff --git a/src/renderer/src/TerminalPanel.tsx b/src/renderer/src/TerminalPanel.tsx new file mode 100644 index 00000000..5d78fb63 --- /dev/null +++ b/src/renderer/src/TerminalPanel.tsx @@ -0,0 +1,786 @@ +import { SearchAddon } from '@xterm/addon-search' +import { FitAddon } from '@xterm/addon-fit' +import { WebglAddon } from '@xterm/addon-webgl' +import { Terminal } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' +import { + Clipboard, + Copy, + Eraser, + Pencil, + RefreshCw, + Search +} from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState +} from 'react' +import type { DesktopApi } from '../../shared/contracts' +import type { + TerminalEvent, + TerminalSessionId, + TerminalSnapshot, + TerminalTarget +} from '../../shared/terminal-contracts' +import './terminal-panel.css' + +export type TerminalAdapter = Omit & { + subscribe: DesktopApi['terminal']['onEvent'] +} + +type Disposable = { dispose: () => void } + +export type TerminalEmulator = { + readonly cols: number + readonly rows: number + open: (element: HTMLElement) => void + onData: (listener: (data: string) => void) => Disposable + write: (data: string, callback?: () => void) => void + clear: () => void + selectAll: () => void + getSelection: () => string + focus: () => void + dispose: () => void +} + +export type TerminalFitAddon = { + fit: () => void +} + +export type TerminalSearchAddon = { + findNext: (term: string) => boolean +} + +export type TerminalFactoryResult = { + terminal: TerminalEmulator + fitAddon: TerminalFitAddon + searchAddon: TerminalSearchAddon + activateRenderer?: () => void +} + +export type TerminalFactory = () => TerminalFactoryResult + +export type TerminalPanelProps = { + adapter: TerminalAdapter + target: TerminalTarget + sessionId?: TerminalSessionId + title?: string + terminalFactory?: TerminalFactory + onSessionChange?: (snapshot: TerminalSnapshot) => void + onRename?: (title: string) => void +} + +const announcedStates = new Set([ + 'running', + 'exited', + 'interrupted', + 'failed' +]) + +const fallbackTerminalFontFamily = [ + '"Cascadia Mono"', + '"Cascadia Code"', + '"SFMono-Regular"', + 'Menlo', + 'Monaco', + 'Consolas', + '"Noto Sans Mono"', + '"DejaVu Sans Mono"', + '"Liberation Mono"', + 'monospace' +].join(', ') + +export function resolveTerminalFontFamily(): string { + const configured = getComputedStyle(document.documentElement) + .getPropertyValue('--font-family-mono') + .trim() + return configured || fallbackTerminalFontFamily +} + +function createDefaultTerminal(): TerminalFactoryResult { + const terminal = new Terminal({ + cursorBlink: true, + scrollback: 10_000, + fontFamily: resolveTerminalFontFamily(), + fontSize: 13, + rescaleOverlappingGlyphs: true + }) + const fitAddon = new FitAddon() + const searchAddon = new SearchAddon() + terminal.loadAddon(fitAddon) + terminal.loadAddon(searchAddon) + return { + terminal, + fitAddon, + searchAddon, + activateRenderer: () => { + const webglAddon = new WebglAddon() + const contextLossSubscription = webglAddon.onContextLoss(() => { + contextLossSubscription.dispose() + webglAddon.dispose() + }) + try { + terminal.loadAddon(webglAddon) + } catch { + contextLossSubscription.dispose() + webglAddon.dispose() + } + } + } +} + +function failureMessage(reason: unknown, fallback: string): string { + return reason instanceof Error && reason.message + ? reason.message + : fallback +} + +function isValidSize( + cols: number, + rows: number +): boolean { + return cols >= 2 && rows >= 1 +} + +export function TerminalPanel({ + adapter, + target, + sessionId, + title, + terminalFactory = createDefaultTerminal, + onSessionChange, + onRename +}: TerminalPanelProps): React.JSX.Element { + const { t } = useTranslation('workspace') + const searchInputId = useId() + const titleInputId = useId() + const hostRef = useRef(null) + const emulatorRef = useRef(undefined) + const fitAddonRef = useRef(undefined) + const searchAddonRef = useRef( + undefined + ) + const activeSessionRef = useRef( + undefined + ) + const snapshotRef = useRef(undefined) + const mountedRef = useRef(false) + const acceptedSequenceRef = useRef(0) + const eventQueueRef = useRef(Promise.resolve()) + const lastSizeRef = useRef< + { cols: number; rows: number } | undefined + >(undefined) + const operationGenerationRef = useRef(0) + const onSessionChangeRef = useRef(onSessionChange) + const onRenameRef = useRef(onRename) + const translationRef = useRef(t) + const [snapshot, setSnapshot] = useState< + TerminalSnapshot | undefined + >(undefined) + const [error, setError] = useState(undefined) + const [announcement, setAnnouncement] = useState('') + const [searchOpen, setSearchOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [renaming, setRenaming] = useState(false) + const [draftTitle, setDraftTitle] = useState(title ?? '') + const [hasMeasuredSize, setHasMeasuredSize] = useState(false) + + useLayoutEffect(() => { + onSessionChangeRef.current = onSessionChange + onRenameRef.current = onRename + translationRef.current = t + }, [onRename, onSessionChange, t]) + + const publishSnapshot = useCallback((next: TerminalSnapshot): void => { + snapshotRef.current = next + setSnapshot(next) + onSessionChangeRef.current?.(next) + if (announcedStates.has(next.state)) { + setAnnouncement( + translationRef.current( + `sidebar.terminal.states.${next.state}` + ) + ) + } + }, []) + + const consumeEvent = useCallback( + async (event: TerminalEvent): Promise => { + if ( + !mountedRef.current || + event.sessionId !== activeSessionRef.current || + event.sequence <= acceptedSequenceRef.current + ) { + return + } + acceptedSequenceRef.current = event.sequence + + if (event.type === 'output') { + await new Promise((resolve) => { + emulatorRef.current?.write(event.data, resolve) + if (!emulatorRef.current) { + resolve() + } + }) + } else { + const current = snapshotRef.current + if (current) { + if (event.type === 'state') { + publishSnapshot({ ...current, state: event.state }) + } else if (event.type === 'exit') { + publishSnapshot({ + ...current, + state: 'exited', + exit: event.exit, + error: null + }) + } else { + publishSnapshot({ + ...current, + state: 'failed', + error: event.error, + exit: null + }) + setError(event.error.message) + } + } + } + + if (mountedRef.current) { + try { + await adapter.ack({ + sessionId: event.sessionId, + sequence: event.sequence + }) + } catch (reason) { + if (mountedRef.current) { + setError( + failureMessage( + reason, + translationRef.current('sidebar.terminal.errors.ack') + ) + ) + } + } + } + }, + [adapter, publishSnapshot] + ) + + useEffect(() => { + mountedRef.current = true + const { + terminal, + fitAddon, + searchAddon, + activateRenderer + } = terminalFactory() + emulatorRef.current = terminal + fitAddonRef.current = fitAddon + searchAddonRef.current = searchAddon + if (hostRef.current) { + terminal.open(hostRef.current) + activateRenderer?.() + } + + const inputSubscription = terminal.onData((data) => { + const current = snapshotRef.current + if (!current || current.state !== 'running') { + return + } + void adapter + .write({ sessionId: current.sessionId, data }) + .catch((reason: unknown) => { + if (mountedRef.current) { + setError( + failureMessage( + reason, + translationRef.current('sidebar.terminal.errors.write') + ) + ) + } + }) + }) + const unsubscribe = adapter.subscribe((event) => { + eventQueueRef.current = eventQueueRef.current.then( + () => consumeEvent(event), + () => consumeEvent(event) + ) + }) + + return () => { + mountedRef.current = false + operationGenerationRef.current += 1 + unsubscribe() + inputSubscription.dispose() + terminal.dispose() + emulatorRef.current = undefined + fitAddonRef.current = undefined + searchAddonRef.current = undefined + } + }, [adapter, consumeEvent, terminalFactory]) + + const attachSession = useCallback( + async ( + id: TerminalSessionId, + generation: number + ): Promise => { + try { + const next = await adapter.getSnapshot({ sessionId: id }) + if ( + !mountedRef.current || + operationGenerationRef.current !== generation + ) { + return + } + activeSessionRef.current = next.sessionId + acceptedSequenceRef.current = next.lastSequence + publishSnapshot(next) + setError(next.error?.message) + emulatorRef.current?.focus() + } catch (reason) { + if ( + mountedRef.current && + operationGenerationRef.current === generation + ) { + setError( + failureMessage( + reason, + translationRef.current('sidebar.terminal.errors.snapshot') + ) + ) + setAnnouncement( + translationRef.current('sidebar.terminal.states.failed') + ) + } + } + }, + [adapter, publishSnapshot] + ) + + useEffect(() => { + if (!sessionId) { + return + } + const generation = ++operationGenerationRef.current + void attachSession(sessionId, generation) + }, [attachSession, sessionId]) + + const createSession = useCallback( + async (cols: number, rows: number): Promise => { + const generation = ++operationGenerationRef.current + setError(undefined) + try { + const next = await adapter.create({ target, cols, rows }) + if ( + !mountedRef.current || + operationGenerationRef.current !== generation + ) { + try { + await adapter.close({ sessionId: next.sessionId }) + } catch { + // The owning tab is gone; Main shutdown cleanup remains the fallback. + } + return + } + activeSessionRef.current = next.sessionId + acceptedSequenceRef.current = 0 + publishSnapshot(next) + setError(next.error?.message) + emulatorRef.current?.focus() + } catch (reason) { + if ( + mountedRef.current && + operationGenerationRef.current === generation + ) { + setError( + failureMessage( + reason, + translationRef.current('sidebar.terminal.errors.create') + ) + ) + setAnnouncement( + translationRef.current('sidebar.terminal.states.failed') + ) + } + } + }, + [adapter, publishSnapshot, target] + ) + + useEffect(() => { + const host = hostRef.current + if (!host) { + return + } + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if ( + !entry || + entry.contentRect.width <= 0 || + entry.contentRect.height <= 0 + ) { + return + } + fitAddonRef.current?.fit() + const emulator = emulatorRef.current + if ( + !emulator || + !isValidSize(emulator.cols, emulator.rows) + ) { + return + } + const size = { cols: emulator.cols, rows: emulator.rows } + if ( + lastSizeRef.current?.cols === size.cols && + lastSizeRef.current.rows === size.rows + ) { + return + } + lastSizeRef.current = size + setHasMeasuredSize(true) + + const current = snapshotRef.current + if (!current && !sessionId) { + void createSession(size.cols, size.rows) + return + } + if (current && current.state === 'running') { + void adapter + .resize({ sessionId: current.sessionId, ...size }) + .catch((reason: unknown) => { + if (mountedRef.current) { + setError( + failureMessage( + reason, + translationRef.current( + 'sidebar.terminal.errors.resize' + ) + ) + ) + } + }) + } + }) + observer.observe(host) + return () => observer.disconnect() + }, [adapter, createSession, sessionId]) + + const runSearch = (): void => { + if (searchTerm) { + searchAddonRef.current?.findNext(searchTerm) + emulatorRef.current?.focus() + } + } + + const copySelection = async (): Promise => { + const value = emulatorRef.current?.getSelection() ?? '' + if (!value) { + return + } + try { + await navigator.clipboard.writeText(value) + } catch (reason) { + setError( + failureMessage( + reason, + translationRef.current('sidebar.terminal.errors.copy') + ) + ) + } + } + + const paste = async (): Promise => { + const current = snapshotRef.current + if (!current || current.state !== 'running') { + return + } + try { + const data = await navigator.clipboard.readText() + if (data) { + await adapter.write({ sessionId: current.sessionId, data }) + } + emulatorRef.current?.focus() + } catch (reason) { + setError( + failureMessage( + reason, + translationRef.current('sidebar.terminal.errors.paste') + ) + ) + } + } + + const reopenSession = async (): Promise => { + const size = lastSizeRef.current + if (!size) { + return + } + const current = snapshotRef.current + if (current) { + try { + await adapter.close({ sessionId: current.sessionId }) + } catch (reason) { + if (mountedRef.current) { + setError( + failureMessage( + reason, + translationRef.current( + 'sidebar.terminal.errors.closePrevious' + ) + ) + ) + } + return + } + } + if (!mountedRef.current) { + return + } + emulatorRef.current?.clear() + activeSessionRef.current = undefined + acceptedSequenceRef.current = 0 + snapshotRef.current = undefined + setSnapshot(undefined) + setError(undefined) + await createSession(size.cols, size.rows) + } + + const submitRename = (): void => { + const nextTitle = draftTitle.trim() + if (!nextTitle) { + return + } + onRenameRef.current?.(nextTitle) + setRenaming(false) + emulatorRef.current?.focus() + } + + const canReopen = + snapshot?.state === 'exited' || + snapshot?.state === 'interrupted' || + snapshot?.state === 'failed' + const location = + snapshot?.targetLabel ?? + (target.type === 'local' + ? t('sidebar.terminal.location.local') + : t('sidebar.terminal.location.target')) + + return ( +
+
+ + + + + + {onRename ? ( + + ) : null} + + {canReopen ? ( + + ) : null} +
+ + {searchOpen ? ( +
{ + event.preventDefault() + runSearch() + }} + > + + setSearchTerm(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setSearchOpen(false) + emulatorRef.current?.focus() + } + }} + /> + +
+ ) : null} + + {renaming ? ( +
{ + event.preventDefault() + submitRename() + }} + > + + setDraftTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setRenaming(false) + emulatorRef.current?.focus() + } + }} + /> + + +
+ ) : null} + + {error ? ( +
+ {error} + {!snapshot ? ( + + ) : null} +
+ ) : null} + +
+ +
+ {location} + + {snapshot?.workingDirectory ?? + t('sidebar.terminal.status.readingDirectory')} + + + {snapshot?.shell ?? + t('sidebar.terminal.status.readingShell')} + + + {snapshot + ? t(`sidebar.terminal.states.${snapshot.state}`) + : error + ? t('sidebar.terminal.states.failed') + : t('sidebar.terminal.states.creating')} + {snapshot?.state === 'exited' && snapshot.exit?.exitCode !== null + ? t('sidebar.terminal.status.exitCode', { + code: snapshot.exit?.exitCode + }) + : ''} + +
+
+ {announcement} +
+
+ ) +} diff --git a/src/renderer/src/WorkbarShell.test.tsx b/src/renderer/src/WorkbarShell.test.tsx new file mode 100644 index 00000000..c0719dfe --- /dev/null +++ b/src/renderer/src/WorkbarShell.test.tsx @@ -0,0 +1,240 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within +} from '@testing-library/react' +import { + useState, + type ReactNode +} from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + WorkbarTabInstance, + WorkbarTargetRef +} from '../../shared/workbar-contracts' +import { + DEFAULT_WORKBAR_INSTANCES, + WorkbarShell, + type WorkbarInstanceCreateRequest +} from './WorkbarShell' + +afterEach(cleanup) + +const terminalOne: WorkbarTabInstance = { + id: '20000000-0000-4000-8000-000000000001', + appId: 'terminal', + title: '终端 · 本机 1', + targetRef: { type: 'local' } +} + +function ControlledShell({ + initialInstances = DEFAULT_WORKBAR_INSTANCES, + onCreate = vi.fn(), + onTerminalTarget = vi.fn((): WorkbarTargetRef => ({ + type: 'local' + })) +}: { + initialInstances?: readonly WorkbarTabInstance[] + onCreate?: (request: WorkbarInstanceCreateRequest) => void + onTerminalTarget?: () => + | WorkbarTargetRef + | Promise +}): ReactNode { + const [instances, setInstances] = useState(initialInstances) + const [activeId, setActiveId] = useState( + initialInstances[0]?.id ?? null + ) + + return ( + + setInstances((current) => + current.filter((candidate) => candidate.id !== instance.id) + ) + } + onCreateInstance={onCreate} + onResolveTerminalTarget={onTerminalTarget} + renderPanel={(instance) =>

{instance.title}内容

} + /> + ) +} + +describe('WorkbarShell', () => { + it('provides four default single-instance tabs and keeps add outside the tablist', () => { + render() + + const tablist = screen.getByRole('tablist', { + name: '已打开的工作栏应用' + }) + expect(within(tablist).getAllByRole('tab')).toHaveLength(4) + expect( + within(tablist).queryByRole('button', { + name: '打开工作栏应用' + }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: '打开工作栏应用' }) + ).toBeVisible() + expect(tablist.parentElement).toHaveClass( + 'workbar-shell__tab-scroll' + ) + expect( + screen.getByRole('tab', { name: '任务中心' }).parentElement + ).toHaveClass('workbar-shell__tab-item--active') + expect( + screen.getByRole('tab', { name: '工作区' }).parentElement + ).not.toHaveClass('workbar-shell__tab-item--active') + }) + + it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => { + render() + const addButton = screen.getByRole('button', { + name: '打开工作栏应用' + }) + + fireEvent.click(addButton) + const firstChoice = screen + .getByText('任务中心', { selector: 'strong' }) + .closest('button')! + await waitFor(() => expect(firstChoice).toHaveFocus()) + expect( + screen.getByRole('heading', { name: '新建工作栏应用' }) + ).toBeInTheDocument() + + fireEvent.keyDown(firstChoice, { key: 'Escape' }) + await waitFor(() => expect(addButton).toHaveFocus()) + expect( + screen.queryByRole('heading', { name: '新建工作栏应用' }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('tab', { name: '任务中心' }) + ).toHaveAttribute('aria-selected', 'true') + }) + + it('focuses an existing single instance instead of requesting a duplicate', async () => { + const onCreate = vi.fn() + render() + + fireEvent.click( + screen.getByRole('button', { name: '打开工作栏应用' }) + ) + fireEvent.click( + screen + .getByText('工作区', { selector: 'strong' }) + .closest('button')! + ) + + const workspaceTab = screen.getByRole('tab', { + name: '工作区' + }) + await waitFor(() => expect(workspaceTab).toHaveFocus()) + expect(workspaceTab).toHaveAttribute('aria-selected', 'true') + expect(onCreate).not.toHaveBeenCalled() + }) + + it('resolves the current terminal target for every multiple-instance creation', async () => { + const onCreate = vi.fn() + const onTerminalTarget = vi.fn((): WorkbarTargetRef => ({ + type: 'local' + })) + render( + + ) + + for (let count = 1; count <= 2; count += 1) { + fireEvent.click( + screen.getByRole('button', { + name: '打开工作栏应用' + }) + ) + fireEvent.click( + screen.getByRole('button', { name: /^终端/u }) + ) + await waitFor(() => + expect(onCreate).toHaveBeenCalledTimes(count) + ) + } + + expect(onTerminalTarget).toHaveBeenCalledTimes(2) + expect(onCreate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + appId: 'terminal', + targetRef: { type: 'local' } + }) + ) + }) + + it('supports roving Arrow, Home, and End navigation', () => { + render() + const tasks = screen.getByRole('tab', { name: '任务中心' }) + + tasks.focus() + fireEvent.keyDown(tasks, { key: 'ArrowRight' }) + const workspace = screen.getByRole('tab', { name: '工作区' }) + expect(workspace).toHaveFocus() + expect(workspace).toHaveAttribute('tabindex', '0') + + fireEvent.keyDown(workspace, { key: 'End' }) + const results = screen.getByRole('tab', { name: '成果' }) + expect(results).toHaveFocus() + + fireEvent.keyDown(results, { key: 'Home' }) + expect(tasks).toHaveFocus() + }) + + it('closes the active tab, activates its right neighbor, and restores focus', async () => { + render() + const workspace = screen.getByRole('tab', { name: '工作区' }) + fireEvent.click(workspace) + fireEvent.click( + screen.getByRole('button', { name: '关闭工作区' }) + ) + + const browser = screen.getByRole('tab', { name: '浏览器' }) + await waitFor(() => expect(browser).toHaveFocus()) + expect(browser).toHaveAttribute('aria-selected', 'true') + expect( + screen.queryByRole('tab', { name: '工作区' }) + ).not.toBeInTheDocument() + }) + + it('keeps the current active tab focused when a background tab closes', async () => { + render() + const tasks = screen.getByRole('tab', { name: '任务中心' }) + fireEvent.click( + screen.getByRole('button', { name: '关闭成果' }) + ) + + await waitFor(() => expect(tasks).toHaveFocus()) + expect(tasks).toHaveAttribute('aria-selected', 'true') + }) + + it('shows the empty state while preserving the add command after the last close', async () => { + render( + + ) + fireEvent.click( + screen.getByRole('button', { name: '关闭任务中心' }) + ) + + const addButton = screen.getByRole('button', { + name: '打开工作栏应用' + }) + await waitFor(() => expect(addButton).toHaveFocus()) + expect( + screen.getByText('还没有打开的工作栏应用') + ).toBeInTheDocument() + expect(addButton).toBeVisible() + }) +}) diff --git a/src/renderer/src/WorkbarShell.tsx b/src/renderer/src/WorkbarShell.tsx new file mode 100644 index 00000000..375a3c4b --- /dev/null +++ b/src/renderer/src/WorkbarShell.tsx @@ -0,0 +1,555 @@ +import { + ClipboardCheck, + FileOutput, + FolderTree, + Globe2, + SquareTerminal, + X, + type LucideIcon +} from 'lucide-react' +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type KeyboardEvent, + type ReactNode +} from 'react' +import { useTranslation } from 'react-i18next' +import { + WORKBAR_APP_DEFINITIONS, + WORKBAR_LIMITS, + type WorkbarAppDefinition, + type WorkbarAppId, + type WorkbarTabInstance, + type WorkbarTargetRef +} from '../../shared/workbar-contracts' + +const WORKBAR_APP_ICONS: Record = { + tasks: ClipboardCheck, + workspace: FolderTree, + browser: Globe2, + results: FileOutput, + terminal: SquareTerminal +} + +const DEFAULT_INSTANCE_IDS = { + tasks: '10000000-0000-4000-8000-000000000001', + workspace: '10000000-0000-4000-8000-000000000002', + browser: '10000000-0000-4000-8000-000000000003', + results: '10000000-0000-4000-8000-000000000004' +} as const + +export const DEFAULT_WORKBAR_INSTANCES: readonly WorkbarTabInstance[] = + WORKBAR_APP_DEFINITIONS.filter( + (definition) => definition.defaultOpen + ).map((definition) => ({ + id: DEFAULT_INSTANCE_IDS[definition.id], + appId: definition.id, + title: definition.label + })) + +export type WorkbarInstanceCreateRequest = { + appId: WorkbarAppId + insertAfterInstanceId: string | null + targetRef?: WorkbarTargetRef +} + +export type WorkbarShellProps = { + instances: readonly WorkbarTabInstance[] + activeInstanceId: string | null + renderPanel: (instance: WorkbarTabInstance) => ReactNode + renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode + onActiveInstanceChange: (instanceId: string) => void + onCreateInstance: (request: WorkbarInstanceCreateRequest) => void + onCloseInstance: ( + instance: WorkbarTabInstance + ) => boolean | void | Promise + onResolveTerminalTarget: () => + | WorkbarTargetRef + | Promise + appDefinitions?: readonly WorkbarAppDefinition[] + onResolveUnavailableApp?: (definition: WorkbarAppDefinition) => void + className?: string +} + +function joinClassNames(...values: (string | false | undefined)[]): string { + return values.filter(Boolean).join(' ') +} + +function adjacentIndex( + key: string, + currentIndex: number, + itemCount: number +): number | undefined { + if (key === 'Home') { + return 0 + } + if (key === 'End') { + return itemCount - 1 + } + if (key === 'ArrowRight') { + return (currentIndex + 1) % itemCount + } + if (key === 'ArrowLeft') { + return (currentIndex - 1 + itemCount) % itemCount + } + return undefined +} + +export function WorkbarShell({ + activeInstanceId, + appDefinitions = WORKBAR_APP_DEFINITIONS, + className, + instances, + onActiveInstanceChange, + onCloseInstance, + onCreateInstance, + onResolveUnavailableApp, + onResolveTerminalTarget, + renderPanel, + renderTabAdornment +}: WorkbarShellProps): React.JSX.Element { + const { t } = useTranslation('workspace') + const idPrefix = useId() + const addButtonRef = useRef(null) + const catalogRef = useRef(null) + const returnInstanceIdRef = useRef(null) + const pendingTabFocusRef = useRef(undefined) + const focusCreatedInstanceRef = useRef(false) + const [catalogOpen, setCatalogOpen] = useState(false) + const [creatingAppId, setCreatingAppId] = + useState(null) + const [targetError, setTargetError] = useState(null) + + const activeInstance = + instances.find((instance) => instance.id === activeInstanceId) ?? + null + + const tabId = useCallback( + (instanceId: string): string => + `${idPrefix}-tab-${instanceId}`, + [idPrefix] + ) + const panelId = (instanceId: string): string => + `${idPrefix}-panel-${instanceId}` + + useEffect(() => { + if (!catalogOpen) { + return + } + const firstAvailable = + catalogRef.current?.querySelector( + '[data-workbar-app-choice]:not(:disabled)' + ) + firstAvailable?.focus() + }, [catalogOpen]) + + useEffect(() => { + if ( + focusCreatedInstanceRef.current && + !catalogOpen && + activeInstanceId + ) { + const activeTab = document.getElementById(tabId(activeInstanceId)) + if (activeTab instanceof HTMLButtonElement) { + activeTab.focus() + focusCreatedInstanceRef.current = false + } + } + + const pendingId = pendingTabFocusRef.current + if (pendingId === undefined) { + return + } + if (pendingId === null) { + addButtonRef.current?.focus() + pendingTabFocusRef.current = undefined + return + } + const tab = document.getElementById(tabId(pendingId)) + if (tab instanceof HTMLButtonElement) { + tab.focus() + pendingTabFocusRef.current = undefined + } + }, [activeInstanceId, catalogOpen, instances, tabId]) + + const focusAndActivate = (instanceId: string): void => { + onActiveInstanceChange(instanceId) + const tab = document.getElementById(tabId(instanceId)) + if (tab instanceof HTMLButtonElement) { + tab.focus() + } + } + + const closeCatalog = (restoreToAddButton: boolean): void => { + setCatalogOpen(false) + setCreatingAppId(null) + setTargetError(null) + const returnInstanceId = returnInstanceIdRef.current + if ( + returnInstanceId && + instances.some((instance) => instance.id === returnInstanceId) + ) { + onActiveInstanceChange(returnInstanceId) + } + if (restoreToAddButton) { + queueMicrotask(() => addButtonRef.current?.focus()) + } + } + + const openCatalog = (): void => { + returnInstanceIdRef.current = activeInstanceId + setTargetError(null) + setCatalogOpen(true) + } + + const chooseApp = async ( + definition: WorkbarAppDefinition + ): Promise => { + if (definition.availability.state === 'unavailable') { + return + } + + if (definition.instancePolicy === 'single') { + const existing = instances.find( + (instance) => instance.appId === definition.id + ) + if (existing) { + setCatalogOpen(false) + setTargetError(null) + pendingTabFocusRef.current = existing.id + onActiveInstanceChange(existing.id) + return + } + } + + if (instances.length >= WORKBAR_LIMITS.maximumOpenInstances) { + return + } + + const insertAfterInstanceId = activeInstanceId + if (definition.id !== 'terminal') { + focusCreatedInstanceRef.current = true + setCatalogOpen(false) + onCreateInstance({ + appId: definition.id, + insertAfterInstanceId + }) + return + } + + setCreatingAppId('terminal') + setTargetError(null) + try { + const targetRef = await onResolveTerminalTarget() + focusCreatedInstanceRef.current = true + setCatalogOpen(false) + onCreateInstance({ + appId: 'terminal', + insertAfterInstanceId, + targetRef + }) + } catch (error) { + setTargetError( + error instanceof Error + ? error.message + : t('sidebar.workbar.targetError') + ) + } finally { + setCreatingAppId(null) + } + } + + const handleTabKeyDown = ( + event: KeyboardEvent, + index: number + ): void => { + if (event.key === 'Escape') { + event.preventDefault() + if (catalogOpen) { + closeCatalog(false) + } + focusAndActivate(activeInstance?.id ?? instances[index]!.id) + return + } + const nextIndex = adjacentIndex( + event.key, + index, + instances.length + ) + if (nextIndex === undefined) { + return + } + event.preventDefault() + focusAndActivate(instances[nextIndex]!.id) + } + + const handleClose = async ( + instance: WorkbarTabInstance + ): Promise => { + const closingIndex = instances.findIndex( + (candidate) => candidate.id === instance.id + ) + const isActive = instance.id === activeInstanceId + const nextActive = isActive + ? (instances[closingIndex + 1] ?? + instances[closingIndex - 1] ?? + null) + : activeInstance + + const closeResult = onCloseInstance(instance) + const closeAccepted = + closeResult && + typeof (closeResult as PromiseLike).then === + 'function' + ? await closeResult + : closeResult + if (closeAccepted === false) { + return + } + pendingTabFocusRef.current = nextActive?.id ?? null + if (nextActive && nextActive.id !== activeInstanceId) { + onActiveInstanceChange(nextActive.id) + } + } + + return ( +
+
+
+
+ {instances.map((instance, index) => { + const selected = + !catalogOpen && instance.id === activeInstanceId + const AppIcon = WORKBAR_APP_ICONS[instance.appId] + return ( +
+ + +
+ ) + })} +
+
+ +
+ +
+ {instances.map((instance) => ( + + ))} + + {catalogOpen ? ( +
{ + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + closeCatalog(true) + } + }} + ref={catalogRef} + > +
+
+

{t('sidebar.workbar.catalogTitle')}

+

{t('sidebar.workbar.catalogDescription')}

+
+ +
+ {targetError ? ( +

+ {targetError} +

+ ) : null} +
    + {appDefinitions.map((definition) => { + const AppIcon = WORKBAR_APP_ICONS[definition.id] + const existing = + definition.instancePolicy === 'single' && + instances.some( + (instance) => instance.appId === definition.id + ) + const availability = definition.availability + const unavailable = + availability.state === 'unavailable' + const atLimit = + !existing && + instances.length >= + WORKBAR_LIMITS.maximumOpenInstances + const disabled = + unavailable || + atLimit || + creatingAppId !== null + return ( +
  • + + {availability.state === 'unavailable' ? ( +
    + {availability.reason} + {onResolveUnavailableApp ? ( + + ) : null} +
    + ) : atLimit ? ( + + {t('sidebar.workbar.limit', { + count: WORKBAR_LIMITS.maximumOpenInstances + })} + + ) : null} +
  • + ) + })} +
+
+ ) : instances.length === 0 ? ( +
+ {t('sidebar.workbar.emptyTitle')} +

{t('sidebar.workbar.emptyDescription')}

+ +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 794db9fe..95083f8a 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -62,7 +62,9 @@ export const app = { localWorkspace: 'Local workspace', loading: 'Loading', close: 'Close sidebar', - toggle: 'Toggle sidebar' + toggle: 'Toggle sidebar', + resizeAriaLabel: 'Resize main sidebar', + resizeValue: 'Main sidebar, {{width}} pixels' }, topbar: { toggleAssistantSidebar: 'Toggle assistant workspace', diff --git a/src/renderer/src/i18n/locales/en-US/magicNotes.ts b/src/renderer/src/i18n/locales/en-US/magicNotes.ts index 509698ff..92b0f0da 100644 --- a/src/renderer/src/i18n/locales/en-US/magicNotes.ts +++ b/src/renderer/src/i18n/locales/en-US/magicNotes.ts @@ -27,6 +27,8 @@ export const magicNotes = { analyzeAgain: 'Analyze again', analyzing: 'Analyzing…', openSourceNote: 'Open source note', + hideListPane: 'Hide left list', + showListPane: 'Show left list', hideAiComments: 'Hide AI comments', showAiComments: 'Show AI comments', pinNote: 'Pin note', @@ -161,6 +163,8 @@ export const magicNotes = { 'Choose “Analyze with AI” on an entry to show comments here.' }, accessibility: { + resizeListPane: 'Resize the note list and editor panes', + listPaneWidth: 'Left list, {{width}} pixels', resizeAiPane: 'Resize the editor and AI comments panes', aiPaneWidth: 'AI comments pane, {{width}} pixels' }, diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index 5dd38854..2ae3cf1f 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -191,6 +191,97 @@ export const workspace = { results: { label: 'Results', description: 'View generated or imported standalone results' + }, + terminal: { + label: 'Terminal', + description: 'Open a user terminal for the current project' + } + }, + workbar: { + ariaLabel: 'Workbar applications', + tablist: 'Open workbar applications', + add: 'Open workbar application', + close: 'Close {{title}}', + catalogTitle: 'New workbar application', + catalogDescription: 'Choose an application to open in the workbar.', + cancel: 'Cancel', + targetError: + 'Could not resolve the current project terminal target. Try again.', + catalog: 'Workbar application catalog', + existing: 'Already open. Selecting switches to the existing instance.', + multiple: 'Multiple instances supported', + single: 'Single-instance application', + resolve: 'Resolve', + limit: 'You can open up to {{count}} instances.', + emptyTitle: 'No workbar applications are open', + emptyDescription: 'Open an application to switch to it here.', + open: 'Open application' + }, + terminal: { + loading: 'Loading terminal…', + ariaLabel: 'User terminal: {{title}}', + location: { + local: 'Local', + target: 'Target' + }, + states: { + starting: 'Connecting', + running: 'Connected', + exited: 'Shell exited', + interrupted: 'Connection interrupted', + closing: 'Closing', + failed: 'Connection failed', + creating: 'Creating' + }, + toolbar: { + ariaLabel: 'Terminal actions', + search: 'Search terminal', + copy: 'Copy selection', + paste: 'Paste', + selectAll: 'Select all', + selectAllAriaLabel: 'Select all terminal content', + clear: 'Clear terminal', + rename: 'Rename terminal' + }, + reopen: { + interrupted: 'Reconnect with a new shell', + default: 'Reopen' + }, + search: { + label: 'Search terminal content', + next: 'Find next' + }, + rename: { + label: 'Terminal name', + save: 'Save name', + cancel: 'Cancel' + }, + status: { + emulatorAriaLabel: 'Terminal input and output', + ariaLabel: 'Terminal status', + readingDirectory: 'Reading directory', + readingShell: 'Reading shell', + exitCode: '(exit code {{code}})' + }, + errors: { + ack: 'Could not acknowledge terminal output. Try again.', + write: 'Could not write input to the terminal.', + snapshot: 'Could not read the terminal session. Reopen it.', + create: 'Could not create the terminal. Try again.', + resize: 'Could not synchronize the terminal size.', + copy: 'Could not copy the terminal selection.', + paste: 'Could not paste into the terminal.', + closePrevious: 'Could not close the previous terminal. Try again.' + }, + closeDialog: { + ariaLabel: 'Close terminal', + title: 'Close terminal?', + description: + 'Closing this tab ends the shell and its child processes.', + cancel: 'Cancel', + closing: 'Closing…', + confirm: 'Close terminal', + error: 'Could not close the terminal. Try again.' } }, tasks: { diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 7fa03e5c..0cf8078b 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -59,7 +59,9 @@ export const app = { localWorkspace: '本地工作区', loading: '加载中', close: '关闭侧栏', - toggle: '切换侧栏' + toggle: '切换侧栏', + resizeAriaLabel: '调整主侧栏宽度', + resizeValue: '主侧栏 {{width}} 像素' }, topbar: { toggleAssistantSidebar: '切换助手工作栏', diff --git a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts index 800d593e..d5a6404c 100644 --- a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts +++ b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts @@ -23,6 +23,8 @@ export const magicNotes = { analyzeAgain: '重新分析', analyzing: '分析中…', openSourceNote: '打开原笔记修改', + hideListPane: '隐藏左侧列表', + showListPane: '显示左侧列表', hideAiComments: '隐藏 AI 评论', showAiComments: '显示 AI 评论', pinNote: '置顶笔记', @@ -149,6 +151,8 @@ export const magicNotes = { manualHint: '在记录上点击“AI 分析”,评论会显示在这里。' }, accessibility: { + resizeListPane: '调整笔记列表与编辑区宽度', + listPaneWidth: '左侧列表 {{width}} 像素', resizeAiPane: '调整编辑区与 AI 评论宽度', aiPaneWidth: 'AI 评论栏 {{width}} 像素' }, diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index 8d4f4b01..5f17a6d0 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -185,6 +185,95 @@ export const workspace = { results: { label: '成果', description: '查看生成或手动导入的独立成果' + }, + terminal: { + label: '终端', + description: '打开当前项目的用户终端' + } + }, + workbar: { + ariaLabel: '工作栏应用', + tablist: '已打开的工作栏应用', + add: '打开工作栏应用', + close: '关闭{{title}}', + catalogTitle: '新建工作栏应用', + catalogDescription: '选择要在工作栏中打开的应用。', + cancel: '取消', + targetError: '无法确定当前项目的终端目标,请重试。', + catalog: '工作栏应用目录', + existing: '已打开,选择后切换到现有实例', + multiple: '可打开多个实例', + single: '单实例应用', + resolve: '前往处理', + limit: '最多可打开 {{count}} 个实例。', + emptyTitle: '还没有打开的工作栏应用', + emptyDescription: '打开应用后,可以在这里快速切换和处理内容。', + open: '打开应用' + }, + terminal: { + loading: '正在加载终端…', + ariaLabel: '用户终端:{{title}}', + location: { + local: '本机', + target: '目标' + }, + states: { + starting: '正在连接', + running: '已连接', + exited: 'Shell 已退出', + interrupted: '连接已中断', + closing: '正在关闭', + failed: '连接失败', + creating: '正在创建' + }, + toolbar: { + ariaLabel: '终端操作', + search: '搜索终端', + copy: '复制选中内容', + paste: '粘贴', + selectAll: '全选', + selectAllAriaLabel: '全选终端内容', + clear: '清屏', + rename: '重命名终端' + }, + reopen: { + interrupted: '重新连接并新建 Shell', + default: '重新打开' + }, + search: { + label: '搜索终端内容', + next: '查找下一个' + }, + rename: { + label: '终端名称', + save: '保存名称', + cancel: '取消' + }, + status: { + emulatorAriaLabel: '终端输入与输出', + ariaLabel: '终端状态', + readingDirectory: '正在读取目录', + readingShell: '正在读取 Shell', + exitCode: '(退出码 {{code}})' + }, + errors: { + ack: '无法确认终端输出,请重试。', + write: '无法向终端写入输入。', + snapshot: '无法读取终端会话,请重新打开。', + create: '无法创建终端,请重试。', + resize: '无法同步终端窗口尺寸。', + copy: '无法复制终端选区。', + paste: '无法粘贴到终端。', + closePrevious: '无法关闭原终端,请重试。' + }, + closeDialog: { + ariaLabel: '关闭终端', + title: '关闭终端?', + description: '关闭此 Tab 将结束 Shell 及其子进程。', + cancel: '取消', + closing: '正在关闭…', + confirm: '关闭终端', + error: '无法关闭终端,请重试。' } }, tasks: { diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 73f944b2..aacfd872 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -76,7 +76,9 @@ "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", sans-serif; --font-family-mono: - "Cascadia Code", "SFMono-Regular", Consolas, monospace; + "Cascadia Mono", "Cascadia Code", "SFMono-Regular", Menlo, Monaco, + Consolas, "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", + monospace; font-family: var(--font-family-ui); font-size: var(--font-body); font-optical-sizing: auto; @@ -1250,6 +1252,7 @@ height: 100%; flex-direction: column; gap: var(--space-4); + container: magic-notes-page / inline-size; } .magic-notes-layout { @@ -1261,14 +1264,26 @@ border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); - container: speech-model-list / inline-size; grid-template-columns: - 220px minmax(300px, 1fr) 9px + var(--magic-notes-list-width, 220px) 9px + minmax(300px, 1fr) 9px var(--magic-notes-ai-width, 280px); } .magic-notes-layout--ai-hidden { - grid-template-columns: 220px minmax(300px, 1fr); + grid-template-columns: + var(--magic-notes-list-width, 220px) 9px + minmax(300px, 1fr); +} + +.magic-notes-layout--list-hidden { + grid-template-columns: + minmax(300px, 1fr) 9px + var(--magic-notes-ai-width, 280px); +} + +.magic-notes-layout--list-hidden.magic-notes-layout--ai-hidden { + grid-template-columns: minmax(300px, 1fr); } .magic-notes-layout--resizing { @@ -1276,21 +1291,34 @@ user-select: none; } +.magic-notes-list-resize-handle, .magic-notes-ai-resize-handle { position: relative; z-index: 2; width: 9px; min-width: 9px; padding: 0; + cursor: col-resize; + touch-action: none; +} + +.magic-notes-list-resize-handle { + background: linear-gradient( + to right, + var(--surface-subtle) 0 4px, + var(--surface-raised) 4px 100% + ); +} + +.magic-notes-ai-resize-handle { background: linear-gradient( to right, var(--surface-raised) 0 4px, var(--surface-subtle) 4px 100% ); - cursor: col-resize; - touch-action: none; } +.magic-notes-list-resize-handle::after, .magic-notes-ai-resize-handle::after { position: absolute; top: 0; @@ -1304,14 +1332,19 @@ background var(--motion-fast) ease-out; } +.magic-notes-list-resize-handle:hover::after, +.magic-notes-list-resize-handle:focus-visible::after, .magic-notes-ai-resize-handle:hover::after, .magic-notes-ai-resize-handle:focus-visible::after, -.magic-notes-layout--resizing +.magic-notes-layout--list-resizing + > .magic-notes-list-resize-handle::after, +.magic-notes-layout--ai-resizing > .magic-notes-ai-resize-handle::after { width: 2px; background: var(--accent); } +.magic-notes-list-resize-handle:focus-visible, .magic-notes-ai-resize-handle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; @@ -1330,10 +1363,14 @@ background: var(--surface-subtle); } +.magic-notes-list-pane[hidden], +.magic-notes-ai-pane[hidden] { + display: none; +} + .magic-notes-list-pane { display: flex; padding: var(--space-4); - border-right: 1px solid var(--border-subtle); flex-direction: column; gap: var(--space-3); } @@ -2175,12 +2212,18 @@ cursor: pointer; } -@container (max-width: 800px) { +@container magic-notes-page (max-width: 800px) { .magic-notes-layout { - grid-template-columns: 210px minmax(320px, 1fr); + grid-template-columns: + var(--magic-notes-list-width, 210px) 9px + minmax(320px, 1fr); grid-template-rows: minmax(0, 1fr) auto; } + .magic-notes-layout--list-hidden { + grid-template-columns: minmax(320px, 1fr); + } + .magic-notes-ai-resize-handle { display: none; } @@ -2193,11 +2236,7 @@ } } -@container (max-width: 720px) { - .magic-notes-page { - height: auto; - } - +@container magic-notes-page (max-width: 720px) { .magic-notes-layout { min-height: 0; overflow: visible; @@ -2205,6 +2244,10 @@ grid-template-rows: auto auto auto; } + .magic-notes-list-resize-handle { + display: none; + } + .magic-notes-list-pane { max-height: 240px; border-right: 0; @@ -2321,7 +2364,7 @@ textarea:focus-visible { .sidebar { display: flex; - flex: 0 0 278px; + flex: 0 0 var(--primary-sidebar-width, 278px); flex-direction: column; min-width: 0; padding: 20px 14px 14px; @@ -2334,12 +2377,63 @@ textarea:focus-visible { padding 180ms ease; } +.sidebar--resizing { + transition: none; +} + .sidebar--closed { flex-basis: 0; padding-right: 0; padding-left: 0; } +.sidebar > .brand, +.sidebar > .project-switcher, +.sidebar > .new-chat, +.sidebar > .sidebar-search, +.sidebar > .primary-nav, +.sidebar > .conversation-list, +.sidebar > .sidebar-footer { + min-width: 0; +} + +.primary-sidebar-resize-handle { + position: relative; + z-index: 3; + width: 9px; + min-width: 9px; + padding: 0; + margin-left: -5px; + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.primary-sidebar-resize-handle::after { + position: absolute; + top: 0; + bottom: 0; + left: 4px; + width: 1px; + background: var(--border-subtle); + content: ''; + transition: + width var(--motion-fast) ease-out, + background var(--motion-fast) ease-out; +} + +.primary-sidebar-resize-handle:hover::after, +.primary-sidebar-resize-handle:focus-visible::after, +.sidebar--resizing + .primary-sidebar-resize-handle::after { + width: 2px; + background: var(--accent); +} + +.primary-sidebar-resize-handle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + .sidebar-backdrop { display: none; } @@ -3714,6 +3808,372 @@ button > svg { outline-offset: -2px; } +.workbar-shell { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.workbar-shell__tab-row { + display: flex; + min-width: 0; + min-height: 46px; + padding: 6px 46px 0 8px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-subtle); + flex: 0 0 46px; + align-items: flex-end; + gap: var(--space-1); +} + +.workbar-shell__tab-scroll { + min-width: 0; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.workbar-shell__tab-scroll::-webkit-scrollbar { + display: none; +} + +.workbar-shell__tablist { + display: flex; + width: max-content; + min-width: 100%; + align-items: flex-end; + gap: 2px; +} + +.workbar-shell__tab-item { + position: relative; + display: flex; + min-width: 88px; + max-width: 180px; + height: 39px; + align-items: center; + border: 1px solid transparent; + border-bottom: 0; + border-radius: var(--radius-control) var(--radius-control) 0 0; + background: transparent; + transition: + border-color var(--motion-fast) ease-out, + background var(--motion-fast) ease-out, + box-shadow var(--motion-fast) ease-out; +} + +.workbar-shell__tab-item:hover { + background: var(--surface-muted); +} + +.workbar-shell__tab-item--active { + border-color: var(--border-default); + background: var(--surface-raised); + box-shadow: inset 0 2px 0 var(--accent); +} + +.workbar-shell__tab { + position: relative; + display: flex; + min-width: 0; + height: 100%; + padding: 0 32px 0 var(--space-2); + align-items: center; + flex: 1; + gap: 6px; + overflow: hidden; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-caption); + text-align: left; +} + +.workbar-shell__tab:hover { + color: var(--text-primary); +} + +.workbar-shell__tab--active { + color: var(--text-primary); + font-weight: 600; +} + +.workbar-shell__tab-icon { + display: grid; + width: 15px; + height: 15px; + flex: 0 0 15px; + place-items: center; + color: var(--text-muted); +} + +.workbar-shell__tab-icon > svg { + width: 15px; + height: 15px; + stroke-width: 1.8; +} + +.workbar-shell__tab--active .workbar-shell__tab-icon { + color: var(--accent); +} + +.workbar-shell__tab-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workbar-shell__tab-close, +.workbar-shell__add { + display: grid; + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + place-items: center; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.workbar-shell__tab-close { + position: absolute; + z-index: 1; + top: 50%; + right: var(--space-1); + width: 24px; + min-width: 24px; + height: 24px; + background: var(--surface-raised); + opacity: 0; + pointer-events: none; + transform: translateY(-50%) scale(0.86); + transition: + opacity var(--motion-fast) ease-out, + transform var(--motion-fast) ease-out, + background var(--motion-fast) ease-out, + color var(--motion-fast) ease-out; +} + +.workbar-shell__tab-close > svg { + width: 13px; + height: 13px; + stroke-width: 2; +} + +.workbar-shell__tab-item:hover .workbar-shell__tab-close, +.workbar-shell__tab-item:focus-within .workbar-shell__tab-close { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) scale(1); +} + +.workbar-shell__tab-close:hover { + background: var(--danger-subtle); + color: var(--danger); +} + +@media (hover: none), (pointer: coarse) { + .workbar-shell__tab-close { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%); + } +} + +.workbar-shell__add:hover, +.workbar-shell__add--active { + background: var(--accent-selected); + color: var(--accent); +} + +.workbar-shell__add { + margin-bottom: 5px; + background: var(--accent-subtle); + color: var(--accent); +} + +.workbar-shell__content, +.workbar-shell__tabpanel { + min-width: 0; + min-height: 0; + flex: 1; +} + +.workbar-shell__content { + display: flex; + flex-direction: column; +} + +.workbar-shell__tabpanel { + height: 100%; + overflow: hidden; +} + +.workbar-shell__tabpanel[hidden] { + display: none; +} + +.workbar-shell__catalog, +.workbar-shell__empty { + min-height: 0; + padding: var(--space-4); + overflow-y: auto; +} + +.workbar-shell__catalog-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.workbar-shell__catalog-header h2, +.workbar-shell__empty strong { + margin: 0; + color: var(--text-primary); + font-size: var(--font-section-title); +} + +.workbar-shell__catalog-header p, +.workbar-shell__empty p { + margin: var(--space-1) 0 0; + color: var(--text-secondary); + font-size: var(--font-body); +} + +.workbar-shell__catalog-list { + display: flex; + padding: 0; + margin: var(--space-4) 0 0; + flex-direction: column; + gap: var(--space-2); + list-style: none; +} + +.workbar-shell__catalog-item { + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + background: var(--surface-raised); +} + +.workbar-shell__catalog-choice { + display: flex; + width: 100%; + padding: var(--space-3); + align-items: flex-start; + border-radius: var(--radius-card); + background: transparent; + color: var(--text-primary); + cursor: pointer; + text-align: left; +} + +.workbar-shell__app-icon { + display: grid; + width: 32px; + height: 32px; + margin-right: var(--space-3); + flex: 0 0 32px; + place-items: center; + border-radius: var(--radius-control); + background: var(--accent-subtle); + color: var(--accent); +} + +.workbar-shell__app-icon > svg { + width: 17px; + height: 17px; + stroke-width: 1.8; +} + +.workbar-shell__catalog-choice:hover:not(:disabled) { + background: var(--accent-subtle); +} + +.workbar-shell__catalog-choice:disabled { + cursor: not-allowed; + opacity: 0.68; +} + +.workbar-shell__catalog-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-1); +} + +.workbar-shell__catalog-copy span, +.workbar-shell__catalog-copy small, +.workbar-shell__unavailable, +.workbar-shell__limit { + color: var(--text-secondary); + font-size: var(--font-caption); +} + +.workbar-shell__unavailable { + display: flex; + padding: 0 var(--space-3) var(--space-3); + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.workbar-shell__catalog-error { + padding: var(--space-2); + border: 1px solid var(--danger-border); + border-radius: var(--radius-control); + margin: var(--space-3) 0 0; + background: var(--danger-subtle); + color: var(--danger); +} + +.workbar-shell__empty { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; +} + +.workbar-shell__empty .primary-button { + margin-top: var(--space-3); +} + +.assistant-sidebar__overlay { + position: absolute; + z-index: 4; + inset: 46px 0 0; + overflow-y: auto; + border-top: 1px solid var(--border-subtle); + background: var(--surface-raised); +} + +.assistant-sidebar__terminal-close { + display: flex; + min-height: 100%; + padding: var(--space-6); + align-items: flex-start; + justify-content: center; + flex-direction: column; + gap: var(--space-3); +} + +.assistant-sidebar__terminal-close h2, +.assistant-sidebar__terminal-close p { + margin: 0; +} + +.assistant-sidebar__terminal-close > div { + display: flex; + gap: var(--space-2); +} + .assistant-sidebar__tabs { display: grid; min-height: 46px; @@ -3767,6 +4227,7 @@ button > svg { } .assistant-sidebar__body { + height: 100%; min-height: 0; flex: 1; overflow-y: auto; @@ -14829,7 +15290,7 @@ details.settings-section > :not(summary) + :not(summary) { @media (max-width: 1020px) { .sidebar { - flex-basis: 236px; + flex-basis: var(--primary-sidebar-width, 236px); } .brand, @@ -14877,6 +15338,10 @@ details.settings-section > :not(summary) + :not(summary) { } @media (max-width: 899px) { + .primary-sidebar-resize-handle { + display: none; + } + .sidebar { position: absolute; z-index: 40; diff --git a/src/renderer/src/terminal-panel.css b/src/renderer/src/terminal-panel.css new file mode 100644 index 00000000..ac25db50 --- /dev/null +++ b/src/renderer/src/terminal-panel.css @@ -0,0 +1,194 @@ +.terminal-panel { + display: flex; + min-width: 0; + min-height: 300px; + height: 100%; + flex-direction: column; + overflow: hidden; + color: var(--text-primary); + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); +} + +.terminal-panel--loading { + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.terminal-panel__toolbar, +.terminal-panel__inline-form, +.terminal-panel__status, +.terminal-panel__error { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-subtle); +} + +.terminal-panel__toolbar button, +.terminal-panel__inline-form button, +.terminal-panel__error button { + display: inline-flex; + min-width: 32px; + min-height: 32px; + align-items: center; + justify-content: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + color: var(--text-secondary); + font: inherit; + background: var(--surface-raised); + border: 1px solid var(--border-control); + border-radius: var(--radius-control); + cursor: pointer; +} + +.terminal-panel__toolbar button:hover:not(:disabled), +.terminal-panel__inline-form button:hover:not(:disabled), +.terminal-panel__error button:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--accent); +} + +.terminal-panel__toolbar button:focus-visible, +.terminal-panel__inline-form button:focus-visible, +.terminal-panel__inline-form input:focus-visible, +.terminal-panel__error button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.terminal-panel__toolbar button:disabled, +.terminal-panel__inline-form button:disabled, +.terminal-panel__error button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.terminal-panel__toolbar button svg { + width: 16px; + height: 16px; +} + +.terminal-panel__toolbar-spacer { + flex: 1; +} + +.terminal-panel__toolbar .terminal-panel__reopen { + color: var(--accent); +} + +.terminal-panel__inline-form { + flex-wrap: wrap; + color: var(--text-secondary); + font-size: var(--font-body); +} + +.terminal-panel__inline-form input { + min-width: 120px; + min-height: 32px; + flex: 1 1 160px; + padding: var(--space-1) var(--space-2); + color: var(--text-primary); + font: inherit; + background: var(--surface-raised); + border: 1px solid var(--border-control); + border-radius: var(--radius-control); +} + +.terminal-panel__error { + justify-content: space-between; + color: var(--danger); + background: var(--danger-subtle); +} + +.terminal-panel__emulator { + min-width: 0; + min-height: 0; + flex: 1; + padding: var(--space-2); + overflow: hidden; + background: var(--surface-canvas); +} + +.terminal-panel__emulator .xterm { + height: 100%; +} + +.terminal-panel__status { + min-width: 0; + flex-wrap: wrap; + border-top: 1px solid var(--border-default); + border-bottom: 0; + color: var(--text-muted); + font-size: var(--font-caption); +} + +.terminal-panel__status span { + min-width: 0; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-panel__status span + span::before { + content: "·"; + margin-right: var(--space-2); + color: var(--border-control); +} + +.terminal-panel__status strong { + margin-left: auto; + color: var(--text-secondary); + white-space: nowrap; +} + +.terminal-panel__status strong[data-state="running"] { + color: var(--success); +} + +.terminal-panel__status strong[data-state="interrupted"], +.terminal-panel__status strong[data-state="failed"] { + color: var(--danger); +} + +.terminal-panel__status strong[data-state="starting"], +.terminal-panel__status strong[data-state="closing"] { + color: var(--warning); +} + +.terminal-panel__announcement { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 720px) { + .terminal-panel__toolbar { + flex-wrap: wrap; + } + + .terminal-panel__toolbar-spacer { + display: none; + } + + .terminal-panel__status span { + max-width: 100%; + } + + .terminal-panel__status strong { + width: 100%; + margin-left: 0; + } +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 85fcf636..b677107d 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -150,6 +150,16 @@ import type { FeedbackSubmitInput, FeedbackSubmitResult } from './feedback-contracts' +import type { + TerminalAckRequest, + TerminalCloseRequest, + TerminalCreateRequest, + TerminalEvent, + TerminalResizeRequest, + TerminalSnapshot, + TerminalSnapshotRequest, + TerminalWriteRequest +} from './terminal-contracts' export type { KnowledgeTaskError, KnowledgeTaskItem, @@ -1548,6 +1558,17 @@ export type DesktopApi = { stop: (conversationId: string) => Promise onState: (listener: (state: BrowserLiveState) => void) => () => void } + terminal: { + create: (request: TerminalCreateRequest) => Promise + write: (request: TerminalWriteRequest) => Promise + resize: (request: TerminalResizeRequest) => Promise + close: (request: TerminalCloseRequest) => Promise + getSnapshot: ( + request: TerminalSnapshotRequest + ) => Promise + ack: (request: TerminalAckRequest) => Promise + onEvent: (listener: (event: TerminalEvent) => void) => () => void + } settings: { getRuntime: () => Promise updateRuntime: (input: RuntimeSettingsInput) => Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index e522f03a..454dce8d 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -23,6 +23,13 @@ export const ipcChannels = { browserInteract: 'browser:interact', browserStop: 'browser:stop', browserState: 'browser:state', + terminalCreate: 'terminal:create', + terminalWrite: 'terminal:write', + terminalResize: 'terminal:resize', + terminalClose: 'terminal:close', + terminalSnapshot: 'terminal:snapshot', + terminalAck: 'terminal:ack', + terminalEvent: 'terminal:event', runtimeSettingsGet: 'settings:runtime:get', runtimeSettingsUpdate: 'settings:runtime:update', runtimeSettingsSelectWorkspace: 'settings:runtime:select-workspace', diff --git a/src/shared/terminal-contracts.test.ts b/src/shared/terminal-contracts.test.ts new file mode 100644 index 00000000..30fd4b23 --- /dev/null +++ b/src/shared/terminal-contracts.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIMITS, + terminalAckRequestSchema, + terminalCreateRequestSchema, + terminalEventSchema, + terminalResizeRequestSchema, + terminalSnapshotSchema, + terminalWriteRequestSchema +} from './terminal-contracts' + +const sessionId = '00000000-0000-4000-8000-000000000101' +const projectId = '00000000-0000-4000-8000-000000000201' + +describe('terminal contracts', () => { + it('accepts bounded create, write, resize, snapshot, event, and ACK data', () => { + expect( + terminalCreateRequestSchema.parse({ + target: { type: 'project', projectId }, + cols: 120, + rows: 30 + }) + ).toEqual({ + target: { type: 'project', projectId }, + cols: 120, + rows: 30 + }) + expect( + terminalWriteRequestSchema.parse({ sessionId, data: '你好\r' }) + ).toEqual({ sessionId, data: '你好\r' }) + expect( + terminalResizeRequestSchema.parse({ + sessionId, + cols: 80, + rows: 24 + }) + ).toBeTruthy() + expect( + terminalSnapshotSchema.parse({ + sessionId, + target: { type: 'project', projectId }, + targetLabel: '构建主机', + title: '终端 · 构建主机 1', + state: 'running', + shell: 'bash', + workingDirectory: '/home/builder', + size: { cols: 120, rows: 30 }, + lastSequence: 3, + exit: null, + error: null + }) + ).toBeTruthy() + expect( + terminalEventSchema.parse({ + type: 'output', + sessionId, + sequence: 4, + data: '\u001b[32mdone\u001b[0m\r\n' + }) + ).toBeTruthy() + expect( + terminalAckRequestSchema.parse({ sessionId, sequence: 4 }) + ).toEqual({ sessionId, sequence: 4 }) + }) + + it('uses UTF-8 byte limits for input and output chunks', () => { + expect( + terminalWriteRequestSchema.safeParse({ + sessionId, + data: '界'.repeat( + Math.floor(TERMINAL_LIMITS.maximumInputBytes / 3) + ) + }).success + ).toBe(true) + expect( + terminalWriteRequestSchema.safeParse({ + sessionId, + data: '界'.repeat( + Math.floor(TERMINAL_LIMITS.maximumInputBytes / 3) + 1 + ) + }).success + ).toBe(false) + expect( + terminalEventSchema.safeParse({ + type: 'output', + sessionId, + sequence: 1, + data: 'x'.repeat(TERMINAL_LIMITS.maximumEventBytes + 1) + }).success + ).toBe(false) + }) + + it('enforces session, sequence, and terminal dimension boundaries', () => { + for (const size of [ + { cols: TERMINAL_LIMITS.minimumColumns - 1, rows: 24 }, + { cols: TERMINAL_LIMITS.maximumColumns + 1, rows: 24 }, + { cols: 80, rows: TERMINAL_LIMITS.minimumRows - 1 }, + { cols: 80, rows: TERMINAL_LIMITS.maximumRows + 1 }, + { cols: 80.5, rows: 24 } + ]) { + expect( + terminalResizeRequestSchema.safeParse({ + sessionId, + ...size + }).success + ).toBe(false) + } + expect( + terminalWriteRequestSchema.safeParse({ + sessionId: 'renderer-selected-pid-123', + data: 'pwd\r' + }).success + ).toBe(false) + expect( + terminalEventSchema.safeParse({ + type: 'state', + sessionId, + sequence: 0, + state: 'running' + }).success + ).toBe(false) + expect( + terminalAckRequestSchema.safeParse({ + sessionId, + sequence: -1 + }).success + ).toBe(false) + }) + + it('strictly rejects shell, path, command, environment, and credentials', () => { + for (const extra of [ + { shell: 'C:\\custom\\shell.exe' }, + { cwd: 'C:\\private' }, + { command: 'curl example.invalid' }, + { environment: { MODEL_API_KEY: 'secret' } }, + { password: 'secret' }, + { privateKey: 'secret' }, + { pid: 42 } + ]) { + expect( + terminalCreateRequestSchema.safeParse({ + target: { type: 'local' }, + cols: 80, + rows: 24, + ...extra + }).success + ).toBe(false) + } + expect( + terminalCreateRequestSchema.safeParse({ + target: { + type: 'project', + projectId, + hostname: 'host.internal', + username: 'builder' + }, + cols: 80, + rows: 24 + }).success + ).toBe(false) + }) + + it('requires coherent exited snapshots and strict event variants', () => { + const snapshot = { + sessionId, + target: { type: 'local' }, + targetLabel: '本机', + title: '终端 · 本机 1', + state: 'exited', + shell: 'PowerShell 7', + workingDirectory: 'C:\\Users\\tester', + size: { cols: 80, rows: 24 }, + lastSequence: 2, + exit: null, + error: null + } as const + expect(terminalSnapshotSchema.safeParse(snapshot).success).toBe(false) + expect( + terminalSnapshotSchema.safeParse({ + ...snapshot, + exit: { exitCode: 0, signal: null } + }).success + ).toBe(true) + expect( + terminalEventSchema.safeParse({ + type: 'output', + sessionId, + sequence: 1, + data: 'ok', + state: 'running' + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/terminal-contracts.ts b/src/shared/terminal-contracts.ts new file mode 100644 index 00000000..80babedf --- /dev/null +++ b/src/shared/terminal-contracts.ts @@ -0,0 +1,254 @@ +import { z } from 'zod' +import { workbarTargetRefSchema } from './workbar-contracts' + +export const TERMINAL_LIMITS = { + maximumSessionsPerWindow: 12, + minimumColumns: 2, + maximumColumns: 500, + minimumRows: 1, + maximumRows: 200, + maximumTitleBytes: 256, + maximumShellLabelBytes: 256, + maximumTargetLabelBytes: 512, + maximumWorkingDirectoryBytes: 4 * 1024, + maximumInputBytes: 64 * 1024, + maximumEventBytes: 64 * 1024, + maximumPendingEvents: 256, + maximumBufferedOutputBytes: 4 * 1024 * 1024, + maximumErrorMessageBytes: 2 * 1024 +} as const + +const boundedUtf8StringSchema = ( + maximumBytes: number, + minimumBytes = 0 +): z.ZodType => + z.string().refine( + (value) => { + const size = new TextEncoder().encode(value).byteLength + return size >= minimumBytes && size <= maximumBytes + }, + `Value must contain ${minimumBytes}-${maximumBytes} UTF-8 bytes` + ) + +export const terminalSessionIdSchema = z.string().uuid() +export type TerminalSessionId = z.infer< + typeof terminalSessionIdSchema +> + +export const terminalTargetSchema = workbarTargetRefSchema +export type TerminalTarget = z.infer + +export const terminalSessionStateSchema = z.enum([ + 'starting', + 'running', + 'exited', + 'interrupted', + 'closing', + 'failed' +]) +export type TerminalSessionState = z.infer< + typeof terminalSessionStateSchema +> + +export const terminalErrorCodeSchema = z.enum([ + 'session-limit-reached', + 'target-not-found', + 'target-unavailable', + 'host-unverified', + 'credentials-unavailable', + 'interactive-shell-unsupported', + 'launch-failed', + 'connection-failed', + 'session-not-found', + 'session-not-running', + 'interrupted', + 'output-limit-exceeded', + 'internal-error' +]) +export type TerminalErrorCode = z.infer< + typeof terminalErrorCodeSchema +> + +export const terminalErrorSchema = z + .object({ + code: terminalErrorCodeSchema, + message: boundedUtf8StringSchema( + TERMINAL_LIMITS.maximumErrorMessageBytes, + 1 + ), + retryable: z.boolean() + }) + .strict() +export type TerminalError = z.infer + +export const terminalSizeSchema = z + .object({ + cols: z + .number() + .int() + .min(TERMINAL_LIMITS.minimumColumns) + .max(TERMINAL_LIMITS.maximumColumns), + rows: z + .number() + .int() + .min(TERMINAL_LIMITS.minimumRows) + .max(TERMINAL_LIMITS.maximumRows) + }) + .strict() +export type TerminalSize = z.infer + +export const terminalCreateRequestSchema = z + .object({ + target: terminalTargetSchema, + cols: terminalSizeSchema.shape.cols, + rows: terminalSizeSchema.shape.rows + }) + .strict() +export type TerminalCreateRequest = z.infer< + typeof terminalCreateRequestSchema +> + +export const terminalWriteRequestSchema = z + .object({ + sessionId: terminalSessionIdSchema, + data: boundedUtf8StringSchema(TERMINAL_LIMITS.maximumInputBytes, 1) + }) + .strict() +export type TerminalWriteRequest = z.infer< + typeof terminalWriteRequestSchema +> + +export const terminalResizeRequestSchema = z + .object({ + sessionId: terminalSessionIdSchema, + cols: terminalSizeSchema.shape.cols, + rows: terminalSizeSchema.shape.rows + }) + .strict() +export type TerminalResizeRequest = z.infer< + typeof terminalResizeRequestSchema +> + +export const terminalCloseRequestSchema = z + .object({ + sessionId: terminalSessionIdSchema + }) + .strict() +export type TerminalCloseRequest = z.infer< + typeof terminalCloseRequestSchema +> + +export const terminalSnapshotRequestSchema = terminalCloseRequestSchema +export type TerminalSnapshotRequest = TerminalCloseRequest + +const terminalSequenceSchema = z.number().int().safe().min(0) +const terminalEventSequenceSchema = terminalSequenceSchema.min(1) + +export const terminalAckRequestSchema = z + .object({ + sessionId: terminalSessionIdSchema, + sequence: terminalSequenceSchema + }) + .strict() +export type TerminalAckRequest = z.infer< + typeof terminalAckRequestSchema +> + +export const terminalExitSchema = z + .object({ + exitCode: z.number().int().safe().nullable(), + signal: boundedUtf8StringSchema(128, 1).nullable() + }) + .strict() + .refine( + (exit) => exit.exitCode !== null || exit.signal !== null, + 'An exit must include an exit code or signal' + ) +export type TerminalExit = z.infer + +export const terminalSnapshotSchema = z + .object({ + sessionId: terminalSessionIdSchema, + target: terminalTargetSchema, + targetLabel: boundedUtf8StringSchema( + TERMINAL_LIMITS.maximumTargetLabelBytes, + 1 + ), + title: boundedUtf8StringSchema( + TERMINAL_LIMITS.maximumTitleBytes, + 1 + ), + state: terminalSessionStateSchema, + shell: boundedUtf8StringSchema( + TERMINAL_LIMITS.maximumShellLabelBytes, + 1 + ), + workingDirectory: boundedUtf8StringSchema( + TERMINAL_LIMITS.maximumWorkingDirectoryBytes, + 1 + ), + size: terminalSizeSchema, + lastSequence: terminalSequenceSchema, + exit: terminalExitSchema.nullable(), + error: terminalErrorSchema.nullable() + }) + .strict() + .superRefine((snapshot, context) => { + if (snapshot.state === 'exited' && snapshot.exit === null) { + context.addIssue({ + code: 'custom', + path: ['exit'], + message: 'Exited terminal snapshots require exit details' + }) + } + if (snapshot.state !== 'exited' && snapshot.exit !== null) { + context.addIssue({ + code: 'custom', + path: ['exit'], + message: 'Only exited terminal snapshots include exit details' + }) + } + }) +export type TerminalSnapshot = z.infer< + typeof terminalSnapshotSchema +> + +const terminalEventBase = { + sessionId: terminalSessionIdSchema, + sequence: terminalEventSequenceSchema +} as const + +export const terminalEventSchema = z.discriminatedUnion('type', [ + z + .object({ + ...terminalEventBase, + type: z.literal('output'), + data: boundedUtf8StringSchema( + TERMINAL_LIMITS.maximumEventBytes, + 1 + ) + }) + .strict(), + z + .object({ + ...terminalEventBase, + type: z.literal('state'), + state: terminalSessionStateSchema + }) + .strict(), + z + .object({ + ...terminalEventBase, + type: z.literal('exit'), + exit: terminalExitSchema + }) + .strict(), + z + .object({ + ...terminalEventBase, + type: z.literal('error'), + error: terminalErrorSchema + }) + .strict() +]) +export type TerminalEvent = z.infer diff --git a/src/shared/workbar-contracts.test.ts b/src/shared/workbar-contracts.test.ts new file mode 100644 index 00000000..c77359b2 --- /dev/null +++ b/src/shared/workbar-contracts.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { + WORKBAR_APP_DEFINITIONS, + WORKBAR_LIMITS, + workbarAppDefinitionSchema, + workbarLayoutPreferencesSchema, + workbarTabInstanceSchema +} from './workbar-contracts' + +const taskId = '00000000-0000-4000-8000-000000000101' +const terminalId = '00000000-0000-4000-8000-000000000102' +const projectId = '00000000-0000-4000-8000-000000000201' + +describe('workbar contracts', () => { + it('freezes the first application catalog and instance policies', () => { + expect( + WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({ + id, + instancePolicy, + defaultOpen + })) + ).toEqual([ + { id: 'tasks', instancePolicy: 'single', defaultOpen: true }, + { id: 'workspace', instancePolicy: 'single', defaultOpen: true }, + { id: 'browser', instancePolicy: 'single', defaultOpen: true }, + { id: 'results', instancePolicy: 'single', defaultOpen: true }, + { + id: 'terminal', + instancePolicy: 'multiple', + defaultOpen: false + } + ]) + for (const definition of WORKBAR_APP_DEFINITIONS) { + expect(workbarAppDefinitionSchema.parse(definition)).toEqual( + definition + ) + } + }) + + it('accepts an ordered layout with public target references', () => { + const layout = { + instances: [ + { id: taskId, appId: 'tasks', title: '任务中心' }, + { + id: terminalId, + appId: 'terminal', + title: '终端 · 项目', + targetRef: { type: 'project', projectId } + } + ], + activeInstanceId: terminalId, + expanded: true, + dock: 'right', + widthRatio: 0.35 + } as const + + expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout) + }) + + it('rejects unknown and sensitive target parameters', () => { + for (const targetRef of [ + { type: 'local', cwd: 'C:\\secret' }, + { type: 'ssh-host', hostId: projectId, password: 'secret' }, + { + type: 'project', + projectId, + environment: { MODEL_API_KEY: 'secret' } + } + ]) { + expect( + workbarTabInstanceSchema.safeParse({ + id: terminalId, + appId: 'terminal', + title: '终端', + targetRef + }).success + ).toBe(false) + } + expect( + workbarTabInstanceSchema.safeParse({ + id: taskId, + appId: 'tasks', + title: '任务中心', + privateState: {} + }).success + ).toBe(false) + }) + + it('enforces title, count, uniqueness, and active-instance bounds', () => { + expect( + workbarTabInstanceSchema.safeParse({ + id: terminalId, + appId: 'terminal', + title: 'x'.repeat(WORKBAR_LIMITS.maximumTitleBytes + 1), + targetRef: { type: 'local' } + }).success + ).toBe(false) + + const task = { id: taskId, appId: 'tasks', title: '任务中心' } + const base = { + instances: [task], + activeInstanceId: taskId, + expanded: true, + dock: 'right', + widthRatio: 0.4 + } + expect( + workbarLayoutPreferencesSchema.safeParse({ + ...base, + instances: [task, { ...task, id: terminalId }] + }).success + ).toBe(false) + expect( + workbarLayoutPreferencesSchema.safeParse({ + ...base, + activeInstanceId: terminalId + }).success + ).toBe(false) + expect( + workbarLayoutPreferencesSchema.safeParse({ + ...base, + instances: Array.from( + { length: WORKBAR_LIMITS.maximumOpenInstances + 1 }, + (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + appId: 'terminal', + title: `终端 ${index}`, + targetRef: { type: 'local' } + }) + ) + }).success + ).toBe(false) + }) +}) diff --git a/src/shared/workbar-contracts.ts b/src/shared/workbar-contracts.ts new file mode 100644 index 00000000..f0a31ff8 --- /dev/null +++ b/src/shared/workbar-contracts.ts @@ -0,0 +1,246 @@ +import { z } from 'zod' + +export const WORKBAR_LIMITS = { + maximumOpenInstances: 32, + maximumTitleBytes: 256, + maximumDescriptionBytes: 512, + maximumUnavailableReasonBytes: 512 +} as const + +const boundedUtf8TextSchema = ( + maximumBytes: number, + minimumBytes = 1 +): z.ZodType => + z.string().refine( + (value) => { + const size = new TextEncoder().encode(value).byteLength + return size >= minimumBytes && size <= maximumBytes + }, + `Text must contain ${minimumBytes}-${maximumBytes} UTF-8 bytes` + ) + +export const workbarAppIdSchema = z.enum([ + 'tasks', + 'workspace', + 'browser', + 'results', + 'terminal' +]) +export type WorkbarAppId = z.infer + +export const workbarInstancePolicySchema = z.enum([ + 'single', + 'multiple' +]) +export type WorkbarInstancePolicy = z.infer< + typeof workbarInstancePolicySchema +> + +export const workbarDefaultContextSchema = z.enum([ + 'application', + 'current-project' +]) +export type WorkbarDefaultContext = z.infer< + typeof workbarDefaultContextSchema +> + +export const workbarAvailabilitySchema = z.discriminatedUnion( + 'state', + [ + z.object({ state: z.literal('available') }).strict(), + z + .object({ + state: z.literal('unavailable'), + reason: boundedUtf8TextSchema( + WORKBAR_LIMITS.maximumUnavailableReasonBytes + ), + remedy: z.enum([ + 'open-project-settings', + 'open-host-settings', + 'open-application-settings', + 'retry' + ]) + }) + .strict() + ] +) +export type WorkbarAvailability = z.infer< + typeof workbarAvailabilitySchema +> + +export const workbarAppDefinitionSchema = z + .object({ + id: workbarAppIdSchema, + label: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes), + icon: workbarAppIdSchema, + description: boundedUtf8TextSchema( + WORKBAR_LIMITS.maximumDescriptionBytes + ), + instancePolicy: workbarInstancePolicySchema, + defaultContext: workbarDefaultContextSchema, + defaultOpen: z.boolean(), + availability: workbarAvailabilitySchema + }) + .strict() + .refine( + (definition) => definition.icon === definition.id, + 'The stable icon identifier must match the application identifier' + ) +export type WorkbarAppDefinition = z.infer< + typeof workbarAppDefinitionSchema +> + +export const WORKBAR_APP_DEFINITIONS = [ + { + id: 'tasks', + label: '任务中心', + icon: 'tasks', + description: '查看和管理当前任务。', + instancePolicy: 'single', + defaultContext: 'current-project', + defaultOpen: true, + availability: { state: 'available' } + }, + { + id: 'workspace', + label: '工作区', + icon: 'workspace', + description: '查看当前项目的工作区。', + instancePolicy: 'single', + defaultContext: 'current-project', + defaultOpen: true, + availability: { state: 'available' } + }, + { + id: 'browser', + label: '浏览器', + icon: 'browser', + description: '浏览任务相关内容。', + instancePolicy: 'single', + defaultContext: 'application', + defaultOpen: true, + availability: { state: 'available' } + }, + { + id: 'results', + label: '成果', + icon: 'results', + description: '查看任务生成的成果。', + instancePolicy: 'single', + defaultContext: 'current-project', + defaultOpen: true, + availability: { state: 'available' } + }, + { + id: 'terminal', + label: '终端', + icon: 'terminal', + description: '打开当前项目的用户终端。', + instancePolicy: 'multiple', + defaultContext: 'current-project', + defaultOpen: false, + availability: { state: 'available' } + } +] as const satisfies readonly WorkbarAppDefinition[] + +export const workbarTargetRefSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('local') }).strict(), + z + .object({ + type: z.literal('project'), + projectId: z.string().uuid() + }) + .strict() +]) +export type WorkbarTargetRef = z.infer< + typeof workbarTargetRefSchema +> + +export const workbarTabInstanceSchema = z + .object({ + id: z.string().uuid(), + appId: workbarAppIdSchema, + title: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes), + targetRef: workbarTargetRefSchema.optional() + }) + .strict() + .superRefine((instance, context) => { + if (instance.appId === 'terminal' && !instance.targetRef) { + context.addIssue({ + code: 'custom', + path: ['targetRef'], + message: 'Terminal instances require a public target reference' + }) + } + }) +export type WorkbarTabInstance = z.infer< + typeof workbarTabInstanceSchema +> + +const singleInstanceAppIds = new Set( + WORKBAR_APP_DEFINITIONS.filter( + (definition) => definition.instancePolicy === 'single' + ).map((definition) => definition.id) +) + +export const workbarLayoutPreferencesSchema = z + .object({ + instances: z + .array(workbarTabInstanceSchema) + .max(WORKBAR_LIMITS.maximumOpenInstances), + activeInstanceId: z.string().uuid().nullable(), + expanded: z.boolean(), + dock: z.literal('right'), + widthRatio: z.number().finite().gt(0).lt(1) + }) + .strict() + .superRefine((layout, context) => { + const instanceIds = new Set() + const seenSingleApps = new Set() + + layout.instances.forEach((instance, index) => { + if (instanceIds.has(instance.id)) { + context.addIssue({ + code: 'custom', + path: ['instances', index, 'id'], + message: 'Workbar instance identifiers must be unique' + }) + } + instanceIds.add(instance.id) + + if (singleInstanceAppIds.has(instance.appId)) { + if (seenSingleApps.has(instance.appId)) { + context.addIssue({ + code: 'custom', + path: ['instances', index, 'appId'], + message: 'Single-instance applications cannot be duplicated' + }) + } + seenSingleApps.add(instance.appId) + } + }) + + if ( + layout.activeInstanceId !== null && + !instanceIds.has(layout.activeInstanceId) + ) { + context.addIssue({ + code: 'custom', + path: ['activeInstanceId'], + message: 'The active workbar instance must be open' + }) + } + if ( + layout.instances.length > 0 && + layout.activeInstanceId === null + ) { + context.addIssue({ + code: 'custom', + path: ['activeInstanceId'], + message: 'An open workbar layout requires an active instance' + }) + } + }) +export type WorkbarLayoutPreferences = z.infer< + typeof workbarLayoutPreferencesSchema +>