feat(gui): animate sidebar collapse and grow the rail control set

The collapsed rail becomes a 56px icon column (24px controls between 16px
paddings) carrying expand, new session, search, and new workspace — each
aligned with its expanded counterpart; rail search expands the sidebar and
focuses the search box. Collapse/expand now animates: the frame transitions
grid-template-columns (and the surviving handle its left) on the deepsuite
sider curve — --ds-ease-in-out over --ds-transition-duration-slow, supplied
by ui-theme's base sheet. Transitions pause during drags (data-dragging on
the frame, set for the whole gesture) and under prefers-reduced-motion.
This commit is contained in:
imccyu
2026-07-23 11:45:03 +08:00
parent a6649fb545
commit 5da8e3b787
13 changed files with 186 additions and 66 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 60px control rail while details closes to zero width. Contract: api-contracts v3 §5.
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.

View File

@@ -5,6 +5,21 @@
height: 100%;
overflow: hidden;
background: var(--dsw-alias-bg-base);
/* Collapse/expand animates the tracks on the deepsuite sider curve
(--ds-ease-in-out / --ds-transition-duration-slow, ui-theme base.css). */
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Dragging writes widths at pointer cadence; easing them would detach the
column from the handle. */
.frame[data-dragging] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.frame {
transition: none;
}
}
.sidebarCol {
@@ -46,6 +61,19 @@
cursor: col-resize;
z-index: 2;
touch-action: none;
/* Rides the same curve as the tracks so the pill stays on the moving
border during collapse/expand; paused while dragging (frame rule). */
transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-dragging] .handle {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.handle {
transition: none;
}
}
.handle::after {

View File

@@ -35,13 +35,13 @@ function DetailsColumn(props: { children?: ReactNode }) {
}
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void }) {
function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
const [dragging, setDragging] = useState(false)
const origin = useRef(0)
const latest = useRef(0)
const frame = useRef<number | null>(null)
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag })
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag }
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd })
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
@@ -65,6 +65,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
callbacks.current.onDrag(latest.current - origin.current)
setDragging(false)
callbacks.current.onEnd()
}, [])
return (
@@ -114,8 +115,12 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
// it stays frozen for the whole gesture so dx deltas do not compound.
const sidebarBase = useRef(0)
const detailsBase = useRef(0)
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, [])
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, [])
// Track-level transitions pause for the whole gesture: eased tracks would
// detach the column edge from the pointer (AppFrame.module.css).
const [dragging, setDragging] = useState(false)
const onDragEnd = useCallback(() => { setDragging(false) }, [])
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, [])
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details; setDragging(true) }, [])
const onSidebarDrag = useCallback((dx: number) => {
actions.setSidebar(sidebarBase.current + dx)
}, [actions])
@@ -130,6 +135,7 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
data-sidebar-collapsed={panels.sidebar === 0 || undefined}
data-details-collapsed={cols.details === 0 || undefined}
data-dragging={dragging || undefined}
>
<div className={css.sidebarCol}>
{/* Render-site slot call with live concession output: a closed
@@ -155,8 +161,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
)}
</SessionProvider>
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
</div>
)
}

View File

@@ -21,8 +21,8 @@ export const SIDEBAR_MIN = 240
export const SIDEBAR_MAX = 420
/** Sidebar width before any user drag. */
export const SIDEBAR_DEFAULT = 300
/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 60
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 56
/** Details drag clamp floor. */
export const DETAILS_MIN = 300
/** Details drag clamp ceiling. */

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-sidebar
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. The collapsed render is the layout-owned compact rail: expand / new session / search (expands and focuses the search box) / new workspace icons plus the settings entry. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.

View File

@@ -15,24 +15,24 @@
font-size: 14px;
}
/* Closed state is a persistent rail: the layout reserves exactly the root's
horizontal padding plus one icon control. */
/* Closed state is a persistent rail: a 24px icon column between the 16px
horizontal paddings (SIDEBAR_COLLAPSED = 56). Controls mirror their
expanded counterparts top-down: expand, new session, search, new
workspace; settings keeps the foot. */
.root.collapsed {
gap: 0;
align-items: center;
gap: 8px;
padding: 14px 16px 6px;
}
.collapsed .headerBlock {
padding-bottom: 0;
}
.collapsed .logoRow {
justify-content: center;
padding-inline: 0;
.collapsed .iconButton {
width: 24px;
height: 24px;
}
.collapsed .foot {
justify-content: center;
width: 28px;
width: 24px;
margin-top: auto;
padding: 0;
}

View File

@@ -5,10 +5,11 @@
* standard useSessions hook, viewing state (expansion, search) is local
* component state, and rows are derived in render via useMemo (slot design
* section 6: derived data is a pure function, no materializing store).
* The collapsed render keeps only the rail controls (expand toggle +
* Settings); the body unmounts, dropping its sessions subscription.
* The collapsed render is the compact rail: expand / new session / search /
* new workspace icons plus the Settings foot; the body unmounts, dropping
* its sessions subscription. Rail search expands and focuses the search box.
*/
import { Fragment, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -33,10 +34,13 @@ function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'>
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
/** Focus the search input on mount (rail search control expands into search). */
autoFocusSearch: boolean
}
/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */
function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) {
function SidebarBody({ useSessions, onOpen, onCreate, autoFocusSearch }: SidebarBodyProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
@@ -99,6 +103,7 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) {
type="text"
placeholder="Search name, keywords..."
value={query}
autoFocus={autoFocusSearch}
onChange={(e) => { setQuery(e.target.value) }}
/>
{query !== '' && (
@@ -152,41 +157,90 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) {
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
// Rail search = expand + land in the search box: the flag arms right before
// the expand toggle, the remounting SidebarBody autofocuses its input, and
// the post-commit effect disarms so later remounts stay unfocused.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) setSearchOnExpand(false)
}, [collapsed, searchOnExpand])
if (collapsed) {
// Rail (figma parity with deepsuite CollapsedSider): the four controls
// mirror their expanded counterparts top-down; actions that need the
// expanded surface expand first.
return (
<div className={clsx(css.root, css.collapsed)}>
<button
type="button"
className={css.iconButton}
aria-label="Expand sidebar"
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="Search sessions"
onClick={() => { setSearchOnExpand(true); onToggleSidebar() }}
>
<IconSearchOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
</div>
</div>
)
}
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div className={css.root}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
{!collapsed && (
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
)}
<span className={css.brand}>
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
<FishLogo size={23} />
<span className={css.wordmark}>deepseek</span>
<span className={css.badge}>HARNESS</span>
</span>
<button
type="button"
className={css.iconButton}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label="Collapse sidebar"
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
{!collapsed && (
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
)}
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
</div>
{!collapsed && <SidebarBody useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} />}
<SidebarBody useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} autoFocusSearch={searchOnExpand} />
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
{!collapsed && <span>Settings</span>}
<span>Settings</span>
</div>
</div>
)

