feat: add multi-terminal workbar and resizable sidebars
The assistant workbar now uses persistent application tabs with a permanent catalog entry point and supports multiple local or managed SSH terminals bound to the current project. Terminal sessions flow through the Main, preload, and renderer boundaries with bounded output, resize, lifecycle, and cleanup behavior. The main sidebar and Magic Notes list now support pointer and keyboard resizing. Magic Notes can also hide and restore its list, remembers the chosen layout, and stacks its panes when the available width is narrow. Closing a terminal tab ends its Shell session. Restored terminal tabs do not restart prior Shell processes and require an explicit reconnect. Release note: 助手工作栏现可通过“+”打开多个本机或当前远程项目终端;主侧栏和魔法笔记列表支持拖拽调宽,魔法笔记列表可隐藏并记住布局。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+22
-4
@@ -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 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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 索引。
|
||||
|
||||
@@ -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-<UUID>` 临时目录,结束后已删除并确认无遗留 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 保存、环境准备和项目使用之间的职责。
|
||||
Generated
+32
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const runtimeProviderEnvironmentNames = [
|
||||
export const runtimeProviderEnvironmentNames = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
|
||||
+24
-1
@@ -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<EmbeddingInferenceBroker>()
|
||||
let stopRuntimeReconfiguration: (() => Promise<void>) | 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()],
|
||||
|
||||
@@ -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<typeof registerIpcHandlers> = [
|
||||
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,
|
||||
|
||||
+105
-1
@@ -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<void> {
|
||||
type ActiveRequestLease = {
|
||||
controller: AbortController
|
||||
@@ -1097,6 +1107,12 @@ export function registerIpcHandlers(
|
||||
release(): void
|
||||
}
|
||||
const activeRequests = new Map<string, ActiveRequestLease>()
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ClientChannel>;
|
||||
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<ClientChannel>;
|
||||
}
|
||||
|
||||
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<ClientChannel> {
|
||||
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<SshTerminalConnectionLease> {
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown> = {}
|
||||
) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean>
|
||||
directoryExists?: (path: string) => Promise<boolean>
|
||||
spawn?: LocalPtySpawn
|
||||
terminate?: (pty: IPty, platform: NodeJS.Platform) => Promise<void>
|
||||
}
|
||||
|
||||
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> = T extends TerminalEvent
|
||||
? Omit<T, 'sessionId' | 'sequence'>
|
||||
: never
|
||||
type LocalTerminalEvent = UnsequencedTerminalEvent<TerminalEvent>
|
||||
|
||||
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<boolean> {
|
||||
try {
|
||||
await access(
|
||||
path,
|
||||
process.platform === 'win32'
|
||||
? fileSystemConstants.F_OK
|
||||
: fileSystemConstants.X_OK
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultDirectoryExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findExecutableOnPath(
|
||||
name: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform,
|
||||
exists: (path: string) => Promise<boolean>
|
||||
): Promise<string | undefined> {
|
||||
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<string> {
|
||||
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<LocalTerminalLaunch> {
|
||||
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<void> {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((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<void> {
|
||||
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<LocalTerminalSession> {
|
||||
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<LocalTerminalEventListener>()
|
||||
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<TerminalSnapshot> | 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<void> {
|
||||
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<TerminalSnapshot> {
|
||||
if (this.closePromise) {
|
||||
return this.closePromise
|
||||
}
|
||||
this.closePromise = this.closeInternal()
|
||||
return this.closePromise
|
||||
}
|
||||
|
||||
private async closeInternal(): Promise<TerminalSnapshot> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createHarness>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<SshTerminalSession> {
|
||||
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,
|
||||
};
|
||||
@@ -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<typeof vi.fn>
|
||||
write: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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<TerminalSessionManagerDependencies> = {}
|
||||
) {
|
||||
let nextId = 700
|
||||
const delivered: Array<{ ownerId: number; event: TerminalEvent }> = []
|
||||
const localSessions: ReturnType<typeof fakeSession>[] = []
|
||||
const sshSessions: ReturnType<typeof fakeSession>[] = []
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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<void | TerminalSnapshot>
|
||||
onEvent?: (listener: (event: TerminalEvent) => void) => () => void
|
||||
}
|
||||
|
||||
export type TerminalSessionManagerDependencies = {
|
||||
database: Pick<AssistantDatabase, 'getProject'>
|
||||
executionSpaceResolver: Pick<ExecutionSpaceResolver, 'resolveProject'>
|
||||
targetResolver: RemoteAgentTargetResolver
|
||||
sshPool: SshConnectionPool
|
||||
remoteEnabled: () => boolean | Promise<boolean>
|
||||
deliverEvent: (ownerWebContentsId: number, event: TerminalEvent) => void
|
||||
createLocalSession?: (
|
||||
options: LocalTerminalSessionOptions
|
||||
) => Promise<ManagedTerminalSession>
|
||||
createSshSession?: (
|
||||
pool: SshConnectionPool,
|
||||
options: SshTerminalSessionOptions
|
||||
) => Promise<ManagedTerminalSession>
|
||||
createSessionId?: () => string
|
||||
}
|
||||
|
||||
type SessionRecord = {
|
||||
ownerId: number
|
||||
sessionId: string
|
||||
deliveryEnabled: boolean
|
||||
delayedEvents: TerminalEvent[]
|
||||
closing: boolean
|
||||
session?: ManagedTerminalSession
|
||||
ready: Promise<ManagedTerminalSession>
|
||||
closePromise?: Promise<TerminalSnapshot>
|
||||
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<string, SessionRecord>()
|
||||
private disposePromise: Promise<void> | undefined
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: TerminalSessionManagerDependencies
|
||||
) {}
|
||||
|
||||
async create(
|
||||
ownerWebContentsId: number,
|
||||
request: TerminalCreateRequest
|
||||
): Promise<TerminalSnapshot> {
|
||||
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<TerminalSnapshot> {
|
||||
const record = this.requireOwned(ownerWebContentsId, sessionId)
|
||||
return this.closeRecord(record)
|
||||
}
|
||||
|
||||
async closeOwner(ownerWebContentsId: number): Promise<void> {
|
||||
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<void> {
|
||||
this.disposePromise ??= this.disposeInternal()
|
||||
return this.disposePromise
|
||||
}
|
||||
|
||||
private async disposeInternal(): Promise<void> {
|
||||
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<ManagedTerminalSession> {
|
||||
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<SshTerminalSession>)
|
||||
)(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<ResolvedLaunch> {
|
||||
if (request.target.type === 'local') {
|
||||
return { kind: 'local', targetLabel: '本机' }
|
||||
}
|
||||
|
||||
let project: ReturnType<AssistantDatabase['getProject']>
|
||||
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<SshConnectionPoolTarget> {
|
||||
try {
|
||||
return await this.dependencies.targetResolver.resolve(hostId)
|
||||
} catch (error) {
|
||||
throw this.resolutionError(
|
||||
'target-unavailable',
|
||||
error,
|
||||
'SSH 主机不可用'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async assertRemoteEnabled(): Promise<void> {
|
||||
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<TerminalSnapshot> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<TerminalSnapshot>,
|
||||
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<TerminalSnapshot>,
|
||||
getSnapshot: (request: TerminalSnapshotRequest) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.terminalSnapshot,
|
||||
request
|
||||
) as Promise<TerminalSnapshot>,
|
||||
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(
|
||||
|
||||
@@ -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: \{(?<body>[\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'),
|
||||
|
||||
@@ -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<typeof import('./ActivityPanel')>()
|
||||
})
|
||||
|
||||
vi.mock('./TerminalPanel', () => ({
|
||||
TerminalPanel: ({ title }: { title?: string }) => (
|
||||
<section aria-label={`用户终端:${title ?? '终端'}`} />
|
||||
)
|
||||
}))
|
||||
|
||||
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(<App />)
|
||||
|
||||
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(<App />)
|
||||
|
||||
try {
|
||||
const sidebar =
|
||||
container.querySelector<HTMLElement>('.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(<App />)
|
||||
expect(
|
||||
persistedRender.container
|
||||
.querySelector<HTMLElement>('.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(<App />)
|
||||
@@ -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()
|
||||
|
||||
+247
-24
@@ -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<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth)
|
||||
const primarySidebarResizePointerIdRef = useRef<number | undefined>(
|
||||
undefined
|
||||
)
|
||||
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
@@ -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<HTMLDivElement>): 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<HTMLDivElement>): 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
|
||||
}
|
||||
>
|
||||
<BrandLockup
|
||||
className="brand"
|
||||
@@ -8757,6 +8924,65 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-controls="primary-sidebar"
|
||||
aria-disabled={!canResizePrimarySidebar}
|
||||
aria-label={t('sidebar.resizeAriaLabel')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={primarySidebarWidthLimits.maximum}
|
||||
aria-valuemin={primarySidebarWidthLimits.minimum}
|
||||
aria-valuenow={primarySidebarWidth}
|
||||
aria-valuetext={t('sidebar.resizeValue', {
|
||||
width: primarySidebarWidth
|
||||
})}
|
||||
className="primary-sidebar-resize-handle"
|
||||
onKeyDown={resizePrimarySidebarWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
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 && (
|
||||
<button
|
||||
aria-label={t('sidebar.close')}
|
||||
@@ -8882,26 +9108,24 @@ function App(): React.JSX.Element {
|
||||
</header>
|
||||
|
||||
<div className="app-content">
|
||||
{view === 'chat' && (
|
||||
<button
|
||||
aria-controls="assistant-sidebar"
|
||||
aria-expanded={assistantSidebarOpen}
|
||||
aria-label={t('topbar.toggleAssistantSidebar')}
|
||||
className="icon-button assistant-sidebar-toggle"
|
||||
onClick={() =>
|
||||
setAssistantSidebarOpen((current) => !current)
|
||||
}
|
||||
ref={assistantSidebarToggleRef}
|
||||
title={t('topbar.toggleAssistantSidebar')}
|
||||
type="button"
|
||||
>
|
||||
{assistantSidebarOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-controls="assistant-sidebar"
|
||||
aria-expanded={assistantSidebarOpen}
|
||||
aria-label={t('topbar.toggleAssistantSidebar')}
|
||||
className="icon-button assistant-sidebar-toggle"
|
||||
onClick={() =>
|
||||
setAssistantSidebarOpen((current) => !current)
|
||||
}
|
||||
ref={assistantSidebarToggleRef}
|
||||
title={t('topbar.toggleAssistantSidebar')}
|
||||
type="button"
|
||||
>
|
||||
{assistantSidebarOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<main
|
||||
aria-hidden={backgroundIsolated ? 'true' : undefined}
|
||||
@@ -10602,6 +10826,7 @@ function App(): React.JSX.Element {
|
||||
artifacts={sidebarArtifacts}
|
||||
browserState={browserStates[activeId]}
|
||||
conversationTitles={conversationTitles}
|
||||
currentProject={activeProject}
|
||||
onCreateCustomTask={() => 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}
|
||||
|
||||
@@ -198,6 +198,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(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(
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
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(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
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(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
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(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<MagicNoteEntry>()
|
||||
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<HTMLDivElement>(null)
|
||||
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
|
||||
const liveListPaneWidthRef = useRef(listPaneWidth)
|
||||
const listResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const liveAiPaneWidthRef = useRef(aiPaneWidth)
|
||||
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const composerRef = useRef<HTMLDivElement>(null)
|
||||
const continueEditingRef = useRef<HTMLButtonElement>(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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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<void> => {
|
||||
const title = newTitle.trim()
|
||||
@@ -1419,6 +1715,24 @@ export function MagicNotesWorkspace({
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
aria-controls="magic-notes-list-pane"
|
||||
aria-expanded={listPaneOpen}
|
||||
className="secondary-button"
|
||||
onClick={() => setListPaneOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{listPaneOpen ? (
|
||||
<PanelLeftClose aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<PanelLeftOpen aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t(
|
||||
listPaneOpen
|
||||
? 'actions.hideListPane'
|
||||
: 'actions.showListPane'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-controls="magic-notes-ai-pane"
|
||||
aria-expanded={aiPaneOpen}
|
||||
@@ -1497,14 +1811,26 @@ export function MagicNotesWorkspace({
|
||||
ref={magicNotesLayoutRef}
|
||||
aria-busy={Boolean(busy)}
|
||||
className={`magic-notes-layout${
|
||||
listPaneOpen ? '' : ' magic-notes-layout--list-hidden'
|
||||
}${
|
||||
aiPaneOpen ? '' : ' magic-notes-layout--ai-hidden'
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
(listPaneResizing && canResizeListPane) ||
|
||||
(aiPaneResizing && canResizeAiPane)
|
||||
? ' magic-notes-layout--resizing'
|
||||
: ''
|
||||
}${
|
||||
listPaneResizing && canResizeListPane
|
||||
? ' magic-notes-layout--list-resizing'
|
||||
: ''
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
? ' magic-notes-layout--ai-resizing'
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
'--magic-notes-list-width': `${listPaneWidth}px`,
|
||||
'--magic-notes-ai-width': `${aiPaneWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
@@ -1516,6 +1842,8 @@ export function MagicNotesWorkspace({
|
||||
: 'todos.listLabel'
|
||||
)}
|
||||
className="magic-notes-list-pane"
|
||||
hidden={!listPaneOpen}
|
||||
id="magic-notes-list-pane"
|
||||
>
|
||||
<PageTabs
|
||||
ariaLabel={t('page.contentLabel')}
|
||||
@@ -1772,6 +2100,59 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{listPaneOpen && (
|
||||
<div
|
||||
aria-controls="magic-notes-list-pane"
|
||||
aria-disabled={!canResizeListPane}
|
||||
aria-label={t('accessibility.resizeListPane')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={listPaneWidthLimits.maximum}
|
||||
aria-valuemin={listPaneWidthLimits.minimum}
|
||||
aria-valuenow={listPaneWidth}
|
||||
aria-valuetext={t('accessibility.listPaneWidth', {
|
||||
width: listPaneWidth
|
||||
})}
|
||||
className="magic-notes-list-resize-handle"
|
||||
onKeyDown={resizeListPaneWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (
|
||||
listResizePointerIdRef.current === event.pointerId
|
||||
) {
|
||||
listResizePointerIdRef.current = undefined
|
||||
setListPaneWidth(liveListPaneWidthRef.current)
|
||||
setListPaneResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishListPaneResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !canResizeListPane) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
listResizePointerIdRef.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizeListPaneFromClientX(event.clientX, true)
|
||||
setListPaneResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
listResizePointerIdRef.current !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!canResizeListPane) {
|
||||
finishListPaneResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizeListPaneFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishListPaneResize}
|
||||
role="separator"
|
||||
tabIndex={canResizeListPane ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-label={t(
|
||||
libraryView === 'notes'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1400
|
||||
|
||||
@@ -12,11 +12,20 @@ import {
|
||||
ShieldAlert,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
AssistantProject,
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryListing,
|
||||
WorkspaceFilePreview
|
||||
@@ -32,6 +41,28 @@ import {
|
||||
findTaskSchedule,
|
||||
TaskScheduleActions
|
||||
} from './TaskScheduleActions'
|
||||
import type {
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
workbarLayoutPreferencesSchema,
|
||||
type WorkbarAppDefinition,
|
||||
type WorkbarTabInstance,
|
||||
type WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
import {
|
||||
DEFAULT_WORKBAR_INSTANCES,
|
||||
WorkbarShell,
|
||||
type WorkbarInstanceCreateRequest
|
||||
} from './WorkbarShell'
|
||||
import type { TerminalAdapter } from './TerminalPanel'
|
||||
import './terminal-panel.css'
|
||||
|
||||
const TerminalPanel = lazy(async () => {
|
||||
const module = await import('./TerminalPanel')
|
||||
return { default: module.TerminalPanel }
|
||||
})
|
||||
|
||||
export type AssistantSidebarTab =
|
||||
| 'tasks'
|
||||
@@ -69,6 +100,7 @@ type RightAssistantSidebarProps = {
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
browserState?: BrowserLiveState
|
||||
currentProject?: AssistantProject
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
onInteractBrowser: () => Promise<void>
|
||||
onStopBrowser: () => Promise<void>
|
||||
@@ -108,6 +140,45 @@ const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
const defaultSidebarRatio = 0.3
|
||||
const minimumPaneWidth = 300
|
||||
const keyboardResizeStep = 16
|
||||
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
|
||||
|
||||
function loadPersistedWorkbarLayout(): ReturnType<
|
||||
typeof workbarLayoutPreferencesSchema.parse
|
||||
> | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(workbarStorageKey)
|
||||
if (!value || value.length > 100_000) {
|
||||
return undefined
|
||||
}
|
||||
return workbarLayoutPreferencesSchema.parse(JSON.parse(value))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistWorkbarLayout(
|
||||
instances: readonly WorkbarTabInstance[],
|
||||
activeInstanceId: string | null,
|
||||
expanded: boolean,
|
||||
widthRatio: number
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
workbarStorageKey,
|
||||
JSON.stringify(
|
||||
workbarLayoutPreferencesSchema.parse({
|
||||
instances,
|
||||
activeInstanceId,
|
||||
expanded,
|
||||
dock: 'right',
|
||||
widthRatio
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// The in-memory workbar remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function getSidebarWidthLimits(layoutWidth: number): {
|
||||
minimum: number
|
||||
@@ -170,6 +241,7 @@ export function RightAssistantSidebar({
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
browserState,
|
||||
currentProject,
|
||||
restoreFocusRef,
|
||||
onInteractBrowser,
|
||||
onStopBrowser,
|
||||
@@ -206,10 +278,68 @@ export function RightAssistantSidebar({
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
const initialLayout = useMemo(
|
||||
() => loadPersistedWorkbarLayout(),
|
||||
[]
|
||||
)
|
||||
const localizedAppDefinitions = useMemo(
|
||||
() =>
|
||||
WORKBAR_APP_DEFINITIONS.map((definition) => {
|
||||
const tabDefinition = tabs.find(
|
||||
(item) => item.id === definition.id
|
||||
)
|
||||
return {
|
||||
...definition,
|
||||
label:
|
||||
tabDefinition?.label ??
|
||||
t('sidebar.tabs.terminal.label'),
|
||||
description:
|
||||
tabDefinition?.description ??
|
||||
t('sidebar.tabs.terminal.description')
|
||||
} satisfies WorkbarAppDefinition
|
||||
}),
|
||||
[t, tabs]
|
||||
)
|
||||
const defaultInstances = useMemo(
|
||||
() =>
|
||||
DEFAULT_WORKBAR_INSTANCES.map((instance) => ({
|
||||
...instance,
|
||||
title:
|
||||
localizedAppDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.label ?? instance.title
|
||||
})),
|
||||
[localizedAppDefinitions]
|
||||
)
|
||||
const [workbarInstances, setWorkbarInstances] = useState<
|
||||
WorkbarTabInstance[]
|
||||
>(() =>
|
||||
(initialLayout?.instances ?? defaultInstances).map((instance) =>
|
||||
instance.appId === 'terminal'
|
||||
? instance
|
||||
: {
|
||||
...instance,
|
||||
title:
|
||||
localizedAppDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.label ?? instance.title
|
||||
}
|
||||
)
|
||||
)
|
||||
const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
|
||||
useState<string | null>(
|
||||
() =>
|
||||
initialLayout?.activeInstanceId ??
|
||||
defaultInstances.find((instance) => instance.appId === tab)?.id ??
|
||||
defaultInstances[0]?.id ??
|
||||
null
|
||||
)
|
||||
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
)
|
||||
const [sidebarRatio, setSidebarRatio] = useState(defaultSidebarRatio)
|
||||
const [sidebarRatio, setSidebarRatio] = useState(
|
||||
initialLayout?.widthRatio ?? defaultSidebarRatio
|
||||
)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const wasOpen = useRef(false)
|
||||
@@ -249,12 +379,41 @@ export function RightAssistantSidebar({
|
||||
'attention' | 'active' | 'paused' | 'finished'
|
||||
>('active')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [terminalSessionIds, setTerminalSessionIds] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [terminalSnapshots, setTerminalSnapshots] = useState<
|
||||
Record<string, TerminalSnapshot>
|
||||
>({})
|
||||
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
|
||||
useState<{
|
||||
instance: WorkbarTabInstance
|
||||
resolve: (accepted: boolean) => void
|
||||
closing: boolean
|
||||
error?: string
|
||||
}>()
|
||||
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
|
||||
const lastExternalTabRef = useRef(tab)
|
||||
const artifactPreview =
|
||||
artifacts.find((artifact) => artifact.id === selectedArtifactId)
|
||||
const currentWorkspacePreview =
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
: undefined
|
||||
const terminalAdapter = useMemo<TerminalAdapter>(
|
||||
() => ({
|
||||
create: (request) => window.goodbuddy.terminal.create(request),
|
||||
write: (request) => window.goodbuddy.terminal.write(request),
|
||||
resize: (request) => window.goodbuddy.terminal.resize(request),
|
||||
close: (request) => window.goodbuddy.terminal.close(request),
|
||||
getSnapshot: (request) =>
|
||||
window.goodbuddy.terminal.getSnapshot(request),
|
||||
ack: (request) => window.goodbuddy.terminal.ack(request),
|
||||
subscribe: (listener) =>
|
||||
window.goodbuddy.terminal.onEvent(listener)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
|
||||
const canResize =
|
||||
open &&
|
||||
@@ -288,6 +447,209 @@ export function RightAssistantSidebar({
|
||||
[taskFilter, topLevelTasks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (lastExternalTabRef.current === tab) {
|
||||
return
|
||||
}
|
||||
lastExternalTabRef.current = tab
|
||||
const requested = workbarInstances.find(
|
||||
(instance) => instance.appId === tab
|
||||
)
|
||||
if (requested) {
|
||||
// This effect intentionally mirrors the legacy external tab prop into
|
||||
// the dynamic workbar only when that prop actually changes.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveWorkbarInstanceId(requested.id)
|
||||
}
|
||||
}, [tab, workbarInstances])
|
||||
|
||||
useEffect(() => {
|
||||
persistWorkbarLayout(
|
||||
workbarInstances,
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio
|
||||
)
|
||||
}, [
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio,
|
||||
workbarInstances
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalCloseConfirmation && !terminalCloseConfirmation.closing) {
|
||||
terminalCloseCancelRef.current?.focus()
|
||||
}
|
||||
}, [terminalCloseConfirmation])
|
||||
|
||||
const resolveTerminalTarget = useCallback(
|
||||
(): WorkbarTargetRef =>
|
||||
currentProject
|
||||
? { type: 'project', projectId: currentProject.id }
|
||||
: { type: 'local' },
|
||||
[currentProject]
|
||||
)
|
||||
|
||||
const updateActiveWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setActiveWorkbarInstanceId(instanceId)
|
||||
const instance = workbarInstances.find(
|
||||
(candidate) => candidate.id === instanceId
|
||||
)
|
||||
if (
|
||||
instance &&
|
||||
instance.appId !== 'terminal'
|
||||
) {
|
||||
onTabChange(instance.appId)
|
||||
}
|
||||
},
|
||||
[onTabChange, workbarInstances]
|
||||
)
|
||||
|
||||
const createWorkbarInstance = useCallback(
|
||||
(request: WorkbarInstanceCreateRequest): void => {
|
||||
const definition = localizedAppDefinitions.find(
|
||||
(candidate) => candidate.id === request.appId
|
||||
)
|
||||
if (!definition) {
|
||||
return
|
||||
}
|
||||
const sameTargetTerminals = workbarInstances.filter(
|
||||
(instance) =>
|
||||
instance.appId === 'terminal' &&
|
||||
JSON.stringify(instance.targetRef) ===
|
||||
JSON.stringify(request.targetRef)
|
||||
).length
|
||||
const instance: WorkbarTabInstance = {
|
||||
id: crypto.randomUUID(),
|
||||
appId: request.appId,
|
||||
title:
|
||||
request.appId === 'terminal'
|
||||
? `${definition.label} ${sameTargetTerminals + 1}`
|
||||
: definition.label,
|
||||
...(request.targetRef
|
||||
? { targetRef: request.targetRef }
|
||||
: {})
|
||||
}
|
||||
setWorkbarInstances((current) => {
|
||||
const afterIndex = request.insertAfterInstanceId
|
||||
? current.findIndex(
|
||||
(candidate) =>
|
||||
candidate.id === request.insertAfterInstanceId
|
||||
)
|
||||
: -1
|
||||
const next = [...current]
|
||||
next.splice(afterIndex >= 0 ? afterIndex + 1 : next.length, 0, instance)
|
||||
return next
|
||||
})
|
||||
setActiveWorkbarInstanceId(instance.id)
|
||||
},
|
||||
[localizedAppDefinitions, workbarInstances]
|
||||
)
|
||||
|
||||
const removeWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setWorkbarInstances((current) =>
|
||||
current.filter((instance) => instance.id !== instanceId)
|
||||
)
|
||||
setTerminalSessionIds((current) => {
|
||||
const next = { ...current }
|
||||
delete next[instanceId]
|
||||
return next
|
||||
})
|
||||
setTerminalSnapshots((current) => {
|
||||
const next = { ...current }
|
||||
delete next[instanceId]
|
||||
return next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const requestCloseWorkbarInstance = useCallback(
|
||||
async (instance: WorkbarTabInstance): Promise<boolean> => {
|
||||
if (instance.appId !== 'terminal') {
|
||||
removeWorkbarInstance(instance.id)
|
||||
return true
|
||||
}
|
||||
const sessionId = terminalSessionIds[instance.id]
|
||||
const snapshot = terminalSnapshots[instance.id]
|
||||
if (
|
||||
sessionId &&
|
||||
(!snapshot ||
|
||||
snapshot.state === 'starting' ||
|
||||
snapshot.state === 'running' ||
|
||||
snapshot.state === 'closing')
|
||||
) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setTerminalCloseConfirmation({
|
||||
instance,
|
||||
resolve,
|
||||
closing: false
|
||||
})
|
||||
})
|
||||
}
|
||||
if (sessionId) {
|
||||
try {
|
||||
await terminalAdapter.close({ sessionId })
|
||||
} catch {
|
||||
// An already-ended session can still be removed from the workbar.
|
||||
}
|
||||
}
|
||||
removeWorkbarInstance(instance.id)
|
||||
return true
|
||||
},
|
||||
[
|
||||
removeWorkbarInstance,
|
||||
terminalAdapter,
|
||||
terminalSessionIds,
|
||||
terminalSnapshots
|
||||
]
|
||||
)
|
||||
|
||||
const confirmTerminalClose = useCallback(async (): Promise<void> => {
|
||||
const confirmation = terminalCloseConfirmation
|
||||
if (!confirmation || confirmation.closing) {
|
||||
return
|
||||
}
|
||||
const sessionId = terminalSessionIds[confirmation.instance.id]
|
||||
setTerminalCloseConfirmation({
|
||||
...confirmation,
|
||||
closing: true,
|
||||
error: undefined
|
||||
})
|
||||
try {
|
||||
if (sessionId) {
|
||||
await terminalAdapter.close({ sessionId })
|
||||
}
|
||||
removeWorkbarInstance(confirmation.instance.id)
|
||||
setTerminalCloseConfirmation(undefined)
|
||||
confirmation.resolve(true)
|
||||
} catch (reason) {
|
||||
setTerminalCloseConfirmation({
|
||||
...confirmation,
|
||||
closing: false,
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('sidebar.terminal.closeDialog.error')
|
||||
})
|
||||
}
|
||||
}, [
|
||||
removeWorkbarInstance,
|
||||
terminalAdapter,
|
||||
terminalCloseConfirmation,
|
||||
terminalSessionIds,
|
||||
t
|
||||
])
|
||||
|
||||
const cancelTerminalClose = useCallback((): void => {
|
||||
const confirmation = terminalCloseConfirmation
|
||||
setTerminalCloseConfirmation(undefined)
|
||||
confirmation?.resolve(false)
|
||||
}, [terminalCloseConfirmation])
|
||||
|
||||
useEffect(() => {
|
||||
const sidebar = sidebarRef.current
|
||||
const workspace = findWorkspaceSibling(sidebar)
|
||||
@@ -470,36 +832,6 @@ export function RightAssistantSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const moveTabFocus = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
tabId: AssistantSidebarTab
|
||||
): void => {
|
||||
const index = tabs.findIndex((item) => item.id === tabId)
|
||||
const targetIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: event.key === 'ArrowLeft'
|
||||
? (index - 1 + tabs.length) % tabs.length
|
||||
: event.key === 'ArrowRight'
|
||||
? (index + 1) % tabs.length
|
||||
: -1
|
||||
if (targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const target = tabs[targetIndex]
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setActionError('')
|
||||
onTabChange(target.id)
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
@@ -573,57 +905,75 @@ export function RightAssistantSidebar({
|
||||
role="separator"
|
||||
tabIndex={canResize ? 0 : -1}
|
||||
/>
|
||||
<nav
|
||||
aria-label={t('sidebar.categoriesAriaLabel')}
|
||||
className="assistant-sidebar__tabs"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
aria-controls="assistant-sidebar-panel"
|
||||
aria-selected={tab === item.id}
|
||||
className={
|
||||
tab === item.id
|
||||
? 'assistant-sidebar__tab assistant-sidebar__tab--active'
|
||||
: 'assistant-sidebar__tab'
|
||||
}
|
||||
id={`assistant-sidebar-tab-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActionError('')
|
||||
onTabChange(item.id)
|
||||
}}
|
||||
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
||||
role="tab"
|
||||
tabIndex={tab === item.id ? 0 : -1}
|
||||
title={item.description}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
{item.id === 'tasks' && approvals.length > 0 && (
|
||||
<span
|
||||
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
|
||||
className="assistant-sidebar__badge"
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
aria-labelledby={`assistant-sidebar-tab-${tab}`}
|
||||
className="assistant-sidebar__body"
|
||||
id="assistant-sidebar-panel"
|
||||
role="tabpanel"
|
||||
>
|
||||
<WorkbarShell
|
||||
activeInstanceId={activeWorkbarInstanceId}
|
||||
appDefinitions={localizedAppDefinitions}
|
||||
instances={workbarInstances}
|
||||
onActiveInstanceChange={updateActiveWorkbarInstance}
|
||||
onCloseInstance={requestCloseWorkbarInstance}
|
||||
onCreateInstance={createWorkbarInstance}
|
||||
onResolveTerminalTarget={resolveTerminalTarget}
|
||||
renderTabAdornment={(instance) =>
|
||||
instance.appId === 'tasks' && approvals.length > 0 ? (
|
||||
<span
|
||||
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
|
||||
className="assistant-sidebar__badge"
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
renderPanel={(instance) => (
|
||||
<div className="assistant-sidebar__body">
|
||||
{actionError ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{tab === 'tasks' && (
|
||||
{instance.appId === 'terminal' && instance.targetRef ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<section
|
||||
aria-busy="true"
|
||||
aria-label={t('sidebar.terminal.loading')}
|
||||
className="terminal-panel terminal-panel--loading"
|
||||
>
|
||||
<span>{t('sidebar.terminal.loading')}</span>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<TerminalPanel
|
||||
adapter={terminalAdapter}
|
||||
onRename={(title) =>
|
||||
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}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{instance.appId === 'tasks' && (
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<ShieldAlert size={15} />
|
||||
@@ -812,7 +1162,7 @@ export function RightAssistantSidebar({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'workspace' && (
|
||||
{instance.appId === 'workspace' && (
|
||||
currentWorkspacePreview ? (
|
||||
<section
|
||||
aria-busy={currentWorkspacePreview.state === 'loading'}
|
||||
@@ -934,7 +1284,7 @@ export function RightAssistantSidebar({
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'results' && (
|
||||
{instance.appId === 'results' && (
|
||||
artifactPreview ? (
|
||||
<section className="assistant-sidebar__preview">
|
||||
<header>
|
||||
@@ -1043,7 +1393,7 @@ export function RightAssistantSidebar({
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'browser' && (
|
||||
{instance.appId === 'browser' && (
|
||||
<section className="assistant-sidebar__browser">
|
||||
<header>
|
||||
<span>
|
||||
@@ -1149,7 +1499,54 @@ export function RightAssistantSidebar({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{terminalCloseConfirmation ? (
|
||||
<div className="assistant-sidebar__overlay">
|
||||
<section
|
||||
aria-label={t('sidebar.terminal.closeDialog.ariaLabel')}
|
||||
className="assistant-sidebar__terminal-close"
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!terminalCloseConfirmation.closing
|
||||
) {
|
||||
event.preventDefault()
|
||||
cancelTerminalClose()
|
||||
}
|
||||
}}
|
||||
role="alertdialog"
|
||||
>
|
||||
<h2>{t('sidebar.terminal.closeDialog.title')}</h2>
|
||||
<p>{t('sidebar.terminal.closeDialog.description')}</p>
|
||||
{terminalCloseConfirmation.error ? (
|
||||
<p role="alert">{terminalCloseConfirmation.error}</p>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={terminalCloseConfirmation.closing}
|
||||
onClick={cancelTerminalClose}
|
||||
ref={terminalCloseCancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.terminal.closeDialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={terminalCloseConfirmation.closing}
|
||||
onClick={() => void confirmTerminalClose()}
|
||||
type="button"
|
||||
>
|
||||
{terminalCloseConfirmation.closing
|
||||
? t('sidebar.terminal.closeDialog.closing')
|
||||
: t('sidebar.terminal.closeDialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> = {}
|
||||
): 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<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
fit: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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<typeof vi.fn>
|
||||
}
|
||||
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
onSessionChange={firstCallback}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
const latestCallback = vi.fn()
|
||||
view.rerender(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
onSessionChange={latestCallback}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={factory}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<DesktopApi['terminal'], 'onEvent'> & {
|
||||
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<TerminalSnapshot['state']>([
|
||||
'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<HTMLDivElement>(null)
|
||||
const emulatorRef = useRef<TerminalEmulator | undefined>(undefined)
|
||||
const fitAddonRef = useRef<TerminalFitAddon | undefined>(undefined)
|
||||
const searchAddonRef = useRef<TerminalSearchAddon | undefined>(
|
||||
undefined
|
||||
)
|
||||
const activeSessionRef = useRef<TerminalSessionId | undefined>(
|
||||
undefined
|
||||
)
|
||||
const snapshotRef = useRef<TerminalSnapshot | undefined>(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<string | undefined>(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<void> => {
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
event.sessionId !== activeSessionRef.current ||
|
||||
event.sequence <= acceptedSequenceRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
acceptedSequenceRef.current = event.sequence
|
||||
|
||||
if (event.type === 'output') {
|
||||
await new Promise<void>((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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<section
|
||||
className="terminal-panel"
|
||||
aria-label={t('sidebar.terminal.ariaLabel', {
|
||||
title: title ?? location
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="terminal-panel__toolbar"
|
||||
role="toolbar"
|
||||
aria-label={t('sidebar.terminal.toolbar.ariaLabel')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.search')}
|
||||
aria-label={t('sidebar.terminal.toolbar.search')}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen((value) => !value)}
|
||||
>
|
||||
<Search aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.copy')}
|
||||
aria-label={t('sidebar.terminal.toolbar.copy')}
|
||||
onClick={() => void copySelection()}
|
||||
>
|
||||
<Copy aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.paste')}
|
||||
aria-label={t('sidebar.terminal.toolbar.paste')}
|
||||
disabled={snapshot?.state !== 'running'}
|
||||
onClick={() => void paste()}
|
||||
>
|
||||
<Clipboard aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.selectAll')}
|
||||
aria-label={t('sidebar.terminal.toolbar.selectAllAriaLabel')}
|
||||
onClick={() => emulatorRef.current?.selectAll()}
|
||||
>
|
||||
{t('sidebar.terminal.toolbar.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.clear')}
|
||||
aria-label={t('sidebar.terminal.toolbar.clear')}
|
||||
onClick={() => {
|
||||
emulatorRef.current?.clear()
|
||||
emulatorRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<Eraser aria-hidden="true" />
|
||||
</button>
|
||||
{onRename ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.rename')}
|
||||
aria-label={t('sidebar.terminal.toolbar.rename')}
|
||||
aria-pressed={renaming}
|
||||
onClick={() => {
|
||||
setDraftTitle(title ?? snapshot?.title ?? '')
|
||||
setRenaming(true)
|
||||
}}
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="terminal-panel__toolbar-spacer" />
|
||||
{canReopen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-panel__reopen"
|
||||
onClick={() => void reopenSession()}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{snapshot.state === 'interrupted'
|
||||
? t('sidebar.terminal.reopen.interrupted')
|
||||
: t('sidebar.terminal.reopen.default')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{searchOpen ? (
|
||||
<form
|
||||
className="terminal-panel__inline-form"
|
||||
role="search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
runSearch()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={searchInputId}>
|
||||
{t('sidebar.terminal.search.label')}
|
||||
</label>
|
||||
<input
|
||||
id={searchInputId}
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSearchOpen(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit">
|
||||
{t('sidebar.terminal.search.next')}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{renaming ? (
|
||||
<form
|
||||
className="terminal-panel__inline-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitRename()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={titleInputId}>
|
||||
{t('sidebar.terminal.rename.label')}
|
||||
</label>
|
||||
<input
|
||||
id={titleInputId}
|
||||
autoFocus
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={!draftTitle.trim()}>
|
||||
{t('sidebar.terminal.rename.save')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setRenaming(false)}>
|
||||
{t('sidebar.terminal.rename.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="terminal-panel__error" role="alert">
|
||||
<span>{error}</span>
|
||||
{!snapshot ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMeasuredSize}
|
||||
onClick={() => void reopenSession()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="terminal-panel__emulator"
|
||||
aria-label={t('sidebar.terminal.status.emulatorAriaLabel')}
|
||||
/>
|
||||
|
||||
<footer
|
||||
className="terminal-panel__status"
|
||||
aria-label={t('sidebar.terminal.status.ariaLabel')}
|
||||
>
|
||||
<span title={location}>{location}</span>
|
||||
<span
|
||||
title={
|
||||
snapshot?.workingDirectory ??
|
||||
t('sidebar.terminal.status.readingDirectory')
|
||||
}
|
||||
>
|
||||
{snapshot?.workingDirectory ??
|
||||
t('sidebar.terminal.status.readingDirectory')}
|
||||
</span>
|
||||
<span
|
||||
title={
|
||||
snapshot?.shell ??
|
||||
t('sidebar.terminal.status.readingShell')
|
||||
}
|
||||
>
|
||||
{snapshot?.shell ??
|
||||
t('sidebar.terminal.status.readingShell')}
|
||||
</span>
|
||||
<strong
|
||||
data-state={snapshot?.state ?? (error ? 'failed' : 'starting')}
|
||||
>
|
||||
{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
|
||||
})
|
||||
: ''}
|
||||
</strong>
|
||||
</footer>
|
||||
<div className="terminal-panel__announcement" aria-live="polite">
|
||||
{announcement}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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<WorkbarTargetRef>
|
||||
}): ReactNode {
|
||||
const [instances, setInstances] = useState(initialInstances)
|
||||
const [activeId, setActiveId] = useState(
|
||||
initialInstances[0]?.id ?? null
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkbarShell
|
||||
activeInstanceId={activeId}
|
||||
instances={instances}
|
||||
onActiveInstanceChange={setActiveId}
|
||||
onCloseInstance={(instance) =>
|
||||
setInstances((current) =>
|
||||
current.filter((candidate) => candidate.id !== instance.id)
|
||||
)
|
||||
}
|
||||
onCreateInstance={onCreate}
|
||||
onResolveTerminalTarget={onTerminalTarget}
|
||||
renderPanel={(instance) => <p>{instance.title}内容</p>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkbarShell', () => {
|
||||
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
|
||||
render(<ControlledShell />)
|
||||
|
||||
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(<ControlledShell />)
|
||||
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(<ControlledShell onCreate={onCreate} />)
|
||||
|
||||
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(
|
||||
<ControlledShell
|
||||
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
|
||||
onCreate={onCreate}
|
||||
onTerminalTarget={onTerminalTarget}
|
||||
/>
|
||||
)
|
||||
|
||||
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(<ControlledShell />)
|
||||
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(<ControlledShell />)
|
||||
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(<ControlledShell />)
|
||||
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(
|
||||
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭任务中心' })
|
||||
)
|
||||
|
||||
const addButton = screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
await waitFor(() => expect(addButton).toHaveFocus())
|
||||
expect(
|
||||
screen.getByText('还没有打开的工作栏应用')
|
||||
).toBeInTheDocument()
|
||||
expect(addButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -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<WorkbarAppId, LucideIcon> = {
|
||||
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<boolean | void>
|
||||
onResolveTerminalTarget: () =>
|
||||
| WorkbarTargetRef
|
||||
| Promise<WorkbarTargetRef>
|
||||
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<HTMLButtonElement>(null)
|
||||
const catalogRef = useRef<HTMLDivElement>(null)
|
||||
const returnInstanceIdRef = useRef<string | null>(null)
|
||||
const pendingTabFocusRef = useRef<string | null | undefined>(undefined)
|
||||
const focusCreatedInstanceRef = useRef(false)
|
||||
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||
const [creatingAppId, setCreatingAppId] =
|
||||
useState<WorkbarAppId | null>(null)
|
||||
const [targetError, setTargetError] = useState<string | null>(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<HTMLButtonElement>(
|
||||
'[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<void> => {
|
||||
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<HTMLButtonElement>,
|
||||
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<void> => {
|
||||
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<boolean | void>).then ===
|
||||
'function'
|
||||
? await closeResult
|
||||
: closeResult
|
||||
if (closeAccepted === false) {
|
||||
return
|
||||
}
|
||||
pendingTabFocusRef.current = nextActive?.id ?? null
|
||||
if (nextActive && nextActive.id !== activeInstanceId) {
|
||||
onActiveInstanceChange(nextActive.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('sidebar.workbar.ariaLabel')}
|
||||
className={joinClassNames('workbar-shell', className)}
|
||||
>
|
||||
<div className="workbar-shell__tab-row">
|
||||
<div className="workbar-shell__tab-scroll">
|
||||
<div
|
||||
aria-label={t('sidebar.workbar.tablist')}
|
||||
className="workbar-shell__tablist"
|
||||
role="tablist"
|
||||
>
|
||||
{instances.map((instance, index) => {
|
||||
const selected =
|
||||
!catalogOpen && instance.id === activeInstanceId
|
||||
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab-item',
|
||||
selected && 'workbar-shell__tab-item--active'
|
||||
)}
|
||||
key={instance.id}
|
||||
>
|
||||
<button
|
||||
aria-controls={panelId(instance.id)}
|
||||
aria-selected={selected}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab',
|
||||
selected && 'workbar-shell__tab--active'
|
||||
)}
|
||||
id={tabId(instance.id)}
|
||||
onClick={() => {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
onActiveInstanceChange(instance.id)
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, index)
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
title={
|
||||
appDefinitions.find(
|
||||
(application) =>
|
||||
application.id === instance.appId
|
||||
)?.description ?? instance.title
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="workbar-shell__tab-icon"
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__tab-label">
|
||||
{instance.title}
|
||||
</span>
|
||||
{renderTabAdornment?.(instance)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
className="workbar-shell__tab-close"
|
||||
onClick={() => void handleClose(instance)}
|
||||
title={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded={catalogOpen}
|
||||
aria-label={t('sidebar.workbar.add')}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__add',
|
||||
catalogOpen && 'workbar-shell__add--active'
|
||||
)}
|
||||
onClick={() =>
|
||||
catalogOpen ? closeCatalog(true) : openCatalog()
|
||||
}
|
||||
ref={addButtonRef}
|
||||
title={t('sidebar.workbar.add')}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="workbar-shell__content">
|
||||
{instances.map((instance) => (
|
||||
<div
|
||||
aria-labelledby={tabId(instance.id)}
|
||||
className="workbar-shell__tabpanel"
|
||||
hidden={catalogOpen || instance.id !== activeInstanceId}
|
||||
id={panelId(instance.id)}
|
||||
key={instance.id}
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
>
|
||||
{renderPanel(instance)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{catalogOpen ? (
|
||||
<div
|
||||
className="workbar-shell__catalog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeCatalog(true)
|
||||
}
|
||||
}}
|
||||
ref={catalogRef}
|
||||
>
|
||||
<div className="workbar-shell__catalog-header">
|
||||
<div>
|
||||
<h2>{t('sidebar.workbar.catalogTitle')}</h2>
|
||||
<p>{t('sidebar.workbar.catalogDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => closeCatalog(true)}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{targetError ? (
|
||||
<p className="workbar-shell__catalog-error" role="alert">
|
||||
{targetError}
|
||||
</p>
|
||||
) : null}
|
||||
<ul
|
||||
aria-label={t('sidebar.workbar.catalog')}
|
||||
className="workbar-shell__catalog-list"
|
||||
>
|
||||
{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 (
|
||||
<li
|
||||
className="workbar-shell__catalog-item"
|
||||
key={definition.id}
|
||||
>
|
||||
<button
|
||||
className="workbar-shell__catalog-choice"
|
||||
data-workbar-app-choice
|
||||
disabled={disabled}
|
||||
onClick={() => void chooseApp(definition)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`workbar-shell__app-icon workbar-shell__app-icon--${definition.icon}`}
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__catalog-copy">
|
||||
<strong>{definition.label}</strong>
|
||||
<span>{definition.description}</span>
|
||||
<small>
|
||||
{existing
|
||||
? t('sidebar.workbar.existing')
|
||||
: definition.instancePolicy === 'multiple'
|
||||
? t('sidebar.workbar.multiple')
|
||||
: t('sidebar.workbar.single')}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
{availability.state === 'unavailable' ? (
|
||||
<div className="workbar-shell__unavailable">
|
||||
<span>{availability.reason}</span>
|
||||
{onResolveUnavailableApp ? (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
onResolveUnavailableApp(definition)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.resolve')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : atLimit ? (
|
||||
<small className="workbar-shell__limit">
|
||||
{t('sidebar.workbar.limit', {
|
||||
count: WORKBAR_LIMITS.maximumOpenInstances
|
||||
})}
|
||||
</small>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : instances.length === 0 ? (
|
||||
<div className="workbar-shell__empty">
|
||||
<strong>{t('sidebar.workbar.emptyTitle')}</strong>
|
||||
<p>{t('sidebar.workbar.emptyDescription')}</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={openCatalog}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.open')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -59,7 +59,9 @@ export const app = {
|
||||
localWorkspace: '本地工作区',
|
||||
loading: '加载中',
|
||||
close: '关闭侧栏',
|
||||
toggle: '切换侧栏'
|
||||
toggle: '切换侧栏',
|
||||
resizeAriaLabel: '调整主侧栏宽度',
|
||||
resizeValue: '主侧栏 {{width}} 像素'
|
||||
},
|
||||
topbar: {
|
||||
toggleAssistantSidebar: '切换助手工作栏',
|
||||
|
||||
@@ -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}} 像素'
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+482
-17
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
||||
}
|
||||
terminal: {
|
||||
create: (request: TerminalCreateRequest) => Promise<TerminalSnapshot>
|
||||
write: (request: TerminalWriteRequest) => Promise<void>
|
||||
resize: (request: TerminalResizeRequest) => Promise<void>
|
||||
close: (request: TerminalCloseRequest) => Promise<TerminalSnapshot>
|
||||
getSnapshot: (
|
||||
request: TerminalSnapshotRequest
|
||||
) => Promise<TerminalSnapshot>
|
||||
ack: (request: TerminalAckRequest) => Promise<void>
|
||||
onEvent: (listener: (event: TerminalEvent) => void) => () => void
|
||||
}
|
||||
settings: {
|
||||
getRuntime: () => Promise<RuntimeSettings>
|
||||
updateRuntime: (input: RuntimeSettingsInput) => Promise<RuntimeSettings>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string> =>
|
||||
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<typeof terminalTargetSchema>
|
||||
|
||||
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<typeof terminalErrorSchema>
|
||||
|
||||
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<typeof terminalSizeSchema>
|
||||
|
||||
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<typeof terminalExitSchema>
|
||||
|
||||
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<typeof terminalEventSchema>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string> =>
|
||||
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<typeof workbarAppIdSchema>
|
||||
|
||||
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<WorkbarAppId>(
|
||||
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<string>()
|
||||
const seenSingleApps = new Set<WorkbarAppId>()
|
||||
|
||||
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
|
||||
>
|
||||
Reference in New Issue
Block a user