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:
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-22-collapsed-sidebar-control-rail.md: 9f244010a1ec14eeafe707aedfffcf7e5bbe7736
|
2026-07-22-collapsed-sidebar-control-rail.md: 90039110f4c1e97002fe45ebe42697452b2c6155
|
||||||
2026-07-22-collapsed-sidebar-control-rail.zh.md: 53007bf717404b151d0a2d1f673c20f111ee8234
|
2026-07-22-collapsed-sidebar-control-rail.zh.md: e5cd1c4911bbe02e10d2f1506943024bde862f12
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ The sidebar close action persisted a zero width preference, and the layout mappe
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 60px: one 28px icon control between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched.
|
The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched.
|
||||||
|
|
||||||
`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site.
|
`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`.
|
||||||
|
|
||||||
`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render removes the brand, creation controls, search, and session tree from the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription; the top control changes to `Expand sidebar`, and the bottom `Settings` control remains in the rail.
|
`SidebarRoot` reads the owner `collapsed` prop. Its collapsed render is the rail: expand toggle, new session, search, and new workspace icons (each aligned with its expanded counterpart's behavior — the search icon expands the sidebar and focuses the search box) plus the `Settings` foot. The brand, capsule button, search field, and session tree leave the rendered and accessibility trees — the body component unmounts, dropping its sessions subscription.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
@@ -24,6 +24,6 @@ The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COL
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- A collapsed sidebar reserves 60px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior.
|
- A collapsed sidebar reserves 56px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior.
|
||||||
- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen.
|
- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen.
|
||||||
- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client.
|
- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ Status: implemented
|
|||||||
|
|
||||||
## 决策
|
## 决策
|
||||||
|
|
||||||
布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 60px:在侧边栏两侧各 16px 的水平内边距之间放置一个 28px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。
|
布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。
|
||||||
|
|
||||||
`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。
|
`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。
|
||||||
|
|
||||||
`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠状态下的渲染会将品牌标识、创建控件、搜索框和会话树从渲染树与可访问性树中移除——主体组件卸载,随之退订会话列表;顶部控件变为 `Expand sidebar`,底部的 `Settings` 控件则留在控制栏中。
|
`SidebarRoot` 读取 owner 的 `collapsed` 属性。折叠渲染即控制栏:展开开关、新建会话、搜索、新建工作区四个图标(行为与展开态对应控件对齐——搜索图标会展开侧边栏并聚焦搜索框),加上底部的 `Settings`。品牌标识、胶囊按钮、搜索框和会话树离开渲染树与可访问性树——主体组件卸载,随之退订会话列表。
|
||||||
|
|
||||||
## 曾考虑的替代方案
|
## 曾考虑的替代方案
|
||||||
|
|
||||||
@@ -24,6 +24,6 @@ Status: implemented
|
|||||||
|
|
||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
- 折叠的侧边栏占用 60px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
|
- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
|
||||||
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
|
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
|
||||||
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。
|
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。
|
||||||
|
|||||||
@@ -136,18 +136,32 @@ describe('web boot chain success pass (keyless, six real bundles, ?fixture)', ()
|
|||||||
expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
|
expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collapsed sidebar keeps a 60px rail with expand and settings controls', async () => {
|
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
|
onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
|
||||||
const frame = page.locator('[class*="frame"]')
|
const frame = page.locator('[class*="frame"]')
|
||||||
const firstTrack = async (): Promise<string> => (await frame.evaluate(
|
const firstTrack = async (): Promise<string> => (await frame.evaluate(
|
||||||
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
|
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
|
||||||
|
// The tracks transition on the deepsuite curve; assert the animated
|
||||||
|
// settle rather than an instant jump.
|
||||||
|
const settledTrack = async (px: string): Promise<void> => {
|
||||||
|
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
|
||||||
|
}
|
||||||
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||||
expect(await firstTrack()).toBe('60px')
|
await settledTrack('56px')
|
||||||
await expect(page.getByRole('button', { name: 'Expand sidebar' }).isVisible()).resolves.toBe(true)
|
for (const name of ['Expand sidebar', 'New session', 'Search sessions', 'New workspace', 'Settings']) {
|
||||||
await expect(page.getByRole('button', { name: 'Settings' }).isVisible()).resolves.toBe(true)
|
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
|
||||||
|
}
|
||||||
await page.getByRole('button', { name: 'Expand sidebar' }).click()
|
await page.getByRole('button', { name: 'Expand sidebar' }).click()
|
||||||
expect(await firstTrack()).toBe('300px')
|
await settledTrack('300px')
|
||||||
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
|
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
|
||||||
|
// Rail search: collapse again, the search control expands and lands in the box.
|
||||||
|
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
||||||
|
await settledTrack('56px')
|
||||||
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||||
|
await settledTrack('300px')
|
||||||
|
const focused = await page.evaluate(() =>
|
||||||
|
(document.activeElement as HTMLInputElement | null)?.placeholder ?? '')
|
||||||
|
expect(focused).toContain('Search')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stayed clean: no page errors across the whole load chain', () => {
|
it('stayed clean: no page errors across the whole load chain', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-client-ui-layout
|
# @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'.
|
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'.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--dsw-alias-bg-base);
|
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 {
|
.sidebarCol {
|
||||||
@@ -46,6 +61,19 @@
|
|||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
touch-action: none;
|
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 {
|
.handle::after {
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ function DetailsColumn(props: { children?: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */
|
/** 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 [dragging, setDragging] = useState(false)
|
||||||
const origin = useRef(0)
|
const origin = useRef(0)
|
||||||
const latest = useRef(0)
|
const latest = useRef(0)
|
||||||
const frame = useRef<number | null>(null)
|
const frame = useRef<number | null>(null)
|
||||||
const callbacks = useRef({ 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 }
|
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault()
|
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 }
|
if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
|
||||||
callbacks.current.onDrag(latest.current - origin.current)
|
callbacks.current.onDrag(latest.current - origin.current)
|
||||||
setDragging(false)
|
setDragging(false)
|
||||||
|
callbacks.current.onEnd()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
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.
|
// it stays frozen for the whole gesture so dx deltas do not compound.
|
||||||
const sidebarBase = useRef(0)
|
const sidebarBase = useRef(0)
|
||||||
const detailsBase = useRef(0)
|
const detailsBase = useRef(0)
|
||||||
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, [])
|
// Track-level transitions pause for the whole gesture: eased tracks would
|
||||||
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, [])
|
// 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) => {
|
const onSidebarDrag = useCallback((dx: number) => {
|
||||||
actions.setSidebar(sidebarBase.current + dx)
|
actions.setSidebar(sidebarBase.current + dx)
|
||||||
}, [actions])
|
}, [actions])
|
||||||
@@ -130,6 +135,7 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
|
|||||||
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
|
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
|
||||||
data-sidebar-collapsed={panels.sidebar === 0 || undefined}
|
data-sidebar-collapsed={panels.sidebar === 0 || undefined}
|
||||||
data-details-collapsed={cols.details === 0 || undefined}
|
data-details-collapsed={cols.details === 0 || undefined}
|
||||||
|
data-dragging={dragging || undefined}
|
||||||
>
|
>
|
||||||
<div className={css.sidebarCol}>
|
<div className={css.sidebarCol}>
|
||||||
{/* Render-site slot call with live concession output: a closed
|
{/* Render-site slot call with live concession output: a closed
|
||||||
@@ -155,8 +161,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
|
|||||||
)}
|
)}
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
||||||
{panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
|
{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} />}
|
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export const SIDEBAR_MIN = 240
|
|||||||
export const SIDEBAR_MAX = 420
|
export const SIDEBAR_MAX = 420
|
||||||
/** Sidebar width before any user drag. */
|
/** Sidebar width before any user drag. */
|
||||||
export const SIDEBAR_DEFAULT = 300
|
export const SIDEBAR_DEFAULT = 300
|
||||||
/** Closed-sidebar rail: one 28px control between 16px horizontal paddings. */
|
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
|
||||||
export const SIDEBAR_COLLAPSED = 60
|
export const SIDEBAR_COLLAPSED = 56
|
||||||
/** Details drag clamp floor. */
|
/** Details drag clamp floor. */
|
||||||
export const DETAILS_MIN = 300
|
export const DETAILS_MIN = 300
|
||||||
/** Details drag clamp ceiling. */
|
/** Details drag clamp ceiling. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-client-ui-sidebar
|
# @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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -15,24 +15,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Closed state is a persistent rail: the layout reserves exactly the root's
|
/* Closed state is a persistent rail: a 24px icon column between the 16px
|
||||||
horizontal padding plus one icon control. */
|
horizontal paddings (SIDEBAR_COLLAPSED = 56). Controls mirror their
|
||||||
|
expanded counterparts top-down: expand, new session, search, new
|
||||||
|
workspace; settings keeps the foot. */
|
||||||
.root.collapsed {
|
.root.collapsed {
|
||||||
gap: 0;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsed .headerBlock {
|
.collapsed .iconButton {
|
||||||
padding-bottom: 0;
|
width: 24px;
|
||||||
}
|
height: 24px;
|
||||||
|
|
||||||
.collapsed .logoRow {
|
|
||||||
justify-content: center;
|
|
||||||
padding-inline: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsed .foot {
|
.collapsed .foot {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 24px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
* standard useSessions hook, viewing state (expansion, search) is local
|
* standard useSessions hook, viewing state (expansion, search) is local
|
||||||
* component state, and rows are derived in render via useMemo (slot design
|
* component state, and rows are derived in render via useMemo (slot design
|
||||||
* section 6: derived data is a pure function, no materializing store).
|
* section 6: derived data is a pure function, no materializing store).
|
||||||
* The collapsed render keeps only the rail controls (expand toggle +
|
* The collapsed render is the compact rail: expand / new session / search /
|
||||||
* Settings); the body unmounts, dropping its sessions subscription.
|
* 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 clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
FishLogo,
|
FishLogo,
|
||||||
@@ -33,10 +34,13 @@ function toggled(list: readonly string[], key: string): string[] {
|
|||||||
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
|
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. */
|
/** 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)
|
const list = useSessions((s) => s)
|
||||||
// Wave-2 seam: row highlight expects `current` on the sessions list
|
// Wave-2 seam: row highlight expects `current` on the sessions list
|
||||||
// snapshot (sessions.current lives with the runtime sessions service).
|
// snapshot (sessions.current lives with the runtime sessions service).
|
||||||
@@ -99,6 +103,7 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search name, keywords..."
|
placeholder="Search name, keywords..."
|
||||||
value={query}
|
value={query}
|
||||||
|
autoFocus={autoFocusSearch}
|
||||||
onChange={(e) => { setQuery(e.target.value) }}
|
onChange={(e) => { setQuery(e.target.value) }}
|
||||||
/>
|
/>
|
||||||
{query !== '' && (
|
{query !== '' && (
|
||||||
@@ -152,41 +157,90 @@ function SidebarBody({ useSessions, onOpen, onCreate }: SidebarBodyProps) {
|
|||||||
* @returns the sidebar element tree.
|
* @returns the sidebar element tree.
|
||||||
*/
|
*/
|
||||||
export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
|
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 (
|
return (
|
||||||
<div className={clsx(css.root, collapsed && css.collapsed)}>
|
<div className={css.root}>
|
||||||
<div className={css.headerBlock}>
|
<div className={css.headerBlock}>
|
||||||
<div className={css.logoRow}>
|
<div className={css.logoRow}>
|
||||||
{!collapsed && (
|
<span className={css.brand}>
|
||||||
<span className={css.brand}>
|
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
|
||||||
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
|
<FishLogo size={23} />
|
||||||
<FishLogo size={23} />
|
<span className={css.wordmark}>deepseek</span>
|
||||||
<span className={css.wordmark}>deepseek</span>
|
<span className={css.badge}>HARNESS</span>
|
||||||
<span className={css.badge}>HARNESS</span>
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.iconButton}
|
className={css.iconButton}
|
||||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
aria-label="Collapse sidebar"
|
||||||
onClick={() => { onToggleSidebar() }}
|
onClick={() => { onToggleSidebar() }}
|
||||||
>
|
>
|
||||||
<IconPanelLeftOutline16 />
|
<IconPanelLeftOutline16 />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && (
|
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
|
||||||
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
|
<IconNewChatOutline16 size={14} />
|
||||||
<IconNewChatOutline16 size={14} />
|
New Session
|
||||||
New Session
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||||
<IconSettingsOutline14 />
|
<IconSettingsOutline14 />
|
||||||
{!collapsed && <span>Settings</span>}
|
<span>Settings</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function mount(...summaries: SessionSummary[]) {
|
|||||||
)
|
)
|
||||||
const onToggleSidebar = vi.fn(() => {
|
const onToggleSidebar = vi.fn(() => {
|
||||||
collapsed = !collapsed
|
collapsed = !collapsed
|
||||||
utils.rerender(view(collapsed ? 60 : 300))
|
utils.rerender(view(collapsed ? 56 : 300))
|
||||||
})
|
})
|
||||||
const utils = render(view(300))
|
const utils = render(view(300))
|
||||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||||
@@ -158,21 +158,36 @@ describe('SidebarRoot', () => {
|
|||||||
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collapsed rail keeps the expand and settings controls', () => {
|
it('collapsed rail keeps the four controls and settings', () => {
|
||||||
const { onToggleSidebar } = mount(...projectData())
|
const { onToggleSidebar, onCreate } = mount(...projectData())
|
||||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||||
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
|
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.getByLabelText('Settings')).toBeTruthy()
|
||||||
expect(screen.queryByText('HARNESS')).toBeNull()
|
expect(screen.queryByText('HARNESS')).toBeNull()
|
||||||
expect(screen.queryByText('New Session')).toBeNull()
|
expect(screen.queryByText('New Session')).toBeNull()
|
||||||
expect(screen.queryByRole('tree')).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')) })
|
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||||
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
||||||
expect(screen.getByText('New Session')).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', () => {
|
it('group-by menu behaves', () => {
|
||||||
mount(...projectData())
|
mount(...projectData())
|
||||||
expect(screen.queryByText('Update')).toBeNull()
|
expect(screen.queryByText('Update')).toBeNull()
|
||||||
|
|||||||
@@ -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
|
* upstream (deepsuite theme/global.css) — supplied here so the composite
|
||||||
* --dsw-font-* variables resolve. Code stack deliberately omits a bare
|
* --dsw-font-* variables resolve and motion rides the upstream curve. Code
|
||||||
* `monospace` tail (Windows CJK falls back to SimSun otherwise). */
|
* font stack deliberately omits a bare `monospace` tail (Windows CJK falls
|
||||||
|
* back to SimSun otherwise). */
|
||||||
:root {
|
:root {
|
||||||
--dsw-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
--dsw-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
|
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
|
||||||
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
|
'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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user