Merge PR #500 into CI optimization
This commit is contained in:
19
packages/client/ui-layout/README.md
Normal file
19
packages/client/ui-layout/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @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.
|
||||
|
||||
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. The `conversation` entry authorizes `conversation.empty` delegation through `children`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the layout shell manages browser viewing state; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Details open/width state is global** — it does not follow the session (arbitrated for P-I); the per-session keyed upgrade slot is reserved.
|
||||
- **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth.
|
||||
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.
|
||||
59
packages/client/ui-layout/package.json
Normal file
59
packages/client/ui-layout/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-layout",
|
||||
"description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
73
packages/client/ui-layout/src/client/AppFrame.module.css
Normal file
73
packages/client/ui-layout/src/client/AppFrame.module.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.frame {
|
||||
position: relative; /* anchors the drag handles, which straddle column borders */
|
||||
display: grid;
|
||||
grid-template-rows: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.sidebarCol {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
border-right: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.centerCol {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailsCol {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
|
||||
.frame[data-details-collapsed] .detailsCol {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
|
||||
centered on the column border via inline left, above column content. The
|
||||
visible pill (12x32 r10, riding the border at vertical center) is the figma
|
||||
Handle component; the hit strip stays wider than the pill. */
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
margin-left: -4px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
}
|
||||
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
149
packages/client/ui-layout/src/client/AppFrame.tsx
Normal file
149
packages/client/ui-layout/src/client/AppFrame.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Three-column shell frame. Owns the grid tracks (sidebar | center | details),
|
||||
* the two drag handles (pointer capture + rAF throttle), and the concession
|
||||
* chain (columns.ts). Column content arrives via props: `sidebar` is the
|
||||
* sidebar slot render, `children` is the session area (the shell mounts
|
||||
* SessionProvider there; its body renders {@link CenterColumn} and
|
||||
* {@link DetailsColumn}, which land as grid items because neither the provider
|
||||
* nor fragments emit DOM). Zero cordis imports — stores and actions are
|
||||
* injected as props.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { computeColumns } from './columns.ts'
|
||||
import type { PanelState } from './service.ts'
|
||||
import css from './AppFrame.module.css'
|
||||
|
||||
/** AppFrame props: injected viewing-state hooks, stable width actions, column content. */
|
||||
export interface AppFrameProps {
|
||||
/** Selector hook over the sidebar panel store. */
|
||||
useSidebar: SnapshotSelectorHook<PanelState>
|
||||
/** Selector hook over the details panel store. */
|
||||
useDetails: SnapshotSelectorHook<PanelState>
|
||||
/** Persist a sidebar width preference (service clamps). */
|
||||
setSidebarWidth: (px: number) => void
|
||||
/** Persist a details width preference (service clamps). */
|
||||
setDetailsWidth: (px: number) => void
|
||||
/** Sidebar column content (shell: renderSlot('sidebar')). */
|
||||
sidebar: ReactNode
|
||||
/** Session area (shell: SessionProvider whose body renders CenterColumn + DetailsColumn). */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Center column grid item; rendered inside the session provider's body. */
|
||||
export function CenterColumn(props: { children?: ReactNode }) {
|
||||
return <div className={css.centerCol}>{props.children}</div>
|
||||
}
|
||||
|
||||
/** Details column grid item; width 0 keeps the subtree mounted (never unmount on close). */
|
||||
export function DetailsColumn(props: { children?: ReactNode }) {
|
||||
return <div className={css.detailsCol}>{props.children}</div>
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
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 onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
origin.current = e.clientX
|
||||
latest.current = e.clientX
|
||||
callbacks.current.onStart()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
|
||||
latest.current = e.clientX
|
||||
frame.current ??= requestAnimationFrame(() => {
|
||||
frame.current = null
|
||||
callbacks.current.onDrag(latest.current - origin.current)
|
||||
})
|
||||
}, [])
|
||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
|
||||
callbacks.current.onDrag(latest.current - origin.current)
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.handle}
|
||||
style={{ left: props.left }}
|
||||
data-dragging={dragging || undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The three-column frame (see module doc). */
|
||||
export function AppFrame(props: AppFrameProps) {
|
||||
const sidebar = props.useSidebar((s) => s)
|
||||
const details = props.useDetails((s) => s)
|
||||
const frameRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewport, setViewport] = useState(() => window.innerWidth)
|
||||
|
||||
// Track the frame's own box (not the window): rAF-throttled ResizeObserver.
|
||||
useEffect(() => {
|
||||
const el = frameRef.current
|
||||
/* v8 ignore next -- the ref is always attached by effect time: the frame div renders unconditionally. */
|
||||
if (el === null) return
|
||||
let raf: number | null = null
|
||||
const observer = new ResizeObserver(() => {
|
||||
raf ??= requestAnimationFrame(() => {
|
||||
raf = null
|
||||
const width = el.getBoundingClientRect().width
|
||||
if (width > 0) setViewport(width)
|
||||
})
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cols = computeColumns(viewport, sidebar, details)
|
||||
const colsRef = useRef(cols)
|
||||
colsRef.current = cols
|
||||
|
||||
// The drag base is the rendered width captured at drag start (grabbing a
|
||||
// concession-clamped panel must not jump back to the persisted preference);
|
||||
// it stays frozen for the whole gesture so dx deltas do not compound.
|
||||
const sidebarBase = useRef(0)
|
||||
const detailsBase = useRef(0)
|
||||
const { setSidebarWidth, setDetailsWidth } = props
|
||||
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, [])
|
||||
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, [])
|
||||
const onSidebarDrag = useCallback((dx: number) => {
|
||||
setSidebarWidth(sidebarBase.current + dx)
|
||||
}, [setSidebarWidth])
|
||||
const onDetailsDrag = useCallback((dx: number) => {
|
||||
setDetailsWidth(detailsBase.current - dx)
|
||||
}, [setDetailsWidth])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frameRef}
|
||||
className={css.frame}
|
||||
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
|
||||
data-sidebar-collapsed={cols.sidebar === 0 || undefined}
|
||||
data-details-collapsed={cols.details === 0 || undefined}
|
||||
>
|
||||
<div className={css.sidebarCol}>{props.sidebar}</div>
|
||||
{props.children}
|
||||
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
|
||||
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
packages/client/ui-layout/src/client/columns.ts
Normal file
79
packages/client/ui-layout/src/client/columns.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Pure concession-chain column solver for the three-column AppFrame.
|
||||
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
|
||||
* details first, then sidebar, then auto-closing details (derived zero width —
|
||||
* persisted open/width preferences are never rewritten, so widening the window
|
||||
* restores them). Center absorbs any remaining deficit as the last resort.
|
||||
*/
|
||||
|
||||
/** Panel viewing state consumed by the solver (mirrors LayoutService PanelState). */
|
||||
export interface PanelInput { open: boolean; width: number }
|
||||
|
||||
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
|
||||
export interface Columns { sidebar: number; center: number; details: number }
|
||||
|
||||
// Contract-frozen geometry: the three-column concession chain's fixed points.
|
||||
/** Center column floor; only the final fallback may go below it. */
|
||||
export const CENTER_MIN = 640
|
||||
/** Sidebar drag clamp floor. */
|
||||
export const SIDEBAR_MIN = 240
|
||||
/** Sidebar drag clamp ceiling. */
|
||||
export const SIDEBAR_MAX = 420
|
||||
/** Sidebar width before any user drag. */
|
||||
export const SIDEBAR_DEFAULT = 300
|
||||
/** Details drag clamp floor. */
|
||||
export const DETAILS_MIN = 300
|
||||
/** Details drag clamp ceiling. */
|
||||
export const DETAILS_MAX = 520
|
||||
/** Details width before any user drag. */
|
||||
export const DETAILS_DEFAULT = 360
|
||||
|
||||
/**
|
||||
* Clamp a panel width into its contract range.
|
||||
* @param px - requested width.
|
||||
* @param min - range lower bound.
|
||||
* @param max - range upper bound.
|
||||
* @returns the clamped width.
|
||||
*/
|
||||
export function clampWidth(px: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.round(px)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve the three column widths for one viewport frame. Pure: no hysteresis —
|
||||
* the output is a function of (viewport, preferences) only, so recovery on
|
||||
* re-widening is automatic. After the auto-close step the details pressure is
|
||||
* gone, so the sidebar returns to its preferred width when it fits.
|
||||
* @param viewport - available frame width in px.
|
||||
* @param sidebar - sidebar preference (open flag + persisted width).
|
||||
* @param details - details preference (open flag + persisted width).
|
||||
* @returns resolved widths; details 0 means visually closed (never unmounted).
|
||||
*/
|
||||
export function computeColumns(viewport: number, sidebar: PanelInput, details: PanelInput): Columns {
|
||||
const want = (p: PanelInput, min: number, max: number): number =>
|
||||
p.open ? clampWidth(p.width, min, max) : 0
|
||||
const s0 = want(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
|
||||
const d0 = want(details, DETAILS_MIN, DETAILS_MAX)
|
||||
|
||||
// Step 1: everything fits at preferred widths.
|
||||
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
|
||||
|
||||
// Step 2: shrink details toward its minimum.
|
||||
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)
|
||||
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)
|
||||
return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 }
|
||||
}
|
||||
|
||||
// Step 5: center absorbs the deficit (may drop below CENTER_MIN).
|
||||
return { sidebar: s1, center: Math.max(0, viewport - s1 - d1), details: d1 }
|
||||
}
|
||||
81
packages/client/ui-layout/src/client/index.ts
Normal file
81
packages/client/ui-layout/src/client/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Layout plugin, browser half: three-column AppFrame plus ctx.layout, the
|
||||
* shell-level viewing-state authority (navigation + panel geometry).
|
||||
* Contract: api-contracts v3 section 5. apply provides the service and
|
||||
* defines the three top-level slots; frame components are exported for the
|
||||
* web shell's assembly (the shell resolves this surface from the loader
|
||||
* module table and closes the slots over its own scopedSlots).
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LayoutService } from './service.ts'
|
||||
|
||||
export { AppFrame, CenterColumn, DetailsColumn, type AppFrameProps } from './AppFrame.tsx'
|
||||
export {
|
||||
clampWidth, computeColumns,
|
||||
CENTER_MIN, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
|
||||
type Columns, type PanelInput,
|
||||
} from './columns.ts'
|
||||
export { LayoutService, type NavState, type PanelState, type ViewId } from './service.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
layout: LayoutService
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
|
||||
// children deliberately absent on every entry: the B-a validation layer
|
||||
// gates COMPONENT delegation, and no P-I slot component delegates —
|
||||
// conversation.empty is rendered by the shell's assembly closure, not
|
||||
// handed down by ConversationRoot (its slots face is ScopedSlots<never>).
|
||||
'conversation': { kind: 'single'; scope: 'session'; owner: ConvOwnerProps }
|
||||
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
|
||||
'conversation.empty': { kind: 'single'; scope: 'root'; owner: EmptyOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
// OwnerShare contracts — the render-side share the slot owner supplies at
|
||||
// renderSlot. Registrants IMPORT these and compose their full component props
|
||||
// as OwnerOf<K> & StandardOf<K> & OwnInjected (reference, never re-typed).
|
||||
|
||||
/** Sidebar owner share: the owner supplies nothing — everything arrives via inject. */
|
||||
export interface SidebarOwnerProps { slots?: never }
|
||||
|
||||
/** Conversation owner share. */
|
||||
export interface ConvOwnerProps { sessionId: SessionId }
|
||||
|
||||
/** Details owner share. */
|
||||
export interface DetailsOwnerProps { sessionId: SessionId }
|
||||
|
||||
/** Empty-state owner share (ui-conversation registers EmptyState here). */
|
||||
export interface EmptyOwnerProps { slots?: never }
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide ctx.layout and define the three top-level slots.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const layout = new LayoutService(ctx)
|
||||
ctx.effect(() => {
|
||||
const disposeService = ctx.reflect.provide('layout', layout)
|
||||
const disposeSidebar = ctx.slots.define('sidebar', { kind: 'single', scope: 'root' })
|
||||
const disposeConversation = ctx.slots.define('conversation', { kind: 'single', scope: 'session' })
|
||||
const disposeDetails = ctx.slots.define('details', { kind: 'single', scope: 'session' })
|
||||
const disposeEmpty = ctx.slots.define('conversation.empty', { kind: 'single', scope: 'root' })
|
||||
return () => {
|
||||
disposeEmpty()
|
||||
disposeDetails()
|
||||
disposeConversation()
|
||||
disposeSidebar()
|
||||
// provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget.
|
||||
void disposeService()
|
||||
layout.dispose()
|
||||
}
|
||||
}, 'ui-layout: service + slot definitions')
|
||||
}
|
||||
132
packages/client/ui-layout/src/client/service.ts
Normal file
132
packages/client/ui-layout/src/client/service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* LayoutService implementation: the shell-level viewing-state authority.
|
||||
* Four persisted stores (nav + two panels); actions clamp and validate. The
|
||||
* concession chain lives in columns.ts and never writes back into these
|
||||
* stores — persisted preferences survive window shrinking.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
|
||||
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
|
||||
} from './columns.ts'
|
||||
|
||||
/** Active conversation view id (keys merged into ConversationViewMap by ui-conversation). */
|
||||
export type ViewId = string
|
||||
|
||||
/** Navigation state: selected session and per-session active view. */
|
||||
export interface NavState { sessionId?: SessionId; viewFor: Record<SessionId, ViewId> }
|
||||
|
||||
/** Panel viewing state: open flag plus persisted width. */
|
||||
export interface PanelState { open: boolean; width: number }
|
||||
|
||||
/** Shell-level viewing-state authority (zustand + persist). */
|
||||
export class LayoutService {
|
||||
/** Navigation state store. */
|
||||
readonly current: SnapshotStore<NavState>
|
||||
/** Sidebar panel store (default 300, clamp [240, 420]). */
|
||||
readonly sidebar: SnapshotStore<PanelState>
|
||||
/** Details panel store (default 360, clamp [300, 520]; P-I global, not per-session). */
|
||||
readonly details: SnapshotStore<PanelState>
|
||||
|
||||
#sessions: SessionsService
|
||||
#unprune: () => void
|
||||
|
||||
/**
|
||||
* @param ctx - root context (resolves the sessions service for open validation and list pruning).
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
|
||||
// while the client/host `sessions` declaration collision awaits
|
||||
// arbitration (see the runtime package's Context merge note).
|
||||
const sessions = ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('layout: sessions service unavailable')
|
||||
this.#sessions = sessions
|
||||
this.current = createSnapshotStore<NavState>(
|
||||
{ viewFor: {} },
|
||||
{ persist: { name: 'dsh.layout.nav' } })
|
||||
this.sidebar = createSnapshotStore<PanelState>(
|
||||
{ open: true, width: SIDEBAR_DEFAULT },
|
||||
{ persist: { name: 'dsh.layout.sidebar' } })
|
||||
this.details = createSnapshotStore<PanelState>(
|
||||
{ open: false, width: DETAILS_DEFAULT },
|
||||
{ persist: { name: 'dsh.layout.details' } })
|
||||
// Prune is one-directional: list removals clear keyed viewing state, and a
|
||||
// selection pointing at a removed session falls back to the empty state.
|
||||
this.#unprune = sessions.list.subscribe(() => { this.#prune() })
|
||||
}
|
||||
|
||||
/** Drop the sessions.list subscription (plugin teardown). */
|
||||
dispose(): void {
|
||||
this.#unprune()
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const byId = this.#sessions.list.getSnapshot().byId
|
||||
const nav = this.current.getSnapshot()
|
||||
// Object.keys erases the branded key type; these entries were written with SessionId keys.
|
||||
const viewKeys = Object.keys(nav.viewFor) as SessionId[]
|
||||
const staleView = viewKeys.some(id => byId[id] === undefined)
|
||||
const staleCurrent = nav.sessionId !== undefined && byId[nav.sessionId] === undefined
|
||||
if (!staleView && !staleCurrent) return
|
||||
this.current.update((draft) => {
|
||||
// Rebuild instead of dynamic delete: viewFor is a plain keyed record and
|
||||
// the survivors are the entries whose session still exists.
|
||||
draft.viewFor = Object.fromEntries(
|
||||
Object.entries(draft.viewFor).filter(([id]) => byId[id as SessionId] !== undefined))
|
||||
if (draft.sessionId !== undefined && byId[draft.sessionId] === undefined) delete draft.sessionId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session. Unknown ids fail loud instead of navigating nowhere.
|
||||
* @param id - session id (must exist in sessions.list).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
if (this.#sessions.list.getSnapshot().byId[id] === undefined) {
|
||||
throw new Error(`layout.open: unknown session ${id}`)
|
||||
}
|
||||
this.current.update((draft) => { draft.sessionId = id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a view for a session.
|
||||
* @param sessionId - session id.
|
||||
* @param view - view id.
|
||||
*/
|
||||
openView(sessionId: SessionId, view: ViewId): void {
|
||||
this.current.update((draft) => { draft.viewFor[sessionId] = view })
|
||||
}
|
||||
|
||||
/** Toggle the sidebar panel. */
|
||||
toggleSidebar(): void {
|
||||
this.sidebar.update((draft) => { draft.open = !draft.open })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sidebar width (clamped to [240, 420]).
|
||||
* @param px - width in pixels.
|
||||
*/
|
||||
setSidebarWidth(px: number): void {
|
||||
this.sidebar.update((draft) => { draft.width = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) })
|
||||
}
|
||||
|
||||
/** Open the details panel. */
|
||||
openDetails(): void {
|
||||
this.details.update((draft) => { draft.open = true })
|
||||
}
|
||||
|
||||
/** Close the details panel. */
|
||||
closeDetails(): void {
|
||||
this.details.update((draft) => { draft.open = false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the details width (clamped to [300, 520]).
|
||||
* @param px - width in pixels.
|
||||
*/
|
||||
setDetailsWidth(px: number): void {
|
||||
this.details.update((draft) => { draft.width = clampWidth(px, DETAILS_MIN, DETAILS_MAX) })
|
||||
}
|
||||
}
|
||||
6
packages/client/ui-layout/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-layout/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
10
packages/client/ui-layout/src/index.ts
Normal file
10
packages/client/ui-layout/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Layout plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Contract: api-contracts
|
||||
* v3 sections 0.3 and 5.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the layout plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-layout/src/invariant.ts
Normal file
31
packages/client/ui-layout/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-layout`.
|
||||
* @module @deepseek-ai/dsh-client-ui-layout/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-layout-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: shell viewing-state stores (zustand+persist) behind
|
||||
* ctx.layout — it emits no cordis events; clamp/prune/concession-chain
|
||||
* sequencing is asserted directly by this package's columns and service specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
208
packages/client/ui-layout/tests/app-frame.spec.tsx
Normal file
208
packages/client/ui-layout/tests/app-frame.spec.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AppFrame interaction spec: drag sequences (pointer capture + rAF flush),
|
||||
* concession response to viewport change, details stays mounted at zero
|
||||
* width. jsdom has no layout engine, so the frame width comes from a mocked
|
||||
* getBoundingClientRect and resizes are driven through the ResizeObserver
|
||||
* stub; assertions read the inline grid template.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
/** Observer stub: captures the callback so tests can fire resizes manually. */
|
||||
let fireResize: (() => void) | null = null
|
||||
class ResizeObserverStub {
|
||||
#cb: ResizeObserverCallback
|
||||
constructor(cb: ResizeObserverCallback) { this.#cb = cb }
|
||||
observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } }
|
||||
unobserve(): void {}
|
||||
disconnect(): void { fireResize = null }
|
||||
}
|
||||
|
||||
let frameWidth = 1920
|
||||
|
||||
function mountFrame() {
|
||||
window.innerWidth = frameWidth // first-render viewport source before the observer fires
|
||||
const sidebar = createSnapshotStore<PanelState>({ open: true, width: 300 })
|
||||
const details = createSnapshotStore<PanelState>({ open: true, width: 360 })
|
||||
const utils = render(
|
||||
<AppFrame
|
||||
useSidebar={sidebar.useSelector}
|
||||
useDetails={details.useSelector}
|
||||
setSidebarWidth={(px) => { sidebar.update((d) => { d.width = clampWidth(px, 240, 420) }) }}
|
||||
setDetailsWidth={(px) => { details.update((d) => { d.width = clampWidth(px, 300, 520) }) }}
|
||||
sidebar={<div data-testid="sidebar-content" />}
|
||||
>
|
||||
<CenterColumn><div data-testid="center-content" /></CenterColumn>
|
||||
<DetailsColumn><div data-testid="details-content" /></DetailsColumn>
|
||||
</AppFrame>,
|
||||
)
|
||||
const frame = utils.container.firstElementChild as HTMLElement
|
||||
return { sidebar, details, frame, ...utils }
|
||||
}
|
||||
|
||||
function tracks(frame: HTMLElement): number[] {
|
||||
const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
|
||||
if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
|
||||
return [Number(m[1]), Number(m[2])]
|
||||
}
|
||||
|
||||
function drag(handle: Element, fromX: number, toX: number): void {
|
||||
const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
|
||||
const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
|
||||
const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
|
||||
act(() => { handle.dispatchEvent(down) })
|
||||
act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
|
||||
act(() => { handle.dispatchEvent(up) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
frameWidth = 1920
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
|
||||
window.innerWidth = frameWidth
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
// jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
|
||||
const captured = new WeakSet<Element>()
|
||||
Element.prototype.setPointerCapture = function () { captured.add(this) }
|
||||
Element.prototype.releasePointerCapture = function () { captured.delete(this) }
|
||||
Element.prototype.hasPointerCapture = function () { return captured.has(this) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('AppFrame', () => {
|
||||
it('renders three tracks from panel state', () => {
|
||||
const { frame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
})
|
||||
|
||||
it('sidebar drag widens through rAF-batched pointer moves', () => {
|
||||
const { frame } = mountFrame()
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[0]!, 300, 350)
|
||||
expect(tracks(frame)[0]).toBe(350)
|
||||
})
|
||||
|
||||
it('details drag widens leftward (negative dx grows the panel)', () => {
|
||||
const { frame } = mountFrame()
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 1560, 1500)
|
||||
expect(tracks(frame)[1]).toBe(420)
|
||||
})
|
||||
|
||||
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
|
||||
const { frame, details } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
|
||||
expect(details.getSnapshot().width).toBe(300)
|
||||
})
|
||||
|
||||
it('details column stays mounted at zero width', () => {
|
||||
const { frame, details, getByTestId } = mountFrame()
|
||||
act(() => { details.update((d) => { d.open = false }) })
|
||||
expect(tracks(frame)).toEqual([300, 0])
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
})
|
||||
|
||||
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
frameWidth = 1920
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
})
|
||||
|
||||
it('drag handles disappear for collapsed columns', () => {
|
||||
const { frame, details, sidebar } = mountFrame()
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
|
||||
act(() => { details.update((d) => { d.open = false }) })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
act(() => { sidebar.update((d) => { d.open = false }) })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppFrame — guard branches', () => {
|
||||
it('pointer moves without capture are ignored (no width write)', () => {
|
||||
const { frame, sidebar } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
const before = sidebar.getSnapshot().width
|
||||
// Move + up without a preceding pointerdown: hasPointerCapture is false.
|
||||
act(() => {
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
|
||||
vi.advanceTimersByTime(20)
|
||||
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
|
||||
})
|
||||
expect(sidebar.getSnapshot().width).toBe(before)
|
||||
})
|
||||
|
||||
it('two moves inside one frame coalesce through the pending rAF', () => {
|
||||
const { frame, sidebar } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => {
|
||||
// Two moves before the frame flushes: the second must ride the pending
|
||||
// rAF (frame.current ??= guard), and the flush sees the latest x.
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
|
||||
vi.advanceTimersByTime(20)
|
||||
})
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
|
||||
expect(sidebar.getSnapshot().width).toBe(340)
|
||||
})
|
||||
|
||||
it('pointerup with a pending rAF cancels it and commits the final position', () => {
|
||||
const { frame, sidebar } = mountFrame()
|
||||
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
|
||||
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
|
||||
act(() => {
|
||||
handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
|
||||
// No timer advance: the rAF is still pending when pointerup arrives.
|
||||
handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
|
||||
})
|
||||
expect(sidebar.getSnapshot().width).toBe(360)
|
||||
})
|
||||
|
||||
it('zero-width resize reports are ignored (display:none window)', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 0
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
// Track template still reflects the last non-zero viewport.
|
||||
expect(tracks(frame)).toEqual([300, 360])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppFrame — unmount with an in-flight resize frame', () => {
|
||||
it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
|
||||
const { unmount } = mountFrame()
|
||||
frameWidth = 800
|
||||
act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
|
||||
unmount()
|
||||
// Flushing after unmount must be a no-op (the frame was cancelled).
|
||||
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('double resize inside one frame rides the pending rAF (?"?= guard)', () => {
|
||||
const { frame } = mountFrame()
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([300, 310])
|
||||
})
|
||||
})
|
||||
73
packages/client/ui-layout/tests/apply.spec.ts
Normal file
73
packages/client/ui-layout/tests/apply.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
// Client apply wiring: ctx.layout provided, the four layout-owned slots
|
||||
// defined, teardown cascades (service unprovided + slot specs removed + list
|
||||
// subscription dropped). Node half and the invariant companion ride along —
|
||||
// they are one-line surfaces the aggregate coverage gate still requires
|
||||
// exercised.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
|
||||
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
ctx.provide('sessions', { list })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
describe('ui-layout client apply', () => {
|
||||
it('declares its service dependencies', () => {
|
||||
expect(inject).toContain('slots')
|
||||
})
|
||||
|
||||
it('provides ctx.layout and defines the four layout-owned slots', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: ['slots'], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
|
||||
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('teardown unwinds service, slot specs, and the prune subscription', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: ['slots'], apply })
|
||||
await fiber.await()
|
||||
const layout = ctx.get('layout') as LayoutService
|
||||
const disposeSpy = vi.spyOn(layout, 'dispose')
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('layout')).toBeUndefined()
|
||||
expect(slots.spec('sidebar')).toBeUndefined()
|
||||
expect(slots.spec('conversation.empty')).toBeUndefined()
|
||||
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('node half + invariant companion', () => {
|
||||
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('invariant companion registers under the package name', async () => {
|
||||
const register = vi.fn().mockReturnValue(() => {})
|
||||
const ctx = { invariants: { register } } as never
|
||||
// The /invariant subpath types live in lib/types (build product); assert
|
||||
// the surface so the call stays typed where lint runs without a build.
|
||||
const dispose = await (invariant as { apply: (ctx: never) => Promise<() => void> }).apply(ctx)
|
||||
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-client-ui-layout', expect.any(Function))
|
||||
// The installer is the declared no-op — calling it must not throw.
|
||||
expect(() => { (register.mock.calls[0]![1] as (c: never) => void)(undefined as never) }).not.toThrow()
|
||||
expect(dispose).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
100
packages/client/ui-layout/tests/columns.spec.ts
Normal file
100
packages/client/ui-layout/tests/columns.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CENTER_MIN, clampWidth, computeColumns,
|
||||
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
|
||||
} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
const open = (width: number) => ({ open: true, width })
|
||||
const closed = (width: number) => ({ open: false, width })
|
||||
|
||||
describe('clampWidth', () => {
|
||||
it('clamps into the range and rounds', () => {
|
||||
expect(clampWidth(250.4, 240, 420)).toBe(250)
|
||||
expect(clampWidth(100, 240, 420)).toBe(240)
|
||||
expect(clampWidth(9999, 240, 420)).toBe(420)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeColumns', () => {
|
||||
it('step 1: everything fits at preferred widths', () => {
|
||||
const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
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('preferences beyond the clamp range are clamped before solving', () => {
|
||||
const cols = computeColumns(1920, open(9999), open(1))
|
||||
expect(cols.sidebar).toBe(420)
|
||||
expect(cols.details).toBe(300)
|
||||
})
|
||||
|
||||
it('step 2: details shrinks first, center pinned at min', () => {
|
||||
// 300 + 360 + 640 = 1300 > 1250; details concedes to 1250-300-640 = 310.
|
||||
const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 310 })
|
||||
})
|
||||
|
||||
it('boundary: exactly at the step-1/step-2 seam', () => {
|
||||
const cols = computeColumns(300 + 360 + CENTER_MIN, open(300), open(360))
|
||||
expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 360 })
|
||||
const one = computeColumns(300 + 360 + CENTER_MIN - 1, open(300), open(360))
|
||||
expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 })
|
||||
})
|
||||
|
||||
it('step 3: sidebar concedes after details hits its min', () => {
|
||||
// details floor 300: sidebar = 1220-300-640 = 280.
|
||||
const cols = computeColumns(1220, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: DETAILS_MIN })
|
||||
})
|
||||
|
||||
it('step 4: details auto-closes when both panels are at min and center still starves', () => {
|
||||
// 240 + 300 + 640 = 1180 > 1100 → details 0; sidebar preference (300) fits: 1100-300 = 800 center.
|
||||
const cols = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 300, center: 800, details: 0 })
|
||||
})
|
||||
|
||||
it('step 4 keeps squeezing sidebar when preference no longer fits', () => {
|
||||
// 900 < 300+640: sidebar = max(240, 900-640) = 260.
|
||||
const cols = computeColumns(900, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: 260, center: CENTER_MIN, details: 0 })
|
||||
})
|
||||
|
||||
it('step 5: center absorbs the deficit as last resort (details closed)', () => {
|
||||
// 700 < 240+640: sidebar floors at 240, center takes 460 < CENTER_MIN.
|
||||
const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT))
|
||||
expect(cols).toEqual({ sidebar: SIDEBAR_MIN, center: 460, details: 0 })
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
it('tiny viewport: both panels yield everything to center', () => {
|
||||
const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(cols.details).toBe(0)
|
||||
expect(cols.sidebar).toBe(SIDEBAR_MIN)
|
||||
expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_MIN))
|
||||
})
|
||||
|
||||
it('recovery is pure: re-widening restores preferred widths untouched', () => {
|
||||
const squeezed = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(squeezed.details).toBe(0)
|
||||
const restored = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
|
||||
expect(restored.details).toBe(DETAILS_DEFAULT)
|
||||
expect(restored.sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
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).
|
||||
expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
|
||||
.toEqual({ sidebar: 0, center: 500, details: 0 })
|
||||
})
|
||||
})
|
||||
138
packages/client/ui-layout/tests/service.spec.ts
Normal file
138
packages/client/ui-layout/tests/service.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* LayoutService over the real snapshot-store engine (persist rides jsdom
|
||||
* localStorage). ctx is faked down to the one surface the service reads:
|
||||
* ctx.sessions.list as a real store, so prune subscriptions are exercised
|
||||
* for real.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LayoutService, DETAILS_DEFAULT, SIDEBAR_DEFAULT } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
function makeCtx() {
|
||||
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
|
||||
// The service resolves sessions via ctx.get (typed merge suspended, see service).
|
||||
const ctx = { get: (name: string) => (name === 'sessions' ? { list } : undefined) } as unknown as Context
|
||||
return { ctx, list }
|
||||
}
|
||||
|
||||
/** Test-side brand: specs mint ids the wire would normally brand. */
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
const summary = (id: SessionId) => ({ id, title: id as string, running: false, updatedAt: 1 })
|
||||
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('LayoutService', () => {
|
||||
it('defaults: sidebar open 300, details closed 360, empty nav', () => {
|
||||
const svc = new LayoutService(makeCtx().ctx)
|
||||
expect(svc.sidebar.getSnapshot()).toEqual({ open: true, width: SIDEBAR_DEFAULT })
|
||||
expect(svc.details.getSnapshot()).toEqual({ open: false, width: DETAILS_DEFAULT })
|
||||
expect(svc.current.getSnapshot()).toEqual({ viewFor: {} })
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('open validates against sessions.list and selects', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
expect(() => { svc.open(sid('nope')) }).toThrow(/unknown session/)
|
||||
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
|
||||
svc.open(sid('s1'))
|
||||
expect(svc.current.getSnapshot().sessionId).toBe('s1')
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('width setters clamp into contract ranges', () => {
|
||||
const svc = new LayoutService(makeCtx().ctx)
|
||||
svc.setSidebarWidth(10)
|
||||
expect(svc.sidebar.getSnapshot().width).toBe(240)
|
||||
svc.setSidebarWidth(10_000)
|
||||
expect(svc.sidebar.getSnapshot().width).toBe(420)
|
||||
svc.setDetailsWidth(10)
|
||||
expect(svc.details.getSnapshot().width).toBe(300)
|
||||
svc.setDetailsWidth(10_000)
|
||||
expect(svc.details.getSnapshot().width).toBe(520)
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('toggle and open/close flip flags without touching widths', () => {
|
||||
const svc = new LayoutService(makeCtx().ctx)
|
||||
svc.toggleSidebar()
|
||||
expect(svc.sidebar.getSnapshot()).toEqual({ open: false, width: SIDEBAR_DEFAULT })
|
||||
svc.openDetails()
|
||||
expect(svc.details.getSnapshot().open).toBe(true)
|
||||
svc.closeDetails()
|
||||
expect(svc.details.getSnapshot().open).toBe(false)
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('prune clears viewFor entries and the current selection of removed sessions', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => {
|
||||
d.ids.push(sid('s1'), sid('s2'))
|
||||
d.byId[sid('s1')] = summary(sid('s1'))
|
||||
d.byId[sid('s2')] = summary(sid('s2'))
|
||||
})
|
||||
svc.open(sid('s1'))
|
||||
svc.openView(sid('s1'), 'chat')
|
||||
svc.openView(sid('s2'), 'chat')
|
||||
list.update((d) => { d.ids = [sid('s2')]; d.byId = { [sid('s2')]: d.byId[sid('s2')]! } })
|
||||
expect(svc.current.getSnapshot().sessionId).toBeUndefined()
|
||||
expect(svc.current.getSnapshot().viewFor).toEqual({ s2: 'chat' })
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('prune leaves untouched state alone (no gratuitous store writes)', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
|
||||
svc.open(sid('s1'))
|
||||
const before = svc.current.getSnapshot()
|
||||
list.update((d) => { d.byId[sid('s1')] = { ...d.byId[sid('s1')]!, title: 'renamed' } })
|
||||
expect(svc.current.getSnapshot()).toBe(before)
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it('persists panel state and nav across instances (fresh service, same storage)', () => {
|
||||
const first = new LayoutService(makeCtx().ctx)
|
||||
first.setSidebarWidth(320)
|
||||
first.openDetails()
|
||||
first.dispose()
|
||||
const second = new LayoutService(makeCtx().ctx)
|
||||
expect(second.sidebar.getSnapshot().width).toBe(320)
|
||||
expect(second.details.getSnapshot().open).toBe(true)
|
||||
second.dispose()
|
||||
})
|
||||
|
||||
it('dispose stops pruning', () => {
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
|
||||
svc.open(sid('s1'))
|
||||
svc.dispose()
|
||||
list.update((d) => { d.ids = []; d.byId = {} })
|
||||
expect(svc.current.getSnapshot().sessionId).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LayoutService — construction and prune edge branches', () => {
|
||||
it('throws loud when the sessions service is absent', () => {
|
||||
const bare = { get: () => undefined } as unknown as Context
|
||||
expect(() => new LayoutService(bare)).toThrow(/sessions service unavailable/)
|
||||
})
|
||||
|
||||
it('prunes stale viewFor while the current selection stays valid', () => {
|
||||
// Covers the prune branch where staleView holds but staleCurrent does not.
|
||||
const { ctx, list } = makeCtx()
|
||||
const svc = new LayoutService(ctx)
|
||||
list.update((d) => { d.ids.push(sid('s1'), sid('s2')); d.byId[sid('s1')] = summary(sid('s1')); d.byId[sid('s2')] = summary(sid('s2')) })
|
||||
svc.open(sid('s1'))
|
||||
svc.openView(sid('s2'), 'chat')
|
||||
list.update((d) => { d.ids = [sid('s1')]; d.byId = { [sid('s1')]: d.byId[sid('s1')]! } })
|
||||
expect(svc.current.getSnapshot().sessionId).toBe('s1')
|
||||
expect(svc.current.getSnapshot().viewFor).toEqual({})
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
37
packages/client/ui-layout/tsconfig.json
Normal file
37
packages/client/ui-layout/tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"jsx": "react-jsx",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-layout/tsdown.config.ts
Normal file
3
packages/client/ui-layout/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-layout', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user