sidebar: + menu always reachable on empty panes, unified default width (content-area half), expand/collapse animation

- An empty pane (fresh split, or last tab dragged out) now keeps the tab
  strip with its + menu mounted, so a new tab can always be created or
  dragged in; the placeholder body still explains the state.
- Default width unified: new sessions open at half the CONTENT area
  (viewport minus the left sidebar), so the panel never swallows half the
  whole window and old/new sessions no longer disagree (previously old
  persisted widths ~1/4 vs fresh sessions 1/2). Fullscreen restore targets
  the same content-half width.
- Expand/collapse now animates: the panel stays mounted and slides
  off-screen (translateX + visibility after the slide); fullscreen width
  changes animate too, and drag-resizing disables the transition.
This commit is contained in:
Menghuan1918
2026-08-08 12:05:25 +08:00
parent c7a7c7e5af
commit 56a27990d7
4 changed files with 70 additions and 32 deletions
+12 -4
View File
@@ -196,8 +196,15 @@ export function Sidebar(props: { ctx: Context }) {
</Tooltip> </Tooltip>
</div> </div>
)} )}
{state.panelOpen && ( {/*
<div className={css.panel} style={{ width: state.width }} data-dragging={draggingWidth || undefined}> The panel stays mounted while collapsed (hidden off-screen) so the
slide in/out can animate; visibility hides it after the slide settles.
*/}
<div
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: state.width }}
data-dragging={draggingWidth || undefined}
>
<div <div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)} className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => { onPointerDown={(event) => {
@@ -225,7 +232,9 @@ export function Sidebar(props: { ctx: Context }) {
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')} title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => { onClick={() => {
const viewport = window.innerWidth const viewport = window.innerWidth
sidebarStore.reduce(s => setWidth(s, fullscreen ? Math.round(viewport / 2) : viewport)) sidebarStore.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
}} }}
> >
<IconFullscreenOutline16 /> <IconFullscreenOutline16 />
@@ -254,7 +263,6 @@ export function Sidebar(props: { ctx: Context }) {
/> />
</div> </div>
</div> </div>
)}
</> </>
) )
} }
+21
View File
@@ -57,6 +57,27 @@
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2); border-left: 1px solid var(--dsw-alias-border-l2);
box-shadow: var(--dsw-shadow-lv2); box-shadow: var(--dsw-shadow-lv2);
/* Expand/collapse slides the panel; fullscreen animates the width. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
.panelHidden {
transform: translateX(102%);
pointer-events: none;
box-shadow: none;
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
/* Width drags write at pointer cadence; easing would detach the edge. */
.panel[data-dragging] {
transition: none;
} }
.panelResize { .panelResize {
+6 -3
View File
@@ -114,8 +114,11 @@ function LeafView(props: {
}} }}
> >
{dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />} {dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
{leaf.tabs.length > 0 ? ( {/*
<> The tab strip renders even for an empty pane: the + menu must stay
reachable when the pane has no tabs (fresh split, or the last tab was
dragged out), so a new tab can always be created or dragged in.
*/}
<TabBar <TabBar
paneId={leaf.id} paneId={leaf.id}
tabs={leaf.tabs} tabs={leaf.tabs}
@@ -129,8 +132,8 @@ function LeafView(props: {
else actions.moveTabBefore(payload, leaf.id, before) else actions.moveTabBefore(payload, leaf.id, before)
}} }}
/> />
{leaf.tabs.length > 0 ? (
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div> <div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</div>
</>
) : ( ) : (
<div className={css.paneEmpty}>{t('emptyPane')}</div> <div className={css.paneEmpty}>{t('emptyPane')}</div>
)} )}
+9 -3
View File
@@ -54,6 +54,8 @@ export interface SidebarState {
export const PANEL_MIN = 280 export const PANEL_MIN = 280
export const PANEL_MAX = 640 export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400 export const PANEL_DEFAULT = 400
/** Approximate default width of the host's left sidebar (columns.ts SIDEBAR_DEFAULT). */
export const LEFT_SIDEBAR_DEFAULT = 320
export const TAB_MAX_WIDTH = 160 export const TAB_MAX_WIDTH = 160
export const TERMINAL_LIMIT = 3 export const TERMINAL_LIMIT = 3
@@ -372,9 +374,13 @@ function loadState(sessionId: string): SidebarState {
} catch { } catch {
// Corrupt or unavailable storage: fall through to the default. // Corrupt or unavailable storage: fall through to the default.
} }
// New sessions open at half the viewport (VSCode-like default split). // New sessions open at half the CONTENT area (the viewport minus the
const half = typeof window !== 'undefined' ? Math.max(PANEL_MIN, Math.round(window.innerWidth / 2)) : PANEL_DEFAULT // left sidebar's default width), so the panel never swallows half the
return makeDefaultState(half) // whole window; the same default applies to every session.
const contentHalf = typeof window !== 'undefined'
? Math.max(PANEL_MIN, Math.round((window.innerWidth - LEFT_SIDEBAR_DEFAULT) / 2))
: PANEL_DEFAULT
return makeDefaultState(contentHalf)
} }
/** The session-scoped store: one state per conversation, localStorage-backed. */ /** The session-scoped store: one state per conversation, localStorage-backed. */