diff --git a/README.md b/README.md index 6a6a8c4..06cb830 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 - **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开 - **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 +- **📱 移动端**:视口 < 1024px 时(与 DSH 自身的窄屏断点一致)自动合并为**单面板抽屉**——右侧栏与底部面板两个工作台上下同显(中间可拖分隔条,高度与桌面底部面板互通),右上角只剩一枚开关;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区 - **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 - **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开 - **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑 @@ -118,6 +119,7 @@ pnpm watch # tsdown --watch - `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢 - 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈 - HTML 预览渲染的是已保存文件(不反映未保存草稿) +- 移动端(<1024px)无底部面板概念:底部面板的标签页并入抽屉下工作台,桌面端的 `bottomOpen` 状态只在宽视口下生效;移动端底部首展自动开终端不触发 ## 🖥️ 平台支持 diff --git a/docs/plans/2026-08-12-mobile-layout-design.md b/docs/plans/2026-08-12-mobile-layout-design.md new file mode 100644 index 0000000..ab98f29 --- /dev/null +++ b/docs/plans/2026-08-12-mobile-layout-design.md @@ -0,0 +1,73 @@ +# 移动端(窄视口 <1024px)侧边栏布局设计 + +**日期**:2026-08-12 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:v0.7.0 + +## 1. 目标 + +按**视口宽度**(<1024px,与 DSH 宿主 `SIDEBAR_AUTO_COLLAPSE` 一致)切换移动端侧边栏体验: + +1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。 +2. **底部面板与右侧面板合并显示**:右侧面板成为全屏抽屉,内部上下堆叠两个工作台——上 = 原右侧栏工作台(`state.splits`),下 = 原底部面板工作台(`state.bottomSplits`),中间可拖分隔条。底部面板浮层(关闭按钮 / 顶部拖动条 / 共享拐角)在移动端整体不渲染。 +3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。 + +## 2. 非目标 + +- 不改持久化 schema:`bottomOpen` / `bottomHeight` / 两棵树字段原样保留,跨断点不迁移状态。 +- 不改桌面行为:≥1024px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。 +- 不改公开服务 API、PrefsSchema、host 半。 +- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。 + +## 3. 现状回顾 + +- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。 +- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`。 +- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。 + +## 4. 设计 + +### 4.1 断点模块(新 `src/client/breakpoints.ts`) + +- `NARROW_MAX_WIDTH = 1024`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。 +- CSS 侧配对 `@media (max-width: 1023px)`(1023 ≡ <1024),两端注释互指。 + +### 4.2 合并布局(`Sidebar.tsx` + `split-pane.tsx` `MobileWorkbench`) + +- 窄屏下:面板宽度 `100vw`(全屏抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮覆盖,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。 +- `MobileWorkbench`:column flex → 上区 `Workbench(state.splits)`(flex:1)→ 分隔条(复用 `.dividerCol` 命中区 + hairline)→ 下区 `Workbench(state.bottomSplits)`(高度 = `state.bottomHeight`,`flex:none`)。 +- **下区高度复用 `bottomHeight`**:桌面底部面板高度语义延续到移动端;移动端拖分隔条写回同一字段并持久化,回桌面后一致。 +- 分隔条拖动:镜像现有面板拖动模式(pointer capture + rAF 批处理 DOM 直写 + 松手一次 `setBottomHeight` commit),避免终端/编辑器逐帧重渲染。 +- 两个工作台**始终渲染**(空底部树显示欢迎卡片);跨工作台拖 Tab 由既有跨树 `moveTabToEdge` / `moveTab` 支持。 +- 底部树 tab 的 `visible` 在窄屏 = `panelOpen && active`(抽屉开合决定),不再看 `bottomOpen`。 + +### 4.3 行为修正 + +- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。 +- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。 +- 底部首展自动终端 effect 加 `narrow` 守卫(桌面专属行为)。 +- 子代理自动展开 / jump-back 不动(展开 `panelOpen` = 展开抽屉,语义正确)。 + +### 4.4 样式(`sidebar.module.css`) + +- `@media (max-width: 1023px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。 +- 新增 `.mobileWorkbench` / `.mobilePane` / `.mobilePaneBottom`;分隔条复用 `.dividerCol`。 + +### 4.5 测试 + +- `tests/breakpoints.spec.ts`:断点边界。 +- `tests/service.spec.ts`:窄屏 auto-expand 5 例(path/url 展开、类型不展开、宽屏不展开、dedupe 聚焦仍展开)。 +- `tests/unit.spec.ts`:窄屏新会话默认收起(stub `window.innerWidth=390`,try/finally 还原)。 +- `tests/mobile-workbench.spec.tsx`:双树并显 + 分隔条 + 底部高度(renderToString 结构断言)。 +- 分隔条拖动 = 指针捕获逻辑,人工验证(与桌面拖动同策略);store 钳制由既有 `setBottomHeight` 测试覆盖。 + +## 5. 边界情况 + +- 跨断点往返:状态零迁移;桌面 `bottomOpen=false` 但底部树有标签的会话,移动端仍显示(合并语义),回桌面仍隐藏。 +- 底部高度越界:渲染直接用 `state.bottomHeight`(store 写入已钳制);拖动中按面板体高度钳制,松手再走 store 钳制。 +- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。 + +## 6. 实施偏差记录 + +- 计划时考虑过「空底部树隐藏 + 拖 Tab 创建」方案,最终采用**始终渲染两工作台**(用户确认的选项语义:上下并显 + 可调分隔;空底部树显示欢迎卡片,发现性更好)。 +- 计划时考虑过 matchMedia 方案,最终用 resize + rAF(jsdom 兼容,仓库既有拖动模式同款)。 diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 83a2713..56b24e4 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -16,8 +16,16 @@ * content to the views. New tabs come from the + menu (explorer / git / * terminal; editors open from the explorer). Tabs live in one tree only — * they never cross panels; only the panel sizes drag against each other. + * + * Narrow (mobile, <1024px) viewports collapse the two panels into one: the + * right panel becomes a full-width drawer holding BOTH workbenches stacked + * (MobileWorkbench, see split-pane.tsx), the bottom panel and its toggle + * button disappear, and the layout push is disabled (the drawer floats). + * Crossing the breakpoint never rewrites per-session state — bottomOpen / + * bottomHeight / both trees survive untouched, so desktop restores exactly + * what the user left. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -29,7 +37,8 @@ import { type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, } from './state.ts' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' -import { Workbench, type WorkbenchActions } from './split-pane.tsx' +import { MobileWorkbench, Workbench, type WorkbenchActions } from './split-pane.tsx' +import { useNarrowViewport } from './breakpoints.ts' import type { NewTabOption } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx' import { relativeTo } from './paths.ts' @@ -94,6 +103,15 @@ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionSco export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { ctx, store } = props + // Narrow (mobile) viewports collapse the two panels into one: the right + // panel becomes a full-width drawer holding BOTH workbenches, the bottom + // panel (and its toggle button) disappears, and the layout push is + // disabled (the drawer floats over the app shell). Crossing the + // breakpoint never rewrites the per-session state — bottomOpen / + // bottomHeight / both trees survive untouched, so desktop restores + // exactly what the user left. + const narrow = useNarrowViewport() + // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]), @@ -268,6 +286,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { */ const bottomWasOpenRef = useRef(undefined) useEffect(() => { + // The bottom panel does not exist on narrow viewports (the two + // workbenches merge into one panel), so the first-expansion auto + // terminal is a desktop-only behavior. + if (narrow) return if (state === undefined) return const wasOpen = bottomWasOpenRef.current bottomWasOpenRef.current = state.bottomOpen @@ -279,7 +301,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // atomically so later expansions never repeat the auto-open. store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true })) ctx.betterSidebar?.openTab({ type: 'terminal' }) - }, [state, store, ctx]) + }, [state, store, ctx, narrow]) // Panel drags: the right panel's width (left edge strip), the bottom // panel's height (top edge strip), and the shared corner (both at once). @@ -356,16 +378,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // are squeezed instead of covered. The margins are capped at the viewport // so a stale persisted size (e.g. fullscreen on a bigger window) can never // crush the app shell to zero. Dragging disables the layout transition. + // On NARROW viewports the drawer FLOATS over the app shell — no push, the + // conversation keeps the full width behind the drawer. useEffect(() => { - const width = snapshot.state?.panelOpen === true + const width = !narrow && snapshot.state?.panelOpen === true ? Math.min(snapshot.state.width, window.innerWidth) : 0 - const height = snapshot.state?.bottomOpen === true + const height = !narrow && snapshot.state?.bottomOpen === true ? Math.min(snapshot.state.bottomHeight, window.innerHeight) : 0 document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) - }, [snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) useEffect(() => { if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '') else document.body.removeAttribute('data-dsh-sidebar-dragging') @@ -450,11 +474,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (state === undefined || sessionId === undefined) { return (
- - - + {!narrow && ( + + + + )} - + {/* + Narrow viewports merge the two workbenches into the one drawer — + there is no bottom panel, so its toggle button is not offered. + */} + {!narrow && ( + + + + )}