View File

@@ -75,7 +75,7 @@ function mount(...summaries: SessionSummary[]) {
)
const onToggleSidebar = vi.fn(() => {
collapsed = !collapsed
utils.rerender(view(collapsed ? 60 : 300))
utils.rerender(view(collapsed ? 56 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
@@ -158,21 +158,36 @@ describe('SidebarRoot', () => {
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapsed rail keeps the expand and settings controls', () => {
const { onToggleSidebar } = mount(...projectData())
it('collapsed rail keeps the four controls and settings', () => {
const { onToggleSidebar, onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
expect(screen.getByLabelText('New session')).toBeTruthy()
expect(screen.getByLabelText('Search sessions')).toBeTruthy()
expect(screen.getByLabelText('New workspace')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
// Rail creation entries route like their expanded counterparts.
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
})
it('rail search expands the sidebar and focuses the search box', () => {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
const input = screen.getByPlaceholderText('Search name, keywords...')
expect(document.activeElement).toBe(input)
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(screen.queryByText('Update')).toBeNull()

View File

@@ -1,10 +1,13 @@
/* Base font-family variables referenced by the token sheets but defined
/* Base variables referenced by the token sheets and component CSS but defined
* upstream (deepsuite theme/global.css) — supplied here so the composite
* --dsw-font-* variables resolve. Code stack deliberately omits a bare
* `monospace` tail (Windows CJK falls back to SimSun otherwise). */
* --dsw-font-* variables resolve and motion rides the upstream curve. Code
* font stack deliberately omits a bare `monospace` tail (Windows CJK falls
* back to SimSun otherwise). */
:root {
--dsw-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ds-transition-duration-slow: 0.3s;
}