fix: 移动端合并改为底部标签直接并入右侧栏(迁移式,删除双工作台堆叠方案)+ 窄屏断点收紧至 768px(不对齐宿主 1024)

This commit is contained in:
Menghuan1918
2026-08-12 18:27:28 +08:00
parent b391ebffed
commit ffc2270d77
10 changed files with 196 additions and 305 deletions
+2 -2
View File
@@ -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)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
## 🖥️ 平台支持 ## 🖥️ 平台支持
+35 -31
View File
@@ -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
View File
@@ -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>
{/* {/*
+9 -8
View File
@@ -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 {
+7 -39
View File
@@ -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
View File
@@ -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>
)
}
+38
View File
@@ -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') {
+8 -5
View File
@@ -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)
-68
View File
@@ -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
View File
@@ -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)