Merge remote-tracking branch 'origin/master' into codex/gui-context-polish

# Conflicts:
#	apps/web/tests/smoke-fixture.e2e.ts
This commit is contained in:
imccyu
2026-07-23 15:03:26 +08:00
30 changed files with 940 additions and 326 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. 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 {
@@ -27,13 +42,8 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Collapsed columns keep children mounted; the border must not paint a 1px seam.
Flags live on the frame — DetailsColumn renders inside the provider body and
does not know its own width. */
.frame[data-sidebar-collapsed] .sidebarCol {
border-right: none;
}
/* The details subtree stays mounted at zero width, so its border must not paint
a 1px seam. The collapsed sidebar instead retains a bordered compact rail. */
.frame[data-details-collapsed] .detailsCol {
border-left: none;
}
@@ -51,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])
@@ -128,14 +133,16 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
ref={frameRef}
className={css.frame}
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
data-sidebar-collapsed={cols.sidebar === 0 || undefined}
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: the sidebar
stays mounted at zero width (CSS hides it), and sees its rendered
state as owner params decided here, not precomputed upstream. */}
{renderSlot('sidebar', { collapsed: cols.sidebar === 0, width: cols.sidebar })}
{/* Render-site slot call with live concession output: a closed
sidebar keeps the mounted slot at the compact-rail width, and the
component sees its rendered state as owner params decided here
(collapsed follows the preference, not the resolved width). */}
{renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
</div>
<SessionProvider
empty={() => (
@@ -153,8 +160,9 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
</>
)}
</SessionProvider>
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{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

@@ -4,7 +4,9 @@
* details first, then sidebar, then auto-closing details (derived zero width —
* persisted width preferences are never rewritten, so widening the window
* restores them). Center absorbs any remaining deficit as the last resort.
* Inputs are the layout store's plain width preferences (0 = closed).
* Inputs are the layout store's plain width preferences (0 = closed); a
* closed sidebar resolves to the fixed SIDEBAR_COLLAPSED control rail while
* closed details resolve to zero width.
*/
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
@@ -19,6 +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: 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. */
@@ -47,10 +51,10 @@ export function clampWidth(px: number, min: number, max: number): number {
* @param viewport - available frame width in px.
* @param sidebar - sidebar width preference in px (0 = closed).
* @param details - details width preference in px (0 = closed).
* @returns resolved widths; details 0 means visually closed (never unmounted).
* @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail.
*/
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
const s0 = sidebar === 0 ? 0 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)
// Step 1: everything fits at preferred widths.
@@ -60,15 +64,15 @@ export function computeColumns(viewport: number, sidebar: number, details: numbe
const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN)
if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 }
// Step 3: shrink sidebar toward its minimum.
const s1 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
// Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks).
const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN)
if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 }
// Step 4: auto-close details (derived — preferences untouched). With the
// details pressure gone the sidebar concession is re-solved from preference.
if (d1 > 0) {
if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 }
const s2 = s0 === 0 ? 0 : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN)
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
}

View File

@@ -50,9 +50,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Sidebar owner share: live column state from the frame's concession solve. */
export interface SidebarOwnerProps {
/** True when the concession chain rendered the column at zero width. */
/** True when the sidebar is closed (the column renders the compact control rail). */
collapsed: boolean
/** Rendered column width in px (0 when collapsed). */
/** Rendered column width in px (SIDEBAR_COLLAPSED when collapsed). */
width: number
}

View File

