fix(gui): keep sidebar controls when collapsed

A closed sidebar previously resolved to a zero-width grid track, clipping
the only toggle and the settings entry with no visible recovery; the closed
preference persisted across reloads, locking the sidebar shut.

- columns.ts maps the closed preference (width 0) to a fixed 60px
  SIDEBAR_COLLAPSED rail through every step of the concession solve;
  closed details still resolve to zero width.
- AppFrame derives data-sidebar-collapsed and the sidebar slot's collapsed
  owner prop from the persisted preference instead of the resolved track
  width, and drops the resize handle while collapsed.
- SidebarRoot reads the owner collapsed prop; the expanded-only body is a
  separate component that unmounts while collapsed (dropping its sessions
  subscription), leaving the expand toggle and Settings in the rail.
- The keyless web smoke gains the ui-sidebar bundle (six real bundles) and
  pins the 60px rail collapse/expand round through the assembled client.
This commit is contained in:
imccyu
2026-07-23 10:51:57 +08:00
parent 59a2fc408b
commit 7e6f0128b5
12 changed files with 153 additions and 80 deletions

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. 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 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).
`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

@@ -5,6 +5,8 @@
* 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.
*/
import { Fragment, useMemo, useState } from 'react'
import clsx from 'clsx'
@@ -31,12 +33,10 @@ function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/**
* Render the sidebar column.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'>
/** Expanded-only content; unmounting drops the sessions subscription and viewing state while the rail is collapsed. */
function SidebarBody({ useSessions, onOpen, onCreate }: 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).
@@ -61,32 +61,7 @@ export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }:
}
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={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<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
@@ -167,11 +142,51 @@ export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }:
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the sidebar column.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<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>
)}
<button
type="button"
className={css.iconButton}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
{!collapsed && (
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
)}
</div>
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
{!collapsed && <SidebarBody useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} />}
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
Settings
{!collapsed && <span>Settings</span>}
</div>
</div>
)

View File

@@ -60,17 +60,24 @@ function mount(...summaries: SessionSummary[]) {
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
const onCreate = vi.fn()
const onToggleSidebar = vi.fn()
const utils = render(
// The owner decides collapsed in production (AppFrame maps the preference);
// the harness mirrors that loop so the toggle drives a re-render.
let collapsed = false
const view = (width: number) => (
<SidebarRoot
collapsed={false}
width={300}
collapsed={collapsed}
width={width}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>,
/>
)
const onToggleSidebar = vi.fn(() => {
collapsed = !collapsed
utils.rerender(view(collapsed ? 60 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
}
@@ -151,10 +158,23 @@ describe('SidebarRoot', () => {
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
it('collapsed rail keeps the expand and settings controls', () => {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
expect(onToggleSidebar).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()