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`)
@@ -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`) |
| 卡片 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 例(阶段相关用例随机制一起删除)。
+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
* once the tree has many agents — the manual toggle always wins afterwards.
*
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see
* AnchoredPopover) instead of docking a pane inside the page. It shows the
* output the host streams for an OBSERVER (never the model's consuming
* cursor), with a copy action, a follow-latest switch, the two-click kill, and
* a terminal-style tail while the job runs.
* Window: clicking a row opens a PERSISTENT floating window (FloatingWindow —
* draggable, resizable, closed only by its own button or Escape), because a
* job's output is something the reader watches for minutes: the popover it
* used to open was dismissed by any click elsewhere and had no height bound,
* 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 /
* TerminalBlock).
@@ -28,6 +32,7 @@ import {
jobStatusLabel,
type TreeJob,
} from './subagent-jobs.ts'
import { FloatingWindow } from './FloatingWindow.tsx'
import { t } from './locales.ts'
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
* (replayed from the owner session's event log), refreshed every
* {@link JOB_POLL_MS} while the job runs and the page is visible. The reader
* can copy the output and turn the terminal-style tail off; the kill action
* keeps its two-click confirm.
* The output WINDOW of one job: what the host streams for an observer of this
* job, inside the plugin's persistent floating window. The window owns the
* frame, the geometry and the dismissal contract; this component owns the
* content (status header rows, the terminal tail) and the footer controls
* (follow-latest, two-click kill).
*/
export function JobOutputPopoverContent(props: {
export function JobOutputWindow(props: {
/** The host client jobs service (absent → the panel only reports that). */
jobs: SidebarClientJobsService | undefined
job: SidebarJobView
@@ -216,14 +221,18 @@ export function JobOutputPopoverContent(props: {
ownerSessionId: string
/** The host's streamed observation of THIS job (undefined until it arrives). */
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 {
const { jobs, job, ownerSessionId, observed } = props
const { jobs, job, ownerSessionId, observed, anchor, onClose } = props
const [follow, setFollow] = useState(true)
const [copied, setCopied] = useState(false)
const [armed, setArmed] = useState(false)
const [killing, setKilling] = useState(false)
const [killFailed, setKillFailed] = useState(false)
const preRef = useRef<HTMLPreElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const live = isJobLive(job)
const text = observed?.text ?? ''
const labels = useMemo(() => terminalBlockLabels(), [])
@@ -235,15 +244,16 @@ export function JobOutputPopoverContent(props: {
return () => { window.clearTimeout(timer) }
}, [armed])
// Terminal-tail behavior: every frame pins the view to the newest output
// while the reader keeps the follow switch on.
// Terminal-tail behavior: every frame pins the BODY (the window's own scroll
// container — the element the previous popover wrapped in a div that never
// scrolled) to the newest output while the reader keeps follow on.
useEffect(() => {
if (!live || !follow || text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
const body = bodyRef.current
if (body !== null) body.scrollTop = body.scrollHeight
}, [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> => {
if (jobs === undefined) return
setKilling(true)
@@ -259,10 +269,13 @@ export function JobOutputPopoverContent(props: {
}
return (
<div className={css.popCard} data-popover-handle>
<div className={css.popHead}>
<span>{t('jobs')}</span>
<span className={css.popHeadActions} data-popover-no-drag>
<FloatingWindow
title={job.label}
onClose={onClose}
{...(anchor === undefined ? {} : { anchor })}
bodyRef={bodyRef}
actions={(
<>
<Button
variant="ghost"
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}>
<StateDot state={jobDotState(job.status)} size={6} />
<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.text.length > 0
? (
<div ref={preRef as unknown as React.RefObject<HTMLDivElement>} data-popover-no-drag>
<div>
<TerminalBlock
command={job.label}
output={observed.text}
@@ -311,38 +359,6 @@ export function JobOutputPopoverContent(props: {
: <div className={css.popHint}>{t('jobNoOutput')}</div>}
</>
)}
<div className={css.jobPopActions} data-popover-no-drag>
{/*
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>
</FloatingWindow>
)
}
+30
View File
@@ -900,6 +900,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</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
grid; features declaring `settings.toggles` open their settings in
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 { api, type TeamsViewResult } from './api.ts'
import { usePolling } from './use-polling.ts'
import { useNarrowViewport } from './breakpoints.ts'
import {
clientJobs, collectRows, useJobObservation, useJobWatchers, useJobsSnapshot,
} from './jobs-client.ts'
@@ -57,7 +58,7 @@ import { t } from './locales.ts'
import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts'
import { TasksGraph } from './TasksGraph.tsx'
import { TasksTree } from './TasksTree.tsx'
import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx'
import { JobsDrawer, JobOutputWindow } from './JobsDrawer.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx'
import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.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 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(
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 mode = modeOverride ?? prefsMode
@@ -453,11 +461,13 @@ export function SubagentView(props: {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null
return (
<JobOutputPopoverContent
<JobOutputWindow
jobs={treeJobs.jobs}
ownerSessionId={row.ownerSessionId}
job={row.job}
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 })
}}
/>
{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
: (
<AnchoredPopover
anchor={popover?.anchor ?? null}
onClose={closePopover}
draggable={popover?.kind === 'job'}
width={popover?.kind === 'job' ? 380 : 280}
>
{popoverContent}
</AnchoredPopover>
)}
: popover?.kind === 'task'
? popoverContent
: (
<AnchoredPopover anchor={popover?.anchor ?? null} onClose={closePopover} width={280}>
{popoverContent}
</AnchoredPopover>
)}
</div>
)
}
+5
View File
@@ -253,6 +253,11 @@ export const ar: Record<string, string> = {
settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)',
settingsTabsTitle: 'محتوى الشريط الجانبي',
settingsViewersTitle: 'عارضات الملفات',
settingsMobileTitle: "الجوال",
settingsMobileNoAutoOpenTitle: "عدم الفتح التلقائي على الشاشات الضيقة",
settingsMobileNoAutoOpenDesc: "على الهاتف أو الشاشة الضيقة، لن يؤدي وكيل فرعي أو مهمة خلفية جديدة إلى الانتقال تلقائيًا إلى صفحة المهام (يمكنك فتحها يدويًا)",
settingsMobileTreeTitle: "افتراض الشجرة لصفحة المهام",
settingsMobileTreeDesc: "على الهاتف أو الشاشة الضيقة تُفتح صفحة المهام كشجرة كلاسيكية بدل مخطط سير العمل (مفتاح الصفحة يظل يعمل)",
settingsGeneralTitle: 'عام',
settingsPopup: 'إعدادات الميزة',
settingsViewerCatchAll: 'شامل: أي ملف',
+5
View File
@@ -238,6 +238,11 @@ export const de: Record<string, string> = {
settingsShellArgsPlaceholder: 'z. B. -l (leer = Standardargumente)',
settingsTabsTitle: 'Seitenleisten-Inhalt',
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',
settingsPopup: 'Funktionseinstellungen',
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)',
settingsTabsTitle: 'Contenu de la barre latérale',
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',
settingsPopup: 'Réglages de la fonctionnalité',
settingsViewerCatchAll: 'Par défaut : n’importe quel fichier',
+5
View File
@@ -252,6 +252,11 @@ export const hi: Record<string, string> = {
settingsShellArgsPlaceholder: 'जैसे -l (खाली = डिफ़ॉल्ट)',
settingsTabsTitle: 'साइडबार सामग्री',
settingsViewersTitle: 'फ़ाइल दर्शक',
settingsMobileTitle: "मोबाइल",
settingsMobileNoAutoOpenTitle: "छोटी स्क्रीन पर स्वतः न खोलें",
settingsMobileNoAutoOpenDesc: "फ़ोन / छोटी स्क्रीन पर नया सबएजेंट या पृष्ठभूमि कार्य अपने आप कार्य पृष्ठ नहीं खोलेगा (मैन्युअल रूप से खोल सकते हैं)",
settingsMobileTreeTitle: "कार्य पृष्ठ डिफ़ॉल्ट रूप से ट्री",
settingsMobileTreeDesc: "फ़ोन / छोटी स्क्रीन पर कार्य पृष्ठ वर्कफ़्लो ग्राफ़ की जगह क्लासिक ट्री में खुलता है; पृष्ठ का टॉगल काम करता रहता है",
settingsGeneralTitle: 'सामान्य',
settingsPopup: 'सुविधा सेटिंग्स',
settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल',
+5
View File
@@ -250,6 +250,11 @@ export const id: Record<string, string> = {
settingsShellArgsPlaceholder: 'mis. -l (kosong = default)',
settingsTabsTitle: 'Konten sidebar',
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',
settingsPopup: 'Pengaturan fitur',
settingsViewerCatchAll: 'Catch-all: berkas apa pun',
+5
View File
@@ -243,6 +243,11 @@ export const it: Record<string, string> = {
settingsShellArgsPlaceholder: 'es. -l (vuoto = predefiniti)',
settingsTabsTitle: 'Contenuto della barra laterale',
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',
settingsPopup: 'Impostazioni della funzionalità',
settingsViewerCatchAll: 'Raccoglitore: qualsiasi file',
+5
View File
@@ -252,6 +252,11 @@ export const ja: Record<string, string> = {
settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)',
settingsTabsTitle: 'サイドバーの内容',
settingsViewersTitle: 'ファイルプレビュー',
settingsMobileTitle: "モバイル",
settingsMobileNoAutoOpenTitle: "狭い画面では自動で開かない",
settingsMobileNoAutoOpenDesc: "スマホ / 狭い画面では、新しいサブエージェントやバックグラウンドタスクがあってもタスクページへ自動で切り替わりません(手動では開けます)",
settingsMobileTreeTitle: "タスクページはツリーを既定にする",
settingsMobileTreeDesc: "スマホ / 狭い画面ではタスクページをワークフロー図ではなくツリーで開きます(ページ内の切り替えはそのまま使えます)",
settingsGeneralTitle: '一般',
settingsPopup: '機能設定',
settingsViewerCatchAll: 'フォールバック:任意のファイル',
+5
View File
@@ -244,6 +244,11 @@ export const ko: Record<string, string> = {
settingsShellArgsPlaceholder: '예: -l (비워두면 기본 인수 사용)',
settingsTabsTitle: '사이드바 콘텐츠',
settingsViewersTitle: '파일 미리보기',
settingsMobileTitle: "모바일",
settingsMobileNoAutoOpenTitle: "좁은 화면에서는 자동으로 열지 않기",
settingsMobileNoAutoOpenDesc: "휴대폰 / 좁은 화면에서는 새 하위 에이전트나 백그라운드 작업이 있어도 작업 페이지로 자동 전환되지 않습니다(직접 열 수는 있습니다)",
settingsMobileTreeTitle: "작업 페이지 기본을 트리로",
settingsMobileTreeDesc: "휴대폰 / 좁은 화면에서 작업 페이지가 워크플로 그래프 대신 클래식 트리로 열립니다. 페이지 내 전환은 그대로 동작합니다",
settingsGeneralTitle: '일반',
settingsPopup: '기능 설정',
settingsViewerCatchAll: '폴백: 모든 파일',
+5
View File
@@ -250,6 +250,11 @@ export const nl: Record<string, string> = {
settingsShellArgsPlaceholder: 'bijv. -l (leeg = standaardwaarden)',
settingsTabsTitle: 'Zijbalk-inhoud',
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',
settingsPopup: 'Functie-instellingen',
settingsViewerCatchAll: 'Catch-all: elk bestand',
+5
View File
@@ -254,6 +254,11 @@ export const pl: Record<string, string> = {
settingsShellArgsPlaceholder: 'np. -l (puste = domyślne)',
settingsTabsTitle: 'Treść panelu bocznego',
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',
settingsPopup: 'Ustawienia funkcji',
settingsViewerCatchAll: 'Zapasowy: dowolny plik',
+5
View File
@@ -235,6 +235,11 @@ export const pt: Record<string, string> = {
settingsShellArgsPlaceholder: 'ex.: -l (vazio = padrões)',
settingsTabsTitle: 'Conteúdo da barra lateral',
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',
settingsPopup: 'Configurações do recurso',
settingsViewerCatchAll: 'Curinga: qualquer arquivo',
+5
View File
@@ -249,6 +249,11 @@ export const ru: Record<string, string> = {
settingsShellArgsPlaceholder: 'напр. -l (пусто = по умолчанию)',
settingsTabsTitle: 'Содержимое боковой панели',
settingsViewersTitle: 'Просмотрщики файлов',
settingsMobileTitle: "Мобильный",
settingsMobileNoAutoOpenTitle: "Не открывать автоматически на узком экране",
settingsMobileNoAutoOpenDesc: "На телефоне / узком экране новый субагент или фоновая задача больше не переключает на страницу задач само (открыть вручную можно)",
settingsMobileTreeTitle: "Страница задач по умолчанию — дерево",
settingsMobileTreeDesc: "На телефоне / узком экране страница задач открывается классическим деревом, а не графом процесса; переключатель на странице продолжает работать",
settingsGeneralTitle: 'Общие',
settingsPopup: 'Настройки функции',
settingsViewerCatchAll: 'Универсальный: любой файл',
+5
View File
@@ -235,6 +235,11 @@ export const sv: Record<string, string> = {
settingsShellArgsPlaceholder: 't.ex. -l (tomt = standard)',
settingsTabsTitle: 'Sidopanelens innehåll',
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',
settingsPopup: 'Funktionsinställningar',
settingsViewerCatchAll: 'Fånga alla: alla filer',
+5
View File
@@ -252,6 +252,11 @@ export const th: Record<string, string> = {
settingsShellArgsPlaceholder: 'เช่น -l (ว่าง = ค่าเริ่มต้น)',
settingsTabsTitle: 'เนื้อหาแถบด้านข้าง',
settingsViewersTitle: 'ตัวพรีวิวไฟล์',
settingsMobileTitle: "มือถือ",
settingsMobileNoAutoOpenTitle: "ไม่เปิดอัตโนมัติบนจอแคบ",
settingsMobileNoAutoOpenDesc: "บนมือถือ / จอแคบ เอเจนต์ย่อยหรืองานเบื้องหลังใหม่จะไม่สลับไปหน้าภารกิจให้เองอีก (ยังเปิดเองได้)",
settingsMobileTreeTitle: "หน้าภารกิจใช้ทรีเป็นค่าเริ่มต้น",
settingsMobileTreeDesc: "บนมือถือ / จอแคบ หน้าภารกิจจะเปิดเป็นทรีแบบคลาสสิกแทนกราฟเวิร์กโฟลว์ ปุ่มสลับในหน้ายังใช้ได้",
settingsGeneralTitle: 'ทั่วไป',
settingsPopup: 'การตั้งค่าคุณสมบัติ',
settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ',
+5
View File
@@ -252,6 +252,11 @@ export const tr: Record<string, string> = {
settingsShellArgsPlaceholder: 'ör. -l (boş = varsayılanlar)',
settingsTabsTitle: 'Kenar çubuğu içeriği',
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',
settingsPopup: 'Özellik ayarları',
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)',
settingsTabsTitle: 'Nội dung thanh bên',
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',
settingsPopup: 'Cài đặt tính năng',
settingsViewerCatchAll: 'Catch-all: mọi tệp',
+5
View File
@@ -267,6 +267,11 @@ export const zhHK: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
+5
View File
@@ -267,6 +267,11 @@ export const zhMO: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
+5
View File
@@ -267,6 +267,11 @@ export const zhTW: Record<string, string> = {
settingsShellArgsPlaceholder: '如 -l(留空用預設參數)',
settingsTabsTitle: '側邊欄內容',
settingsViewersTitle: '檔案預覽',
settingsMobileTitle: "手機",
settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出",
settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)",
settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖",
settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖",
settingsGeneralTitle: '一般',
settingsPopup: '功能設定',
settingsViewerCatchAll: '兜底:任意檔案',
+10
View File
@@ -264,6 +264,11 @@ export const zh = {
settingsShellArgsPlaceholder: '如 -l(留空用默认参数)',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsMobileTitle: '手机',
settingsMobileNoAutoOpenTitle: '窄屏时不自动弹出',
settingsMobileNoAutoOpenDesc: '在手机 / 窄屏下,新的子代理或后台任务都不再自动切到任务页(页内仍可手动打开)',
settingsMobileTreeTitle: '窄屏时任务页默认树状图',
settingsMobileTreeDesc: '手机 / 窄屏下任务页默认用经典树,而不是工作流图;页内切换仍可临时改成图',
settingsGeneralTitle: '常规',
settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件',
@@ -738,6 +743,11 @@ export const en: Record<keyof typeof zh, string> = {
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
settingsTabsTitle: 'Sidebar content',
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',
settingsPopup: 'Feature settings',
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'
? record.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'
? record.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
* 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
* 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
@@ -202,6 +207,7 @@ export function useHostFeeds(feeds: {
autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (store.getPrefs().mobileNoAutoOpen && isNarrowWidth(window.innerWidth)) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true })
}, 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
* 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
* reference-counted `watchRows` stream for the current session replaces the
* 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 (!detectNewJob(prev, rows, jobWatchStartRef.current)) return
if (!store.getPrefs().autoOpenJobs) return
if (store.getPrefs().mobileNoAutoOpen && isNarrowWidth(window.innerWidth)) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
activateTasksPage(ctx, sessionId, { background: true })
}, [jobsService, jobsSnapshot, sessionId, store, ctx])
+26 -61
View File
@@ -148,7 +148,11 @@
padding: 0;
background: var(--dsw-alias-bg-layer-1);
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;
overflow: hidden;
}
@@ -157,17 +161,18 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* The current session: the card keeps the native selected language — the
same 2px accent bar the tree row wears, never a shadow. */
.nodeCurrent::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background: var(--dsw-alias-state-business-primary);
/* The current session: a THICKER accent edge (the reader's ask was "边框加粗
高亮", and the previous 2px left stripe was the card's one leftover piece of
chrome — it also doubled the level edge on the root card, which is why the
two read as a single bar).
2px, not 1.5px: Chromium FLOORS a sub-pixel border width at device-pixel
ratio 1, so 1.5px computes (and paints) as 1px — measured on the real host,
where the "thicker" edge was indistinguishable from the plain one. The border
sits inside the border box, so the layout's height/width budgets are
untouched (the card's own content absorbs the extra pixel). */
.nodeCurrent {
border-color: var(--dsw-alias-state-business-primary);
border-width: 2px;
}
/* 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 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
tints the top segment faintly, so a wide graph still reads as bands. */
.depth0 { --card-edge: 100%; --card-tint: 7%; }
.depth1 { --card-edge: 72%; --card-tint: 5%; }
.depth2 { --card-edge: 48%; --card-tint: 3.5%; }
.depth3 { --card-edge: 28%; --card-tint: 2.5%; }
token. The level is carried by the TOP SEGMENT'S FAINT TINT alone — the 2px
left edge it used to add was the one piece of card chrome the reader asked
to remove ("就是左侧的竖条,这个去掉"), and the tint was lifted a notch
(7/5/3.5/2.5% → 9/7/5/3.5%) to compensate for the lost contrast. */
.depth0 { --card-tint: 9%; }
.depth1 { --card-tint: 7%; }
.depth2 { --card-tint: 5%; }
.depth3 { --card-tint: 3.5%; }
.cardTop {
position: relative;
@@ -207,25 +214,12 @@
gap: 2px;
flex: 1;
min-width: 0;
padding: 6px 8px 5px 10px;
padding: 6px 8px 5px;
background: color-mix(
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 {
display: flex;
align-items: center;
@@ -857,15 +851,6 @@
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 drawer's terminate button arms into, so one destructive action reads the
same in both places. */
@@ -938,26 +923,6 @@
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
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. */
+2
View File
@@ -81,6 +81,8 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
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),
editorExplorer: z.boolean().default(false),
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).
*/
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
* 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,
autoOpenJobs: true,
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,
editorExplorer: false,
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,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: true,
editorExplorer: false,
workspaceFence: true,
@@ -57,6 +59,8 @@ describe('side card preferences', () => {
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
@@ -79,6 +83,8 @@ describe('side card preferences', () => {
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
mobileNoAutoOpen: true,
mobileDefaultTree: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
@@ -109,6 +115,19 @@ describe('side card preferences', () => {
.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 () => {
// Absent or malformed → off (separate file windows are the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(false)
+29 -9
View File
@@ -92,12 +92,15 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
// Default prefs: the general switch is off (agentOpenTools defaults off),
// 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).
expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, none checked).
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
// The custom switches are real checkboxes: the two mobile adaptations are
// 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')
})
@@ -112,6 +115,20 @@ describe('SideCardSection declarative inventory', () => {
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', () => {
const { store, service } = mount()
const html = renderSection(store, service)
@@ -153,9 +170,10 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
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(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)', () => {
@@ -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).not.toContain('<select')
expect(html).toContain('>Auto-detect<')
// One general-row switch remains (agentOpenTools), off by default — the
// scheme row is a dropdown, not a switch.
expect(html.match(/type="checkbox"/g)?.length).toBe(1)
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
// Three general-row switches remain (agentOpenTools off by default, plus
// the two mobile adaptations on) — the scheme row is a dropdown, not a
// switch.
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.
expect(html).not.toContain('Position compatibility mode Feature settings')
+40 -6
View File
@@ -228,7 +228,7 @@ function setViewport(width: number): void {
function mountSidebar(
width: number,
bottomOpen = false,
options: { columnExpanded?: boolean } = {},
options: { columnExpanded?: boolean; prefs?: Record<string, unknown> } = {},
): MountedSidebar {
setViewport(width)
vi.stubGlobal('WebSocket', FakeWebSocket)
@@ -243,7 +243,12 @@ function mountSidebar(
// (`ctx.jobs`): the feed below is what a test pushes rosters into.
const jobs = makeJobsFeed()
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.reduce(state => ({ ...state, bottomOpen }))
const service = createBetterSidebarService(store)
@@ -408,7 +413,10 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
{ source: 'subagent', width: 1024 },
{ source: 'job', width: 1024 },
] 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)
expectNativeTasksOpen(sidebar, sidebar.sessionId)
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) => {
const sidebar = mountSidebar(390)
const sidebar = mountSidebar(390, false, { prefs: { mobileNoAutoOpen: false } })
await publishActivity(sidebar, source)
expect(sidebar.column.toggles).toBe(1)
})
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')
expectNativeTasksOpen(sidebar, sidebar.sessionId)
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', () => {
const sidebar = mountSidebar(1024)
const sidebar = mountSidebar(1024, false, { prefs: { mobileNoAutoOpen: false } })
publishSubagent(sidebar)
setViewport(390)
flushSubagentDebounce()
@@ -479,6 +487,32 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
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 () => {
const sidebar = mountSidebar(1024, true)
await publishActivity(sidebar, 'subagent')
+63 -6
View File
@@ -10,6 +10,7 @@ import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import type {
Context,
SidebarClientJobsService,
@@ -232,6 +233,46 @@ describe('Tasks page interactions', () => {
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 () => {
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
@@ -478,7 +519,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
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 { container, unmount } = renderRoot(
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()
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
const card = document.querySelector('[role="dialog"]') as HTMLElement
expect(card).not.toBeNull()
const before = { left: card.style.left, top: card.style.top }
// The job output is a WINDOW, not the 280px anchored popover: it carries
// the plugin's frame (a title bar to drag, a scrolling body).
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 () => {
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('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()
})
})