refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -2,7 +2,7 @@
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. No entry declares `children` (declaring it requires the registered component to carry the slots face): no P-I slot component delegates — `conversation.empty` is rendered by the shell's assembly closure, not handed down by ConversationRoot.
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'.
The export surface is the cross-package contract only: the AppFrame trio (+ `AppFrameProps`) consumed by the web shell's assembly, `LayoutService` with its store shapes (`NavState`/`PanelState`/`ViewId`), and the OwnerShare contracts. The concession-chain solver (`computeColumns`) and its geometry constants are package-internal; tests import them from `/src`.

View File

@@ -1,43 +1,35 @@
/**
* 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.
* Three-column shell frame, registered into the built-in 'root' slot (the web
* shell renders only 'root'). Owns the grid tracks (sidebar | center |
* details), the drag handles (pointer capture + rAF throttle), the concession
* chain (columns.ts), and the child-slot render decisions: the sidebar slot
* renders HERE with live parameters from the concession solve, and the
* session pair renders under the framework-wired SessionProvider (render-prop
* form; session slots get sessionId as a framework-standard prop, so the
* owner shares stay empty). Pure component: everything arrives through the
* four prop shares — zero cordis imports, zero self-made hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { SessionProvider } from '@deepseek-ai/dsh-client-web-react'
import { computeColumns } from './columns.ts'
import type { PanelState } from './service.ts'
import type { createLayoutStore } from './stores.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
}
/** Full composed props: runtime share + child-slot render share + store share (no business face). */
export type AppFrameProps =
& PropsRuntime<'root'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'>
& PropsStore<ReturnType<typeof createLayoutStore>>
/** Center column grid item; rendered inside the session provider's body. */
export function CenterColumn(props: { children?: ReactNode }) {
/** Center column grid item (session-body building block). */
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 }) {
function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div>
}
@@ -87,9 +79,8 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
}
/** The three-column frame (see module doc). */
export function AppFrame(props: AppFrameProps) {
const sidebar = props.useSidebar((s) => s)
const details = props.useDetails((s) => s)
export function AppFrame({ useStore, actions, renderSlot }: AppFrameProps) {
const panels = useStore((s) => s)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
@@ -113,7 +104,7 @@ export function AppFrame(props: AppFrameProps) {
}
}, [])
const cols = computeColumns(viewport, sidebar, details)
const cols = computeColumns(viewport, panels.sidebar, panels.details)
const colsRef = useRef(cols)
colsRef.current = cols
@@ -122,15 +113,14 @@ export function AppFrame(props: AppFrameProps) {
// 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])
actions.setSidebar(sidebarBase.current + dx)
}, [actions])
const onDetailsDrag = useCallback((dx: number) => {
setDetailsWidth(detailsBase.current - dx)
}, [setDetailsWidth])
actions.setDetails(detailsBase.current - dx)
}, [actions])
return (
<div
@@ -140,8 +130,28 @@ export function AppFrame(props: AppFrameProps) {
data-sidebar-collapsed={cols.sidebar === 0 || undefined}
data-details-collapsed={cols.details === 0 || undefined}
>
<div className={css.sidebarCol}>{props.sidebar}</div>
{props.children}
<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 })}
</div>
<SessionProvider
empty={() => (
<>
<CenterColumn>{renderSlot('conversation.empty', {})}</CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* sessionId is a framework-standard prop on session slots — the owner passes nothing. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
</div>

View File

@@ -2,13 +2,11 @@
* 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
* 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).
*/
/** 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 }
@@ -44,16 +42,16 @@ export function clampWidth(px: number, min: number, max: number): number {
* 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.
* Preferences re-clamp here because they cross a durable boundary
* (localStorage rehydration may carry stale ranges).
* @param viewport - available frame width in px.
* @param sidebar - sidebar preference (open flag + persisted width).
* @param details - details preference (open flag + persisted width).
* @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).
*/
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)
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
const s0 = sidebar === 0 ? 0 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = details === 0 ? 0 : clampWidth(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 }

View File

@@ -1,23 +1,23 @@
/**
* 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).
* Layout plugin, browser half: one register() call contributes AppFrame into
* the runtime's built-in 'root' slot and, in the same breath, declares the
* four child slots (declaration = exclusive render authority), seats the
* layout store (panel geometry), and wires the panel-action service face.
* ctx.layout is the cross-plugin panel-action seam; navigation state lives
* with the runtime sessions service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { PanelActions } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutService } from './service.ts'
// Contract surface only (export-convergence rule: cross-package consumers
// keep a symbol exported; test-only/package-internal symbols live off /src).
// AppFrame trio + AppFrameProps: consumed by the web shell's assembly closure.
// LayoutService: the ctx.layout service class (consumers type against it).
// PanelState rides AppFrameProps' hooks; NavState/ViewId are service-store
// shapes referenced through LayoutService's members.
export { AppFrame, CenterColumn, DetailsColumn, type AppFrameProps } from './AppFrame.tsx'
export { LayoutService, type NavState, type PanelState, type ViewId } from './service.ts'
// OwnerShare contracts below are the render-side halves registrants compose
// against; the frame components and the store factory are package-internal.
export { LayoutService } from './service.ts'
declare module 'cordis' {
interface Context {
@@ -27,11 +27,11 @@ declare module 'cordis' {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
// The 'root' entry itself is the runtime's built-in slot (declared
// there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session slots carry no
// owner share: the framework injects sessionId as a standard prop.
'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 }
@@ -40,43 +40,67 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// 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).
// through the four-share intersection (PropsRuntime & PropsRenderSlots &
// PropsStore & I). Session owner shares stay literally empty: a phantom
// `sessionId?: never` would intersect with the framework's mandatory
// SessionStandardProps.sessionId and collapse the composed props to never —
// the anti-smuggling guard is mutually exclusive with standard injection, so
// the standard member's own type is the only guard on standard keys. Phantom
// members remain fine on keys the standards never claim (EmptyOwnerProps).
/** Sidebar owner share: the owner supplies nothing — everything arrives via inject. */
export interface SidebarOwnerProps { slots?: never }
/** 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. */
collapsed: boolean
/** Rendered column width in px (0 when collapsed). */
width: number
}
/** Conversation owner share. */
export interface ConvOwnerProps { sessionId: SessionId }
/** Conversation owner share: empty — sessionId arrives as a framework-standard prop. */
export interface ConvOwnerProps {}
/** Details owner share. */
export interface DetailsOwnerProps { sessionId: SessionId }
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
export interface DetailsOwnerProps {}
/** Empty-state owner share (ui-conversation registers EmptyState here). */
export interface EmptyOwnerProps { slots?: never }
export interface EmptyOwnerProps { children?: 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.
* Client plugin body: provide ctx.layout, then one register() call — AppFrame
* into 'root' with the four child-slot declarations, the layout store seat,
* and the inject hook that hands the store's bound actions to the service.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const layout = new LayoutService(ctx)
export function apply(ctx: ClientContext): void {
const layout = new LayoutService()
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' })
const disposeRegistration = ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
// Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props.
store: createLayoutStore,
// No business face for the frame (I = {}): the hook's job is the
// assembly side effect wiring the entry's bound actions into the
// cross-plugin service seam.
inject: (actions: PanelActions) => {
layout.attachPanels(actions)
return {}
},
}, AppFrame)
return () => {
disposeEmpty()
disposeDetails()
disposeConversation()
disposeSidebar()
disposeRegistration()
// provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget.
void disposeService()
layout.dispose()
}
}, 'ui-layout: service + slot definitions')
}, 'ui-layout: service + root registration')
}

View File

