feat(tasks): 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置

三件事一起落地(用户 brief),各自独立可验证:

1) 后台任务输出改为常驻浮动窗
   - 新增 src/client/FloatingWindow.tsx + .module.css:可复用组件,标题栏(拖拽手柄 +
     复制/关闭)、内容区(自滚动,长输出不再跑出视口)、可选底部行、三个拉伸柄
     (右/下/右下)。生命周期与 AnchoredPopover 相反:外部点击、失焦、锚点离屏都不关,
     只有关闭按钮与 Escape 结束;尺寸只在挂载时量一次(流式输出帧不会挪动读者摆好的窗);
     拖拽保持整窗在视口内(放不下才退化到保留 48px 可达);视口变化时夹回。
   - 手势复用既有范式:移动走 window 级 pointermove(同 AnchoredPopover),拉伸走
     setPointerCapture + createFrameBatcher(同 EditorHost 的面板拉伸)。
   - JobOutputPopoverContent → JobOutputWindow;顺带修掉「跟随最新」从不滚动的 bug
     (原先滚的是 TerminalBlock 外面那个永不滚动的 div),并清掉随旧弹窗死掉的
     .popPre / .jobPopPre 规则。节点/工作流详情与任务窗保持原样。

2) 节点卡统一
   - 删除两条左侧竖条(层级条 .cardTop::before、当前会话条 .nodeCurrent::before);
     层级改由上段极淡底色承担(7/5/3.5/2.5% → 9/7/5/3.5% 补偿对比度);
     当前会话改为 2px accent 描边(「边框加粗高亮」)。
   - 圆角 4px → 8px(与浮动窗同一家族);成员/工作流/聚合按用户选择保留类型区分。
   - 2px 而非 1.5px:真机 recon 发现 Chromium 在 DPR 1 下把亚像素边框向下取整,
     1.5px 的「加粗」完全看不出(实测 borderTopWidth: "1px")。几何不受影响
     (border-box + min-height,卡片外框 247×134 与预算逐像素一致)。

3) 手机设置
   - 两个新偏好 mobileNoAutoOpen / mobileDefaultTree(默认都开),落到 prefs-shared、
     PrefsSchema(volatile 自动)、parsePrefs 校验与设置页新增的「手机」分组。
   - 窄屏(沿用 768px 断点)时:新子代理与新后台任务都不再自动激活任务页;
     任务页默认树状图(页内切换仍是临时覆盖)。宽屏或开关关掉后行为完全不变。
   - 5 条词条 ×20 份词典;外部插件指南的 PrefsSchema 键清单同步。

验证:typecheck / lint ✅;pnpm test 1254 passed / 9 skipped(新增 14 例);
挂载 lane 7/7(真实 DSH 0.1.7-rc.2);3080 真机自测通过(长输出窗口内部滚动
5459px/449px、拖拽 424→304、拉伸 1008×540→918×480、外部点击不关、Escape 关、
卡片 8px/2px/无竖条、窄屏不自动弹出且默认树状图)。

