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
+35 -39
View File
@@ -17,13 +17,13 @@
* 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.
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
* narrow migrates the bottom panel's tabs INTO the right tree
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
* strips. The right panel becomes a full-width drawer, the bottom panel
* and its toggle button disappear, and the layout push is disabled (the
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } 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 type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
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,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
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 type { NewTabOption } 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
// 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.
// disabled (the drawer floats over the app shell). Entering narrow
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
// — the merged display is the right sidebar alone, the bottom tabs thrown
// into its strips. Widening never rewrites the migrated state: the tabs
// keep living in the right tree.
const narrow = useNarrowViewport()
// Current conversation (the sessions list feed).
@@ -130,6 +131,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const sessionId = snapshot.sessionId
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),
// 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
@@ -528,10 +541,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
// 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}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
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}>
{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}
/>
)}
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
/>
</div>
</div>
{/*
+9 -8
View File
@@ -1,18 +1,19 @@
/**
* 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.
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
* — 767px ≡ 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.
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
* tablet experience. The value is deliberately NOT aligned to the DSH app
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
* panes) keep the desktop two-panel layout.
*/
import { useEffect, useState } from 'react'
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 1023px). */
export const NARROW_MAX_WIDTH = 1024
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
export const NARROW_MAX_WIDTH = 768
/** Whether a viewport width is narrow (mobile). */
export function isNarrowWidth(width: number): boolean {
+7 -39
View File
@@ -264,38 +264,6 @@
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;
@@ -1899,13 +1867,13 @@
/* ── 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) {
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
mobile layout — one full-screen drawer (the bottom panel's tabs were
migrated into the right tree), one toggle button. The structural
switches (which panels render, the migration) are JS-driven via
useNarrowViewport; this block only adapts the pure style details. */
@media (max-width: 767px) {
/* 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
+1 -112
View File
@@ -12,7 +12,7 @@
import { Fragment, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
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 { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import css from './sidebar.module.css'
@@ -290,114 +290,3 @@ 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>
)
}
+38
View File
@@ -206,6 +206,44 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
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. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {