Merge branch 'master' into feat/tui-extension-service

This commit is contained in:
Ziya
2026-07-23 04:02:19 -04:00
committed by GitHub
52 changed files with 1349 additions and 370 deletions

View File

@@ -69,8 +69,9 @@ function buildAlphaLog(): SessionEvent[] {
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
// stays presenter-less as the unknown fallback.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -87,7 +88,8 @@ function buildAlphaLog(): SessionEvent[] {
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
return events as unknown as SessionEvent[]
}
@@ -112,8 +114,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'fx-note':
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
case 'edit':
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}

View File

@@ -2,6 +2,8 @@
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -32,6 +32,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}

View File

@@ -4,7 +4,7 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolViewProps } from '../contract/toolview.ts'
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -17,6 +17,8 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
others: <IconSparkle16 />,
}

View File

@@ -4,7 +4,7 @@
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
import { useState, type ReactNode } from 'react'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -20,6 +20,8 @@ export interface ToolRowProps {
/** Expanded-body text; null = not expandable (leading slot never toggles). */
body: string | null
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
}
@@ -35,31 +37,56 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
export function ToolRow({ variant, icon, title, summary, body, state, onOpenDetails }: ToolRowProps) {
export function ToolRow({
variant,
icon,
title,
summary,
body,
state,
expandOnRowClick = false,
onOpenDetails,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded((v) => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div
className={css.row}
data-clickable={onOpenDetails !== undefined || undefined}
onClick={onOpenDetails}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable ? (
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</button>
) : (
<span className={css.leading}>{leadingFor(state, icon)}</span>
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (

View File

@@ -10,18 +10,19 @@ export type { ToolCallBlock } from './toolview.ts'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The five figma row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'others'
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', others: 'Tool call',
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
}
/** Known tool name -> variant; fs write/edit intentionally fall to others (no figma form). */
/** Known tool name -> variant. */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
read: 'read',
@@ -29,6 +30,8 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
web_search: 'search',
grep: 'search',
glob: 'search',
write: 'write',
edit: 'edit',
}
/**
@@ -78,6 +81,8 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
read: ['path', 'file_path', 'url'],
search: ['query', 'pattern', 'url'],
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
others: [],
}

View File

@@ -6,6 +6,7 @@ afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -28,6 +29,8 @@ describe('tool-call-model', () => {
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -48,6 +51,8 @@ describe('tool-call-model', () => {
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
@@ -114,6 +119,25 @@ describe('ToolRow', () => {
})
})
describe('ThinkRow', () => {
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({
callId: 'c1', toolName, block,
@@ -138,6 +162,32 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('renders edit with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('edit', running({
name: 'edit',
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
}))} />,
)
expect(view.getByText('Edit')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('renders write with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('write', running({
name: 'write',
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
}))} />,
)
expect(view.getByText('Write')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches actions.openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)

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

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
@@ -9,6 +9,7 @@ Which plugins mount and with what defaults is decided only here — shells must
| Key | Default | Contract |
|---|---:|---|
| `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. |
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
@@ -18,7 +19,7 @@ Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt
## Model Experience
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents.
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. When `workspaceContext` is enabled, each agent-loop instance freezes the applicable workspace instructions into its logged request prefix; the owning package documents the exact [model-visible framing](../../context/workspace-context/README.md#prompt-shape).
#### KV Cache effect
@@ -27,5 +28,5 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi
## Known Limitations and Deferred Work
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version.
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

View File

@@ -69,6 +69,7 @@
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
},
"peerDependencies": {

View File

@@ -23,6 +23,7 @@ import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as fsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as toolFs from '@deepseek-ai/dsh-tool-fs'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -42,6 +43,8 @@ import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
export interface BootHostOptions {
/** Root directory for JSONL session persistence. */
persistenceRoot: string
/** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */
workspaceContext: workspaceContext.Config | false
/** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */
provider?: string
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
@@ -76,7 +79,7 @@ export interface HostHandle {
/**
* Compose the harness host plugin assembly (the one place deciding which plugins mount and
* with what defaults — shells must not alter the assembly).
* @param options - persistence root and optional default provider/model.
* @param options - persistence, workspace instructions, and optional default routing.
* @returns the booted handle (ctx + defaults + dispose).
*/
export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
@@ -109,6 +112,9 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
await ctx.plugin(fsPolicy)
await ctx.plugin(toolFs, {})
await ctx.plugin(toolFsSearch, {})
if (options.workspaceContext !== false) {
await ctx.plugin(workspaceContext, options.workspaceContext)
}
// Skill stack with the demo default dshHome (~/.dsh via resolveDshHome).
await ctx.plugin(SkillService, {})
await ctx.plugin(SkillLocal, {})

View File

@@ -16,10 +16,9 @@ import { createApiProxy } from './api-proxy.ts'
/** Options for startHost. */
export interface StartHostOptions {
/**
* Passed through to bootHost verbatim (persistenceRoot required +
* provider?/model?). Future host-level knobs (profile, log sink — any
* output added to the assembly MUST be switchable off here) land as
* additive fields.
* Passed through to bootHost verbatim. Future host-level knobs (profile,
* log sink — any output added to the assembly MUST be switchable off here)
* land as additive fields.
*/
boot: BootHostOptions
}

View File

@@ -1,4 +1,4 @@
import { mkdtempSync } from 'node:fs'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -15,11 +15,14 @@ import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/i
/** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
class ScriptedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: (StreamChunk[] | 'hang')[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
if (entry === 'hang') {
@@ -79,7 +82,12 @@ afterEach(async () => {
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
host = await startHost({
boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' },
boot: {
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
workspaceContext: false,
provider: 'scripted',
model: 'test-model',
},
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
return host
@@ -87,7 +95,10 @@ async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHos
describe('bootHost / startHost', () => {
it('falls back to the deepseek defaults and disposes idempotently', async () => {
const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')) })
const handle: HostHandle = await bootHost({
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')),
workspaceContext: false,
})
expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
expect(typeof handle.defaults.cwd).toBe('string')
await handle.dispose()
@@ -105,6 +116,41 @@ describe('bootHost / startHost', () => {
await first
host = undefined
})
it('routes workspace instructions through the assembled agent request prefix', async () => {
const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-'))
mkdirSync(join(workspace, '.git'))
writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n')
const adapter = new ScriptedAdapter([textResponse('done')])
host = await startHost({
boot: {
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')),
workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 },
provider: 'scripted',
model: 'test-model',
cwd: workspace,
},
})
host.ctx.llm.registerAdapter(['scripted'], adapter)
const { sessionId } = expectOk(await host.api.sessions.create(request({})))
const agent = host.ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(host.ctx, agent)
expectOk(await host.api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'go' }],
})))
await idle
const requestText = adapter.requests[0]?.messages
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n') ?? ''
expect(requestText).toContain('Instructions from: AGENTS.md')
expect(requestText).toContain('host-workspace-context-probe')
})
})
describe('host.describe', () => {
@@ -196,7 +242,9 @@ describe('sessions.prompt / cancel', () => {
describe('sessions.history', () => {
it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
const first = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
const first = await startHost({
boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
})
first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
const { sessionId } = expectOk(await first.api.sessions.create(request({})))
const agent = first.ctx.agents.get(sessionId) as Agent
@@ -205,7 +253,9 @@ describe('sessions.history', () => {
await idle
await first.dispose()
host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
host = await startHost({
boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
const [a, b] = await Promise.all([

View File

@@ -62,6 +62,9 @@
{
"path": "../../fs/tool-fs-search"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../llm/token-meter"
},