From d38fefe55da266c7cb0809e43988320fa525bdfa Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 12:35:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E4=BB=BB=E5=8A=A1=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=B8=B8=E9=A9=BB=E6=B5=AE=E5=8A=A8=E7=AA=97=20+=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=8D=A1=E5=8E=BB=E7=AB=96=E7=BA=BF=20+=20?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事一起落地(用户 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) --- docs/external-plugin-guide.md | 2 +- ...6-09-27-floating-window-mobile-settings.md | 122 ++++++ docs/plans/2026-09-27-tasks-graph-polish.md | 6 + src/client/FloatingWindow.module.css | 128 +++++++ src/client/FloatingWindow.tsx | 346 ++++++++++++++++++ src/client/JobsDrawer.tsx | 132 ++++--- src/client/SideCardSection.tsx | 30 ++ src/client/SubagentView.tsx | 42 ++- src/client/locales-ar.ts | 5 + src/client/locales-de.ts | 5 + src/client/locales-fr.ts | 5 + src/client/locales-hi.ts | 5 + src/client/locales-id.ts | 5 + src/client/locales-it.ts | 5 + src/client/locales-ja.ts | 5 + src/client/locales-ko.ts | 5 + src/client/locales-nl.ts | 5 + src/client/locales-pl.ts | 5 + src/client/locales-pt.ts | 5 + src/client/locales-ru.ts | 5 + src/client/locales-sv.ts | 5 + src/client/locales-th.ts | 5 + src/client/locales-tr.ts | 5 + src/client/locales-vi.ts | 5 + src/client/locales-zh-HK.ts | 5 + src/client/locales-zh-MO.ts | 5 + src/client/locales-zh-TW.ts | 5 + src/client/locales.ts | 10 + src/client/prefs.ts | 6 + src/client/sidebar/use-host-feeds.ts | 11 + src/client/tasks-graph.module.css | 87 ++--- src/config.ts | 2 + src/prefs-shared.ts | 21 ++ tests/floating-window.spec.tsx | 244 ++++++++++++ tests/prefs.spec.ts | 19 + tests/side-card-section.spec.tsx | 38 +- tests/sidebar-auto-activation.spec.tsx | 46 ++- tests/tasks-page.spec.tsx | 69 +++- 38 files changed, 1300 insertions(+), 156 deletions(-) create mode 100644 docs/plans/2026-09-27-floating-window-mobile-settings.md create mode 100644 src/client/FloatingWindow.module.css create mode 100644 src/client/FloatingWindow.tsx create mode 100644 tests/floating-window.spec.tsx diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 57605b6..520985e 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -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`) diff --git a/docs/plans/2026-09-27-floating-window-mobile-settings.md b/docs/plans/2026-09-27-floating-window-mobile-settings.md new file mode 100644 index 0000000..97e5cc1 --- /dev/null +++ b/docs/plans/2026-09-27-floating-window-mobile-settings.md @@ -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` 用 `` 而不是 `<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`(本轮范围外)。 diff --git a/docs/plans/2026-09-27-tasks-graph-polish.md b/docs/plans/2026-09-27-tasks-graph-polish.md index 7e14089..d747f4a 100644 --- a/docs/plans/2026-09-27-tasks-graph-polish.md +++ b/docs/plans/2026-09-27-tasks-graph-polish.md @@ -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 例(阶段相关用例随机制一起删除)。 diff --git a/src/client/FloatingWindow.module.css b/src/client/FloatingWindow.module.css new file mode 100644 index 0000000..bbb7757 --- /dev/null +++ b/src/client/FloatingWindow.module.css @@ -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% + ); +} diff --git a/src/client/FloatingWindow.tsx b/src/client/FloatingWindow.tsx new file mode 100644 index 0000000..0e51899 --- /dev/null +++ b/src/client/FloatingWindow.tsx @@ -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, + ) +} diff --git a/src/client/JobsDrawer.tsx b/src/client/JobsDrawer.tsx index 20b6baf..f8fac71 100644 --- a/src/client/JobsDrawer.tsx +++ b/src/client/JobsDrawer.tsx @@ -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> ) } diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 321ab99..1676586 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -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. */} diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index e2309fa..00ba8f6 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -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> ) } diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 4b715ca..e8666d2 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -253,6 +253,11 @@ export const ar: Record<string, string> = { settingsShellArgsPlaceholder: 'مثل -l (فارغ = الافتراضيات)', settingsTabsTitle: 'محتوى الشريط الجانبي', settingsViewersTitle: 'عارضات الملفات', + settingsMobileTitle: "الجوال", + settingsMobileNoAutoOpenTitle: "عدم الفتح التلقائي على الشاشات الضيقة", + settingsMobileNoAutoOpenDesc: "على الهاتف أو الشاشة الضيقة، لن يؤدي وكيل فرعي أو مهمة خلفية جديدة إلى الانتقال تلقائيًا إلى صفحة المهام (يمكنك فتحها يدويًا)", + settingsMobileTreeTitle: "افتراض الشجرة لصفحة المهام", + settingsMobileTreeDesc: "على الهاتف أو الشاشة الضيقة تُفتح صفحة المهام كشجرة كلاسيكية بدل مخطط سير العمل (مفتاح الصفحة يظل يعمل)", settingsGeneralTitle: 'عام', settingsPopup: 'إعدادات الميزة', settingsViewerCatchAll: 'شامل: أي ملف', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 1b3ae4a..344120f 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -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', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 16f7064..e9eb14e 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -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', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index a822cb5..80d2bf8 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -252,6 +252,11 @@ export const hi: Record<string, string> = { settingsShellArgsPlaceholder: 'जैसे -l (खाली = डिफ़ॉल्ट)', settingsTabsTitle: 'साइडबार सामग्री', settingsViewersTitle: 'फ़ाइल दर्शक', + settingsMobileTitle: "मोबाइल", + settingsMobileNoAutoOpenTitle: "छोटी स्क्रीन पर स्वतः न खोलें", + settingsMobileNoAutoOpenDesc: "फ़ोन / छोटी स्क्रीन पर नया सबएजेंट या पृष्ठभूमि कार्य अपने आप कार्य पृष्ठ नहीं खोलेगा (मैन्युअल रूप से खोल सकते हैं)", + settingsMobileTreeTitle: "कार्य पृष्ठ डिफ़ॉल्ट रूप से ट्री", + settingsMobileTreeDesc: "फ़ोन / छोटी स्क्रीन पर कार्य पृष्ठ वर्कफ़्लो ग्राफ़ की जगह क्लासिक ट्री में खुलता है; पृष्ठ का टॉगल काम करता रहता है", settingsGeneralTitle: 'सामान्य', settingsPopup: 'सुविधा सेटिंग्स', settingsViewerCatchAll: 'कैच-ऑल: कोई भी फ़ाइल', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 3e67b1b..026348a 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -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', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index cb6115e..2fba3a4 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -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', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 3994b11..578344b 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -252,6 +252,11 @@ export const ja: Record<string, string> = { settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)', settingsTabsTitle: 'サイドバーの内容', settingsViewersTitle: 'ファイルプレビュー', + settingsMobileTitle: "モバイル", + settingsMobileNoAutoOpenTitle: "狭い画面では自動で開かない", + settingsMobileNoAutoOpenDesc: "スマホ / 狭い画面では、新しいサブエージェントやバックグラウンドタスクがあってもタスクページへ自動で切り替わりません(手動では開けます)", + settingsMobileTreeTitle: "タスクページはツリーを既定にする", + settingsMobileTreeDesc: "スマホ / 狭い画面ではタスクページをワークフロー図ではなくツリーで開きます(ページ内の切り替えはそのまま使えます)", settingsGeneralTitle: '一般', settingsPopup: '機能設定', settingsViewerCatchAll: 'フォールバック:任意のファイル', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 995c494..5475b29 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -244,6 +244,11 @@ export const ko: Record<string, string> = { settingsShellArgsPlaceholder: '예: -l (비워두면 기본 인수 사용)', settingsTabsTitle: '사이드바 콘텐츠', settingsViewersTitle: '파일 미리보기', + settingsMobileTitle: "모바일", + settingsMobileNoAutoOpenTitle: "좁은 화면에서는 자동으로 열지 않기", + settingsMobileNoAutoOpenDesc: "휴대폰 / 좁은 화면에서는 새 하위 에이전트나 백그라운드 작업이 있어도 작업 페이지로 자동 전환되지 않습니다(직접 열 수는 있습니다)", + settingsMobileTreeTitle: "작업 페이지 기본을 트리로", + settingsMobileTreeDesc: "휴대폰 / 좁은 화면에서 작업 페이지가 워크플로 그래프 대신 클래식 트리로 열립니다. 페이지 내 전환은 그대로 동작합니다", settingsGeneralTitle: '일반', settingsPopup: '기능 설정', settingsViewerCatchAll: '폴백: 모든 파일', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 7d55318..bcb7a36 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -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', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 78a7b88..0ed13f5 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -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', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 14aa27c..865e720 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -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', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 3a5b07a..2c9655b 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -249,6 +249,11 @@ export const ru: Record<string, string> = { settingsShellArgsPlaceholder: 'напр. -l (пусто = по умолчанию)', settingsTabsTitle: 'Содержимое боковой панели', settingsViewersTitle: 'Просмотрщики файлов', + settingsMobileTitle: "Мобильный", + settingsMobileNoAutoOpenTitle: "Не открывать автоматически на узком экране", + settingsMobileNoAutoOpenDesc: "На телефоне / узком экране новый субагент или фоновая задача больше не переключает на страницу задач само (открыть вручную можно)", + settingsMobileTreeTitle: "Страница задач по умолчанию — дерево", + settingsMobileTreeDesc: "На телефоне / узком экране страница задач открывается классическим деревом, а не графом процесса; переключатель на странице продолжает работать", settingsGeneralTitle: 'Общие', settingsPopup: 'Настройки функции', settingsViewerCatchAll: 'Универсальный: любой файл', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 9dac67a..362f65c 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -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', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 5a5b195..bf9d1ea 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -252,6 +252,11 @@ export const th: Record<string, string> = { settingsShellArgsPlaceholder: 'เช่น -l (ว่าง = ค่าเริ่มต้น)', settingsTabsTitle: 'เนื้อหาแถบด้านข้าง', settingsViewersTitle: 'ตัวพรีวิวไฟล์', + settingsMobileTitle: "มือถือ", + settingsMobileNoAutoOpenTitle: "ไม่เปิดอัตโนมัติบนจอแคบ", + settingsMobileNoAutoOpenDesc: "บนมือถือ / จอแคบ เอเจนต์ย่อยหรืองานเบื้องหลังใหม่จะไม่สลับไปหน้าภารกิจให้เองอีก (ยังเปิดเองได้)", + settingsMobileTreeTitle: "หน้าภารกิจใช้ทรีเป็นค่าเริ่มต้น", + settingsMobileTreeDesc: "บนมือถือ / จอแคบ หน้าภารกิจจะเปิดเป็นทรีแบบคลาสสิกแทนกราฟเวิร์กโฟลว์ ปุ่มสลับในหน้ายังใช้ได้", settingsGeneralTitle: 'ทั่วไป', settingsPopup: 'การตั้งค่าคุณสมบัติ', settingsViewerCatchAll: 'Catch-all: ไฟล์ใดๆ', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 548bd95..2ba12b8 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -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', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index c795f5a..44237b8 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -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', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 62d2594..ba8cc42 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -267,6 +267,11 @@ export const zhHK: Record<string, string> = { settingsShellArgsPlaceholder: '如 -l(留空用預設參數)', settingsTabsTitle: '側邊欄內容', settingsViewersTitle: '檔案預覽', + settingsMobileTitle: "手機", + settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出", + settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)", + settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖", + settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖", settingsGeneralTitle: '一般', settingsPopup: '功能設定', settingsViewerCatchAll: '兜底:任意檔案', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 84ee904..0944e6b 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -267,6 +267,11 @@ export const zhMO: Record<string, string> = { settingsShellArgsPlaceholder: '如 -l(留空用預設參數)', settingsTabsTitle: '側邊欄內容', settingsViewersTitle: '檔案預覽', + settingsMobileTitle: "手機", + settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出", + settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)", + settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖", + settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖", settingsGeneralTitle: '一般', settingsPopup: '功能設定', settingsViewerCatchAll: '兜底:任意檔案', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 49b1980..83701bb 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -267,6 +267,11 @@ export const zhTW: Record<string, string> = { settingsShellArgsPlaceholder: '如 -l(留空用預設參數)', settingsTabsTitle: '側邊欄內容', settingsViewersTitle: '檔案預覽', + settingsMobileTitle: "手機", + settingsMobileNoAutoOpenTitle: "窄屏時不自動彈出", + settingsMobileNoAutoOpenDesc: "在手機 / 窄屏下,新的子代理或後台任務都不再自動切到任務頁(頁內仍可手動打開)", + settingsMobileTreeTitle: "窄屏時任務頁預設樹狀圖", + settingsMobileTreeDesc: "手機 / 窄屏下任務頁預設用經典樹,而不是工作流圖;頁內切換仍可臨時改成圖", settingsGeneralTitle: '一般', settingsPopup: '功能設定', settingsViewerCatchAll: '兜底:任意檔案', diff --git a/src/client/locales.ts b/src/client/locales.ts index c359c3d..10f3393 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index c03e2a3..7d4b9a0 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -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, diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts index ebcc53d..3a9d6c7 100644 --- a/src/client/sidebar/use-host-feeds.ts +++ b/src/client/sidebar/use-host-feeds.ts @@ -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]) diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css index 71ac0c5..6b69300 100644 --- a/src/client/tasks-graph.module.css +++ b/src/client/tasks-graph.module.css @@ -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. */ diff --git a/src/config.ts b/src/config.ts index 419f43b..f2f9fbc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index a23b016..96cfc8f 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -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, diff --git a/tests/floating-window.spec.tsx b/tests/floating-window.spec.tsx new file mode 100644 index 0000000..d91fdd4 --- /dev/null +++ b/tests/floating-window.spec.tsx @@ -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() + }) +}) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index f5a493c..085746b 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -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) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 08c7b70..7c906fc 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -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') diff --git a/tests/sidebar-auto-activation.spec.tsx b/tests/sidebar-auto-activation.spec.tsx index a92ed56..658f535 100644 --- a/tests/sidebar-auto-activation.spec.tsx +++ b/tests/sidebar-auto-activation.spec.tsx @@ -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') diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx index 5fe4243..fc847b4 100644 --- a/tests/tasks-page.spec.tsx +++ b/tests/tasks-page.spec.tsx @@ -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() }) })