fix: 移动端合并改为底部标签直接并入右侧栏(迁移式,删除双工作台堆叠方案)+ 窄屏断点收紧至 768px(不对齐宿主 1024)
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||||
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
|
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
|
||||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||||
- **📱 移动端**:视口 < 1024px 时(与 DSH 自身的窄屏断点一致)自动合并为**单面板抽屉**——右侧栏与底部面板两个工作台上下同显(中间可拖分隔条,高度与桌面底部面板互通),右上角只剩一枚开关;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
||||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
|
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
|
||||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||||
@@ -119,7 +119,7 @@ pnpm watch # tsdown --watch
|
|||||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||||
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
||||||
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
||||||
- 移动端(<1024px)无底部面板概念:底部面板的标签页并入抽屉下工作台,桌面端的 `bottomOpen` 状态只在宽视口下生效;移动端底部首展自动开终端不触发
|
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||||
|
|
||||||
## 🖥️ 平台支持
|
## 🖥️ 平台支持
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 移动端(窄视口 <1024px)侧边栏布局设计
|
# 移动端(窄视口 <768px)侧边栏布局设计
|
||||||
|
|
||||||
**日期**:2026-08-12
|
**日期**:2026-08-12
|
||||||
**状态**:已实施(本文档含实施偏差记录)
|
**状态**:已实施(本文档含实施偏差记录)
|
||||||
@@ -6,68 +6,72 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
按**视口宽度**(<1024px,与 DSH 宿主 `SIDEBAR_AUTO_COLLAPSE` 一致)切换移动端侧边栏体验:
|
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
|
||||||
|
|
||||||
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
|
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
|
||||||
2. **底部面板与右侧面板合并显示**:右侧面板成为全屏抽屉,内部上下堆叠两个工作台——上 = 原右侧栏工作台(`state.splits`),下 = 原底部面板工作台(`state.bottomSplits`),中间可拖分隔条。底部面板浮层(关闭按钮 / 顶部拖动条 / 共享拐角)在移动端整体不渲染。
|
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
|
||||||
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
|
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
|
||||||
|
|
||||||
## 2. 非目标
|
## 2. 非目标
|
||||||
|
|
||||||
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / 两棵树字段原样保留,跨断点不迁移状态。
|
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
|
||||||
- 不改桌面行为:≥1024px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
|
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
|
||||||
- 不改公开服务 API、PrefsSchema、host 半。
|
- 不改公开服务 API、PrefsSchema、host 半。
|
||||||
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
|
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
|
||||||
|
|
||||||
## 3. 现状回顾
|
## 3. 现状回顾
|
||||||
|
|
||||||
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
|
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
|
||||||
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`。
|
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
|
||||||
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
|
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
|
||||||
|
|
||||||
## 4. 设计
|
## 4. 设计
|
||||||
|
|
||||||
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
|
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
|
||||||
|
|
||||||
- `NARROW_MAX_WIDTH = 1024`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
|
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
|
||||||
- CSS 侧配对 `@media (max-width: 1023px)`(1023 ≡ <1024),两端注释互指。
|
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
|
||||||
|
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
|
||||||
|
|
||||||
### 4.2 合并布局(`Sidebar.tsx` + `split-pane.tsx` `MobileWorkbench`)
|
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
|
||||||
|
|
||||||
- 窄屏下:面板宽度 `100vw`(全屏抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮覆盖,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
|
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
|
||||||
- `MobileWorkbench`:column flex → 上区 `Workbench(state.splits)`(flex:1)→ 分隔条(复用 `.dividerCol` 命中区 + hairline)→ 下区 `Workbench(state.bottomSplits)`(高度 = `state.bottomHeight`,`flex:none`)。
|
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
|
||||||
- **下区高度复用 `bottomHeight`**:桌面底部面板高度语义延续到移动端;移动端拖分隔条写回同一字段并持久化,回桌面后一致。
|
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
|
||||||
- 分隔条拖动:镜像现有面板拖动模式(pointer capture + rAF 批处理 DOM 直写 + 松手一次 `setBottomHeight` commit),避免终端/编辑器逐帧重渲染。
|
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
|
||||||
- 两个工作台**始终渲染**(空底部树显示欢迎卡片);跨工作台拖 Tab 由既有跨树 `moveTabToEdge` / `moveTab` 支持。
|
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
|
||||||
- 底部树 tab 的 `visible` 在窄屏 = `panelOpen && active`(抽屉开合决定),不再看 `bottomOpen`。
|
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
|
||||||
|
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
|
||||||
|
|
||||||
### 4.3 行为修正
|
### 4.3 窄屏渲染(`Sidebar.tsx`)
|
||||||
|
|
||||||
|
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
|
||||||
|
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
|
||||||
|
- 底部首展自动终端 effect 加 `narrow` 守卫。
|
||||||
|
|
||||||
|
### 4.4 行为修正
|
||||||
|
|
||||||
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
|
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
|
||||||
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
|
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
|
||||||
- 底部首展自动终端 effect 加 `narrow` 守卫(桌面专属行为)。
|
|
||||||
- 子代理自动展开 / jump-back 不动(展开 `panelOpen` = 展开抽屉,语义正确)。
|
|
||||||
|
|
||||||
### 4.4 样式(`sidebar.module.css`)
|
### 4.5 样式(`sidebar.module.css`)
|
||||||
|
|
||||||
- `@media (max-width: 1023px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
|
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
|
||||||
- 新增 `.mobileWorkbench` / `.mobilePane` / `.mobilePaneBottom`;分隔条复用 `.dividerCol`。
|
|
||||||
|
|
||||||
### 4.5 测试
|
### 4.6 测试
|
||||||
|
|
||||||
- `tests/breakpoints.spec.ts`:断点边界。
|
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
|
||||||
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(path/url 展开、类型不展开、宽屏不展开、dedupe 聚焦仍展开)。
|
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
|
||||||
- `tests/unit.spec.ts`:窄屏新会话默认收起(stub `window.innerWidth=390`,try/finally 还原)。
|
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
|
||||||
- `tests/mobile-workbench.spec.tsx`:双树并显 + 分隔条 + 底部高度(renderToString 结构断言)。
|
|
||||||
- 分隔条拖动 = 指针捕获逻辑,人工验证(与桌面拖动同策略);store 钳制由既有 `setBottomHeight` 测试覆盖。
|
|
||||||
|
|
||||||
## 5. 边界情况
|
## 5. 边界情况
|
||||||
|
|
||||||
- 跨断点往返:状态零迁移;桌面 `bottomOpen=false` 但底部树有标签的会话,移动端仍显示(合并语义),回桌面仍隐藏。
|
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
|
||||||
- 底部高度越界:渲染直接用 `state.bottomHeight`(store 写入已钳制);拖动中按面板体高度钳制,松手再走 store 钳制。
|
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
|
||||||
|
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
|
||||||
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
|
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
|
||||||
|
|
||||||
## 6. 实施偏差记录
|
## 6. 实施偏差记录
|
||||||
|
|
||||||
- 计划时考虑过「空底部树隐藏 + 拖 Tab 创建」方案,最终采用**始终渲染两工作台**(用户确认的选项语义:上下并显 + 可调分隔;空底部树显示欢迎卡片,发现性更好)。
|
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
|
||||||
- 计划时考虑过 matchMedia 方案,最终用 resize + rAF(jsdom 兼容,仓库既有拖动模式同款)。
|
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
|
||||||
|
|||||||
+35
-39
@@ -17,13 +17,13 @@
|
|||||||
* terminal; editors open from the explorer). Tabs live in one tree only —
|
* terminal; editors open from the explorer). Tabs live in one tree only —
|
||||||
* they never cross panels; only the panel sizes drag against each other.
|
* they never cross panels; only the panel sizes drag against each other.
|
||||||
*
|
*
|
||||||
* Narrow (mobile, <1024px) viewports collapse the two panels into one: the
|
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
|
||||||
* right panel becomes a full-width drawer holding BOTH workbenches stacked
|
* narrow migrates the bottom panel's tabs INTO the right tree
|
||||||
* (MobileWorkbench, see split-pane.tsx), the bottom panel and its toggle
|
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
|
||||||
* button disappear, and the layout push is disabled (the drawer floats).
|
* strips. The right panel becomes a full-width drawer, the bottom panel
|
||||||
* Crossing the breakpoint never rewrites per-session state — bottomOpen /
|
* and its toggle button disappear, and the layout push is disabled (the
|
||||||
* bottomHeight / both trees survive untouched, so desktop restores exactly
|
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||||
* what the user left.
|
* the right tree.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
@@ -31,13 +31,13 @@ import clsx from 'clsx'
|
|||||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||||
import {
|
import {
|
||||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
|
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||||
reconcileAgentTerminals,
|
reconcileAgentTerminals,
|
||||||
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||||
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||||
} from './state.ts'
|
} from './state.ts'
|
||||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||||
import { MobileWorkbench, Workbench, type WorkbenchActions } from './split-pane.tsx'
|
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||||
import { useNarrowViewport } from './breakpoints.ts'
|
import { useNarrowViewport } from './breakpoints.ts'
|
||||||
import type { NewTabOption } from './TabBar.tsx'
|
import type { NewTabOption } from './TabBar.tsx'
|
||||||
import type { TabDragPayload } from './TabBar.tsx'
|
import type { TabDragPayload } from './TabBar.tsx'
|
||||||
@@ -106,10 +106,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
// Narrow (mobile) viewports collapse the two panels into one: the right
|
// Narrow (mobile) viewports collapse the two panels into one: the right
|
||||||
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
|
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
|
||||||
// panel (and its toggle button) disappears, and the layout push is
|
// panel (and its toggle button) disappears, and the layout push is
|
||||||
// disabled (the drawer floats over the app shell). Crossing the
|
// disabled (the drawer floats over the app shell). Entering narrow
|
||||||
// breakpoint never rewrites the per-session state — bottomOpen /
|
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
|
||||||
// bottomHeight / both trees survive untouched, so desktop restores
|
// — the merged display is the right sidebar alone, the bottom tabs thrown
|
||||||
// exactly what the user left.
|
// into its strips. Widening never rewrites the migrated state: the tabs
|
||||||
|
// keep living in the right tree.
|
||||||
const narrow = useNarrowViewport()
|
const narrow = useNarrowViewport()
|
||||||
|
|
||||||
// Current conversation (the sessions list feed).
|
// Current conversation (the sessions list feed).
|
||||||
@@ -130,6 +131,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
const sessionId = snapshot.sessionId
|
const sessionId = snapshot.sessionId
|
||||||
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||||
|
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||||
|
* throw the bottom tree's tabs into the right tree. Idempotent — after
|
||||||
|
* the first migration the bottom tree is empty and the reducer returns
|
||||||
|
* the same reference, so this effect settles immediately.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!narrow || sessionId === undefined) return
|
||||||
|
store.reduce(migrateBottomTabs)
|
||||||
|
}, [narrow, sessionId, store])
|
||||||
|
|
||||||
// While the session's header is still hydrating (or the session is blank),
|
// While the session's header is still hydrating (or the session is blank),
|
||||||
// the list summary may carry no cwd; ask the host once (it falls back to
|
// the list summary may carry no cwd; ask the host once (it falls back to
|
||||||
// the process cwd) so the explorer root and terminal cwd are real from
|
// the process cwd) so the explorer root and terminal cwd are real from
|
||||||
@@ -528,10 +541,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
onReferenceFile={referenceInChat}
|
onReferenceFile={referenceInChat}
|
||||||
ctx={ctx}
|
ctx={ctx}
|
||||||
store={store}
|
store={store}
|
||||||
// On narrow viewports BOTH trees live in the one drawer, so the bottom
|
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
|
||||||
// tree's visibility is the drawer's (panelOpen), not the bottom
|
|
||||||
// panel's — the bottom panel itself does not exist there.
|
|
||||||
visible={narrow ? state.panelOpen && active : bottom ? state.bottomOpen && active : state.panelOpen && active}
|
|
||||||
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
||||||
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
||||||
/>
|
/>
|
||||||
@@ -617,28 +627,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={css.panelBody}>
|
<div className={css.panelBody}>
|
||||||
{narrow
|
<Workbench
|
||||||
? (
|
state={state}
|
||||||
<MobileWorkbench
|
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
||||||
state={state}
|
actions={actions}
|
||||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
onNewTab={onNewTab}
|
||||||
actions={actions}
|
renderTab={renderTab}
|
||||||
onNewTab={onNewTab}
|
getTabIcon={tabIconOf}
|
||||||
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
|
/>
|
||||||
getTabIcon={tabIconOf}
|
|
||||||
onBottomHeightCommit={(height) => { store.reduce(s => setBottomHeight(s, height)) }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<Workbench
|
|
||||||
state={state}
|
|
||||||
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
|
|
||||||
actions={actions}
|
|
||||||
onNewTab={onNewTab}
|
|
||||||
renderTab={renderTab}
|
|
||||||
getTabIcon={tabIconOf}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
|
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
|
||||||
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
|
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
|
||||||
* with this file via `@media (max-width: 1023px)` rules (sidebar.module.css)
|
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
|
||||||
* — 1023px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
|
* — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
|
||||||
*
|
*
|
||||||
* The value matches the DSH app shell's own narrow breakpoint
|
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
|
||||||
* (SIDEBAR_AUTO_COLLAPSE = 1024, ui-layout/columns.ts), so the sidebar's
|
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
|
||||||
* mobile layout switches at exactly the width where the host app collapses
|
* tablet experience. The value is deliberately NOT aligned to the DSH app
|
||||||
* its own sidebar to the rail.
|
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
|
||||||
|
* panes) keep the desktop two-panel layout.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 1023px). */
|
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
|
||||||
export const NARROW_MAX_WIDTH = 1024
|
export const NARROW_MAX_WIDTH = 768
|
||||||
|
|
||||||
/** Whether a viewport width is narrow (mobile). */
|
/** Whether a viewport width is narrow (mobile). */
|
||||||
export function isNarrowWidth(width: number): boolean {
|
export function isNarrowWidth(width: number): boolean {
|
||||||
|
|||||||
@@ -264,38 +264,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The narrow-viewport workbench (MobileWorkbench): BOTH workbench trees
|
|
||||||
stacked in the one drawer — the right panel's tree flexes after the
|
|
||||||
fixed-height bottom tree, with a draggable divider (the .dividerCol
|
|
||||||
hit-strip + hairline) between them. */
|
|
||||||
.mobileWorkbench {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobilePane {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The bottom tree of the merged layout: fixed height (state.bottomHeight),
|
|
||||||
flex: none so the top tree takes the remainder. */
|
|
||||||
.mobilePaneBottom {
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The divider of the merged layout sits between the two panes; .dividerCol
|
|
||||||
already supplies the 7px hit strip, row-resize cursor and hairline. */
|
|
||||||
.mobileWorkbench > .dividerCol {
|
|
||||||
flex: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split {
|
.split {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1899,13 +1867,13 @@
|
|||||||
|
|
||||||
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
|
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Pairs with NARROW_MAX_WIDTH = 1024 in src/client/breakpoints.ts: widths
|
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
|
||||||
strictly below 1024px (i.e. max-width: 1023px) switch the sidebar to the
|
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
|
||||||
mobile layout — one drawer holding both workbenches, one toggle button.
|
mobile layout — one full-screen drawer (the bottom panel's tabs were
|
||||||
The structural switches (which panels render, the merged MobileWorkbench)
|
migrated into the right tree), one toggle button. The structural
|
||||||
are JS-driven via useNarrowViewport; this block only adapts the pure
|
switches (which panels render, the migration) are JS-driven via
|
||||||
style details. */
|
useNarrowViewport; this block only adapts the pure style details. */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 767px) {
|
||||||
/* The toggle cluster holds a single button on narrow viewports (the
|
/* The toggle cluster holds a single button on narrow viewports (the
|
||||||
bottom panel button is not rendered), so the open panel's tab strip
|
bottom panel button is not rendered), so the open panel's tab strip
|
||||||
reserves 40px instead of the desktop 72px — the tabs and + menu still
|
reserves 40px instead of the desktop 72px — the tabs and + menu still
|
||||||
|
|||||||
+1
-112
@@ -12,7 +12,7 @@
|
|||||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { BOTTOM_MIN, type SidebarState, type SidebarTab, type SplitNode } from './state.ts'
|
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||||
import type { DropZone } from './state.ts'
|
import type { DropZone } from './state.ts'
|
||||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
@@ -290,114 +290,3 @@ export function Workbench(props: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The narrow-viewport workbench: BOTH workbench trees stacked in one panel —
|
|
||||||
* the right panel's tree on top, the bottom panel's tree below, with a
|
|
||||||
* draggable divider between them (the desktop "two panels" collapse into one
|
|
||||||
* on mobile; see the breakpoint module). The bottom tree's height is the
|
|
||||||
* shared `state.bottomHeight` — the same field the desktop bottom panel uses
|
|
||||||
* — so a height set on mobile carries over to the desktop bottom panel and
|
|
||||||
* vice versa. Tabs stay in their own trees; the cross-tree drag logic
|
|
||||||
* (state.moveTabToEdge / moveTab) already handles moving a tab between the
|
|
||||||
* two panes.
|
|
||||||
*
|
|
||||||
* The divider writes the height DIRECTLY to the DOM while dragging (the top
|
|
||||||
* tree flexes after the fixed-height bottom tree), committing to the store
|
|
||||||
* once on release — the same pattern as the desktop panel drags, so
|
|
||||||
* terminals/editors never re-render on every pointer move.
|
|
||||||
*/
|
|
||||||
export function MobileWorkbench(props: {
|
|
||||||
state: SidebarState
|
|
||||||
actions: WorkbenchActions
|
|
||||||
newTabOptions: NewTabOption[]
|
|
||||||
onNewTab: (optionId: string) => void
|
|
||||||
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
|
||||||
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
||||||
/** Commit a new bottom-tree height (store-backed, clamped by setBottomHeight). */
|
|
||||||
onBottomHeightCommit: (height: number) => void
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
state, actions, newTabOptions, onNewTab, renderTab, getTabIcon, onBottomHeightCommit,
|
|
||||||
} = props
|
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const dividerRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const dragRef = useRef({ startY: 0, startHeight: 0 })
|
|
||||||
const [dragging, setDragging] = useState(false)
|
|
||||||
// One DOM write per frame (like the desktop panel drags): pointer events
|
|
||||||
// outrun the display refresh, and each write reflows the pane content.
|
|
||||||
const dragFrame = useRef<number | null>(null)
|
|
||||||
const pending = useRef<number | null>(null)
|
|
||||||
const scheduleHeight = (height: number): void => {
|
|
||||||
pending.current = height
|
|
||||||
if (dragFrame.current !== null) return
|
|
||||||
dragFrame.current = requestAnimationFrame(() => {
|
|
||||||
dragFrame.current = null
|
|
||||||
const next = pending.current
|
|
||||||
if (next !== null) bottomRef.current?.style.setProperty('height', `${next}px`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={css.mobileWorkbench}>
|
|
||||||
<div className={css.mobilePane}>
|
|
||||||
<Workbench
|
|
||||||
state={state}
|
|
||||||
newTabOptions={newTabOptions}
|
|
||||||
actions={actions}
|
|
||||||
onNewTab={onNewTab}
|
|
||||||
renderTab={renderTab}
|
|
||||||
getTabIcon={getTabIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={dividerRef}
|
|
||||||
className={clsx(css.divider, css.dividerCol, dragging && css.dividerActive)}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
|
||||||
dragRef.current = { startY: event.clientY, startHeight: state.bottomHeight }
|
|
||||||
setDragging(true)
|
|
||||||
}}
|
|
||||||
onPointerMove={(event) => {
|
|
||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
||||||
const { startY, startHeight } = dragRef.current
|
|
||||||
const box = dividerRef.current?.parentElement?.getBoundingClientRect()
|
|
||||||
const bodyHeight = box === undefined || box.height === 0 ? window.innerHeight : box.height
|
|
||||||
const next = Math.min(
|
|
||||||
Math.max(BOTTOM_MIN, startHeight + (event.clientY - startY)),
|
|
||||||
Math.max(BOTTOM_MIN, bodyHeight - BOTTOM_MIN),
|
|
||||||
)
|
|
||||||
scheduleHeight(next)
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
||||||
if (dragFrame.current !== null) {
|
|
||||||
cancelAnimationFrame(dragFrame.current)
|
|
||||||
dragFrame.current = null
|
|
||||||
}
|
|
||||||
const final = pending.current
|
|
||||||
pending.current = null
|
|
||||||
setDragging(false)
|
|
||||||
if (final !== null) onBottomHeightCommit(final)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={bottomRef}
|
|
||||||
className={clsx(css.mobilePane, css.mobilePaneBottom)}
|
|
||||||
style={{ height: state.bottomHeight }}
|
|
||||||
>
|
|
||||||
<Workbench
|
|
||||||
state={state}
|
|
||||||
tree={state.bottomSplits}
|
|
||||||
newTabOptions={newTabOptions}
|
|
||||||
actions={actions}
|
|
||||||
onNewTab={onNewTab}
|
|
||||||
renderTab={renderTab}
|
|
||||||
getTabIcon={getTabIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -206,6 +206,44 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
|
|||||||
return firstLeaf(node.children[0]!)
|
return firstLeaf(node.children[0]!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
|
||||||
|
function clearAllTabs(node: SplitNode): SplitNode {
|
||||||
|
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
|
||||||
|
return { ...node, children: node.children.map(clearAllTabs) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
|
||||||
|
* right sidebar — the "merged display" on mobile is the right panel alone,
|
||||||
|
* whose tab strips now carry the bottom tree's tabs (depth-first order,
|
||||||
|
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
|
||||||
|
* structure stays — the desktop bottom panel re-renders its welcome cards)
|
||||||
|
* and the panel closes. The active pane moves to the right tree's first
|
||||||
|
* leaf so every new tab lands in the visible panel.
|
||||||
|
*
|
||||||
|
* Idempotent: a bottom tree with no tabs and a closed panel returns the
|
||||||
|
* same reference. Runs when the viewport enters narrow (see the Sidebar
|
||||||
|
* shell); migrating is permanent for the session — the tabs now live in the
|
||||||
|
* right tree, exactly like the user "threw them in".
|
||||||
|
*/
|
||||||
|
export function migrateBottomTabs(state: SidebarState): SidebarState {
|
||||||
|
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||||
|
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
|
||||||
|
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
|
||||||
|
const target = firstLeaf(state.splits)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activePane: target.id,
|
||||||
|
bottomOpen: false,
|
||||||
|
splits: bottomTabs.length > 0
|
||||||
|
? mapLeaf(state.splits, target.id, leaf => {
|
||||||
|
leaf.tabs = [...leaf.tabs, ...bottomTabs]
|
||||||
|
})
|
||||||
|
: state.splits,
|
||||||
|
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Find the leaf containing a tab id, if any. */
|
/** Find the leaf containing a tab id, if any. */
|
||||||
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
|
||||||
if (node.kind === 'leaf') {
|
if (node.kind === 'leaf') {
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Narrow-viewport breakpoint tests: the boundary the mobile sidebar layout
|
* Narrow-viewport breakpoint tests: the boundary the mobile sidebar layout
|
||||||
* keys off (paired with the CSS @media (max-width: 1023px) gates in
|
* keys off (paired with the CSS @media (max-width: 767px) gates in
|
||||||
* sidebar.module.css — 1023px ≡ widths strictly below NARROW_MAX_WIDTH).
|
* sidebar.module.css — 767px ≡ widths strictly below NARROW_MAX_WIDTH).
|
||||||
|
* Deliberately NOT aligned to the DSH app shell's 1024px breakpoint: the
|
||||||
|
* mobile layout is for real narrow screens (phones / portrait tablets).
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isNarrowWidth, NARROW_MAX_WIDTH } from '../src/client/breakpoints.ts'
|
import { isNarrowWidth, NARROW_MAX_WIDTH } from '../src/client/breakpoints.ts'
|
||||||
|
|
||||||
describe('narrow-viewport breakpoint', () => {
|
describe('narrow-viewport breakpoint', () => {
|
||||||
it('treats widths below NARROW_MAX_WIDTH as narrow (mobile)', () => {
|
it('treats widths below NARROW_MAX_WIDTH as narrow (mobile)', () => {
|
||||||
expect(NARROW_MAX_WIDTH).toBe(1024)
|
expect(NARROW_MAX_WIDTH).toBe(768)
|
||||||
expect(isNarrowWidth(320)).toBe(true)
|
expect(isNarrowWidth(320)).toBe(true)
|
||||||
expect(isNarrowWidth(390)).toBe(true)
|
expect(isNarrowWidth(390)).toBe(true)
|
||||||
expect(isNarrowWidth(768)).toBe(true)
|
expect(isNarrowWidth(600)).toBe(true)
|
||||||
expect(isNarrowWidth(1023)).toBe(true)
|
expect(isNarrowWidth(767)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('treats NARROW_MAX_WIDTH and above as wide (desktop)', () => {
|
it('treats NARROW_MAX_WIDTH and above as wide (desktop)', () => {
|
||||||
|
expect(isNarrowWidth(768)).toBe(false)
|
||||||
expect(isNarrowWidth(1024)).toBe(false)
|
expect(isNarrowWidth(1024)).toBe(false)
|
||||||
expect(isNarrowWidth(1280)).toBe(false)
|
expect(isNarrowWidth(1280)).toBe(false)
|
||||||
expect(isNarrowWidth(1920)).toBe(false)
|
expect(isNarrowWidth(1920)).toBe(false)
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* MobileWorkbench tests: the narrow-viewport merged workbench — BOTH trees
|
|
||||||
* (right + bottom) stacked in one panel with a draggable divider between
|
|
||||||
* them, the bottom tree at the shared state.bottomHeight. Structure
|
|
||||||
* assertions via renderToString (the repo's component-test pattern). The
|
|
||||||
* divider drag itself is pointer-capture logic — exercised manually, like
|
|
||||||
* the desktop panel drags; the store clamp it commits through
|
|
||||||
* (setBottomHeight) is unit-tested in unit.spec.ts.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { renderToString } from 'react-dom/server'
|
|
||||||
import { MobileWorkbench, type WorkbenchActions } from '../src/client/split-pane.tsx'
|
|
||||||
import { makeDefaultState } from '../src/client/state.ts'
|
|
||||||
|
|
||||||
const noopActions: WorkbenchActions = {
|
|
||||||
closeTab: () => {},
|
|
||||||
activateTab: () => {},
|
|
||||||
focusPane: () => {},
|
|
||||||
moveTabToEdge: () => {},
|
|
||||||
moveTabBefore: () => {},
|
|
||||||
resizeSplit: () => {},
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MobileWorkbench (narrow merged layout)', () => {
|
|
||||||
it('renders BOTH trees stacked, with the divider and the shared bottom height', () => {
|
|
||||||
const state = makeDefaultState()
|
|
||||||
// A terminal tab in the bottom tree (a desktop bottom-panel session).
|
|
||||||
const bottomPane = state.bottomSplits as { tabs: Array<{ id: string; type: string; title: string }> }
|
|
||||||
bottomPane.tabs = [{ id: 'tab:b1', type: 'terminal', title: 'Terminal' }]
|
|
||||||
const html = renderToString((
|
|
||||||
<MobileWorkbench
|
|
||||||
state={state}
|
|
||||||
actions={noopActions}
|
|
||||||
newTabOptions={[]}
|
|
||||||
onNewTab={() => {}}
|
|
||||||
renderTab={(tab) => <div data-testid={`tab-${tab.id}`}>{tab.title}</div>}
|
|
||||||
onBottomHeightCommit={() => {}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
// The right tree's default explorer tab renders (tab strip).
|
|
||||||
expect(html).toContain('Explorer')
|
|
||||||
// The bottom tree's terminal tab renders — the merge shows both trees.
|
|
||||||
expect(html).toContain('Terminal')
|
|
||||||
// The divider sits between the two workbenches.
|
|
||||||
expect(html).toContain('divider')
|
|
||||||
// The bottom pane carries the shared bottomHeight (the desktop bottom
|
|
||||||
// panel's height field drives the mobile bottom tree's height).
|
|
||||||
expect(html).toContain(`height:${state.bottomHeight}px`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders an empty bottom tree with its welcome cards (both workbenches always visible)', () => {
|
|
||||||
const state = makeDefaultState()
|
|
||||||
const html = renderToString((
|
|
||||||
<MobileWorkbench
|
|
||||||
state={state}
|
|
||||||
actions={noopActions}
|
|
||||||
newTabOptions={[{ id: 'explorer', label: 'Explorer' }, { id: 'terminal', label: 'Terminal' }]}
|
|
||||||
onNewTab={() => {}}
|
|
||||||
renderTab={() => null}
|
|
||||||
onBottomHeightCommit={() => {}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
// The top tree's explorer TAB and the empty bottom pane's welcome cards
|
|
||||||
// both render (each option card carries its label).
|
|
||||||
expect(html).toContain('Explorer')
|
|
||||||
expect(html).toContain('Terminal')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+61
-1
@@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
|||||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||||
import {
|
import {
|
||||||
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||||
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
|
migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||||
type SidebarState, type SidebarTab, type SplitNode,
|
type SidebarState, type SidebarTab, type SplitNode,
|
||||||
} from '../src/client/state.ts'
|
} from '../src/client/state.ts'
|
||||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||||
@@ -550,6 +550,66 @@ describe('sidebar state', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ──
|
||||||
|
|
||||||
|
it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => {
|
||||||
|
let s = state()
|
||||||
|
s = toggleBottomPanel(s)
|
||||||
|
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||||
|
// Two bottom tabs in their own pane; the right pane holds explorer.
|
||||||
|
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||||
|
s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' })
|
||||||
|
const migrated = migrateBottomTabs(s)
|
||||||
|
// All tabs now live in the right tree's first leaf, bottom tabs appended.
|
||||||
|
expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id))
|
||||||
|
.toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2'])
|
||||||
|
// The bottom tree is emptied (structure stays), the panel closes, and
|
||||||
|
// new tabs land in the right tree.
|
||||||
|
expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
|
||||||
|
expect(migrated.bottomOpen).toBe(false)
|
||||||
|
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
|
||||||
|
// The migrated tabs are fully functional: closing one works through the
|
||||||
|
// right tree.
|
||||||
|
expect(tabOpenIn(migrated, 'terminal:1')).toBe(true)
|
||||||
|
expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => {
|
||||||
|
let s = state()
|
||||||
|
s = splitPane(s, 'row') // splits the active pane into two leaves
|
||||||
|
s = toggleBottomPanel(s)
|
||||||
|
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||||
|
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' })
|
||||||
|
const migrated = migrateBottomTabs(s)
|
||||||
|
// The first (leftmost) leaf carries the bottom tab; the second leaf
|
||||||
|
// keeps its own tabs untouched.
|
||||||
|
const leaves = allLeaves(migrated.splits)
|
||||||
|
expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9')
|
||||||
|
expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => {
|
||||||
|
const s = state()
|
||||||
|
expect(migrateBottomTabs(s)).toBe(s)
|
||||||
|
// With the panel open but no tabs, the migration only closes the panel.
|
||||||
|
const open = toggleBottomPanel(s)
|
||||||
|
const migrated = migrateBottomTabs(open)
|
||||||
|
expect(migrated).not.toBe(open)
|
||||||
|
expect(migrated.bottomOpen).toBe(false)
|
||||||
|
expect(migrateBottomTabs(migrated)).toBe(migrated)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => {
|
||||||
|
let s = state()
|
||||||
|
const bottomPane = (s.bottomSplits as { id: string }).id
|
||||||
|
s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed
|
||||||
|
const migrated = migrateBottomTabs(s)
|
||||||
|
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
|
||||||
|
// A tab opened after the migration lands in the VISIBLE right tree.
|
||||||
|
const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' })
|
||||||
|
expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
|
||||||
|
})
|
||||||
|
|
||||||
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
|
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
|
||||||
let s = state()
|
let s = state()
|
||||||
s = toggleBottomPanel(s)
|
s = toggleBottomPanel(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user