@@ -15,6 +15,7 @@ import { act, cleanup, render } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
// Session-mode switch for the SessionProvider stub prop.
@@ -175,6 +176,16 @@ describe('AppFrame', () => {
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
})
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
const { frame, instance, slotCalls, getByTestId } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)!
expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
})
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
const { frame } = mountFrame()
frameWidth = 1250

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
CENTER_MIN, clampWidth, computeColumns,
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
// Numeric preference form (0 = closed); helpers keep the scenario names readable.
@@ -22,8 +22,9 @@ describe('computeColumns', () => {
expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 })
})
it('closed panels contribute zero width', () => {
expect(computeColumns(1920, closed(300), closed(360))).toEqual({ sidebar: 0, center: 1920, details: 0 })
it('closed sidebar keeps its compact rail while closed details contribute zero width', () => {
expect(computeColumns(1920, closed(300), closed(360)))
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 })
})
it('preferences beyond the clamp range are clamped before solving', () => {
@@ -70,10 +71,14 @@ describe('computeColumns', () => {
})
it('sidebar-closed narrow window: details concedes then auto-closes', () => {
const fits = computeColumns(DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
expect(fits).toEqual({ sidebar: 0, center: CENTER_MIN, details: DETAILS_MIN })
const starved = computeColumns(DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
expect(starved).toEqual({ sidebar: 0, center: DETAILS_MIN + CENTER_MIN - 1, details: 0 })
const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN })
const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
expect(starved).toEqual({
sidebar: SIDEBAR_COLLAPSED,
center: DETAILS_MIN + CENTER_MIN - 1,
details: 0,
})
})
it('tiny viewport: both panels yield everything to center', () => {
@@ -93,9 +98,9 @@ describe('computeColumns', () => {
})
describe('computeColumns — degenerate viewports', () => {
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes all', () => {
// Reaches step 4's re-solve with s0 = 0 (the closed-sidebar arm).
it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => {
// Reaches step 4's re-solve with the compact rail as the sidebar floor.
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
.toEqual({ sidebar: 0, center: 500, details: 0 })
.toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
})
})

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. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. 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

@@ -1,39 +1,64 @@
/* Sidebar column (figma 133:7629): vertical stack, gap 8, padding 16/6,
sidebar fill + 1px right border painted by the layout column. Header block
(logo + New Session) and list area (section header + search + cells) carry
their own inner gaps per the style spec (1.2 / 1.3). */
/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar
fill + 1px right border painted by the layout column. Collapse morphs in
place: the four control rows persist into the 56px rail (one icon each,
x-converged by the shrinking column), geometry rides the deepsuite curve
while wide-only content cross-fades 200ms; explicit margins own the
vertical rhythm in both states so every gap can transition. */
.root {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
padding: 6px 16px;
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
.headerBlock {
flex: none;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 12px;
.root.collapsed {
padding-top: 14px;
}
/* Logo row: 60px, brand mark left, collapse button right.
figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */
/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and
unmounts once the collapse settles; remounts fade back in. */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
transition: opacity 200ms var(--ds-ease-in-out);
}
.collapsed .wide {
opacity: 0;
}
@keyframes wide-in {
from { opacity: 0; }
}
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
the toggle is the rail's expand control and slides in with the right edge. */
.logoRow {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 60px;
padding: 8px 4px;
margin-bottom: 16px;
box-sizing: border-box;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .logoRow {
height: 24px;
padding: 0;
margin-bottom: 8px;
}
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
@@ -79,13 +104,22 @@
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
transition:
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* New Session: 38px capsule (figma 133:7634). */
.collapsed .iconButton {
width: 24px;
height: 24px;
}
/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
icon control — border and fill fade with the label. */
.newSession {
flex: none;
display: flex;
@@ -94,6 +128,7 @@
gap: 6px;
height: 38px;
padding: 8px 16px;
margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
@@ -103,65 +138,84 @@
font-weight: 510;
line-height: 22px;
cursor: pointer;
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
/* List area (figma 133:7640): section header + search + cells, gap 4.
Relative for the bottom fade overlay. */
.listArea {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 4px;
.collapsed .newSession {
height: 24px;
padding: 0;
margin-bottom: 8px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
.collapsed .newSession:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
.newSessionLabel {
max-width: 200px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons. */
.collapsed .newSessionLabel {
max-width: 0;
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
the right-anchored new-workspace button is the row's rail survivor. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: 12px;
margin-bottom: 4px;
box-sizing: border-box;
border-radius: 12px;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .sectionHeader {
height: 24px;
padding-left: 0;
margin-bottom: 8px;
}
.sectionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649). Upstream binds a dedicated
design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped
alias — a component token pinned to the static scale mirrors it (ruled
compliant: indirect via custom property, upstream-variable equivalent). */
/* Search input: 38px capsule (figma 133:7649) morphing into the rail's
search control. Upstream binds a dedicated design-system variable (light
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
pinned to the static scale mirrors it (ruled compliant: indirect via
custom property, upstream-variable equivalent). */
.search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
flex: none;
@@ -169,19 +223,64 @@
align-items: center;
gap: 8px;
height: 38px;
margin-bottom: 8px; /* + 4px area gap = 12px to the first cell (spec padB12) */
margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
transition:
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
margin var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background-color 200ms var(--ds-ease-in-out);
}
:global(body[data-ds-dark-theme]) .search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
}
.collapsed .search {
height: 24px;
padding: 0;
margin-bottom: 8px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* The capsule's leading icon, upgraded to the rail's search control. While
expanded it is decorative: pointer-events off so clicks reach the label
(native input focus); collapsed it becomes the hit target. */
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
pointer-events: none;
color: inherit;
}
.collapsed .searchButton {
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
.collapsed .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchInput {
flex: 1;
min-width: 0;
@@ -212,6 +311,44 @@
color: var(--dsw-alias-label-secondary);
}
/* Tree seat: always mounted so the foot never moves; the tree content inside
is wide-only and clips while the column squeezes. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
}
/* Tree list: the only scrolling region. */
.list {
flex: 1;
@@ -229,20 +366,57 @@
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668). */
/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph
on the rail's icon axis when collapsed. */
.foot {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 29px;
margin: 10px 0;
margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */
padding: 0 2px 0 6px;
border-radius: 12px;
cursor: pointer;
overflow: hidden;
color: var(--dsw-alias-label-primary);
transition:
padding var(--ds-transition-duration-slow) var(--ds-ease-in-out),
gap var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.foot:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.collapsed .foot {
gap: 0;
padding: 0 0 0 5px;
}
.footLabel {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.collapsed .footLabel {
max-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.root,
.wide,
.logoRow,
.iconButton,
.newSession,
.newSessionLabel,
.sectionHeader,
.search,
.foot,
.footLabel {
transition: none;
animation: none;
}
}

View File

@@ -1,12 +1,19 @@
/**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, search,
* WorkSpace section header with the group-by menu, session tree list,
* Settings foot. Pure presentational — the session list arrives through the
* 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).
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
* section header with the group-by menu, search, session tree list, Settings
* foot. Pure presentational — the session list arrives through the 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).
*
* Collapse is a morph, not a swap: the four control rows persist into the
* 56px rail (collapse/new session/new workspace/search, one icon each, same
* top-down order as their expanded rows) and animate their geometry on the
* deepsuite curve, while wide-only content (brand, labels, input, tree)
* cross-fades out and unmounts once the collapse settles — dropping the
* sessions subscription. Rail search expands and focuses the search box.
*/
import { Fragment, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -19,6 +26,9 @@ import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */
const COLLAPSE_SETTLE_MS = 300
const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' },
// Update/Status grouping has no design yet (figma §3) — visible, disabled.
@@ -31,24 +41,48 @@ 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) {
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu() {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
/** Live search filter owned by the root (the query outlives the tree). */
query: string
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
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).
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const [query, setQuery] = useState('')
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const [menuOpen, setMenuOpen] = useState(false)
const now = Date.now()
// Presentational lookup (not tree derivation): the group holding the
@@ -61,83 +95,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.sectionHeader}>
<span className={css.sectionLabel}>WorkSpace</span>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setMenuOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={css.iconButton}
aria-label="Group by"
onClick={() => { setMenuOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
</div>
<label className={css.search}>
<IconSearchOutline16 size={14} />
<input
className={css.searchInput}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</label>
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
@@ -167,11 +125,128 @@ 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) {
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const searchInput = useRef<HTMLInputElement | null>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
const [settled, setSettled] = useState(collapsed)
useEffect(() => {
if (!collapsed) { setSettled(false); return }
const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS)
return () => { window.clearTimeout(timer) }
}, [collapsed])
const wide = !collapsed || !settled
// Rail search = expand + land in the search box: the flag arms before the
// expand toggle; once expanded the input is mounted and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) {
searchInput.current?.focus()
setSearchOnExpand(false)
}
}, [collapsed, searchOnExpand])
return (
<div className={clsx(css.root, collapsed && css.collapsed)}>
<div className={css.logoRow}>
{wide && (
<span className={clsx(css.brand, css.wide)}>
{/* 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>
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
<button
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
>
<IconNewChatOutline16 size={14} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
</button>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <GroupByMenu />}
<button
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Collapsed: the icon is the rail's search control. */}
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
>
<IconSearchOutline16 size={14} />
</button>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</div>
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
both states while the tree itself is wide-only. */}
<div className={css.listArea}>
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 />
Settings
{wide && <span className={clsx(css.footLabel, css.wide)}>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 ? 56 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
}
@@ -151,10 +158,81 @@ describe('SidebarRoot', () => {
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar, onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
// Fade window: the wide chrome is still mounted while it fades.
expect(screen.getByText('HARNESS')).toBeTruthy()
expect(screen.getByRole('tree')).toBeTruthy()
// Settle: wide content unmounts, the rail controls remain.
act(() => { vi.advanceTimersByTime(300) })
expect(screen.queryByText('HARNESS')).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
// Rail order mirrors the expanded rows: expand, new session, new workspace, search.
const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
.map((label) => screen.getByLabelText(label))
for (let i = 1; i < rail.length; i++) {
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
// 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()
} finally {
vi.useRealTimers()
}
})
it('rail search expands the sidebar and focuses the search box', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
const input = screen.getByPlaceholderText('Search name, keywords...')
expect(document.activeElement).toBe(input)
} finally {
vi.useRealTimers()
}
})
it('expanded search focuses without toggling the sidebar', () => {
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(document.activeElement).toBe(input)
expect(onToggleSidebar).not.toHaveBeenCalled()
})
it('the search query survives a collapse/expand round trip', () => {
vi.useFakeTimers()
try {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
expect(restored.value).toBe('forked')
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()

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;
}