@@ -1,132 +1,54 @@
/**
* 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.
* LayoutService: the cross-plugin panel-action face behind ctx.layout.
* Panel geometry itself lives in the root entry's layout store (stores.ts);
* the current-session selection lives with the runtime sessions service, and
* the per-session active view dissolved into ui-conversation's session store
* (its only consumer). What remains here is the seam other plugins'
* apply worlds reach for panel transitions (sidebar toggle from ui-sidebar,
* details open/close from ui-conversation) — writes stay inside the store's
* declared action set, delivered as the registration's bound actions.
*/
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'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { createLayoutStore } from './stores.ts'
/** Active conversation view id (keys merged into ConversationViewMap by ui-conversation). */
export type ViewId = string
/** The layout store's bound action set (framework-baked, draft params peeled). */
export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
/** 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). */
/** Cross-plugin panel-action face (ctx.layout). */
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
#panels: PanelActions | undefined
/**
* @param ctx - root context (resolves the sessions service for open validation and list pruning).
* Adopt the root entry's bound store actions. Called from the root
* registration's inject hook (a sanctioned assembly side effect), so the
* face is live from the entry's first render; on entry re-register the
* fresh actions overwrite the stale set.
* @param actions - bound actions of the entry's layout store instance.
*/
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() })
attachPanels(actions: PanelActions): void {
this.#panels = actions
}
/** 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. */
/** Toggle the sidebar panel (closed ⟷ contract default width). */
toggleSidebar(): void {
this.sidebar.update((draft) => { draft.open = !draft.open })
this.#require().toggleSidebar()
}
/**
* 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. */
/** Open the details panel (no-op when already open). */
openDetails(): void {
this.details.update((draft) => { draft.open = true })
this.#require().openDetails()
}
/** Close the details panel. */
closeDetails(): void {
this.details.update((draft) => { draft.open = false })
this.#require().closeDetails()
}
/**
* 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) })
#require(): PanelActions {
// Callers are UI gestures, which cannot fire before the root entry
// rendered (the inject hook runs in its first render) — reaching this
// unwired is a boot-order bug, not a race to tolerate.
if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)')
return this.#panels
}
}

View File

@@ -0,0 +1,36 @@
/**
* The root entry's layout store: panel geometry as plain widths in px
* (0 = closed), persisted across reloads. Module level exports the factory
* only — a module-level handle would pin the store's identity in the module
* cache (a de-facto singleton surviving plugin reloads). register() receives
* the factory (exclusive use: the framework instantiates per entry), AppFrame
* derives its PropsStore share from the return type, and the service face
* receives the bound actions through the registration's inject hook.
*/
import { defineStore } from '@deepseek-ai/dsh-client-web-react'
import {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/**
* Create the layout panel store handle. The persisted preference IS the
* width, so closing a panel forgets its drag width — reopening restores the
* contract default. Actions are the complete write set: drag writes clamp
* into the panel's contract range and never cross the open/closed line;
* open/close transitions write 0 / the default explicitly.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore() {
return defineStore({
init: () => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
persist: 'dsh.layout.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 },
openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT },
closeDetails: (d) => { d.details = 0 },
},
})
}

View File

@@ -1,16 +1,35 @@
// @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.
* AppFrame interaction spec under the four-share props form: real layout
* store instance (createLayoutStore().create() — the test-sanctioned engine
* path), a recording renderSlot stub, and a render-prop SessionProvider stub
* (the real one is framework-wired to the renderer host; its own behavior is
* web-react's spec territory). Drag sequences (pointer capture + rAF flush),
* concession response to viewport change, and details staying mounted at
* zero width are the preserved behavior assertions. jsdom has no layout
* engine, so the frame width comes from a mocked getBoundingClientRect and
* resizes are driven through the ResizeObserver stub.
*/
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/src/client/columns.ts'
import type { ReactNode } 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 { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
// Session-mode switch for the SessionProvider stub (hoisted above the mock).
const sessionMode = vi.hoisted(() => ({ current: true }))
vi.mock('@deepseek-ai/dsh-client-web-react', async (importOriginal) => {
const mod = await importOriginal<object>()
return {
...mod,
// Render-prop contract stub: session mode runs children(id), empty mode
// runs the empty branch — the frame must work against exactly this shape.
SessionProvider: ({ children, empty }: { children: (id: string) => ReactNode; empty?: () => ReactNode }) =>
sessionMode.current ? <>{children('s-test')}</> : <>{empty?.() ?? null}</>,
}
})
/** Observer stub: captures the callback so tests can fire resizes manually. */
let fireResize: (() => void) | null = null
@@ -26,22 +45,27 @@ 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 instance = createLayoutStore().create()
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
const slotCalls: { key: string; props: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, props: owner })
if (key === 'sidebar') return <div data-testid="sidebar-content" />
if (key === 'conversation') return <div data-testid="center-content" />
if (key === 'details') return <div data-testid="details-content" />
return <div data-testid="empty-content" />
}) as AppFrameProps['renderSlot']
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
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>,
useStore={instance.useSelector}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
/>,
)
const frame = utils.container.firstElementChild as HTMLElement
return { sidebar, details, frame, ...utils }
return { instance, frame, slotCalls, ...utils }
}
function tracks(frame: HTMLElement): number[] {
@@ -61,6 +85,8 @@ function drag(handle: Element, fromX: number, toX: number): void {
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
@@ -83,11 +109,37 @@ afterEach(() => {
})
describe('AppFrame', () => {
it('renders three tracks from panel state', () => {
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([300, 360])
})
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(getByTestId('details-content')).toBeTruthy()
const keys = slotCalls.map((c) => c.key)
expect(keys).toContain('conversation')
expect(keys).toContain('details')
expect(keys).not.toContain('conversation.empty')
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
})
it('renders the empty branch through conversation.empty when no session is current', () => {
sessionMode.current = false
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('empty-content')).toBeTruthy()
expect(queryByTestId('center-content')).toBeNull()
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
})
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
@@ -104,16 +156,16 @@ describe('AppFrame', () => {
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()
const { frame, instance } = 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)
expect(instance.store.getSnapshot().details).toBe(300)
})
it('details column stays mounted at zero width', () => {
const { frame, details, getByTestId } = mountFrame()
act(() => { details.update((d) => { d.open = false }) })
const { frame, instance, getByTestId } = mountFrame()
act(() => { instance.actions.closeDetails() })
expect(tracks(frame)).toEqual([300, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
@@ -130,31 +182,31 @@ describe('AppFrame', () => {
})
it('drag handles disappear for collapsed columns', () => {
const { frame, details, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
act(() => { details.update((d) => { d.open = false }) })
act(() => { instance.actions.closeDetails() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { sidebar.update((d) => { d.open = false }) })
act(() => { instance.actions.toggleSidebar() })
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 { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
const before = sidebar.getSnapshot().width
const before = instance.store.getSnapshot().sidebar
// 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)
expect(instance.store.getSnapshot().sidebar).toBe(before)
})
it('two moves inside one frame coalesce through the pending rAF', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
@@ -165,11 +217,11 @@ describe('AppFrame — guard branches', () => {
vi.advanceTimersByTime(20)
})
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
expect(sidebar.getSnapshot().width).toBe(340)
expect(instance.store.getSnapshot().sidebar).toBe(340)
})
it('pointerup with a pending rAF cancels it and commits the final position', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
@@ -177,7 +229,7 @@ describe('AppFrame — guard branches', () => {
// 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)
expect(instance.store.getSnapshot().sidebar).toBe(360)
})
it('zero-width resize reports are ignored (display:none window)', () => {
@@ -199,7 +251,7 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
})
it('double resize inside one frame rides the pending rAF (?"?= guard)', () => {
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })

View File

@@ -1,15 +1,14 @@
// @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.
// Client apply wiring under the terminal register form: ctx.layout provided,
// ONE register() call declares the four child slots + seats the store factory
// + wires the panel actions through the inject hook; teardown cascades
// (service unprovided + declarations gone + registration cleared). Node half
// and the invariant companion ride along — 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'
@@ -18,8 +17,6 @@ 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 }
}
@@ -28,28 +25,31 @@ describe('ui-layout client apply', () => {
expect(inject).toContain('slots')
})
it('provides ctx.layout and defines the four layout-owned slots', async () => {
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
// The one register() call occupied 'root'…
expect(slots.entries('root')).toHaveLength(1)
// …and declared the four children in the ledger.
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 () => {
it('teardown unwinds the service, the root registration, and the child declarations', 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.entries('root')).toHaveLength(0)
expect(slots.spec('sidebar')).toBeUndefined()
expect(slots.spec('conversation.empty')).toBeUndefined()
expect(disposeSpy).toHaveBeenCalledTimes(1)
// The built-in root declaration survives entry teardown (runtime-owned).
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
})
})

View File

@@ -4,8 +4,9 @@ import {
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
const open = (width: number) => ({ open: true, width })
const closed = (width: number) => ({ open: false, width })
// Numeric preference form (0 = closed); helpers keep the scenario names readable.
const open = (width: number) => width
const closed = (_width: number) => 0
describe('clampWidth', () => {
it('clamps into the range and rounds', () => {

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
/**
* createLayoutStore unit account: init shape, the action write set (clamp
* inside actions), and the persist key round-trip over jsdom localStorage.
* Uses the test-sanctioned path: factory self-call + .create() gives the
* real engine instance (same create path as production).
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
import {
DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
const PERSIST_KEY = 'dsh.layout.panels'
beforeEach(() => { localStorage.clear() })
describe('createLayoutStore', () => {
it('initializes with sidebar open at default and details closed', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
})
it('each create() is an independent instance (factory is not a singleton)', () => {
const a = createLayoutStore().create()
const b = createLayoutStore().create()
a.actions.setSidebar(400)
expect(b.store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('setSidebar/setDetails clamp into the contract ranges', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(1)
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MIN)
actions.setSidebar(9999)
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MAX)
actions.setDetails(1)
expect(store.getSnapshot().details).toBe(DETAILS_MIN)
actions.setDetails(9999)
expect(store.getSnapshot().details).toBe(DETAILS_MAX)
})
it('toggleSidebar flips closed <-> contract default (drag width forgotten)', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(400)
actions.toggleSidebar()
expect(store.getSnapshot().sidebar).toBe(0)
actions.toggleSidebar()
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
const { store, actions } = createLayoutStore().create()
actions.openDetails()
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
actions.setDetails(500)
actions.openDetails()
expect(store.getSnapshot().details).toBe(500)
actions.closeDetails()
expect(store.getSnapshot().details).toBe(0)
})
it('persists under dsh.layout.panels and rehydrates on the next create', () => {
const first = createLayoutStore().create()
first.actions.setSidebar(320)
first.actions.openDetails()
expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
const second = createLayoutStore().create()
expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
})
})

View File

@@ -1,139 +1,57 @@
// @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.
* LayoutService behavior: the cross-plugin panel-action face. Geometry
* lives in the entry store (layout-store.spec.ts) — here we assert the
* delegation seam: attachPanels wiring, the three actions forwarding, the
* unwired fail-loud, and re-attach overwriting a stale action set.
*/
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 } from '@deepseek-ai/dsh-client-ui-layout/client'
import { DETAILS_DEFAULT, SIDEBAR_DEFAULT } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { describe, expect, it, vi } from 'vitest'
import { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
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 }
function fakePanels(): PanelActions {
return {
setSidebar: vi.fn(),
setDetails: vi.fn(),
toggleSidebar: vi.fn(),
openDetails: vi.fn(),
closeDetails: vi.fn(),
}
}
/** 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('forwards the three panel actions to the attached set', () => {
const service = new LayoutService()
const panels = fakePanels()
service.attachPanels(panels)
service.toggleSidebar()
service.openDetails()
service.closeDetails()
expect(panels.toggleSidebar).toHaveBeenCalledTimes(1)
expect(panels.openDetails).toHaveBeenCalledTimes(1)
expect(panels.closeDetails).toHaveBeenCalledTimes(1)
expect(panels.setSidebar).not.toHaveBeenCalled()
expect(panels.setDetails).not.toHaveBeenCalled()
})
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('fails loud before the root entry wired its actions', () => {
const service = new LayoutService()
expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
expect(() => { service.openDetails() }).toThrow(/panel actions not wired/)
expect(() => { service.closeDetails() }).toThrow(/panel actions not wired/)
})
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('re-attach overwrites the stale action set (entry re-register)', () => {
const service = new LayoutService()
const stale = fakePanels()
const fresh = fakePanels()
service.attachPanels(stale)
service.attachPanels(fresh)
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()
})
service.toggleSidebar()
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()
expect(stale.toggleSidebar).not.toHaveBeenCalled()
expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
})
})