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>
</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
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
@@ -225,7 +232,9 @@ export function Sidebar(props: { ctx: Context }) {
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
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 />
@@ -254,7 +263,6 @@ export function Sidebar(props: { ctx: Context }) {
/>
</div>
</div>
)}
</>
)
}
+21
View File
@@ -57,6 +57,27 @@
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
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 {
+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)}`])} />}
{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
paneId={leaf.id}
tabs={leaf.tabs}
@@ -129,8 +132,8 @@ function LeafView(props: {
else actions.moveTabBefore(payload, leaf.id, before)
}}
/>
{leaf.tabs.length > 0 ? (
<div className={css.paneContent}>{activeTab !== undefined ? renderTab(activeTab) : null}</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_MAX = 640
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 TERMINAL_LIMIT = 3
@@ -372,9 +374,13 @@ function loadState(sessionId: string): SidebarState {
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
// New sessions open at half the viewport (VSCode-like default split).
const half = typeof window !== 'undefined' ? Math.max(PANEL_MIN, Math.round(window.innerWidth / 2)) : PANEL_DEFAULT
return makeDefaultState(half)
// New sessions open at half the CONTENT area (the viewport minus the
// left sidebar's default width), so the panel never swallows half the
// 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. */