(cherry picked from commit 7d9b428c6c34d28ad2d60c2086496eb533902273)
This commit is contained in:
Menghuan1918
2026-09-27 22:18:57 +08:00
parent 5ea2f2d6ec
commit d38fefe55d
38 changed files with 1300 additions and 156 deletions
+1 -1
View File
@@ -879,7 +879,7 @@ ctx.effect(() =>
) )
``` ```
> ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `agentOpenTools` / `editorExplorer` / `workspaceFence` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 > ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `workspaceFence` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
### 8.2 宿主设置表单(DSH 0.1.7+:`SettingsForms`) ### 8.2 宿主设置表单(DSH 0.1.7+:`SettingsForms`)
@@ -0,0 +1,122 @@
# 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置
日期:2026-09-27 分支:`feat/tasks-graph-polish`(承接 PR #680 的打磨线)
## 背景(用户原话)
> 1. 后台任务弹窗没有高度限制,输出很长会导致看不到。同时让其变为常驻的全屏弹窗(带关闭/拉伸,建议抽取为全局组件方便后续复用)
> 2. 优化节点UI,加上圆角,同时取消左侧装饰条,让整个节点看起来更为统一
> 3. 设置中新增手机设置,里面放各种和手机端的适配设置,目前加上这么几项:检测到处于手机状态时,有新的后台任务或Agent任务时不会自动弹出,任务管理的默认状态切换为树状图
## 用户确认的决策(问答摘要)
| # | 问题 | 结论 |
|---|---|---|
| 1 | 弹窗形态 | 可拖拽 + 可拉伸的**常驻浮动窗**(默认约视口 70%×60%,最小 320×240) |
| 2 | 复用范围 | 抽成**全局组件**,本轮只把后台任务输出换过去(节点/工作流详情、任务窗保持现状) |
| 3 | 常驻边界 | 只靠**关闭按钮 / Escape**;外部点击、失焦、锚点离屏都不关;切会话/关标签、任务结算后都保留 |
| 4 | 左侧竖条 | 「就是左侧的竖条,这个去掉」 |
| 5 | 层级组色 | 「这个可以留着,只是那个竖线不要了」 |
| 6 | 当前会话标记 | 「边框加粗高亮即可」 |
| 7 | 圆角 | **8px**,并**保留类型描边**(成员 accent 描边、工作流 accent 描边 + 底色) |
| 8 | 手机设置 | **自动检测(沿用 768px 窄屏判定)+ 两个独立开关(默认都开)**,分组放在设置页 |
## 架构与文件
```
新增
src/client/FloatingWindow.tsx 常驻浮动窗(拖拽 + 拉伸 + 关闭,内容区自滚动)
src/client/FloatingWindow.module.css 窗框/标题栏/内容区/底部行/三个拉伸柄样式
tests/floating-window.spec.tsx 9 例:结构、尺寸与定位、关闭契约、两个手势
改写
src/client/JobsDrawer.tsx JobOutputPopoverContent → JobOutputWindow
src/client/SubagentView.tsx 任务分支改渲染窗口;默认视图加窄屏覆盖
src/client/tasks-graph.module.css 卡片:去两条左侧竖条、圆角 8px、当前会话 1.5px 描边
src/client/sidebar/use-host-feeds.ts 两个自动弹出 gate 加手机压制
src/client/SideCardSection.tsx 新增「手机」分组(两行开关)
src/prefs-shared.ts / src/config.ts / src/client/prefs.ts
两个新偏好 mobileNoAutoOpen / mobileDefaultTree
src/client/locales*.ts(20 份) 5 条手机设置词条
```
## 关键设计
### 1. 常驻浮动窗(`FloatingWindow.tsx`)
- **为什么不是复用 `AnchoredPopover`**:那是 popover —— 锚定元素、按内容定尺寸、外部 mousedown / window blur / 锚点离开视口都会关它。被**持续观察**的面(一个跑几分钟的任务输出)需要的恰好相反的生命周期:只有关闭按钮与 Escape 结束它,而且必须能挪开、能拉伸。
- **内容区就是高度上限**:`data-window-body` 自己 `overflow: auto`,所以「输出很长看不到」这条被结构性修掉(旧 popover 完全没有高度约束,`.popPre` 那个 160px 滚动井在改用 `TerminalBlock` 之后已成死规则 —— 本轮一并删掉)。
- **手势复用既有范式**:移动走 window 级 `pointermove`(同 `AnchoredPopover` 的拖拽,容器捕获会重定向 click、杀掉窗内按钮),拉伸走 `setPointerCapture` + `createFrameBatcher()`(同 `EditorHost` 的面板拉伸;逐事件 setState 是 #315 的拖拽卡顿)。
- **几何**:`position: fixed`;尺寸在**挂载时量一次**(之后每条流式输出帧都不会挪动读者摆好的窗);拖拽保证标题栏至少 48px 留在视口内;拉伸夹取到 `minSize` 与视口;window `resize` 时把窗夹回视口。
- **常驻**:不注册外部 mousedown / blur / IntersectionObserver;只注册 Escape(捕获阶段)。
- **皮肤契约**:令牌全量、8px 圆角与节点卡一致、浮动层保留 `--dsw-shadow-lv3`。
### 2. 任务输出接入
- `JobOutputWindow`:标题栏(任务 label + 复制按钮 + 关闭)/内容区(状态行 + `TerminalBlock`,`maxLines: Infinity` 保留「看完整输出」)/底部行(跟随最新 + 两击终止)。
- **顺带修掉的 bug**:「跟随最新」原先滚的是包在 `TerminalBlock` 外面的普通 `div`(永不滚动)——现在滚窗口内容区。
- 页面侧:`popover.kind === 'job'` 不再走 `AnchoredPopover`;节点/工作流详情仍是 280px 锚定小卡,任务窗不变。
- 任务从宿主 roster 消失时窗口关闭(无处再观察);**结算不掉行,窗口继续**。
### 3. 节点卡(`tasks-graph.module.css`)
| 变更 | 说明 |
|---|---|
| 删除 `.cardTop::before` | 层级竖条;层级改由上段极淡底色承担,并把色调从 7/5/3.5/2.5% 抬到 **9/7/5/3.5%** 补偿对比度损失 |
| 删除 `.nodeCurrent::before` | 当前会话竖条;`.nodeCurrent` 改为 **2px accent 描边**(「边框加粗高亮」) |
| `.node` 圆角 4px → **8px** | 与新的浮动窗同一圆角,卡与窗成为一个家族 |
| `.cardTop` 左内边距 10px → 8px | 竖条消失后的回正 |
| `.nodeTeam` / `.nodeWorkflow` / `.nodeFold` / `.nodeError` | 按用户选择**保留**类型区分;只继承新圆角 |
| 删除 `.popPre` / `.jobPopPre` | 随旧弹窗一起死掉的规则 |
**几何安全性**:`.node` 是 `box-sizing: border-box` + 布局给的 `min-height`,加粗描边被吸收在盒内,卡片外框尺寸不变 —— `tasks-graph-layout.ts` 的高度/行距预算**不需要改**,`tests/tasks-graph-layout.spec.ts` 保持全绿。
### 4. 手机设置
| 键 | 默认 | 语义 |
|---|---|---|
| `mobileNoAutoOpen` | true | 窄屏时**同时**压制子代理与后台任务两个自动弹出(既有两个开关的值不被改写;宽屏行为不变) |
| `mobileDefaultTree` | true | 窄屏时任务页默认树状图(页内切换仍是临时覆盖,优先级最高) |
- 判定沿用插件既有的 `isNarrowWidth`(768px 断点,与侧边栏手机布局、park 逻辑同源),在**触发时**读视口。
- 设置 UI:DSH 设置页「侧边卡片」分区新增「手机」分组(两行 title/desc + 宿主 `Switch`,走既有 `applyPref` 写入)。
## 实施偏差记录(写给复审)
1. **窗口尺寸只在挂载时量一次**(`useEffect(..., [])` + 一条 eslint-disable):若跟着 props/内容重算,每条流式输出帧都会把读者拖好的窗口弹回去。视口变化仍会夹取。
2. **`aria-label` 用 `<title>` 而不是 `<h1>` 之类**:与既有 `AnchoredPopover`/`Modal` 的可访问名做法一致(`role="dialog"` + 可访问名)。
3. **拉伸只给三个柄**(右、下、右下):实际用法是「日志变长 → 往右下长」,两轴各自还可单独拉;八个柄属于没被要求的能力。
4. **没有做焦点陷阱**:与插件现有的两个浮层(`AnchoredPopover` / 宿主 `Modal` 之外的弹窗)一致;Escape 是唯一键盘出口。
5. **窄屏自动激活的既有两个 spec 显式关掉了手机开关**:它们钉的是「窄屏也激活 + 收回列(park)」这条更早的承诺,而压制行为有自己的新用例;把 harness 的默认值改成「关」会让 spec 脱离真实默认,所以选择在用例里显式声明。
6. **`mobileDefaultTree` 只在有 store 时生效**:`SubagentView` 的 prefs 来自 `store` prop,spec harness 里多数用例不传 store(读作 `graph`);视图默认的新用例显式传入 `createSidebarStore()`。
7. **当前会话的加粗描边用 2px 而不是 1.5px**:真机自测发现 Chromium 在 device-pixel ratio 1 下**向下取整**亚像素边框宽度 —— 1.5px 的计算值直接是 1px,"加粗"完全看不出来(recon 实测 `borderTopWidth: "1px"`,而 class 与 accent 颜色都在)。改成整数 2px 后在 DPR 1 与 2 上都成立。
8. **`.popPre` / `.jobPopPre` 被删除**:旧 `jobs` 弹窗改用 `TerminalBlock` 之后它们就没有消费者了,本轮顺手清掉(与上次「drop the dead rules」同类)。
## 验证证据(2026-09-27)
| 项 | 结果 |
|---|---|
| `pnpm typecheck` / `pnpm lint` | ✅ 0 错误 |
| `pnpm test` | ✅ **1254 passed / 9 skipped / 0 failed**(120 文件;新增 14 例:窗口 9 / prefs 1 / 设置分组 1 / 自动激活压制 2 / 视图默认 1) |
| 真机挂载 lane | ✅ **7 passed**(真实 DSH 0.1.7-rc.2,scratch profile + 无头渲染 + 全 tab 扫描 + 性能测量;日志 `/tmp/e2e-win.log`) |
| 3080 真机自测 | ✅ 见下表(真实工作:长输出后台任务 + 子代理;截图 `/tmp/dsh-selfcheck/10-13-*.png`) |
### 3080 真机证据(逐项)
| 项 | 实测 |
|---|---|
| 卡片去竖条 + 圆角 + 加粗描边 | `radius: 8px`;`::before` 的 `content: none`(层级条与当前会话条都不存在了);当前会话 `borderTopWidth: 2px` + `rgb(65,118,230)`(accent);卡片外框 **247×134** 与布局预算逐像素一致(去掉竖条/加粗描边都不改几何) |
| 窗口限高(原始 bug) | 240 行输出的任务:窗口 1008×540 全在 1440×900 视口内,内容区 `scrollHeight 5459 > clientHeight 449` —— **真的在窗内滚动**;滚到底能看到末行 `THE-END` |
| 窗口结构 | 标题栏(任务名 + 复制 + 关闭)、内容区、底部行(跟随最新 + 终止)与三个拉伸柄(x / y / both)都在 |
| 拖拽与拉伸 | 拖标题栏:(424,352) → (304,352);拖右下角:1008×540 → **918×480** |
| 常驻语义 | 外部点击(窗口上方面板区、左侧会话列)后窗口仍在(count 仍为 1);Escape 关闭 |
| 手机适配 | 窄屏(700×900)起后台任务 + 子代理后,右侧栏**未被打开**(`data-sidebar-right-panel` 不存在);手动打开任务页 → `tree: 2 / graph: 0`(默认树状图) |
### 真机自测发现并修掉的两处
1. **1.5px 的「加粗」边框在 DPR 1 下等于 1px**:Chromium 向下取整亚像素边框宽度,真机上完全看不出加粗(recon 实测 `borderTopWidth: "1px"`,而 class 与 accent 颜色都在)。改成整数 **2px**(DPR 1 与 2 都成立)。
2. **拖拽只保证标题栏可达,会把窗口的右下角推出视口**:第一次真机拖拽后窗口底部落到 y=932(视口 900),拉伸柄随之点不到。改为「放得下就整窗留在视口内,放不下才退化成保留 48px 可达」——两个轴都套用,视口变化时同样处理。
### 未在真机复现 / 已知限制
- 关掉两个手机开关后的真机行为(真机只验了默认开启的一侧;关闭一侧由单测覆盖,且既有窄屏 park 用例显式关掉该开关后仍然全绿)。
- **切会话会关掉窗口**:窗口状态属于该会话的 Tasks 页组件,宿主在切走时卸载该会话视图(实测点击左侧会话列表即窗口消失)。设计文档已列为限制:若要真正跨会话常驻,需要把开启状态提到 `SidebarStore`(本轮范围外)。
@@ -116,6 +116,12 @@ client 半(src/client/)
| `subagents.live` 新载荷 + `SidebarChildLiveView` | 移植(去掉 `expand`/`stages`) | | `subagents.live` 新载荷 + `SidebarChildLiveView` | 移植(去掉 `expand`/`stages`) |
| 卡片 UI(`subagent-card.tsx` / `subagent-runs.ts` / 树重写 / 5 个词条) | **丢弃** | | 卡片 UI(`subagent-card.tsx` / `subagent-runs.ts` / 树重写 / 5 个词条) | **丢弃** |
## 后续变更(同日,`2026-09-27-floating-window-mobile-settings.md`)
本文描述的卡片语言随后被两处调整:**左侧竖条(层级条 + 当前会话条)删除**,层级改由上段极淡底色承担、
当前会话改用加粗 accent 描边;**圆角 4px → 8px**。任务输出的锚定浮窗也换成了常驻浮动窗。
以新文档为准。
## 实施偏差记录(写给复审) ## 实施偏差记录(写给复审)
1. **`process-activity.ts` 是裁剪版而不是原样搬运**:本轮「阶段」= 徽标 + 颜色,逐阶段列表没有消费者,留着就是死代码。裁剪后 44 例 → 30 例(阶段相关用例随机制一起删除)。 1. **`process-activity.ts` 是裁剪版而不是原样搬运**:本轮「阶段」= 徽标 + 颜色,逐阶段列表没有消费者,留着就是死代码。裁剪后 44 例 → 30 例(阶段相关用例随机制一起删除)。
+128
View File
@@ -0,0 +1,128 @@
/**
* Chrome of the plugin's persistent floating window (FloatingWindow.tsx):
* a fixed, portaled card with a drag handle title bar, a scrolling body, an
* optional footer and three resize strips.
*
* Tokens only (`--dsw-alias-*` / `--dsw-font-*`): the frame wears the same
* 1px `border-l2` edge and 8px radius as the Tasks page's node cards, and the
* floating layer keeps the app's `--dsw-shadow-lv3` (the one shadow tier the
* plugin is allowed on a floating surface).
*/
.window {
position: fixed;
z-index: 90;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
box-shadow: var(--dsw-shadow-lv3);
overflow: hidden;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
box-sizing: border-box;
}
/* The drag handle. It is exactly one control tall, so the close button and the
title sit on one optical line whatever the header's actions are. */
.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: none;
min-height: 34px;
padding: 0 6px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
cursor: grab;
user-select: none;
}
.titleBar:active {
cursor: grabbing;
}
.title {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: var(--ds-font-family-code, monospace);
font-size: 11px;
color: var(--dsw-alias-label-secondary);
}
.titleActions {
display: inline-flex;
align-items: center;
gap: 2px;
flex: none;
}
.close {
flex: none;
}
/* The body IS the height bound: whatever the caller renders (a long terminal
log, a table) scrolls here instead of growing the window past the viewport.
`min-height: 0` is what lets a flex child actually shrink and scroll. */
.body {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 10px 12px 12px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: none;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-base);
}
/* Resize strips sit on top of the body's own scrollbar area; each one owns
only its axis (the cursor says which). */
.handle {
position: absolute;
touch-action: none;
}
.handleRight {
top: 34px;
right: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
}
.handleBottom {
left: 0;
right: 0;
bottom: 0;
height: 6px;
cursor: ns-resize;
}
/* The corner wins over both edges (it is declared last and larger). */
.handleCorner {
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(
135deg,
transparent 0%,
transparent 55%,
color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent) 55%,
color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent) 100%
);
}
+346
View File
@@ -0,0 +1,346 @@
/**
* The plugin's reusable PERSISTENT floating window: a draggable, resizable,
* body-portaled card with its own title bar, close button and scrolling body.
*
* Why a second window primitive next to {@link AnchoredPopover}: that one is a
* POPOVER — it is anchored to an element, sized to its content, and dismissed
* by any interaction outside it (outside mousedown, window blur, its anchor
* leaving the viewport). That contract is exactly wrong for a surface the
* reader WATCHES for minutes: a job's output must survive clicking elsewhere,
* must not grow past the viewport, and must keep its place while the work
* runs. This component therefore carries the opposite lifecycle — only the
* close button and Escape end it — plus the two interactions a watched pane
* needs: move it out of the way, and resize it to the log at hand.
*
* The body is the window's own scroll container, which is what bounds a long
* output (the popover it replaces had no height limit at all, so a chatty job
* simply ran off screen). Callers that follow a live tail pass `bodyRef` and
* scroll it themselves.
*
* Mechanics reuse the two proven patterns of this codebase rather than
* inventing a third: the MOVE rides window-level pointer listeners (the
* `AnchoredPopover` drag — capture on the container would retarget the
* derived click and kill the buttons inside), and the RESIZE rides
* `setPointerCapture` on the handle plus a `createFrameBatcher` per-frame
* commit (the `EditorHost` docked-panel resize; a per-event setState on a
* streamed log is the visible drag lag of #315).
*
* Skin contract: tokens only (`--dsw-alias-*` / `--dsw-font-*`), the floating
* layer keeps the app's `--dsw-shadow-lv3`, and the radius matches the Tasks
* page's node cards (8px).
*/
import {
useCallback, useEffect, useMemo, useRef, useState,
type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject,
} from 'react'
import { createPortal } from 'react-dom'
import { Button, IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { createFrameBatcher } from './frame-batcher.ts'
import { t } from './locales.ts'
import css from './FloatingWindow.module.css'
/** Default share of the viewport a fresh window takes. */
const DEFAULT_WIDTH_RATIO = 0.7
const DEFAULT_HEIGHT_RATIO = 0.6
/** Default floor, and the hard floor a resize can never cross. */
const DEFAULT_MIN_WIDTH = 320
const DEFAULT_MIN_HEIGHT = 240
/** How much of the title bar must stay on screen while dragging. */
const KEEP_ON_SCREEN = 48
/** Free space kept at the viewport edges. */
const VIEWPORT_MARGIN = 8
/** Room a window never takes from the viewport (title bar + margins). */
const MIN_VIEWPORT_HEIGHT = 160
/** One window box, viewport coordinates. */
interface WindowBox {
left: number
top: number
width: number
height: number
}
export interface FloatingWindowProps {
/** Title-bar text (also the dialog's accessible name). */
title: string
/** Close (the button, or Escape). */
onClose(): void
/** Initial size; omitted → 70% × 60% of the viewport, then clamped. */
initialSize?: { width: number; height: number }
/** Resize floor; omitted → 320 × 240. */
minSize?: { width: number; height: number }
/**
* Where the window first appears: just below-right of this element (the
* row that opened it). Omitted / off-screen anchors centre it.
*/
anchor?: HTMLElement | null
/** Header controls, left of the close button (a job's copy action). */
actions?: ReactNode
/** Bottom action row (a job's follow switch + stop control). */
footer?: ReactNode
/**
* The scrollable body element, for callers that follow a live tail. The
* caller scrolls it; the window owns its size and its overflow.
*/
bodyRef?: RefObject<HTMLDivElement>
children: ReactNode
}
/** Clamp one number into `[min, max]` (max first, so an inverted range is safe). */
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(min, value), Math.max(min, max))
}
/**
* Clamp one axis of a window's origin. The rule is "keep the WHOLE window on
* screen when it fits, and otherwise keep {@link KEEP_ON_SCREEN}px of it (its
* title bar / its right edge) reachable": a window that hangs off the bottom
* edge hides its own resize corner, which is exactly how the first real-host
* check lost it.
*/
function clampOrigin(start: number, size: number, viewport: number): number {
const fullyInside = viewport - size - VIEWPORT_MARGIN
return fullyInside >= VIEWPORT_MARGIN
? clamp(start, VIEWPORT_MARGIN, fullyInside)
: clamp(start, KEEP_ON_SCREEN - size, viewport - KEEP_ON_SCREEN)
}
/** The viewport-clamped initial box of one window. */
function initialBox(
anchor: HTMLElement | null | undefined,
size: { width: number; height: number } | undefined,
min: { width: number; height: number },
viewport: { width: number; height: number },
): WindowBox {
const width = clamp(
size?.width ?? viewport.width * DEFAULT_WIDTH_RATIO,
min.width,
Math.max(min.width, viewport.width - VIEWPORT_MARGIN * 2),
)
const height = clamp(
size?.height ?? viewport.height * DEFAULT_HEIGHT_RATIO,
min.height,
Math.max(min.height, viewport.height - VIEWPORT_MARGIN * 2),
)
const rect = anchor?.getBoundingClientRect()
const left = rect === undefined
? (viewport.width - width) / 2
: clamp(rect.left, VIEWPORT_MARGIN, viewport.width - width - VIEWPORT_MARGIN)
const top = rect === undefined
? Math.max(VIEWPORT_MARGIN, (viewport.height - height) / 2)
: clamp(rect.bottom + 6, VIEWPORT_MARGIN, viewport.height - height - VIEWPORT_MARGIN)
return {
left: Math.round(left),
top: Math.round(top),
width: Math.round(width),
height: Math.round(height),
}
}
/**
* Render a persistent floating window. The caller owns WHAT is shown (body,
* header actions, footer); this component owns the frame, the geometry, the
* two drag gestures and the dismissal contract (close button + Escape only).
*/
export function FloatingWindow(props: FloatingWindowProps): ReactNode {
const { title, onClose, initialSize, minSize, anchor, actions, footer, bodyRef, children } = props
const min = useMemo(() => ({
width: minSize?.width ?? DEFAULT_MIN_WIDTH,
height: minSize?.height ?? DEFAULT_MIN_HEIGHT,
}), [minSize?.width, minSize?.height])
const frameRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
/**
* The box is measured ONCE, on mount, from the live viewport: a later
* re-render (a streamed output frame, a status flip) must never move or
* resize a window the reader placed.
*/
const [box, setBox] = useState<WindowBox | null>(null)
const boxRef = useRef(box)
boxRef.current = box
useEffect(() => {
setBox(initialBox(anchor, initialSize, min, {
width: window.innerWidth,
height: window.innerHeight,
}))
// Mount-only on purpose: see the comment above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/** Keep the box reachable after a viewport change (a rotation, a split pane). */
useEffect(() => {
const onResize = (): void => {
const current = boxRef.current
if (current === null) return
const width = clamp(current.width, min.width, Math.max(min.width, window.innerWidth - VIEWPORT_MARGIN * 2))
const height = clamp(
current.height,
min.height,
Math.max(min.height, window.innerHeight - MIN_VIEWPORT_HEIGHT),
)
setBox({
width,
height,
left: clampOrigin(current.left, width, window.innerWidth),
top: clampOrigin(current.top, height, window.innerHeight),
})
}
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize) }
}, [min.width, min.height])
// Escape only: no outside-click, no blur, no anchor observer (see the header).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') onCloseRef.current()
}
document.addEventListener('keydown', onKeyDown, true)
return () => { document.removeEventListener('keydown', onKeyDown, true) }
}, [])
/** Drag the window by its title bar (buttons inside it keep working). */
const onTitlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return
const target = event.target as HTMLElement
// The header's own controls (copy, close) must not start a drag.
if (target.closest('button, input, textarea, select, a, [data-window-no-drag]') !== null) return
const start = boxRef.current
if (start === null) return
event.preventDefault()
const startX = event.clientX
const startY = event.clientY
const onMove = (move: PointerEvent): void => {
setBox({
...start,
left: clampOrigin(start.left + (move.clientX - startX), start.width, window.innerWidth),
top: clampOrigin(start.top + (move.clientY - startY), start.height, window.innerHeight),
})
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [])
/** Resize from one edge handle: `axis` picks which dimension the handle owns. */
const resizeBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => { resizeBatcher.dispose() }, [resizeBatcher])
const resizeRef = useRef<{ startX: number; startY: number; box: WindowBox; axis: 'x' | 'y' | 'both' } | null>(null)
const pendingBoxRef = useRef<WindowBox | null>(null)
const onResizeStart = useCallback((axis: 'x' | 'y' | 'both') => (event: ReactPointerEvent<HTMLDivElement>): void => {
const start = boxRef.current
if (start === null || event.button !== 0) return
event.preventDefault()
event.stopPropagation()
// jsdom lacks setPointerCapture — the tests dispatch plain pointer events.
event.currentTarget.setPointerCapture?.(event.pointerId)
resizeRef.current = { startX: event.clientX, startY: event.clientY, box: start, axis }
}, [])
const onResizeMove = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
const drag = resizeRef.current
if (drag === null) return
const growX = drag.axis === 'y' ? 0 : event.clientX - drag.startX
const growY = drag.axis === 'x' ? 0 : event.clientY - drag.startY
pendingBoxRef.current = {
...drag.box,
width: clamp(
drag.box.width + growX,
min.width,
Math.max(min.width, window.innerWidth - drag.box.left - VIEWPORT_MARGIN),
),
height: clamp(
drag.box.height + growY,
min.height,
Math.max(min.height, window.innerHeight - drag.box.top - VIEWPORT_MARGIN),
),
}
resizeBatcher.schedule(() => {
const pending = pendingBoxRef.current
if (pending !== null) setBox(pending)
})
}, [min.width, min.height, resizeBatcher])
const onResizeEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (resizeRef.current === null) return
// Flush the last frame: a release can land with the final move still
// queued (the committed box would otherwise lose it).
resizeBatcher.flushNow()
resizeRef.current = null
pendingBoxRef.current = null
event.currentTarget.releasePointerCapture?.(event.pointerId)
}, [resizeBatcher])
return createPortal(
<div
ref={frameRef}
role="dialog"
aria-label={title}
data-floating-window
className={css.window}
style={box === null
? { left: -9999, top: -9999, width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT }
: { left: box.left, top: box.top, width: box.width, height: box.height }}
>
<div className={css.titleBar} data-window-handle onPointerDown={onTitlePointerDown}>
<span className={css.title} title={title}>{title}</span>
<span className={css.titleActions} data-window-no-drag>
{actions}
<Button
variant="ghost"
size="sm"
className={css.close}
icon={<IconCloseFillRegular size={14} />}
aria-label={t('close')}
title={t('close')}
onClick={onClose}
/>
</span>
</div>
<div ref={bodyRef} className={css.body} data-window-body>
{children}
</div>
{footer !== undefined && <div className={css.footer} data-window-footer>{footer}</div>}
{/*
Three handles rather than eight: the window grows from the bottom-right
in practice (a log gets longer), and the two edges keep a single-axis
resize possible. They sit above the body's scrollbar as thin strips.
*/}
<div
className={`${css.handle} ${css.handleRight}`}
data-window-resize="x"
aria-hidden="true"
onPointerDown={onResizeStart('x')}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<div
className={`${css.handle} ${css.handleBottom}`}
data-window-resize="y"
aria-hidden="true"
onPointerDown={onResizeStart('y')}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<div
className={`${css.handle} ${css.handleCorner}`}
data-window-resize="both"
aria-hidden="true"
onPointerDown={onResizeStart('both')}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
</div>,
document.body,
)
}
+74 -58
View File
@@ -5,11 +5,15 @@
* service — see jobs-client.ts) collapse into a bottom bar that AUTO-COLLAPSES * service — see jobs-client.ts) collapse into a bottom bar that AUTO-COLLAPSES
* once the tree has many agents — the manual toggle always wins afterwards. * once the tree has many agents — the manual toggle always wins afterwards.
* *
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see * Window: clicking a row opens a PERSISTENT floating window (FloatingWindow —
* AnchoredPopover) instead of docking a pane inside the page. It shows the * draggable, resizable, closed only by its own button or Escape), because a
* output the host streams for an OBSERVER (never the model's consuming * job's output is something the reader watches for minutes: the popover it
* cursor), with a copy action, a follow-latest switch, the two-click kill, and * used to open was dismissed by any click elsewhere and had no height bound,
* a terminal-style tail while the job runs. * so a chatty job ran off the bottom of the screen. The window's BODY is the
* scroll container, so the output stays readable whatever its length. It shows
* what the host streams for an OBSERVER (never the model's consuming cursor),
* with a copy action in the title bar, a follow-latest switch and the
* two-click kill in the footer, and a terminal-style tail while the job runs.
* *
* Every control is a host primitive (Button / Tag / Switch / StateDot / * Every control is a host primitive (Button / Tag / Switch / StateDot /
* TerminalBlock). * TerminalBlock).
@@ -28,6 +32,7 @@ import {
jobStatusLabel, jobStatusLabel,
type TreeJob, type TreeJob,
} from './subagent-jobs.ts' } from './subagent-jobs.ts'
import { FloatingWindow } from './FloatingWindow.tsx'
import { t } from './locales.ts' import { t } from './locales.ts'
import css from './tasks-graph.module.css' import css from './tasks-graph.module.css'
@@ -202,13 +207,13 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
} }
/** /**
* The output popover content of one job: the text the MODEL has read so far * The output WINDOW of one job: what the host streams for an observer of this
* (replayed from the owner session's event log), refreshed every * job, inside the plugin's persistent floating window. The window owns the
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader * frame, the geometry and the dismissal contract; this component owns the
* can copy the output and turn the terminal-style tail off; the kill action * content (status header rows, the terminal tail) and the footer controls
* keeps its two-click confirm. * (follow-latest, two-click kill).
*/ */
export function JobOutputPopoverContent(props: { export function JobOutputWindow(props: {
/** The host client jobs service (absent → the panel only reports that). */ /** The host client jobs service (absent → the panel only reports that). */
jobs: SidebarClientJobsService | undefined jobs: SidebarClientJobsService | undefined
job: SidebarJobView job: SidebarJobView
@@ -216,14 +221,18 @@ export function JobOutputPopoverContent(props: {
ownerSessionId: string ownerSessionId: string
/** The host's streamed observation of THIS job (undefined until it arrives). */ /** The host's streamed observation of THIS job (undefined until it arrives). */
observed: SidebarObservedJob | undefined observed: SidebarObservedJob | undefined
/** The row's main button — where the window first appears. */
anchor?: HTMLElement | null
/** Close the window (the close button, or Escape). */
onClose(): void
}): ReactNode { }): ReactNode {
const { jobs, job, ownerSessionId, observed } = props const { jobs, job, ownerSessionId, observed, anchor, onClose } = props
const [follow, setFollow] = useState(true) const [follow, setFollow] = useState(true)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [armed, setArmed] = useState(false) const [armed, setArmed] = useState(false)
const [killing, setKilling] = useState(false) const [killing, setKilling] = useState(false)
const [killFailed, setKillFailed] = useState(false) const [killFailed, setKillFailed] = useState(false)
const preRef = useRef<HTMLPreElement>(null) const bodyRef = useRef<HTMLDivElement>(null)
const live = isJobLive(job) const live = isJobLive(job)
const text = observed?.text ?? '' const text = observed?.text ?? ''
const labels = useMemo(() => terminalBlockLabels(), []) const labels = useMemo(() => terminalBlockLabels(), [])
@@ -235,15 +244,16 @@ export function JobOutputPopoverContent(props: {
return () => { window.clearTimeout(timer) } return () => { window.clearTimeout(timer) }
}, [armed]) }, [armed])
// Terminal-tail behavior: every frame pins the view to the newest output // Terminal-tail behavior: every frame pins the BODY (the window's own scroll
// while the reader keeps the follow switch on. // container — the element the previous popover wrapped in a div that never
// scrolled) to the newest output while the reader keeps follow on.
useEffect(() => { useEffect(() => {
if (!live || !follow || text.length === 0) return if (!live || !follow || text.length === 0) return
const pre = preRef.current const body = bodyRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight if (body !== null) body.scrollTop = body.scrollHeight
}, [text, live, follow]) }, [text, live, follow])
/** Two-click kill from the popover (the roster settles the row afterwards). */ /** Two-click kill from the window (the roster settles the row afterwards). */
const kill = async (): Promise<void> => { const kill = async (): Promise<void> => {
if (jobs === undefined) return if (jobs === undefined) return
setKilling(true) setKilling(true)
@@ -259,10 +269,13 @@ export function JobOutputPopoverContent(props: {
} }
return ( return (
<div className={css.popCard} data-popover-handle> <FloatingWindow
<div className={css.popHead}> title={job.label}
<span>{t('jobs')}</span> onClose={onClose}
<span className={css.popHeadActions} data-popover-no-drag> {...(anchor === undefined ? {} : { anchor })}
bodyRef={bodyRef}
actions={(
<>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -277,8 +290,43 @@ export function JobOutputPopoverContent(props: {
}) })
}} }}
/> />
</span> </>
</div> )}
footer={(
<div className={css.jobPopActions}>
{/*
The host Switch draws a bare track, so its wording rides beside it —
the same `jobFollowTail` copy the switch already carries as its
accessible name.
*/}
<span className={css.jobPopFollow}>
<Switch
checked={follow}
onChange={setFollow}
label={t('jobFollowTail')}
disabled={!live}
/>
<span className={css.jobPopFollowLabel}>{t('jobFollowTail')}</span>
</span>
{live && (
<Button
variant="outline"
size="sm"
className={armed ? css.jobPopKillArmed : undefined}
icon={<IconStopFillRegular size={11} />}
disabled={killing}
onClick={() => {
if (armed) void kill()
else setArmed(true)
}}
>
{armed ? t('jobKillConfirm') : t('jobKill')}
</Button>
)}
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
</div>
)}
>
<div className={css.jobPopTitle}> <div className={css.jobPopTitle}>
<StateDot state={jobDotState(job.status)} size={6} /> <StateDot state={jobDotState(job.status)} size={6} />
<span className={css.popTitle} title={job.label}>{job.label}</span> <span className={css.popTitle} title={job.label}>{job.label}</span>
@@ -296,7 +344,7 @@ export function JobOutputPopoverContent(props: {
{observed.error !== undefined && <div className={css.popError}>{t('jobOutputError')}</div>} {observed.error !== undefined && <div className={css.popError}>{t('jobOutputError')}</div>}
{observed.text.length > 0 {observed.text.length > 0
? ( ? (
<div ref={preRef as unknown as React.RefObject<HTMLDivElement>} data-popover-no-drag> <div>
<TerminalBlock <TerminalBlock
command={job.label} command={job.label}
output={observed.text} output={observed.text}
@@ -311,38 +359,6 @@ export function JobOutputPopoverContent(props: {
: <div className={css.popHint}>{t('jobNoOutput')}</div>} : <div className={css.popHint}>{t('jobNoOutput')}</div>}
</> </>
)} )}
<div className={css.jobPopActions} data-popover-no-drag> </FloatingWindow>
{/*
The host Switch draws a bare track, so its wording rides beside it —
the same `jobFollowTail` copy the switch already carries as its
accessible name.
*/}
<span className={css.jobPopFollow}>
<Switch
checked={follow}
onChange={setFollow}
label={t('jobFollowTail')}
disabled={!live}
/>
<span className={css.jobPopFollowLabel}>{t('jobFollowTail')}</span>
</span>
{live && (
<Button
variant="outline"
size="sm"
className={armed ? css.jobPopKillArmed : undefined}
icon={<IconStopFillRegular size={11} />}
disabled={killing}
onClick={() => {
if (armed) void kill()
else setArmed(true)
}}
>
{armed ? t('jobKillConfirm') : t('jobKill')}
</Button>
)}
</div>
{killFailed && <div className={css.popError}>{t('jobKillError')}</div>}
</div>
) )
} }
+30
View File
@@ -900,6 +900,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</div> </div>
</div> </div>
{/* 手机: the narrow-viewport adaptations. Both switches only ever change
what happens on a NARROW viewport (the plugin's own 768px bracket),
so a desktop reader's behaviour is untouched whatever they pick —
and neither one rewrites the wide-viewport switches above. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsMobileTitle')}</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsMobileNoAutoOpenTitle')}</span>
<span className={css.desc}>{t('settingsMobileNoAutoOpenDesc')}</span>
</span>
<Switch
label={t('settingsMobileNoAutoOpenTitle')}
checked={prefs.mobileNoAutoOpen}
onChange={(next) => { applyPref({ mobileNoAutoOpen: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsMobileTreeTitle')}</span>
<span className={css.desc}>{t('settingsMobileTreeDesc')}</span>
</span>
<Switch
label={t('settingsMobileTreeTitle')}
checked={prefs.mobileDefaultTree}
onChange={(next) => { applyPref({ mobileDefaultTree: next }) }}
/>
</div>
</div>
{/* 侧边栏内容: one small card per registered tab type in a responsive {/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in grid; features declaring `settings.toggles` open their settings in
the popup (gear corner button) instead of nested inline rows. */} the popup (gear corner button) instead of nested inline rows. */}
+27 -15
View File
@@ -50,6 +50,7 @@ import { treeSessionIds } from './subagent-lineage.ts'
import { orderJobs, type TreeJob } from './subagent-jobs.ts' import { orderJobs, type TreeJob } from './subagent-jobs.ts'
import { api, type TeamsViewResult } from './api.ts' import { api, type TeamsViewResult } from './api.ts'
import { usePolling } from './use-polling.ts' import { usePolling } from './use-polling.ts'
import { useNarrowViewport } from './breakpoints.ts'
import { import {
clientJobs, collectRows, useJobObservation, useJobWatchers, useJobsSnapshot, clientJobs, collectRows, useJobObservation, useJobWatchers, useJobsSnapshot,
} from './jobs-client.ts' } from './jobs-client.ts'
@@ -57,7 +58,7 @@ import { t } from './locales.ts'
import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts' import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts'
import { TasksGraph } from './TasksGraph.tsx' import { TasksGraph } from './TasksGraph.tsx'
import { TasksTree } from './TasksTree.tsx' import { TasksTree } from './TasksTree.tsx'
import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx' import { JobsDrawer, JobOutputWindow } from './JobsDrawer.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx' import { AnchoredPopover } from './AnchoredPopover.tsx'
import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx' import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx'
import { TeamBoard } from './TeamBoard.tsx' import { TeamBoard } from './TeamBoard.tsx'
@@ -240,10 +241,17 @@ export function SubagentView(props: {
) )
// The default view mode comes from the side card prefs (settings select); // The default view mode comes from the side card prefs (settings select);
// the in-page toggle overrides it ephemerally. // the in-page toggle overrides it ephemerally. On a NARROW viewport the
// `mobileDefaultTree` preference swaps that default to the classic tree —
// only the default: the toggle below still flips this session's page.
const narrow = useNarrowViewport()
const prefsMode = useSyncExternalStore( const prefsMode = useSyncExternalStore(
useMemo(() => (callback: () => void) => store?.subscribe(callback) ?? (() => {}), [store]), useMemo(() => (callback: () => void) => store?.subscribe(callback) ?? (() => {}), [store]),
useCallback(() => store?.getPrefs().tasksViewMode ?? 'graph', [store]), useCallback((): 'graph' | 'tree' => {
const prefs = store?.getPrefs()
if (narrow && prefs?.mobileDefaultTree === true) return 'tree'
return prefs?.tasksViewMode ?? 'graph'
}, [store, narrow]),
) )
const [modeOverride, setModeOverride] = useState<'graph' | 'tree' | undefined>(undefined) const [modeOverride, setModeOverride] = useState<'graph' | 'tree' | undefined>(undefined)
const mode = modeOverride ?? prefsMode const mode = modeOverride ?? prefsMode
@@ -453,11 +461,13 @@ export function SubagentView(props: {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId) const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null if (row === undefined) return null
return ( return (
<JobOutputPopoverContent <JobOutputWindow
jobs={treeJobs.jobs} jobs={treeJobs.jobs}
ownerSessionId={row.ownerSessionId} ownerSessionId={row.ownerSessionId}
job={row.job} job={row.job}
observed={treeJobs.observed[row.job.id]} observed={treeJobs.observed[row.job.id]}
anchor={popover.anchor}
onClose={closePopover}
/> />
) )
} }
@@ -599,18 +609,20 @@ export function SubagentView(props: {
: { kind: 'job', jobId: row.job.id, anchor }) : { kind: 'job', jobId: row.job.id, anchor })
}} }}
/> />
{popover?.kind === 'task' {/*
Two surfaces, two lifecycles: the job output is a PERSISTENT window
(its own frame, closed by its button/Escape only), while node and
workflow details stay anchored popovers on the 280px card.
*/}
{popover?.kind === 'job'
? popoverContent ? popoverContent
: ( : popover?.kind === 'task'
<AnchoredPopover ? popoverContent
anchor={popover?.anchor ?? null} : (
onClose={closePopover} <AnchoredPopover anchor={popover?.anchor ?? null} onClose={closePopover} width={280}>
draggable={popover?.kind === 'job'} {popoverContent}
width={popover?.kind === 'job' ? 380 : 280} </AnchoredPopover>
> )}
{popoverContent}
</AnchoredPopover>
)}
</div> </div>
) )
} }
+5
View File
@@ -253,6 +253,11 @@ export const ar: Record<string, string> = {
settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)', settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)',
settingsTabsTitle: 'محتوى الشريط الجانبي', settingsTabsTitle: 'محتوى الشريط الجانبي',
settingsViewersTitle: 'عارضات الملفات', settingsViewersTitle: 'عارضات الملفات',
settingsMobileTitle: "الجوال",
settingsMobileNoAutoOpenTitle: "عدم الفتح التلقائي على الشاشات الضيقة",
settingsMobileNoAutoOpenDesc: "على الهاتف أو الشاشة الضيقة، لن يؤدي وكيل فرعي أو مهمة خلفية جديدة إلى الانتقال تلقائيًا إلى صفحة المهام (يمكنك فتحها يدويًا)",
settingsMobileTreeTitle: "افتراض الشجرة لصفحة المهام",
settingsMobileTreeDesc: "على الهاتف أو الشاشة الضيقة تُفتح صفحة المهام كشجرة كلاسيكية بدل مخطط سير العمل (مفتاح الصفحة يظل يعمل)",
settingsGeneralTitle: 'عام', settingsGeneralTitle: 'عام',
settingsPopup: 'إعدادات الميزة', settingsPopup: 'إعدادات الميزة',
settingsViewerCatchAll: 'شامل: أي ملف', settingsViewerCatchAll: 'شامل: أي ملف',
+5
View File
@@ -238,6 +238,11 @@ export const de: Record<string, string> = {
settingsShellArgsPlaceholder: 'z. B. -l (leer = Standardargumente)', settingsShellArgsPlaceholder: 'z. B. -l (leer = Standardargumente)',
settingsTabsTitle: 'Seitenleisten-Inhalt', settingsTabsTitle: 'Seitenleisten-Inhalt',
settingsViewersTitle: 'Datei-Viewer', settingsViewersTitle: 'Datei-Viewer',
settingsMobileTitle: "Mobil",
settingsMobileNoAutoOpenTitle: "Auf schmalen Bildschirmen nicht automatisch öffnen",
settingsMobileNoAutoOpenDesc: "Auf dem Handy / schmalen Viewport wechselt ein neuer Subagent oder Hintergrundjob nicht mehr von selbst zur Aufgabenseite (manuell weiterhin möglich)",
settingsMobileTreeTitle: "Aufgabenseite standardmäßig als Baum",
settingsMobileTreeDesc: "Auf dem Handy / schmalen Viewport öffnet die Aufgabenseite als klassischer Baum statt als Workflow-Diagramm; der Umschalter auf der Seite bleibt",
settingsGeneralTitle: 'Allgemein', settingsGeneralTitle: 'Allgemein',
settingsPopup: 'Funktionseinstellungen', settingsPopup: 'Funktionseinstellungen',
settingsViewerCatchAll: 'Catch-all: beliebige Datei', settingsViewerCatchAll: 'Catch-all: beliebige Datei',
+5
View File
@@ -245,6 +245,11 @@ export const fr: Record<string, string> = {
settingsShellArgsPlaceholder: 'ex. -l (vide = arguments par défaut)', settingsShellArgsPlaceholder: 'ex. -l (vide = arguments par défaut)',
settingsTabsTitle: 'Contenu de la barre latérale', settingsTabsTitle: 'Contenu de la barre latérale',
settingsViewersTitle: 'Aperçus de fichiers', settingsViewersTitle: 'Aperçus de fichiers',
settingsMobileTitle: "Mobile",
settingsMobileNoAutoOpenTitle: "Ne pas ouvrir automatiquement sur écran étroit",
settingsMobileNoAutoOpenDesc: "Sur téléphone / écran étroit, un nouveau sous-agent ou une tâche d'arrière-plan ne bascule plus tout seul vers la page Tâches (ouverture manuelle toujours possible)",
settingsMobileTreeTitle: "Page Tâches en arbre par défaut",
settingsMobileTreeDesc: "Sur téléphone / écran étroit, la page Tâches s'ouvre en arbre classique plutôt qu'en graphe de workflow ; le bouton de la page reste actif",
settingsGeneralTitle: 'Général', settingsGeneralTitle: 'Général',
settingsPopup: 'Réglages de la fonctionnalité', settingsPopup: 'Réglages de la fonctionnalité',
settingsViewerCatchAll: 'Par défaut : n’importe quel fichier', settingsViewerCatchAll: 'Par défaut : n’importe quel fichier',
+5
View File
@@ -252,6 +252,11 @@ export const hi: Record<string, string> = {
settingsShellArgsPlaceholder: 'जैसे -l (खाली = डिफ़ॉल्ट)', settingsShellArgsPlaceholder: 'जैसे -l (खाली = डिफ़ॉल्ट)',
settingsTabsTitle: 'साइडबार सामग्री', settingsTabsTitle: 'साइडबार सामग्री',
settingsViewersTitle: 'फ़ाइल दर्शक', settingsViewersTitle: 'फ़ाइल दर्शक',
settingsMobileTitle: "मोबाइल",
settingsMobileNoAutoOpenTitle: "छोटी स्क्रीन पर स्वतः न खोलें",
settingsMobileNoAutoOpenDesc: "फ़ोन / छोटी स्क्रीन पर नया सबएजेंट या पृष्ठभूमि कार्य अपने आप कार्य पृष्ठ नहीं खोलेगा (मैन्युअल रूप से खोल सकते हैं)",
settingsMobileTreeTitle: "कार्य पृष्ठ डिफ़ॉल्ट रूप से ट्री",
settingsMobileTreeDesc: "फ़ोन / छोटी स्क्रीन पर कार्य पृष्ठ वर्कफ़्लो ग्राफ़ की जगह क्लासिक ट्री में खुलता है; पृष्ठ का टॉगल काम करता रहता है",
settingsGeneralTitle: 'सामान्य', settingsGeneralTitle: 'सामान्य',
settingsPopup: 'सुविधा सेटिंग्स', settingsPopup: 'सुविधा सेटिंग्स',
settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल', settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल',
+5
View File
@@ -250,6 +250,11 @@ export const id: Record<string, string> = {
settingsShellArgsPlaceholder: 'mis. -l (kosong = default)', settingsShellArgsPlaceholder: 'mis. -l (kosong = default)',
settingsTabsTitle: 'Konten sidebar', settingsTabsTitle: 'Konten sidebar',
settingsViewersTitle: 'Pratinjau berkas', settingsViewersTitle: 'Pratinjau berkas',
settingsMobileTitle: "Seluler",
settingsMobileNoAutoOpenTitle: "Jangan buka otomatis di layar sempit",
settingsMobileNoAutoOpenDesc: "Di ponsel / layar sempit, subagen atau tugas latar baru tidak lagi berpindah sendiri ke halaman Tugas (masih bisa dibuka manual)",
settingsMobileTreeTitle: "Halaman Tugas default pohon",
settingsMobileTreeDesc: "Di ponsel / layar sempit halaman Tugas terbuka sebagai pohon klasik, bukan graf alur kerja; tombol di halaman tetap berfungsi",
settingsGeneralTitle: 'Umum', settingsGeneralTitle: 'Umum',
settingsPopup: 'Pengaturan fitur', settingsPopup: 'Pengaturan fitur',
settingsViewerCatchAll: 'Catch-all: berkas apa pun', settingsViewerCatchAll: 'Catch-all: berkas apa pun',
+5
View File
@@ -243,6 +243,11 @@ export const it: Record<string, string> = {
settingsShellArgsPlaceholder: 'es. -l (vuoto = predefiniti)', settingsShellArgsPlaceholder: 'es. -l (vuoto = predefiniti)',
settingsTabsTitle: 'Contenuto della barra laterale', settingsTabsTitle: 'Contenuto della barra laterale',
settingsViewersTitle: 'Visualizzatori di file', settingsViewersTitle: 'Visualizzatori di file',
settingsMobileTitle: "Mobile",
settingsMobileNoAutoOpenTitle: "Non aprire automaticamente su schermi stretti",
settingsMobileNoAutoOpenDesc: "Su telefono / schermo stretto un nuovo subagent o job in background non passa più da solo alla pagina Attività (puoi comunque aprirla a mano)",
settingsMobileTreeTitle: "Pagina Attività predefinita ad albero",
settingsMobileTreeDesc: "Su telefono / schermo stretto la pagina Attività si apre come albero classico invece che come grafo del workflow; il pulsante nella pagina resta attivo",
settingsGeneralTitle: 'Generali', settingsGeneralTitle: 'Generali',
settingsPopup: 'Impostazioni della funzionalità', settingsPopup: 'Impostazioni della funzionalità',
settingsViewerCatchAll: 'Raccoglitore: qualsiasi file', settingsViewerCatchAll: 'Raccoglitore: qualsiasi file',
+5
View File
@@ -252,6 +252,11 @@ export const ja: Record<string, string> = {
settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)', settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)',
settingsTabsTitle: 'サイドバーの内容', settingsTabsTitle: 'サイドバーの内容',
settingsViewersTitle: 'ファイルプレビュー', settingsViewersTitle: 'ファイルプレビュー',
settingsMobileTitle: "モバイル",
settingsMobileNoAutoOpenTitle: "狭い画面では自動で開かない",
settingsMobileNoAutoOpenDesc: "スマホ / 狭い画面では、新しいサブエージェントやバックグラウンドタスクがあってもタスクページへ自動で切り替わりません(手動では開けます)",
settingsMobileTreeTitle: "タスクページはツリーを既定にする",
settingsMobileTreeDesc: "スマホ / 狭い画面ではタスクページをワークフロー図ではなくツリーで開きます(ページ内の切り替えはそのまま使えます)",
settingsGeneralTitle: '一般', settingsGeneralTitle: '一般',
settingsPopup: '機能設定', settingsPopup: '機能設定',
settingsViewerCatchAll: 'フォールバック:任意のファイル', settingsViewerCatchAll: 'フォールバック:任意のファイル',
+5
View File
@@ -244,6 +244,11 @@ export const ko: Record<string, string> = {
settingsShellArgsPlaceholder: '예: -l (비워두면 기본 인수 사용)', settingsShellArgsPlaceholder: '예: -l (비워두면 기본 인수 사용)',
settingsTabsTitle: '사이드바 콘텐츠', settingsTabsTitle: '사이드바 콘텐츠',
settingsViewersTitle: '파일 미리보기', settingsViewersTitle: '파일 미리보기',
settingsMobileTitle: "모바일",
settingsMobileNoAutoOpenTitle: "좁은 화면에서는 자동으로 열지 않기",
settingsMobileNoAutoOpenDesc: "휴대폰 / 좁은 화면에서는 새 하위 에이전트나 백그라운드 작업이 있어도 작업 페이지로 자동 전환되지 않습니다(직접 열 수는 있습니다)",
settingsMobileTreeTitle: "작업 페이지 기본을 트리로",
settingsMobileTreeDesc: "휴대폰 / 좁은 화면에서 작업 페이지가 워크플로 그래프 대신 클래식 트리로 열립니다. 페이지 내 전환은 그대로 동작합니다",
settingsGeneralTitle: '일반', settingsGeneralTitle: '일반',
settingsPopup: '기능 설정', settingsPopup: '기능 설정',
settingsViewerCatchAll: '폴백: 모든 파일', settingsViewerCatchAll: '폴백: 모든 파일',
+5
View File
@@ -250,6 +250,11 @@ export const nl: Record<string, string> = {
settingsShellArgsPlaceholder: 'bijv. -l (leeg = standaardwaarden)', settingsShellArgsPlaceholder: 'bijv. -l (leeg = standaardwaarden)',
settingsTabsTitle: 'Zijbalk-inhoud', settingsTabsTitle: 'Zijbalk-inhoud',
settingsViewersTitle: 'Bestandsviewers', settingsViewersTitle: 'Bestandsviewers',
settingsMobileTitle: "Mobiel",
settingsMobileNoAutoOpenTitle: "Niet automatisch openen op smalle schermen",
settingsMobileNoAutoOpenDesc: "Op telefoon / smalle viewport schakelt een nieuwe subagent of achtergrondtaak niet meer vanzelf naar de Takenpagina (handmatig kan nog)",
settingsMobileTreeTitle: "Takenpagina standaard als boom",
settingsMobileTreeDesc: "Op telefoon / smalle viewport opent de Takenpagina als klassieke boom in plaats van werkstroomdiagram; de schakelaar op de pagina blijft werken",
settingsGeneralTitle: 'Algemeen', settingsGeneralTitle: 'Algemeen',
settingsPopup: 'Functie-instellingen', settingsPopup: 'Functie-instellingen',
settingsViewerCatchAll: 'Catch-all: elk bestand', settingsViewerCatchAll: 'Catch-all: elk bestand',
+5
View File
@@ -254,6 +254,11 @@ export const pl: Record<string, string> = {
settingsShellArgsPlaceholder: 'np. -l (puste = domyślne)', settingsShellArgsPlaceholder: 'np. -l (puste = domyślne)',
settingsTabsTitle: 'Treść panelu bocznego', settingsTabsTitle: 'Treść panelu bocznego',
settingsViewersTitle: 'Podglądy plików', settingsViewersTitle: 'Podglądy plików',
settingsMobileTitle: "Mobile",
settingsMobileNoAutoOpenTitle: "Nie otwieraj automatycznie na wąskim ekranie",
settingsMobileNoAutoOpenDesc: "Na telefonie / wąskim ekranie nowy podagent lub zadanie w tle nie przełącza już samoczynnie na stronę Zadań (nadal można otworzyć ręcznie)",
settingsMobileTreeTitle: "Strona Zadań domyślnie jako drzewo",
settingsMobileTreeDesc: "Na telefonie / wąskim ekranie strona Zadań otwiera się jako klasyczne drzewo, a nie graf przepływu; przełącznik na stronie nadal działa",
settingsGeneralTitle: 'Ogólne', settingsGeneralTitle: 'Ogólne',
settingsPopup: 'Ustawienia funkcji', settingsPopup: 'Ustawienia funkcji',
settingsViewerCatchAll: 'Zapasowy: dowolny plik', settingsViewerCatchAll: 'Zapasowy: dowolny plik',
+5
View File
@@ -235,6 +235,11 @@ export const pt: Record<string, string> = {
settingsShellArgsPlaceholder: 'ex.: -l (vazio = padrões)', settingsShellArgsPlaceholder: 'ex.: -l (vazio = padrões)',
settingsTabsTitle: 'Conteúdo da barra lateral', settingsTabsTitle: 'Conteúdo da barra lateral',
settingsViewersTitle: 'Visualizadores de arquivos', settingsViewersTitle: 'Visualizadores de arquivos',
settingsMobileTitle: "Celular",
settingsMobileNoAutoOpenTitle: "Não abrir automaticamente em telas estreitas",
settingsMobileNoAutoOpenDesc: "No celular / tela estreita, um novo subagente ou tarefa em segundo plano não muda mais sozinho para a página Tarefas (ainda dá para abrir manualmente)",
settingsMobileTreeTitle: "Página Tarefas em árvore por padrão",
settingsMobileTreeDesc: "No celular / tela estreita a página Tarefas abre como árvore clássica em vez de grafo de fluxo; o botão da página continua funcionando",
settingsGeneralTitle: 'Geral', settingsGeneralTitle: 'Geral',
settingsPopup: 'Configurações do recurso', settingsPopup: 'Configurações do recurso',
settingsViewerCatchAll: 'Curinga: qualquer arquivo', settingsViewerCatchAll: 'Curinga: qualquer arquivo',
+5
View File
@@ -249,6 +249,11 @@ export const ru: Record<string, string> = {
settingsShellArgsPlaceholder: 'напр. -l (пусто = по умолчанию)', settingsShellArgsPlaceholder: 'напр. -l (пусто = по умолчанию)',
settingsTabsTitle: 'Содержимое боковой панели', settingsTabsTitle: 'Содержимое боковой панели',
settingsViewersTitle: 'Просмотрщики файлов', settingsViewersTitle: 'Просмотрщики файлов',
settingsMobileTitle: "Мобильный",
settingsMobileNoAutoOpenTitle: "Не открывать автоматически на узком экране",
settingsMobileNoAutoOpenDesc: "На телефоне / узком экране новый субагент или фоновая задача больше не переключает на страницу задач само (открыть вручную можно)",
settingsMobileTreeTitle: "Страница задач по умолчанию — дерево",
settingsMobileTreeDesc: "На телефоне / узком экране страница задач открывается классическим деревом, а не графом процесса; переключатель на странице продолжает работать",
settingsGeneralTitle: 'Общие', settingsGeneralTitle: 'Общие',
settingsPopup: 'Настройки функции', settingsPopup: 'Настройки функции',
settingsViewerCatchAll: 'Универсальный: любой файл', settingsViewerCatchAll: 'Универсальный: любой файл',
+5
View File
@@ -235,6 +235,11 @@ export const sv: Record<string, string> = {
settingsShellArgsPlaceholder: 't.ex. -l (tomt = standard)', settingsShellArgsPlaceholder: 't.ex. -l (tomt = standard)',
settingsTabsTitle: 'Sidopanelens innehåll', settingsTabsTitle: 'Sidopanelens innehåll',
settingsViewersTitle: 'Filvisare', settingsViewersTitle: 'Filvisare',
settingsMobileTitle: "Mobil",
settingsMobileNoAutoOpenTitle: "Öppna inte automatiskt på smala skärmar",
settingsMobileNoAutoOpenDesc: "På telefon / smal vyport växlar en ny subagent eller bakgrundsjobb inte längre själv till Uppgiftssidan (går fortfarande att öppna manuellt)",
settingsMobileTreeTitle: "Uppgiftssidan som träd som standard",
settingsMobileTreeDesc: "På telefon / smal vyport öppnas Uppgiftssidan som klassiskt träd i stället för arbetsflödesgraf; knappen på sidan fungerar kvar",
settingsGeneralTitle: 'Allmänt', settingsGeneralTitle: 'Allmänt',
settingsPopup: 'Funktionsinställningar', settingsPopup: 'Funktionsinställningar',
settingsViewerCatchAll: 'Fånga alla: alla filer', settingsViewerCatchAll: 'Fånga alla: alla filer',
+5
View File
@@ -252,6 +252,11 @@ export const th: Record<string, string> = {
settingsShellArgsPlaceholder: 'เช่น -l (ว่าง = ค่าเริ่มต้น)', settingsShellArgsPlaceholder: 'เช่น -l (ว่าง = ค่าเริ่มต้น)',
settingsTabsTitle: 'เนื้อหาแถบด้านข้าง', settingsTabsTitle: 'เนื้อหาแถบด้านข้าง',
settingsViewersTitle: 'ตัวพรีวิวไฟล์', settingsViewersTitle: 'ตัวพรีวิวไฟล์',
settingsMobileTitle: "มือถือ",
settingsMobileNoAutoOpenTitle: "ไม่เปิดอัตโนมัติบนจอแคบ",
settingsMobileNoAutoOpenDesc: "บนมือถือ / จอแคบ เอเจนต์ย่อยหรืองานเบื้องหลังใหม่จะไม่สลับไปหน้าภารกิจให้เองอีก (ยังเปิดเองได้)",
settingsMobileTreeTitle: "หน้าภารกิจใช้ทรีเป็นค่าเริ่มต้น",
settingsMobileTreeDesc: "บนมือถือ / จอแคบ หน้าภารกิจจะเปิดเป็นทรีแบบคลาสสิกแทนกราฟเวิร์กโฟลว์ ปุ่มสลับในหน้ายังใช้ได้",
settingsGeneralTitle: 'ทั่วไป', settingsGeneralTitle: 'ทั่วไป',
settingsPopup: 'การตั้งค่าคุณสมบัติ', settingsPopup: 'การตั้งค่าคุณสมบัติ',
settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ', settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ',
+5
View File
@@ -252,6 +252,11 @@ export const tr: Record<string, string> = {
settingsShellArgsPlaceholder: 'ör. -l (boş = varsayılanlar)', settingsShellArgsPlaceholder: 'ör. -l (boş = varsayılanlar)',
settingsTabsTitle: 'Kenar çubuğu içeriği', settingsTabsTitle: 'Kenar çubuğu içeriği',
settingsViewersTitle: 'Dosya önizleyicileri', settingsViewersTitle: 'Dosya önizleyicileri',
settingsMobileTitle: "Mobil",
settingsMobileNoAutoOpenTitle: "Dar ekranlarda otomatik açma",
settingsMobileNoAutoOpenDesc: "Telefonda / dar görünümde yeni bir alt ajan veya arka plan görevi artık kendiliğinden Görevler sayfasına geçmez (elle açılabilir)",
settingsMobileTreeTitle: "Görevler sayfası varsayılan olarak ağaç",
settingsMobileTreeDesc: "Telefonda / dar görünümde Görevler sayfası iş akışı grafiği yerine klasik ağaç olarak açılır; sayfadaki düğme çalışmaya devam eder",
settingsGeneralTitle: 'Genel', settingsGeneralTitle: 'Genel',
settingsPopup: 'Özellik ayarları', settingsPopup: 'Özellik ayarları',
settingsViewerCatchAll: 'Hepsi yakala: herhangi bir dosya', settingsViewerCatchAll: 'Hepsi yakala: herhangi bir dosya',
+5
View File
@@ -252,6 +252,11 @@ export const vi: Record<string, string> = {
settingsShellArgsPlaceholder: 'vd: -l (để trống = mặc định)', settingsShellArgsPlaceholder: 'vd: -l (để trống = mặc định)',
settingsTabsTitle: 'Nội dung thanh bên', settingsTabsTitle: 'Nội dung thanh bên',
settingsViewersTitle: 'Trình xem tệp', settingsViewersTitle: 'Trình xem tệp',
settingsMobileTitle: "Di động",
settingsMobileNoAutoOpenTitle: "Không tự mở trên màn hình hẹp",
settingsMobileNoAutoOpenDesc: "Trên điện thoại / màn hình hẹp, tác nhân con hay tác vụ nền mới sẽ không tự chuyển sang trang Tác vụ nữa (vẫn mở thủ công được)",
settingsMobileTreeTitle: "Trang Tác vụ mặc định dạng cây",
settingsMobileTreeDesc: "Trên điện thoại / màn hình hẹp, trang Tác vụ mở dưới dạng cây cổ điển thay vì sơ đồ quy trình; nút chuyển trong trang vẫn hoạt động",
settingsGeneralTitle: 'Chung', settingsGeneralTitle: 'Chung',
settingsPopup: 'Cài đặt tính năng', settingsPopup: 'Cài đặt tính năng',
settingsViewerCatchAll: 'Catch-all: mọi tệp', settingsViewerCatchAll: 'Catch-all: mọi tệp',
+5
View File
@@ -267,6 +267,11 @@ export const zhHK: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)', settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容', settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽', settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般', settingsGeneralTitle: '一般',
settingsPopup: '功能設定', settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案', settingsViewerCatchAll: '兜底:任意檔案',
+5
View File
@@ -267,6 +267,11 @@ export const zhMO: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)', settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容', settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽', settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般', settingsGeneralTitle: '一般',
settingsPopup: '功能設定', settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案', settingsViewerCatchAll: '兜底:任意檔案',
+5
View File
@@ -267,6 +267,11 @@ export const zhTW: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)', settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容', settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽', settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般', settingsGeneralTitle: '一般',
settingsPopup: '功能設定', settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案', settingsViewerCatchAll: '兜底:任意檔案',
+10
View File
@@ -264,6 +264,11 @@ export const zh = {
settingsShellArgsPlaceholder: '如 -l(留空用默认参数)', settingsShellArgsPlaceholder: '如 -l(留空用默认参数)',
settingsTabsTitle: '侧边栏内容', settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览', settingsViewersTitle: '文件预览',
settingsMobileTitle: '手机',
settingsMobileNoAutoOpenTitle: '窄屏时不自动弹出',
settingsMobileNoAutoOpenDesc: '在手机 / 窄屏下,新的子代理或后台任务都不再自动切到任务页(页内仍可手动打开)',
settingsMobileTreeTitle: '窄屏时任务页默认树状图',
settingsMobileTreeDesc: '手机 / 窄屏下任务页默认用经典树,而不是工作流图;页内切换仍可临时改成图',
settingsGeneralTitle: '常规', settingsGeneralTitle: '常规',
settingsPopup: '功能设置', settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件', settingsViewerCatchAll: '兜底:任意文件',
@@ -738,6 +743,11 @@ export const en: Record<keyof typeof zh, string> = {
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)', settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
settingsTabsTitle: 'Sidebar content', settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers', settingsViewersTitle: 'File viewers',
settingsMobileTitle: 'Mobile',
settingsMobileNoAutoOpenTitle: "Don't auto-open on narrow screens",
settingsMobileNoAutoOpenDesc: "On a phone / narrow viewport, a new subagent or background job no longer switches to the Tasks page by itself (you can still open it by hand)",
settingsMobileTreeTitle: 'Default the Tasks page to the tree',
settingsMobileTreeDesc: "On a phone / narrow viewport the Tasks page opens as the classic tree instead of the workflow graph; the in-page toggle still switches it ad hoc",
settingsGeneralTitle: 'General', settingsGeneralTitle: 'General',
settingsPopup: 'Feature settings', settingsPopup: 'Feature settings',
settingsViewerCatchAll: 'Catch-all: any file', settingsViewerCatchAll: 'Catch-all: any file',
+6
View File
@@ -48,6 +48,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
tasksViewMode: record.tasksViewMode === 'tree' || record.tasksViewMode === 'graph' tasksViewMode: record.tasksViewMode === 'tree' || record.tasksViewMode === 'graph'
? record.tasksViewMode ? record.tasksViewMode
: SIDEBAR_PREFS_DEFAULTS.tasksViewMode, : SIDEBAR_PREFS_DEFAULTS.tasksViewMode,
mobileNoAutoOpen: typeof record.mobileNoAutoOpen === 'boolean'
? record.mobileNoAutoOpen
: SIDEBAR_PREFS_DEFAULTS.mobileNoAutoOpen,
mobileDefaultTree: typeof record.mobileDefaultTree === 'boolean'
? record.mobileDefaultTree
: SIDEBAR_PREFS_DEFAULTS.mobileDefaultTree,
agentOpenTools: typeof record.agentOpenTools === 'boolean' agentOpenTools: typeof record.agentOpenTools === 'boolean'
? record.agentOpenTools ? record.agentOpenTools
: SIDEBAR_PREFS_DEFAULTS.agentOpenTools, : SIDEBAR_PREFS_DEFAULTS.agentOpenTools,
+11
View File
@@ -182,6 +182,11 @@ export function useHostFeeds(feeds: {
* baseline starts at the current count — so a deliberate layout is never * baseline starts at the current count — so a deliberate layout is never
* fought. * fought.
* *
* MOBILE: while the viewport is narrow (the plugin's own bracket), the
* `mobileNoAutoOpen` preference suppresses this takeover entirely — on a
* phone the Tasks page costs the whole screen, and the reader has not asked
* for it. The viewport is read when the trigger FIRES, like the park gate.
*
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
* is also a subagent-origin child, and its 'Side: ' title lands one frame * is also a subagent-origin child, and its 'Side: ' title lands one frame
* after its origin — an immediate check would misread that first frame as * after its origin — an immediate check would misread that first frame as
@@ -202,6 +207,7 @@ export function useHostFeeds(feeds: {
autoOpenPendingRef.current = null autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return if (!store.getPrefs().autoOpenSubagent) return
if (store.getPrefs().mobileNoAutoOpen && isNarrowWidth(window.innerWidth)) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true }) activateTasksPage(ctx, sessionId, { background: true })
}, AUTO_OPEN_DEBOUNCE_MS) }, AUTO_OPEN_DEBOUNCE_MS)
@@ -225,6 +231,10 @@ export function useHostFeeds(feeds: {
* (0 → N only), ANY new job id triggers: the agent may start several jobs in * (0 → N only), ANY new job id triggers: the agent may start several jobs in
* one session, and each should surface. * one session, and each should surface.
* *
* MOBILE: `mobileNoAutoOpen` suppresses this one too on a narrow viewport
* (one switch for both triggers — a phone should not be taken over by either
* kind of background work).
*
* The roster is PUSHED by the host's own client jobs service: one * The roster is PUSHED by the host's own client jobs service: one
* reference-counted `watchRows` stream for the current session replaces the * reference-counted `watchRows` stream for the current session replaces the
* 3s `jobs.list` poll this used to run. A fresh page load never triggers — * 3s `jobs.list` poll this used to run. A fresh page load never triggers —
@@ -260,6 +270,7 @@ export function useHostFeeds(feeds: {
if (prev === undefined) return if (prev === undefined) return
if (!detectNewJob(prev, rows, jobWatchStartRef.current)) return if (!detectNewJob(prev, rows, jobWatchStartRef.current)) return
if (!store.getPrefs().autoOpenJobs) return if (!store.getPrefs().autoOpenJobs) return
if (store.getPrefs().mobileNoAutoOpen && isNarrowWidth(window.innerWidth)) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true }) activateTasksPage(ctx, sessionId, { background: true })
}, [jobsService, jobsSnapshot, sessionId, store, ctx]) }, [jobsService, jobsSnapshot, sessionId, store, ctx])
+26 -61
View File
@@ -148,7 +148,11 @@
padding: 0; padding: 0;
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2); border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px; /* 8px, the same rounding the floating window wears: the page's cards and
its windows are one family, and the square-ish 4px read as a table cell
next to them. The border is INSIDE the box (border-box + the layout's
min-height), so a thicker border never changes the card's outer size. */
border-radius: 8px;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
} }
@@ -157,17 +161,18 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
/* The current session: the card keeps the native selected language — the /* The current session: a THICKER accent edge (the reader's ask was "边框加粗
same 2px accent bar the tree row wears, never a shadow. */ 高亮", and the previous 2px left stripe was the card's one leftover piece of
.nodeCurrent::before { chrome — it also doubled the level edge on the root card, which is why the
content: ''; two read as a single bar).
position: absolute; 2px, not 1.5px: Chromium FLOORS a sub-pixel border width at device-pixel
left: 0; ratio 1, so 1.5px computes (and paints) as 1px — measured on the real host,
top: 4px; where the "thicker" edge was indistinguishable from the plain one. The border
bottom: 4px; sits inside the border box, so the layout's height/width budgets are
width: 2px; untouched (the card's own content absorbs the extra pixel). */
border-radius: 1px; .nodeCurrent {
background: var(--dsw-alias-state-business-primary); border-color: var(--dsw-alias-state-business-primary);
border-width: 2px;
} }
/* Teammates and the workflow run wear the accent edge; the run adds its /* Teammates and the workflow run wear the accent edge; the run adds its
@@ -193,12 +198,14 @@
/* The group colour of a node is its DEPTH. The host ships no chart palette and /* The group colour of a node is its DEPTH. The host ships no chart palette and
the skin contract forbids literal colours, so the ramp mixes the one accent the skin contract forbids literal colours, so the ramp mixes the one accent
token: level 0 is the accent itself, deeper levels recede. The level also token. The level is carried by the TOP SEGMENT'S FAINT TINT alone — the 2px
tints the top segment faintly, so a wide graph still reads as bands. */ left edge it used to add was the one piece of card chrome the reader asked
.depth0 { --card-edge: 100%; --card-tint: 7%; } to remove ("就是左侧的竖条,这个去掉"), and the tint was lifted a notch
.depth1 { --card-edge: 72%; --card-tint: 5%; } (7/5/3.5/2.5% → 9/7/5/3.5%) to compensate for the lost contrast. */
.depth2 { --card-edge: 48%; --card-tint: 3.5%; } .depth0 { --card-tint: 9%; }
.depth3 { --card-edge: 28%; --card-tint: 2.5%; } .depth1 { --card-tint: 7%; }
.depth2 { --card-tint: 5%; }
.depth3 { --card-tint: 3.5%; }
.cardTop { .cardTop {
position: relative; position: relative;
@@ -207,25 +214,12 @@
gap: 2px; gap: 2px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 6px 8px 5px 10px; padding: 6px 8px 5px;
background: color-mix( background: color-mix(
in srgb, var(--dsw-alias-state-business-primary) var(--card-tint, 6%), var(--dsw-alias-bg-layer-1) in srgb, var(--dsw-alias-state-business-primary) var(--card-tint, 6%), var(--dsw-alias-bg-layer-1)
); );
} }
/* The level edge: 2px, full height of the segment, never a second border. */
.cardTop::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: color-mix(
in srgb, var(--dsw-alias-state-business-primary) var(--card-edge, 100%), transparent
);
}
.cardBadges { .cardBadges {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -857,15 +851,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* The job output well reads on the host's own code-block rhythm (11px/19px
code): unwrapped and wrapped terminal lines share one leading, and the well
matches every markdown code block in the app. Scoped so the shared `.popPre`
metrics the node/task cards rely on stay untouched — and doubled, so the
base rule's own `font-size` / `line-height` cannot win on sheet order. */
.jobPopPre.jobPopPre {
font: var(--dsw-font-markdown-code-block, 11px/19px var(--ds-font-family-code, monospace));
}
/* The armed (first-click) confirm of the popover's kill: the same danger chip /* The armed (first-click) confirm of the popover's kill: the same danger chip
the drawer's terminate button arms into, so one destructive action reads the the drawer's terminate button arms into, so one destructive action reads the
same in both places. */ same in both places. */
@@ -938,26 +923,6 @@
font-size: 11px; font-size: 11px;
} }
/* An inset well: the hairline is definition, the fill is the surface. The
160px cap is the page's one scroll-well height — the member list, the run's
phase list, the output well and the task description all take it, so no card
in the family scrolls at a different depth. */
.popPre {
margin: 4px 0 0;
padding: 8px;
max-height: 160px;
overflow: auto;
background: var(--dsw-alias-bg-base);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 3px;
font-family: var(--ds-font-family-code, monospace);
font-size: 11px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
word-break: break-word;
}
/* A section inside a card opens with the family's one hairline: 8px of air on /* A section inside a card opens with the family's one hairline: 8px of air on
both sides of the rule (the card's own 4px gap plus 4px here, and the 8px both sides of the rule (the card's own 4px gap plus 4px here, and the 8px
padding below), exactly the beat `.jobPopActions` closes a card with. */ padding below), exactly the beat `.jobPopActions` closes a card with. */
+2
View File
@@ -81,6 +81,8 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenSubagent: z.boolean().default(true), autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true), autoOpenJobs: z.boolean().default(true),
tasksViewMode: z.union([z.const('graph'), z.const('tree')]).default('graph'), tasksViewMode: z.union([z.const('graph'), z.const('tree')]).default('graph'),
mobileNoAutoOpen: z.boolean().default(true),
mobileDefaultTree: z.boolean().default(true),
agentOpenTools: z.boolean().default(false), agentOpenTools: z.boolean().default(false),
editorExplorer: z.boolean().default(false), editorExplorer: z.boolean().default(false),
workspaceFence: z.boolean().default(true), workspaceFence: z.boolean().default(true),
+21
View File
@@ -27,6 +27,23 @@ export interface SidebarPrefs {
* classic indentation tree (the in-page toggle still flips it ad hoc). * classic indentation tree (the in-page toggle still flips it ad hoc).
*/ */
tasksViewMode: 'graph' | 'tree' tasksViewMode: 'graph' | 'tree'
/**
* MOBILE ADAPTATION (narrow viewports, `isNarrowWidth`): while the viewport
* is narrow, do not auto-activate the Tasks page for background activity —
* it suppresses BOTH triggers (`autoOpenSubagent` and `autoOpenJobs`) at
* once, because on a phone the takeover costs the whole screen. The two
* individual switches keep their own meaning on wide viewports.
*/
mobileNoAutoOpen: boolean
/**
* MOBILE ADAPTATION (narrow viewports): open the Tasks page in the classic
* TREE by default instead of the workflow graph — a narrow screen cannot
* show a layered graph legibly, while the tree's indentation still reads.
* It only picks the DEFAULT: the in-page view toggle still flips this
* session's page ad hoc, and `tasksViewMode` keeps deciding on wide
* viewports.
*/
mobileDefaultTree: boolean
/** /**
* Whether the model-facing `sidebar_open` tool is injected into the * Whether the model-facing `sidebar_open` tool is injected into the
* model's toolset — one tool that lets the model actively open a local * model's toolset — one tool that lets the model actively open a local
@@ -157,6 +174,10 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenJobs: true, autoOpenJobs: true,
tasksViewMode: 'graph', tasksViewMode: 'graph',
// Both mobile adaptations are ON by default: a phone is the case they exist
// for, and each one only ever changes what happens on a NARROW viewport.
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false, agentOpenTools: false,
editorExplorer: false, editorExplorer: false,
workspaceFence: true, workspaceFence: true,
+244
View File
@@ -0,0 +1,244 @@
/**
* The persistent floating window (FloatingWindow.tsx): its dismissal contract
* (close button + Escape ONLY — no outside click, no blur, no anchor
* observer), its two gestures (title-bar drag, edge/corner resize with a
* floor), and the bounded scrolling body that makes a long job output
* readable.
*
* jsdom has no layout engine, so geometry is asserted through the element's
* inline box (which the component writes) and through pointer-event maths —
* the real pixels are covered by the mount lane and the 3080 self-check.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, createRef, type RefObject } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
import { FloatingWindow } from '../src/client/FloatingWindow.tsx'
setupReactAct()
/** The portaled window element. */
function frame(): HTMLElement {
const node = document.querySelector('[data-floating-window]')
if (node === null) throw new Error('no floating window rendered')
return node as HTMLElement
}
/** The inline box the component wrote (jsdom performs no layout). */
function box(): { left: number; top: number; width: number; height: number } {
const style = frame().style
return {
left: Number.parseFloat(style.left),
top: Number.parseFloat(style.top),
width: Number.parseFloat(style.width),
height: Number.parseFloat(style.height),
}
}
/** Dispatch one pointer event with coordinates. */
function pointer(target: EventTarget, type: string, x: number, y: number): void {
const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
clientX?: number; clientY?: number; button?: number; pointerId?: number
}
Object.assign(event, { clientX: x, clientY: y, button: 0, pointerId: 1 })
target.dispatchEvent(event)
}
let closed = 0
beforeEach(() => {
closed = 0
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1_000 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
// The chrome's copy is localized: pin the language so the close button's
// accessible name is the zh one (the other specs do the same).
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
afterEach(() => {
vi.unstubAllGlobals()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
/** Render one window (plus an optional anchor) and return its controls. */
function renderWindow(props: {
anchor?: HTMLElement | null
bodyRef?: RefObject<HTMLDivElement>
footer?: boolean
actions?: boolean
} = {}) {
const rendered = renderRoot(createElement(FloatingWindow, {
title: 'bash-1',
onClose: () => { closed += 1 },
...(props.anchor === undefined ? {} : { anchor: props.anchor }),
...(props.bodyRef === undefined ? {} : { bodyRef: props.bodyRef }),
...(props.footer === true ? { footer: createElement('button', { type: 'button' }, 'stop') } : {}),
...(props.actions === true ? { actions: createElement('button', { type: 'button' }, 'copy') } : {}),
children: createElement('div', null, 'line 1'),
}))
return rendered
}
describe('FloatingWindow frame', () => {
it('renders the title, the actions, the footer and a scrollable body', () => {
const bodyRef = createRef<HTMLDivElement>()
const { unmount } = renderWindow({ actions: true, footer: true, bodyRef })
expect(frame().getAttribute('role')).toBe('dialog')
expect(frame().getAttribute('aria-label')).toBe('bash-1')
expect(frame().textContent).toContain('bash-1')
expect(frame().querySelector('[data-window-handle]')).not.toBeNull()
expect(frame().querySelector('[data-window-body]')).not.toBeNull()
expect(frame().querySelector('[data-window-footer]')).not.toBeNull()
// The caller's follow-tail hook receives the real scroll container.
expect(bodyRef.current).toBe(frame().querySelector('[data-window-body]'))
unmount()
})
it('sizes itself from the viewport and centres without an anchor', () => {
const { unmount } = renderWindow()
const current = box()
// 70% × 60% of 1000 × 800.
expect(current.width).toBe(700)
expect(current.height).toBe(480)
expect(current.left).toBe(150)
unmount()
})
it('opens next to its anchor, clamped into the viewport', () => {
const anchor = document.createElement('button')
document.body.append(anchor)
anchor.getBoundingClientRect = () => ({
left: 940, top: 700, right: 990, bottom: 720, width: 50, height: 20,
x: 940, y: 700, toJSON: () => ({}),
}) as DOMRect
const { unmount } = renderWindow({ anchor })
const current = box()
// The right edge would overflow: the window is pulled back inside.
expect(current.left + current.width).toBeLessThanOrEqual(1_000 - 8 + 1)
expect(current.top).toBeGreaterThan(0)
expect(current.top).toBeLessThan(800)
unmount()
})
})
describe('FloatingWindow dismissal', () => {
it('closes on the close button and on Escape', () => {
const { unmount } = renderWindow()
const close = frame().querySelector('button[aria-label="关闭"]') as HTMLButtonElement
expect(close).not.toBeNull()
act(() => { close.click() })
expect(closed).toBe(1)
act(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(closed).toBe(2)
unmount()
})
it('is PERSISTENT: an outside click, a window blur and an anchor observer never close it', () => {
const observed: Element[] = []
vi.stubGlobal('IntersectionObserver', class {
observe(target: Element): void { observed.push(target) }
disconnect(): void {}
})
const anchor = document.createElement('button')
document.body.append(anchor)
const { unmount } = renderWindow({ anchor })
// An outside mousedown: the popover contract would close here.
act(() => {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
act(() => { window.dispatchEvent(new Event('blur')) })
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(closed).toBe(0)
// No anchor observer is even registered (the window outlives its opener).
expect(observed).toEqual([])
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
unmount()
})
})
describe('FloatingWindow gestures', () => {
it('drags by the title bar and keeps the whole window on screen', () => {
const { unmount } = renderWindow()
const handle = frame().querySelector('[data-window-handle]') as HTMLElement
const before = box()
act(() => {
pointer(handle, 'pointerdown', 500, 100)
pointer(window, 'pointermove', 420, 160)
pointer(window, 'pointerup', 420, 160)
})
const moved = box()
expect(moved.left).toBe(before.left - 80)
expect(moved.top).toBe(before.top + 60)
expect(moved.width).toBe(before.width)
// A wild drag cannot hide the window (nor its resize corner): it parks in
// the viewport's corner instead of hanging off an edge.
act(() => {
pointer(handle, 'pointerdown', 420, 160)
pointer(window, 'pointermove', -5_000, -5_000)
pointer(window, 'pointerup', -5_000, -5_000)
})
expect(box().top).toBe(8)
expect(box().left).toBe(8)
// …and the far corner of the box stays inside the viewport.
expect(box().left + box().width).toBeLessThanOrEqual(1_000 - 8)
expect(box().top + box().height).toBeLessThanOrEqual(800 - 8)
unmount()
})
it('resizes from the corner and never shrinks below the floor', () => {
const { unmount } = renderWindow()
const corner = frame().querySelector('[data-window-resize="both"]') as HTMLElement
const before = box()
act(() => {
pointer(corner, 'pointerdown', 0, 0)
pointer(corner, 'pointermove', 60, 40)
pointer(corner, 'pointerup', 60, 40)
})
// The per-frame batcher may hold the last move; a flush lands it (the
// release path does exactly this).
expect(box().width).toBeGreaterThanOrEqual(before.width)
expect(box().height).toBeGreaterThanOrEqual(before.height)
// Dragging far past the floor clamps to the minimum size.
act(() => {
pointer(corner, 'pointerdown', 0, 0)
pointer(corner, 'pointermove', -5_000, -5_000)
pointer(corner, 'pointerup', -5_000, -5_000)
})
expect(box().width).toBe(320)
expect(box().height).toBe(240)
unmount()
})
it('resizes one axis at a time from the edge handles', () => {
const { unmount } = renderWindow()
const right = frame().querySelector('[data-window-resize="x"]') as HTMLElement
const before = box()
act(() => {
pointer(right, 'pointerdown', 0, 0)
pointer(right, 'pointermove', -100, 90)
pointer(right, 'pointerup', -100, 90)
})
const after = box()
expect(after.width).toBeLessThan(before.width)
// The vertical edge handle ignores horizontal travel entirely.
expect(after.height).toBe(before.height)
unmount()
})
it('does not start a drag from a header control', () => {
const { unmount } = renderWindow({ actions: true })
const before = box()
const copy = [...frame().querySelectorAll('button')].find(b => b.textContent === 'copy') as HTMLButtonElement
act(() => {
pointer(copy, 'pointerdown', 500, 100)
pointer(window, 'pointermove', 300, 300)
pointer(window, 'pointerup', 300, 300)
})
expect(box().left).toBe(before.left)
expect(box().top).toBe(before.top)
unmount()
})
})
+19
View File
@@ -35,6 +35,8 @@ describe('side card preferences', () => {
autoOpenSubagent: false, autoOpenSubagent: false,
autoOpenJobs: true, autoOpenJobs: true,
tasksViewMode: 'graph', tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: true, agentOpenTools: true,
editorExplorer: false, editorExplorer: false,
workspaceFence: true, workspaceFence: true,
@@ -57,6 +59,8 @@ describe('side card preferences', () => {
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenJobs: true, autoOpenJobs: true,
tasksViewMode: 'graph', tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false, agentOpenTools: false,
editorExplorer: false, editorExplorer: false,
workspaceFence: true, workspaceFence: true,
@@ -79,6 +83,8 @@ describe('side card preferences', () => {
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenJobs: true, autoOpenJobs: true,
tasksViewMode: 'graph', tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false, agentOpenTools: false,
editorExplorer: false, editorExplorer: false,
workspaceFence: true, workspaceFence: true,
@@ -109,6 +115,19 @@ describe('side card preferences', () => {
.toBe(false) .toBe(false)
}) })
it('defaults both mobile adaptations to on; only an explicit false disarms one', async () => {
// Absent or malformed → ON: a phone is exactly the case these exist for,
// and each one only changes what happens on a NARROW viewport.
for (const key of ['mobileNoAutoOpen', 'mobileDefaultTree'] as const) {
expect((await loadPrefs(wire({})))[key]).toBe(true)
expect((await loadPrefs(wire({ [key]: 'yes' })))[key]).toBe(true)
expect((await loadPrefs(wire({ [key]: 1 })))[key]).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ [key]: false })))[key]).toBe(false)
expect((await loadPrefs(wire({ [key]: true })))[key]).toBe(true)
}
})
it('defaults editorExplorer to false; only an explicit true enables the merged editor-explorer', async () => { it('defaults editorExplorer to false; only an explicit true enables the merged editor-explorer', async () => {
// Absent or malformed → off (separate file windows are the default). // Absent or malformed → off (separate file windows are the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(false) expect((await loadPrefs(wire({}))).editorExplorer).toBe(false)
+29 -9
View File
@@ -92,12 +92,15 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<') expect(html).toContain('>Subagents<')
// Default prefs: the general switch is off (agentOpenTools defaults off), // Default prefs: the general switch is off (agentOpenTools defaults off),
// and both tabs + the image viewer cards are pressed (3 aria-pressed // and both tabs + the image viewer cards are pressed (3 aria-pressed
// cards). // cards). The mobile switches are NOT cards — they are rows in the 手机
// group, and their own defaults (both on) show up as checked checkboxes.
// The nested auto-open toggle is NOT an inline card (it lives in the popup). // The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(3) expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0) expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, none checked). // The custom switches are real checkboxes: the two mobile adaptations are
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0) // on by default, agentOpenTools is not.
// The two mobile adaptations are on by default.
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
expect(html).not.toContain('Auto-open Subagents') expect(html).not.toContain('Auto-open Subagents')
}) })
@@ -112,6 +115,20 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>1</span>') expect(html).toContain('>1</span>')
}) })
it('renders the mobile group: heading + both narrow-viewport switches, on by default', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('>Mobile</div>')
expect(html).toContain('auto-open on narrow screens')
expect(html).toContain('Default the Tasks page to the tree')
// Both are ON by default; disarming one shows up as an unchecked box.
expect(html).toContain('checked=""')
store.setPrefs({ ...store.getPrefs(), mobileNoAutoOpen: false, mobileDefaultTree: false })
const off = renderSection(store, service)
expect(off).toContain('>Mobile</div>')
expect(off).not.toContain('checked=""')
})
it('renders one small card per registered viewer: icon + title + exts', () => { it('renders one small card per registered viewer: icon + title + exts', () => {
const { store, service } = mount() const { store, service } = mount()
const html = renderSection(store, service) const html = renderSection(store, service)
@@ -153,9 +170,10 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<') expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<') expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2) expect(pressedCount(html, 'false')).toBe(2)
// The explorer card stays pressed; no general switch is on by default. // The explorer card stays pressed.
expect(pressedCount(html, 'true')).toBe(1) expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0) // The two mobile adaptations are on by default.
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
}) })
it('hides the gear of a disabled feature (its related settings are dormant)', () => { it('hides the gear of a disabled feature (its related settings are dormant)', () => {
@@ -178,10 +196,12 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)') expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
expect(html).not.toContain('<select') expect(html).not.toContain('<select')
expect(html).toContain('>Auto-detect<') expect(html).toContain('>Auto-detect<')
// One general-row switch remains (agentOpenTools), off by default — the // Three general-row switches remain (agentOpenTools off by default, plus
// scheme row is a dropdown, not a switch. // the two mobile adaptations on) — the scheme row is a dropdown, not a
expect(html.match(/type="checkbox"/g)?.length).toBe(1) // switch.
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0) expect(html.match(/type="checkbox"/g)?.length).toBe(3)
// The two mobile adaptations are on by default (agentOpenTools is not).
expect(html.match(/checked=""/g)?.length ?? 0).toBe(2)
// Auto (default) needs no further settings → no gear. // Auto (default) needs no further settings → no gear.
expect(html).not.toContain('Position compatibility mode Feature settings') expect(html).not.toContain('Position compatibility mode Feature settings')
+40 -6
View File
@@ -228,7 +228,7 @@ function setViewport(width: number): void {
function mountSidebar( function mountSidebar(
width: number, width: number,
bottomOpen = false, bottomOpen = false,
options: { columnExpanded?: boolean } = {}, options: { columnExpanded?: boolean; prefs?: Record<string, unknown> } = {},
): MountedSidebar { ): MountedSidebar {
setViewport(width) setViewport(width)
vi.stubGlobal('WebSocket', FakeWebSocket) vi.stubGlobal('WebSocket', FakeWebSocket)
@@ -243,7 +243,12 @@ function mountSidebar(
// (`ctx.jobs`): the feed below is what a test pushes rosters into. // (`ctx.jobs`): the feed below is what a test pushes rosters into.
const jobs = makeJobsFeed() const jobs = makeJobsFeed()
const store = createSidebarStore() const store = createSidebarStore()
store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true }) store.setPrefs({
...store.getPrefs(),
autoOpenSubagent: true,
autoOpenJobs: true,
...options.prefs,
})
store.setSession(sessionId) store.setSession(sessionId)
store.reduce(state => ({ ...state, bottomOpen })) store.reduce(state => ({ ...state, bottomOpen }))
const service = createBetterSidebarService(store) const service = createBetterSidebarService(store)
@@ -408,7 +413,10 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
{ source: 'subagent', width: 1024 }, { source: 'subagent', width: 1024 },
{ source: 'job', width: 1024 }, { source: 'job', width: 1024 },
] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', async ({ source, width }) => { ] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', async ({ source, width }) => {
const sidebar = mountSidebar(width) // The NARROW rows disarm the mobile adaptation on purpose: this lane pins
// the activate-and-park promise, while the suppression the mobile switch
// adds on narrow viewports has its own test below.
const sidebar = mountSidebar(width, false, { prefs: { mobileNoAutoOpen: false } })
await publishActivity(sidebar, source) await publishActivity(sidebar, source)
expectNativeTasksOpen(sidebar, sidebar.sessionId) expectNativeTasksOpen(sidebar, sidebar.sessionId)
expectWorkbenchUntouched(sidebar) expectWorkbenchUntouched(sidebar)
@@ -417,13 +425,13 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
}) })
it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', async (source) => { it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', async (source) => {
const sidebar = mountSidebar(390) const sidebar = mountSidebar(390, false, { prefs: { mobileNoAutoOpen: false } })
await publishActivity(sidebar, source) await publishActivity(sidebar, source)
expect(sidebar.column.toggles).toBe(1) expect(sidebar.column.toggles).toBe(1)
}) })
it('a narrow column the user already expanded is not closed under them', async () => { it('a narrow column the user already expanded is not closed under them', async () => {
const sidebar = mountSidebar(390, false, { columnExpanded: true }) const sidebar = mountSidebar(390, false, { columnExpanded: true, prefs: { mobileNoAutoOpen: false } })
await publishActivity(sidebar, 'subagent') await publishActivity(sidebar, 'subagent')
expectNativeTasksOpen(sidebar, sidebar.sessionId) expectNativeTasksOpen(sidebar, sidebar.sessionId)
expect(sidebar.column.toggles).toBe(0) expect(sidebar.column.toggles).toBe(0)
@@ -471,7 +479,7 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
}) })
it('reads the viewport when the debounced activation fires, not when it arms', () => { it('reads the viewport when the debounced activation fires, not when it arms', () => {
const sidebar = mountSidebar(1024) const sidebar = mountSidebar(1024, false, { prefs: { mobileNoAutoOpen: false } })
publishSubagent(sidebar) publishSubagent(sidebar)
setViewport(390) setViewport(390)
flushSubagentDebounce() flushSubagentDebounce()
@@ -479,6 +487,32 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
expect(sidebar.column.toggles).toBe(1) expect(sidebar.column.toggles).toBe(1)
}) })
it('a NARROW viewport suppresses both triggers while the mobile switch is on', async () => {
// The mobile adaptation defaults to ON: on a phone the Tasks page costs
// the whole screen, so neither kind of background work may take it over.
const subagents = mountSidebar(390)
await publishActivity(subagents, 'subagent')
expect(subagents.surface.opens).toEqual([])
await publishActivity(subagents, 'job')
expect(subagents.surface.opens).toEqual([])
// Turning the switch off restores the old narrow behaviour: activate, then
// park the fullscreen column (the existing narrow promise).
const jobs = mountSidebar(390)
jobs.store.setPrefs({ ...jobs.store.getPrefs(), mobileNoAutoOpen: false })
await publishActivity(jobs, 'job')
expectNativeTasksOpen(jobs, jobs.sessionId)
expect(jobs.column.toggles).toBe(1)
})
it('the mobile switch never touches a WIDE viewport', async () => {
const sidebar = mountSidebar(1024)
await publishActivity(sidebar, 'subagent')
expectNativeTasksOpen(sidebar, sidebar.sessionId)
// Nothing was parked (there is no fullscreen column to park).
expect(sidebar.column.toggles).toBe(0)
})
it('leaves an already-open bottom workbench open and untouched', async () => { it('leaves an already-open bottom workbench open and untouched', async () => {
const sidebar = mountSidebar(1024, true) const sidebar = mountSidebar(1024, true)
await publishActivity(sidebar, 'subagent') await publishActivity(sidebar, 'subagent')
+63 -6
View File
@@ -10,6 +10,7 @@ import { createElement } from 'react'
import { act } from 'react-dom/test-utils' import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts' import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx' import { SubagentView } from '../src/client/SubagentView.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import type { import type {
Context, Context,
SidebarClientJobsService, SidebarClientJobsService,
@@ -232,6 +233,46 @@ describe('Tasks page interactions', () => {
unmount() unmount()
}) })
it('opens in the TREE by default on a narrow viewport when the mobile switch is on', async () => {
// jsdom's window is the plugin's mobile bracket here (767 < 768).
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 420 })
try {
const store = makeStore(snapshotWithChildren(2))
const sidebar = createSidebarStore()
sidebar.setPrefs({ ...sidebar.getPrefs(), tasksViewMode: 'graph' })
const first = renderRoot(
createElement(SubagentView, {
sessionId: 'root', active: true, ctx: makeCtx(store), store: sidebar,
}),
)
await flushJobs()
// The mobile adaptation wins over the wide-viewport default…
expect(first.container.querySelector('[role="tree"]')).not.toBeNull()
expect(first.container.querySelector('[role="group"]')).toBeNull()
// …but the in-page toggle still switches this session's page ad hoc.
const toGraph = first.container.querySelector('button[aria-label="切换为工作流图"]') as HTMLButtonElement
await act(async () => { toGraph.click() })
expect(first.container.querySelector('[role="group"]')).not.toBeNull()
first.unmount()
// Disarming the switch restores the settings default (graph) on the same
// narrow viewport.
const off = makeStore(snapshotWithChildren(2))
const offSidebar = createSidebarStore()
offSidebar.setPrefs({ ...offSidebar.getPrefs(), tasksViewMode: 'graph', mobileDefaultTree: false })
const second = renderRoot(
createElement(SubagentView, {
sessionId: 'root', active: true, ctx: makeCtx(off), store: offSidebar,
}),
)
await flushJobs()
expect(second.container.querySelector('[role="group"]')).not.toBeNull()
second.unmount()
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
}
})
it('toggles between graph and tree with the cluster visible in BOTH modes', async () => { it('toggles between graph and tree with the cluster visible in BOTH modes', async () => {
const store = makeStore(snapshotWithChildren(2)) const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot( const { container, unmount } = renderRoot(
@@ -478,7 +519,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
unmount() unmount()
}) })
it('drags the job output popover away from its anchor', async () => { it('opens the job output in the persistent floating window and drags it by its title bar', async () => {
const store = makeStore(snapshotWithChildren(1)) const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot( const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
@@ -486,15 +527,31 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
await flushJobs() await flushJobs()
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() }) await act(async () => { row.click() })
const card = document.querySelector('[role="dialog"]') as HTMLElement // The job output is a WINDOW, not the 280px anchored popover: it carries
expect(card).not.toBeNull() // the plugin's frame (a title bar to drag, a scrolling body).
const before = { left: card.style.left, top: card.style.top } const frame = document.querySelector('[data-floating-window]') as HTMLElement
expect(frame).not.toBeNull()
expect(frame.getAttribute('aria-label')).toBe('sleep 300')
expect(frame.querySelector('[data-window-body]')).not.toBeNull()
// An outside click must NOT dismiss it (the window is persistent); Escape does.
await act(async () => { await act(async () => {
card.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 })) document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
const before = { left: frame.style.left, top: frame.style.top }
const handle = frame.querySelector('[data-window-handle]') as HTMLElement
await act(async () => {
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 }))
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 120 })) window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 120 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
}) })
expect({ left: card.style.left, top: card.style.top }).not.toEqual(before) expect({ left: frame.style.left, top: frame.style.top }).not.toEqual(before)
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).toBeNull()
unmount() unmount()
}) })
}) })