fix(gui): keep sidebar controls when collapsed
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user