feat: 移动端侧边栏体验 — 窄视口(<1024px)双工作台合并为单面板抽屉(可拖分隔条、高度与底部面板互通)、不提供底部面板按钮、文件/外链打开自动展开、新会话默认收起

This commit is contained in:
Menghuan1918
2026-08-12 18:20:34 +08:00
parent bab659f8bd
commit b391ebffed
12 changed files with 638 additions and 72 deletions
+2
View File
@@ -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` 状态只在宽视口下生效;移动端底部首展自动开终端不触发
## 🖥️ 平台支持
@@ -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 兼容,仓库既有拖动模式同款)。
+123 -66
View File
@@ -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<boolean | undefined>(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 (
<div className={css.toggleCluster}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
@@ -478,6 +504,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash.
*/
/** The tab icon from the tab-type registry (shared by every workbench). */
const tabIconOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}
/**
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
@@ -495,7 +528,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
// On narrow viewports BOTH trees live in the one drawer, so the bottom
// 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 }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
@@ -512,16 +548,22 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
so the tabs genuinely yield space to it.
*/}
<div className={css.toggleCluster}>
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
{/*
Narrow viewports merge the two workbenches into the one drawer —
there is no bottom panel, so its toggle button is not offered.
*/}
{!narrow && (
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
@@ -538,50 +580,65 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
the slide in/out can animate; visibility hides it after the slide
settles. Its bottom edge follows the bottom panel's height (0 while
the bottom panel is closed) — the VSCode-style "sidebar above panel".
On NARROW viewports it is a full-width drawer holding both
workbenches (see MobileWorkbench); the width drag strip is not
offered there — a full-screen sheet has nothing to drag.
*/}
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
data-dragging={anyDragging || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
setDraggingWidth(false)
}}
/>
{!narrow && (
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
setDraggingWidth(false)
}}
/>
)}
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
/>
{narrow
? (
<MobileWorkbench
state={state}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
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>
{/*
@@ -590,8 +647,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
shell's own left sidebar and ends at the right panel's left edge —
neither sidebar gives up any position (the right panel keeps its
full height). Its resize strip is the top edge; hidden by sliding
down like the right panel.
down like the right panel. On NARROW viewports it does not exist —
the bottom workbench lives inside the drawer (MobileWorkbench).
*/}
{!narrow && (
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
@@ -655,21 +714,19 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
getTabIcon={tabIconOf}
/>
</div>
</div>
)}
{/*
The shared corner (only while BOTH panels are open): the intersection
of the right panel's left edge and the bottom panel's top edge.
Horizontal drags resize the right panel's width, vertical drags the
bottom panel's height — the two panels drag against each other.
(Never on narrow viewports: the bottom panel does not exist there.)
*/}
{state.panelOpen && state.bottomOpen && (
{!narrow && state.panelOpen && state.bottomOpen && (
<div
ref={cornerRef}
className={css.cornerHandle}
+50
View File
@@ -0,0 +1,50 @@
/**
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
* 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)
* — 1023px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
*
* The value matches the DSH app shell's own narrow breakpoint
* (SIDEBAR_AUTO_COLLAPSE = 1024, ui-layout/columns.ts), so the sidebar's
* mobile layout switches at exactly the width where the host app collapses
* its own sidebar to the rail.
*/
import { useEffect, useState } from 'react'
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 1023px). */
export const NARROW_MAX_WIDTH = 1024
/** Whether a viewport width is narrow (mobile). */
export function isNarrowWidth(width: number): boolean {
return width < NARROW_MAX_WIDTH
}
/**
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
* Deliberately avoids `matchMedia` (jsdom does not implement it) — the
* resize listener is equally exact for a breakpoint that never changes
* while the page is open.
*/
export function useNarrowViewport(): boolean {
const [narrow, setNarrow] = useState(
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
)
useEffect(() => {
if (typeof window === 'undefined') return
let frame: number | null = null
const measure = (): void => {
frame = null
setNarrow(isNarrowWidth(window.innerWidth))
}
const onResize = (): void => {
if (frame === null) frame = requestAnimationFrame(measure)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
return narrow
}
+20 -3
View File
@@ -22,9 +22,10 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab,
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel,
type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested
@@ -335,13 +336,29 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// A URL seed pre-fills the tab's path (the browser tab navigates to it
// on mount). An explicit seed.title also wins over a createTab-minted
// default title (e.g. the sidebar-browser's hostname title).
let landed: SidebarState
if (seed.url !== undefined) {
return patchTab(next, tab.id, {
landed = patchTab(next, tab.id, {
path: seed.url,
...(seed.title !== undefined ? { title: seed.title } : {}),
})
} else {
landed = next
}
return next
// Narrow-viewport drawer: a CONTENT open (file / browser) must be
// visible — expand the collapsed drawer so the open lands in sight.
// Type-only opens (+ menu, agent-terminal auto-tabs) never expand; on
// wide viewports nothing here changes (the panel behavior is the
// caller's business).
if (
typeof window !== 'undefined'
&& isNarrowWidth(window.innerWidth)
&& (seed.path !== undefined || seed.url !== undefined)
&& !landed.panelOpen
) {
return togglePanel(landed)
}
return landed
})
}
+57
View File
@@ -264,6 +264,38 @@
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 {
flex: 1;
min-width: 0;
@@ -1864,3 +1896,28 @@
animation: none;
}
}
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
/* Pairs with NARROW_MAX_WIDTH = 1024 in src/client/breakpoints.ts: widths
strictly below 1024px (i.e. max-width: 1023px) switch the sidebar to the
mobile layout — one drawer holding both workbenches, one toggle button.
The structural switches (which panels render, the merged MobileWorkbench)
are JS-driven via useNarrowViewport; this block only adapts the pure
style details. */
@media (max-width: 1023px) {
/* The toggle cluster holds a single button on narrow viewports (the
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
yield space to the one remaining control. */
.panel:not(.panelHidden) .tabBar {
padding-right: 40px;
}
/* Narrower tabs: a phone-width drawer fits more open tabs before the
strip scrolls. */
.tab {
min-width: 48px;
max-width: 128px;
}
}
+112 -1
View File
@@ -12,7 +12,7 @@
import { Fragment, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import { BOTTOM_MIN, type SidebarState, type SidebarTab, type SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import css from './sidebar.module.css'
@@ -290,3 +290,114 @@ export function Workbench(props: {
</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>
)
}
+9 -2
View File
@@ -10,6 +10,7 @@
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
@@ -721,12 +722,18 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the default explorer
// tab is skipped when the user disabled the explorer tab type.
// tab is skipped when the user disabled the explorer tab type. On a
// NARROW viewport a brand-new session starts collapsed instead — the
// panel is a full-screen drawer there, and auto-opening it on first
// paint would cover the conversation before the user asked. Only the
// first seeding is affected: once the user expands the drawer,
// `panelOpen: true` persists like any other state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
}
/**
+23
View File
@@ -0,0 +1,23 @@
/**
* Narrow-viewport breakpoint tests: the boundary the mobile sidebar layout
* keys off (paired with the CSS @media (max-width: 1023px) gates in
* sidebar.module.css — 1023px ≡ widths strictly below NARROW_MAX_WIDTH).
*/
import { describe, expect, it } from 'vitest'
import { isNarrowWidth, NARROW_MAX_WIDTH } from '../src/client/breakpoints.ts'
describe('narrow-viewport breakpoint', () => {
it('treats widths below NARROW_MAX_WIDTH as narrow (mobile)', () => {
expect(NARROW_MAX_WIDTH).toBe(1024)
expect(isNarrowWidth(320)).toBe(true)
expect(isNarrowWidth(390)).toBe(true)
expect(isNarrowWidth(768)).toBe(true)
expect(isNarrowWidth(1023)).toBe(true)
})
it('treats NARROW_MAX_WIDTH and above as wide (desktop)', () => {
expect(isNarrowWidth(1024)).toBe(false)
expect(isNarrowWidth(1280)).toBe(false)
expect(isNarrowWidth(1920)).toBe(false)
})
})
+68
View File
@@ -0,0 +1,68 @@
/**
* 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')
})
})
+78
View File
@@ -465,3 +465,81 @@ describe('service.openTab across the two panels', () => {
expect(allLeaves(after.bottomSplits).flatMap(l => l.tabs).some(t => t.id === gitTab.id)).toBe(false)
})
})
describe('service.openTab narrow-viewport auto-expand', () => {
/** The window stub is a plain object (see the file header), so the width is writable. */
const setWidth = (width: number): void => {
;(g.window as { innerWidth: number }).innerWidth = width
}
it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('expands the collapsed drawer for a URL (browser) open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('keeps a collapsed drawer for a type-only open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(store.getSnapshot().state?.panelOpen).toBe(false)
} finally {
setWidth(1024)
}
})
it('never expands on a wide viewport (path open keeps the caller’s choice)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(false)
})
it('expands on a narrow viewport even when the open focuses an existing tab (id dedupe)', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
})
+23
View File
@@ -1187,6 +1187,29 @@ describe('side card preferences', () => {
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
})
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
// Stub a narrow window (the file otherwise runs without one): only
// innerWidth is read while seeding a fresh session.
const original = (globalThis as Record<string, unknown>).window
;(globalThis as Record<string, unknown>).window = {
innerWidth: 390,
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
}
try {
const store = createSidebarStore()
// Default prefs say openByDefault: true — the narrow viewport overrides
// it for the FIRST seeding only (a later user expansion persists).
store.setSession('narrow-fresh')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
// The width seeding still follows the window (clamped to the floor).
expect(store.getSnapshot().state?.width).toBe(280)
} finally {
if (original === undefined) delete (globalThis as Record<string, unknown>).window
else (globalThis as Record<string, unknown>).window = original
}
})
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })