fix(gui): keep sidebar controls when collapsed

This commit is contained in:
Yichen Jiang
2026-07-22 20:28:40 +08:00
parent b51d2b3d67
commit 93f1eaa972
18 changed files with 240 additions and 76 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# @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. Contract: api-contracts v3 §6.
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Its collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: api-contracts v3 §6.
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session hook, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session/sidebar-open hooks, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
## Model Experience
@@ -15,6 +15,28 @@
font-size: 14px;
}
/* Closed state is a persistent rail: the layout reserves exactly the root's
horizontal padding plus one icon control. */
.root.collapsed {
gap: 0;
}
.collapsed .headerBlock {
padding-bottom: 0;
}
.collapsed .logoRow {
justify-content: center;
padding-inline: 0;
}
.collapsed .foot {
justify-content: center;
width: 28px;
margin-top: auto;
padding: 0;
}
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
.headerBlock {
flex: none;
@@ -24,12 +24,10 @@ const GROUP_BY_ITEMS = [
{ id: 'status', label: 'Status', disabled: true },
]
/**
* Render the sidebar column.
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootComponentProps) {
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useTree' | 'useCurrent' | 'actions' | 'tree'>
/** Expanded-only content; unmounting drops tree/current subscriptions while the rail is collapsed. */
function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
const rows = useTree((s) => s.rows)
const query = useTree((s) => s.query)
const groupBy = useTree((s) => s.groupBy)
@@ -47,32 +45,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
}
return (
<div className={css.root}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
<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="Collapse sidebar"
onClick={() => { actions.toggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
</div>
<div className={css.listArea}>
<div className={css.listArea}>
<div className={css.sectionHeader}>
<span className={css.sectionLabel}>WorkSpace</span>
<Menu
@@ -153,11 +126,66 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the sidebar column.
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot(props: SidebarRootComponentProps) {
const open = props.useSidebarOpen()
return (
<div className={clsx(css.root, !open && css.collapsed)}>
<div className={css.headerBlock}>
<div className={css.logoRow}>
{open
? (
<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>
)
: null}
<button
type="button"
className={css.iconButton}
aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
onClick={() => { props.actions.toggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
{open
? (
<button type="button" className={css.newSession} onClick={() => { props.actions.create() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
)
: null}
</div>
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
{open
? (
<SidebarBody
useTree={props.useTree}
useCurrent={props.useCurrent}
actions={props.actions}
tree={props.tree}
/>
)
: null}
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
Settings
{open ? <span>Settings</span> : null}
</div>
</div>
)
@@ -37,6 +37,8 @@ export type SidebarRootInjected = {
useTree: SnapshotSelectorHook<SidebarTreeState>
/** Current session selector (row highlight); undefined selects nothing. */
useCurrent: () => SessionId | undefined
/** Sidebar open selector; the collapsed render keeps only persistent rail controls. */
useSidebarOpen: () => boolean
actions: SidebarActions
tree: SidebarTreeActions
}
@@ -45,6 +45,7 @@ export function apply(ctx: ClientContext): void {
return {
useTree: tree.store.useSelector,
useCurrent: () => layout.current.useSelector(s => s.sessionId),
useSidebarOpen: () => layout.sidebar.useSelector(s => s.open),
actions: {
open: (id) => { layout.open(id) },
create: (cwd) => {
@@ -31,10 +31,12 @@ async function bench() {
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
})
const sessions = { list, create: vi.fn(async () => sid('minted')) }
const sidebar = createSnapshotStore({ open: true, width: 300 })
const layout = {
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
sidebar,
open: vi.fn(),
toggleSidebar: vi.fn(),
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
}
ctx.provide('sessions', sessions)
ctx.provide('layout', layout)
@@ -83,6 +85,10 @@ describe('apply', () => {
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(layout.toggleSidebar).toHaveBeenCalledTimes(2)
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('alpha')) })
@@ -51,20 +51,22 @@ function mount(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
const sidebar = createSnapshotStore({ open: true })
const actions: SidebarActions = {
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
create: vi.fn(),
toggleSidebar: vi.fn(),
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
}
const utils = render(
<SidebarRoot
useTree={tree.store.useSelector}
useCurrent={() => current.useSelector((s) => s.id)}
useSidebarOpen={() => sidebar.useSelector((s) => s.open)}
actions={actions}
tree={tree}
/>,
)
return { list, tree, current, actions, ...utils }
return { list, tree, current, sidebar, actions, ...utils }
}
const projectData = () => [
@@ -139,10 +141,22 @@ describe('SidebarRoot', () => {
expect(actions.create).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
it('collapsed rail keeps the expand and settings controls', () => {
const { actions } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()