refactor(gui): move the snapshot-store engine into the client runtime

The data layer no longer depends on the React glue package, and business
plugins no longer depend on web-react at all:

- The store engine (zustand vanilla + immer + persist + dev freeze),
  defineStore, and shallowEqual move to @deepseek-ai/dsh-client-runtime,
  exported from the ./client main entry — no ./store subpath survives on
  either package (the web-react one is deleted, none is opened on runtime).
- Store products are bare snapshot sources: useSelector leaves
  SnapshotStore/StoreInstance and Session; every hook is composed at the
  binding site in web-react's renderer (per-source cached uSES binding).
  The SlotRendererHost sessions face carries bare observables only.
- SessionProvider becomes a standard-kit seat: an entry whose children
  declare a session-scope slot receives the framework component as a prop,
  retiring the last value import of web-react from plugin packages.
  UseSession and the session-area types now live in ui-slots.
- web-react shrinks to the shell-only React glue (renderer, providers,
  uSES bridge); zustand/immer belong to runtime alone; the module-table
  seed and tsdown externals drop the web-react/store seat.
- NODE_ENV replacement is defined once in the shared tsdown client preset
  (browser bundles inline the engine and lost vite's define); the 3-line
  process.env typecheck shim moves to runtime with the engine.
- Stray tsc artifacts (.js/.d.ts/.d.ts.map beside sources under src/)
  swept repo-wide; they shadow real sources under vitest resolution.

Verified: both aggregate typecheck programs at zero; 604 client tests
green; repo-wide grep for web-react/store at zero; real-host playwright
run 7/7 including persist round-trip.

ci: fix test/docs
This commit is contained in:
imccyu
2026-07-23 02:56:16 +08:00
parent 004a988168
commit 8b3d1ac943
77 changed files with 484 additions and 317 deletions

View File

@@ -32,8 +32,8 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien
The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
1. **Data object layer** (`runtime`'s sessions machinery, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
2. **Render machinery** (`web-react`): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge, the store engine. The only code that reads React contexts or runs subscriptions.
1. **Data object layer** (`runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
Non-negotiables across the layers:

View File

@@ -33,7 +33,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-web-react": "workspace:^"
"@deepseek-ai/dsh-client-runtime": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -5,11 +5,13 @@
* Contract: api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
// Engine subpath: createSnapshotStore left the public face in the slot
// terminal rework (business stores go through defineStore); framework data
// stores like this locale cell keep the engine via './store'.
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
// The snapshot-store engine lives in runtime (store relocation): framework
// data stores like this locale cell use it directly. The store carries no
// hook — a React consumer binds a selector hook via web-react's
// bindSnapshotSelector at its own seam (none exists today; the current
// consumers are translate() reads and test-side subscribe/set).
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'

View File

@@ -18,7 +18,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../web-react"
"path": "../runtime"
},
{
"path": "../../support/invariants"

View File

@@ -37,10 +37,11 @@
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"@deepseek-ai/dsh-session": "workspace:^"
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",

View File

@@ -12,7 +12,7 @@
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotStore } from './store/index.ts'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
@@ -25,6 +25,13 @@ export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export { createSnapshotStore, defineStore, shallowEqual } from './store/index.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './store/index.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
PendingInteraction, RunningToolCall, SteeringMessageNode,
@@ -45,7 +52,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
export type ClientContext = Context
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or

View File

@@ -17,7 +17,7 @@
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore } from '../store/index.ts'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'

View File

@@ -15,11 +15,9 @@
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
// Engine reach-through: the store subpath is the framework-internal channel
// (the public web-react face carries defineStore only).
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import type { SessionCell } from '@deepseek-ai/dsh-client-web-react'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../store/index.ts'
import { createSnapshotStore } from '../store/index.ts'
import { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
@@ -216,7 +214,9 @@ export class SessionsService {
fiber,
ctx,
binding: { sessionId: id, session, ctx },
cell: { sessionId: id, useSession: session.useSelector },
// Bare source form (store migration): the Session object IS the
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session },
}
this.scopes.set(id, record)
return record

View File

@@ -7,8 +7,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot } from '../store/index.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
} from './conversation.ts'
@@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
export const PAGE_MESSAGES = 50
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
/**
* Per-session state owner: event window + fold + partial, snapshot out via
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

View File

@@ -3,9 +3,11 @@
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative
* shell over it: {@link defineStore} bakes an init/persist/actions literal
* into a {@link StoreHandle}, the registration-side store seat of the slot
* terminal design (§4). The engine ({@link createSnapshotStore}) stays the
* substrate for framework data (runtime sessions/loader/i18n); business
* plugins declare stores through defineStore only.
* terminal design (§4). Lives in the React-free runtime (store-migration
* ruling: the data layer owns its engine; web-react is shell-only React
* glue): engine products are bare observables subscribe/getSnapshot/
* update/set, NO selector hook. Hook synthesis is web-react's (the one
* uSES bridge, cached per source at the binding site).
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
@@ -14,10 +16,9 @@ import { produce } from 'immer'
import type {
ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from '../bind.ts'
// Store contract types are ui-slots authority (wave 1); this module re-exports
// them beside the engine so '/store' consumers get one import surface.
// Store contract types are ui-slots authority; re-exported beside the engine
// so store consumers get one import surface.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -25,7 +26,7 @@ export type {
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
/** Writable snapshot store with an attached typed selector hook. */
/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
/**
* Mutate the state through an immer draft.
@@ -37,14 +38,11 @@ export interface SnapshotStore<T> extends ObservableSnapshot<T> {
* @param next - next state.
*/
set(next: T): void
readonly useSelector: SnapshotSelectorHook<T>
}
/** Typed selector hook: equality defaults to Object.is; pass shallowEqual for object slices. */
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Shallow equality for selector slices (re-export of zustand/shallow semantics).
* Shallow equality for selector slices (zustand/shallow semantics; travels
* with the engine so hook consumers need no zustand dependency).
* @param a - left value.
* @param b - right value.
* @returns whether the values are shallowly equal.
@@ -104,7 +102,7 @@ export function createSnapshotStore<T>(
}
}
const store: SnapshotStore<T> = {
return {
getSnapshot: () => api.getState(),
subscribe: fn => subscribe(fn),
update: (mutator) => {
@@ -115,10 +113,7 @@ export function createSnapshotStore<T>(
set: (next) => {
api.setState(devFreeze(next), true)
},
useSelector: undefined as unknown as SnapshotSelectorHook<T>,
}
;(store as { useSelector: SnapshotSelectorHook<T> }).useSelector = bindSnapshotSelector(store)
return store
}
/**
@@ -230,7 +225,6 @@ export function defineStore<T, A extends ActionsDecl<T>>(
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
}
return {
useSelector: store.useSelector,
actions: actions as BakedActions<T, A>,
getSnapshot: () => store.getSnapshot(),
subscribe: fn => store.subscribe(fn),

View File

@@ -155,13 +155,15 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, useSession} pair; unknown ids yield undefined', async () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const cell = b.svc.cell('s1')
expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1')
expect(cell?.useSession).toBe(b.svc.manager.get(sid('s1')).useSelector)
// Bare-source form (store migration): the cell carries the Session
// observable itself; hook binding happens in the React machinery.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
})

View File

@@ -49,9 +49,10 @@ async function boot(): Promise<Bench> {
return { ctx, svc, erased: svc as unknown as ErasedService }
}
/** Engine-shaped instance stub (the arbitrated persist face: scope-keyed create + clearPersisted). */
/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
interface FakeInstance {
useSelector: () => undefined
getSnapshot: () => undefined
subscribe: () => () => void
actions: Record<string, never>
clearPersisted: ReturnType<typeof vi.fn>
}
@@ -61,7 +62,10 @@ function fakeHandle() {
const created: FakeInstance[] = []
const handle = {
create: vi.fn((_scopeKey?: string): FakeInstance => {
const instance: FakeInstance = { useSelector: () => undefined, actions: {}, clearPersisted: vi.fn() }
const instance: FakeInstance = {
getSnapshot: () => undefined, subscribe: () => () => undefined,
actions: {}, clearPersisted: vi.fn(),
}
created.push(instance)
return instance
}),
@@ -91,7 +95,9 @@ function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
cell: (id: string) => (id === 'known' ? { sessionId: id, useSession: () => undefined } : undefined),
cell: (id: string) => (id === 'known'
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
: undefined),
}
}

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore, defineStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/store/index.ts'
interface State {
a: { n: number }

View File

@@ -36,7 +36,6 @@ export const CLIENT_EXTERNALS = [
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-web-react/store',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-connection/client',
'@deepseek-ai/dsh-client-runtime/client',
@@ -81,6 +80,21 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
dts: false,
clean: false,
external: CLIENT_EXTERNALS,
// Browser bundles inline node-idiom deps (zustand/immer read
// process.env.NODE_ENV; zustand's esm build also probes
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
// EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
// needs the substitutions here or the factory throws ReferenceError at
// boot / the build gate reds. Both keys honor the build's NODE_ENV so a
// dev build keeps the dev-branch semantics; artifacts default to production.
// The bare `import.meta.env` key is required alongside the precise MODE
// key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
// the truthiness probe would otherwise survive as an empty import.meta.
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
},
// tsdown auto-externalizes package dependencies; anything NOT in the
// loader module table must inline instead (wire/type layers, zod, clsx —
// every non-shared dep). A require() the table cannot answer is a

View File

@@ -39,7 +39,6 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},

View File

@@ -16,7 +16,7 @@ import {
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
import type { ToolViewProps } from '../contract/toolview.ts'
@@ -38,7 +38,7 @@ const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
/** web-react's UseSession is deliberately wide (dependency direction); the
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>

View File

@@ -7,7 +7,7 @@
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps } from '../contract/views.ts'
import css from './StatsLine.module.css'

View File

@@ -6,7 +6,7 @@
* implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'

View File

@@ -6,7 +6,7 @@
* import this, never each other.
*/
import type { FC } from 'react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**

View File

@@ -7,8 +7,8 @@
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSlotProps } from '../contract/slots.ts'
import type { ConvViewProps, ViewEntry } from '../contract/views.ts'
import { InputBar } from './InputBar.tsx'

View File

@@ -5,8 +5,8 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { DetailsSlotProps } from '../contract/slots.ts'
import css from './DetailsPanel.module.css'

View File

@@ -9,7 +9,7 @@
* Module exports the factory only — a module-level handle would pin identity
* in the module cache (a de-facto singleton surviving plugin reloads).
*/
import { defineStore } from '@deepseek-ai/dsh-client-web-react'
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
/**

View File

@@ -10,10 +10,10 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationInjected, DetailsInjected, EmptyStateInjected,

View File

@@ -8,7 +8,7 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'

View File

@@ -9,8 +9,8 @@ import { cleanup, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
@@ -85,7 +85,7 @@ describe('small branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})

View File

@@ -8,8 +8,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
@@ -78,7 +78,7 @@ describe('deriveStats', () => {
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
return { sessionId: SID, useSession: hookOf(source) as unknown as UseSession }
}
it('renders the joined stats row and hides with zero steps', () => {

View File

@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'

View File

@@ -9,8 +9,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
@@ -79,8 +79,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
const props: ConvViewProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useStore: chat.useSelector,
useSession: hookOf(source) as unknown as UseSession,
useStore: hookOf(chat),
actions: { openDetails, loadOlder },
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }

View File

@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'

View File

@@ -8,12 +8,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -32,19 +31,6 @@ function snapshotBase(): ConversationSnapshot {
} as ConversationSnapshot
}
describe('apply need() and cwd cache', () => {
it('apply fails loud when a required service is absent', () => {
// Call apply directly (no fiber machinery): need('sessions') on a bare
// context throws synchronously — the loud-failure branch without the
// fiber runner's internal rejection surface. Mount semantics (inject
// gating) are covered by the full bench in apply-inject.spec.
void inject
const ctx = new Context()
expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
})
})
describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
@@ -68,7 +54,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -90,9 +76,9 @@ describe('render branch tails', () => {
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptyList.useSelector}
useStore={chat.useSelector}
useSession={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={hookOf(emptyList)}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,

View File

@@ -0,0 +1,26 @@
/**
* Test-local selector-hook binder: the engine carries no hook since the store
* migration (runtime is React-free); the renderer binds in production, specs
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
* with-selector uSES shim as production, so selector-level render economics —
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
* in Profiler-count specs). Source-relative import: the package dependency
* edge to web-react is gone (store migration §7); tests reach the sibling
* package the same way they reach their own src internals.
*/
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
export interface HookSource<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Bind a selector hook over a snapshot source.
* @param src - the source.
* @returns a SnapshotSelectorHook-shaped hook.
*/
export function hookOf<T>(src: HookSource<T>) {
return bindSnapshotSelector<T>(src)
}

View File

@@ -75,8 +75,8 @@ function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: Session
}
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-web-react').SlotRendererHost {
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-web-react').SlotRendererHost })
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
if (captured._host === undefined) {
b.slots.install({
renderRoot: (host) => {

View File

@@ -7,9 +7,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
@@ -49,7 +49,7 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
}])),
current: undefined,
} as SessionListState)
return store.useSelector
return hookOf(store)
}
describe('ConversationRoot branches', () => {
@@ -66,9 +66,9 @@ describe('ConversationRoot branches', () => {
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={chat.useSelector}
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
@@ -124,9 +124,9 @@ describe('ConversationRoot branches', () => {
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
@@ -147,9 +147,9 @@ describe('DetailsPanel branches', () => {
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
@@ -183,9 +183,9 @@ describe('DetailsPanel branches', () => {
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,

View File

@@ -11,9 +11,9 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
@@ -42,7 +42,7 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
return { store, useSession: hookOf(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
@@ -56,7 +56,7 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: store.useSelector }
return { store, useSessions: hookOf(store) }
}
describe('EmptyState', () => {
@@ -114,7 +114,7 @@ describe('ConversationRoot', () => {
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={chat.useSelector}
useStore={hookOf(chat)}
actions={chat.actions}
views={{
list: () => views,
@@ -199,7 +199,7 @@ describe('DetailsPanel', () => {
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={chat.useSelector}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)

View File

@@ -36,7 +36,6 @@
"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": {

View File

@@ -4,15 +4,16 @@
* 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.
* session pair renders under the SessionProvider standard seat (render-prop
* form, injected by the renderer because the children declaration contains
* session-scope slots; 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 or framework imports, zero
* self-made hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from '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 { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
@@ -78,8 +79,8 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
)
}
/** The three-column frame (see module doc). */
export function AppFrame({ useStore, actions, renderSlot }: AppFrameProps) {
/** The three-column frame (see module doc). SessionProvider arrives as a standard seat (declaring a session-scope child summons it — no framework import). */
export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: AppFrameProps) {
const panels = useStore((s) => s)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)

View File

@@ -7,7 +7,7 @@
* 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 { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,

View File

@@ -12,24 +12,22 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import type { ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { 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 }))
// Session-mode switch for the SessionProvider stub prop.
const sessionMode = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode runs children(id), empty
// mode runs the empty branch — the frame must work against exactly this
// shape. Typed as the seat's own component type so the branded sessionId
// parameter stays contract-checked.
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
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
@@ -43,6 +41,11 @@ class ResizeObserverStub {
let frameWidth = 1920
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
}
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const instance = createLayoutStore().create()
@@ -58,10 +61,11 @@ function mountFrame() {
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
const utils = render(
<AppFrame
useStore={instance.useSelector}
useStore={hookOf(instance) as never}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
SessionProvider={SessionProviderStub}
/>,
)
const frame = utils.container.firstElementChild as HTMLElement
@@ -160,7 +164,7 @@ describe('AppFrame', () => {
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(instance.store.getSnapshot().details).toBe(300)
expect(instance.getSnapshot().details).toBe(300)
})
it('details column stays mounted at zero width', () => {
@@ -195,14 +199,14 @@ describe('AppFrame — guard branches', () => {
it('pointer moves without capture are ignored (no width write)', () => {
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
const before = instance.store.getSnapshot().sidebar
const before = instance.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(instance.store.getSnapshot().sidebar).toBe(before)
expect(instance.getSnapshot().sidebar).toBe(before)
})
it('two moves inside one frame coalesce through the pending rAF', () => {
@@ -217,7 +221,7 @@ describe('AppFrame — guard branches', () => {
vi.advanceTimersByTime(20)
})
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
expect(instance.store.getSnapshot().sidebar).toBe(340)
expect(instance.getSnapshot().sidebar).toBe(340)
})
it('pointerup with a pending rAF cancels it and commits the final position', () => {
@@ -229,7 +233,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(instance.store.getSnapshot().sidebar).toBe(360)
expect(instance.getSnapshot().sidebar).toBe(360)
})
it('zero-width resize reports are ignored (display:none window)', () => {

View File

@@ -38,7 +38,6 @@
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},

View File

@@ -8,7 +8,7 @@
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'

View File

@@ -9,13 +9,19 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
// Engine subpath: createSnapshotStore left the public face (wave 3); the
// engine remains the sanctioned stub source for the standard hooks in tests.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import { act, useSyncExternalStore } from 'react'
// Engine home: runtime/client since the store migration; the engine carries
// no hook (runtime is React-free), so the spec binds the selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
/** Minimal selector hook over an engine store (production binding lives in the renderer). */
function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
}
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
@@ -59,7 +65,7 @@ function mount(...summaries: SessionSummary[]) {
<SidebarRoot
collapsed={false}
width={300}
useSessions={sessions.useSelector}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}

View File

@@ -13,7 +13,7 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co
The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in web-react (the engine's home) and satisfies the `DefineStore` contract exported here.
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.

View File

@@ -14,7 +14,7 @@
* consumer merges keys in and the intersection is what keeps them string-typed.
* The rule fires on the empty-map view, not on real redundancy. */
import type { ReactNode } from 'react'
import type { BoundActions, HandleOf, PropsStore, StoreDecl } from './store.ts'
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
export * from './store.ts'
export * from './renderer.ts'
@@ -98,6 +98,32 @@ export type PropsRuntime<K extends keyof SlotMap & string> =
/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Conversation-session selector hook alias for props contracts. Wide by
* default at this dependency-inverted layer; the runtime narrows at its
* export seam (`UseSession<ConversationSnapshot>`).
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
/** Props of the standard-kit SessionProvider seat (render-prop form). */
export interface SessionAreaProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; the framework remounts it per session (key=sessionId). */
children: (sessionId: SessionIdOf) => ReactNode
}
/**
* The framework-wired session area component (slot terminal design §7):
* subscribes to the current-session selection internally (design fiat ① —
* selection authority lives with runtime sessions) and switches between the
* session body and the empty branch. Delivered as a standard seat to every
* entry whose children declaration contains a session-scope slot (the
* derivation rides {@link PropsRenderSlots}); the value is injected by the
* installed renderer — business code never imports it.
*/
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
/**
* Child-slot render share: `renderSlot` statically narrowed to the entry's
* declared children keys. Delegation is plain props passing (hand
@@ -117,7 +143,12 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
*/
renderSlot: <K extends S>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
readonly __renders?: ((key: S) => void) | undefined
}
} & ('session' extends ScopeOf<S>
// The SessionProvider seat rides the same source as renderSlot: declaring
// a session-scope child is what makes a session area exist, so the seat
// derives from the children key set's scopes (renderer injects the value).
? { SessionProvider: SessionProviderComponent }
: object)
/**
* Registration-position component shape: the bare call signature, so composed

View File

@@ -16,19 +16,33 @@ export interface HostObservable<T> {
/**
* Type-erased store instance face at the render seam (the typed twin is
* {@link StoreInstance}): selector hook plus draft-stripped action callbacks.
* Typing lands at the component seam via {@link PropsStore}.
* {@link StoreInstance}): a bare snapshot source plus the draft-stripped
* action callbacks. No React hook crosses this seam — the render machinery
* binds `useStore` from the source at its own side (cached per instance);
* typing lands at the component seam via {@link PropsStore}.
*/
export interface StoreInstanceLike {
readonly useSelector: unknown
/** Current state snapshot (uSES getSnapshot side). */
getSnapshot(): unknown
/**
* Subscribe to state changes (uSES subscribe side).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: Record<string, (...params: never[]) => void>
}
/** Session standard kit resolved per session id (identity-stable per session scope; a recreated scope yields a new cell). */
export interface SessionCell {
sessionId: string
/** Bound conversation-snapshot selector hook (wide here; runtime narrows at its export seam). */
useSession: unknown
/**
* Bare conversation-snapshot source (wide here; runtime narrows at its
* export seam). The React side binds the `useSession` hook per cell —
* hooks never appear on the host contract.
*/
session: HostObservable<unknown>
}
/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */

View File

@@ -51,19 +51,19 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
/**
* Live engine instance: the create() product consumed by the render machinery
* and by component tests (fed straight into props as useStore/actions).
* Production components and render paths never call create() themselves —
* instance lifecycle is the framework's.
* and by tests. A bare snapshot source plus the baked write set — no React
* hook rides the engine product (the engine lives in the React-free runtime);
* the render machinery binds the `useStore` hook from this source on its own
* side, cached per instance. Production components and render paths never
* call create() themselves — instance lifecycle is the framework's.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Selector hook bound to this instance (delivered to components as `useStore`). */
readonly useSelector: SnapshotSelectorHook<T>
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A>
/** Current state snapshot (test assertions; machinery). */
/** Current state snapshot (uSES getSnapshot side; test assertions). */
getSnapshot(): T
/**
* Subscribe to state changes.
* Subscribe to state changes (uSES subscribe side).
* @param fn - change callback.
* @returns unsubscribe.
*/
@@ -130,8 +130,8 @@ export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
: object
/**
* The defineStore contract (implementation lives in web-react, bound to the
* snapshot-store engine): spec in, handle out, with T inferred from `init`
* and the actions table constrained by T.
* The defineStore contract (implementation lives in the runtime package,
* bound to the snapshot-store engine): spec in, handle out, with T inferred
* from `init` and the actions table constrained by T.
*/
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>

View File

@@ -166,6 +166,12 @@ describe('terminal-design type chain', () => {
acts.setDraft('x')
// @ts-expect-error wrong payload type
acts.setDraft(1)
// SessionProvider seat: derives from a session-scope child declaration.
fp.SessionProvider({ empty: () => null, children: () => null })
const sideOnly: PropsRenderSlots<'chain.side'> = null as never
// @ts-expect-error only root-scope children declared → no SessionProvider seat
void sideOnly.SessionProvider
}
expect(samples).toBeTypeOf('function')
})

View File

@@ -35,7 +35,6 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"react": "^18.2.0"
},
"peerDependencies": {

View File

@@ -6,7 +6,7 @@
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSpanStats } from './spans.ts'
import css from './TrajectoryStatsHeader.module.css'

View File

@@ -4,7 +4,7 @@
import { useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'

View File

@@ -4,7 +4,7 @@
import { useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import css from './views.module.css'

View File

@@ -11,9 +11,9 @@ import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, type FC } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
@@ -53,7 +53,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return store.useSelector
return bindSnapshotSelector(store)
}
/** Chat-view stand-in props for standalone view mounts. */
@@ -62,7 +62,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useStore: chat.useSelector,
useStore: bindSnapshotSelector(chat),
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
} as unknown as ConvViewProps
}
@@ -89,7 +89,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptySessions()}
useStore={chat.useSelector}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
views={{
list: () => svc.views(),

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-web-react
ctx↔React machinery for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop over the host's current-session source), defineStore (the declarative store shell over the internal zustand engine), bindSnapshotSelector, useInvoke. The snapshot-store engine (createSnapshotStore) is framework-internal via the `./store` subpath; business plugins declare stores through defineStore only.
Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
## Model Experience

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-web-react",
"description": "ctx-to-React glue: createSnapshotStore (zustand engine), bindSnapshotSelector, SessionProvider, scopedSlots outlet, useInvoke",
"description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,20 +15,14 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./store": {
"types": "./lib/types/store/index.d.ts",
"default": "./lib/store/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"use-sync-external-store": "1.2.0",
"zustand": "~4.4.7"
"use-sync-external-store": "1.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -42,7 +36,6 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/store/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -1,19 +1,21 @@
/**
* uSES bridge: turns any {@link ObservableSnapshot} into a typed selector
* hook. Client-side-rendered only, so no server snapshot is wired.
* uSES bridge: turns any bare observable snapshot source into a typed
* selector hook. Client-side-rendered only, so no server snapshot is wired.
* This is the ONE hook constructor in the client stack — engines and hosts
* traffic in bare sources; binding happens on the React side.
*/
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
import type { ObservableSnapshot, SnapshotSelectorHook } from './store/index.ts'
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
/**
* Bind an observable snapshot source to a typed uSES selector hook.
* Bind a bare observable source to a typed uSES selector hook.
* subscribe/getSnapshot are captured once per source into stable closures
* (also re-binds `this` for method-based sources), so components never
* resubscribe across renders. Equality defaults to Object.is.
* @param w - snapshot source (Session object or snapshot store).
* @param w - snapshot source (engine store, Session object, store instance).
* @returns the selector hook.
*/
export function bindSnapshotSelector<T>(w: ObservableSnapshot<T>): SnapshotSelectorHook<T> {
export function bindSnapshotSelector<T>(w: HostObservable<T>): SnapshotSelectorHook<T> {
const subscribe = (fn: () => void) => w.subscribe(fn)
const getSnapshot = () => w.getSnapshot()
return function useSelector<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S {

View File

@@ -1,22 +1,15 @@
/**
* ctx-to-React machinery (slot terminal design §8): createSlotRenderer (the
* Shell-side React glue (slot terminal design §8): createSlotRenderer (the
* install-seam implementation), SessionProvider (framework-wired render
* prop), the defineStore shell, and useInvoke. Contract types (SlotRenderer
* family, store family, four-share props) are ui-slots authority — this face
* re-exports the ones its own values traffic in. The snapshot-store ENGINE
* (createSnapshotStore) is framework-internal — runtime/i18n reach it through
* the './store' subpath; business plugins declare stores via defineStore
* only. React contexts stay in-package: business components see none.
* prop, also delivered as a standard seat to session-area entries),
* bindSnapshotSelector (the one hook constructor), and useInvoke. The
* snapshot-store engine and defineStore live in runtime (store relocation);
* contract types are ui-slots authority — this face re-exports only what its
* own values traffic in. React contexts stay in-package: business components
* see none.
*/
import type { SnapshotSelectorHook } from './store/index.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// -- store: the declarative shell is public; the engine stays off this face --
export type {
ActionsDecl, BakedActions, BoundActions, EngineStoreHandle, EngineStoreInstance,
ObservableSnapshot, SnapshotSelectorHook, SnapshotStore,
StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from './store/index.ts'
export { defineStore, shallowEqual } from './store/index.ts'
export { bindSnapshotSelector } from './bind.ts'
/**
@@ -29,7 +22,7 @@ export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
HostObservable, RenderOpts, SessionCell,
HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'

View File

@@ -19,7 +19,7 @@ import {
type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SlotAssemblyError, observableHook, useHost, useSessionCell,
HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
} from './session-provider.tsx'
type InjectedProps = Record<string, unknown>
@@ -120,26 +120,36 @@ class SlotErrorBoundary extends Component<
/**
* Standard-kit synthesis shared by both scope branches: the global
* useSessions hook, the store pair when declared, and the renderSlot binding
* when children are declared. Every member is identity-stable (hook cache /
* host store cache / binding cache), so spreading a fresh kit object per
* render never churns child subscriptions.
* useSessions hook, the session pair, the store pair when declared, the
* renderSlot binding when children are declared, and the SessionProvider
* seat when the children declare a session-scope slot. Hosts hand out BARE
* observable sources (hooks never cross the host contract); every hook is
* bound HERE, cached per source (observableHook), so spreading a fresh kit
* object per render never churns child subscriptions.
*/
function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
kit: InjectedProps; actions: object | undefined
} {
const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) }
if (cell !== undefined) {
kit['useSession'] = cell.useSession
kit['useSession'] = observableHook(cell.session)
kit['sessionId'] = cell.sessionId
}
const store = host.storeOf(entry, cell?.sessionId)
if (store !== undefined) {
kit['useStore'] = store.useSelector
// The instance IS an observable snapshot source (contract getSnapshot/
// subscribe); the useStore hook binds here, cached per instance.
kit['useStore'] = observableHook(store)
kit['actions'] = store.actions
}
if (entry.children !== undefined) {
kit['renderSlot'] = boundRenderSlot(host, entry)
// SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import).
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
kit['SessionProvider'] = SessionProvider
}
}
return { kit, actions: store?.actions }
}

View File

@@ -7,9 +7,10 @@
* index; business components see zero React contexts.
*/
import { createContext, useContext, type ReactNode } from 'react'
import type { HostObservable, SessionCell, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import type {
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from './bind.ts'
import type { SnapshotSelectorHook } from './store/index.ts'
/**
* A missing-provider assembly error: the shell wired the tree wrong. The slot

View File

@@ -2,8 +2,15 @@
import { StrictMode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { bindSnapshotSelector, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react/store'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Local one-level equality: the engine's shallowEqual moved to runtime with
// the store relocation, and web-react tests must not import runtime (the
// dependency direction is runtime → web-react). The eq PARAMETER contract is
// what this suite asserts, not any specific equality implementation.
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
interface Snap { a: number; b: number }
@@ -34,7 +41,7 @@ function Harness<S>({ useSelector, sel, eq, probe }: {
useSelector: SnapshotSelectorHook<Snap>
sel: (s: Snap) => S
eq?: (a: S, b: S) => boolean
probe: { renders: number; value?: S }
probe: { renders: number; value?: S | undefined }
}) {
probe.renders += 1
probe.value = useSelector(sel, eq)

View File

@@ -11,9 +11,9 @@
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, defineStore, SessionProvider, SlotOwnershipError,
createSlotRenderer, SessionProvider, SlotOwnershipError,
type RenderOpts, type SessionCell,
type SlotRendererHost, type StoreInstanceLike,
} from '@deepseek-ai/dsh-client-web-react'
@@ -25,6 +25,36 @@ type DeclaredSpec = SlotSpec<SlotEntryDef>
const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
({ options: {}, ...partial })
/**
* Minimal store handle satisfying the StoreDecl contract shape (spec +
* create(scopeKey?) + instance with clearPersisted): the machinery consumes
* only the StoreInstanceLike face (bare snapshot source + baked actions),
* but entry.store is typed to the full contract — the real defineStore lives
* in runtime, which web-react tests must not import (dependency direction).
*/
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
return {
spec: { init, actions: {} },
create: () => {
let state = init()
const listeners = new Set<() => void>()
const actions: Record<string, (...params: never[]) => void> = {}
for (const key of Object.keys(mutators)) {
actions[key] = (...params: never[]) => {
state = mutators[key]!(state, ...params)
for (const fn of [...listeners]) fn()
}
}
return {
getSnapshot: () => state,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
actions,
clearPersisted: () => {},
} as StoreInstanceLike as ReturnType<StoreHandle<T, ActionsDecl<T>>['create']>
},
}
}
function observable<T>(initial: T) {
let value = initial
const subs = new Set<() => void>()
@@ -109,7 +139,11 @@ function makeHost() {
}
},
addSession: (id: string): SessionCell => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
// Bare source per cell (identity-stable): the machinery binds useSession from it.
const cell: SessionCell = {
sessionId: id,
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
}
cells.set(id, cell)
return cell
},
@@ -242,12 +276,17 @@ describe('standard-kit synthesis', () => {
expect(view.container.textContent).toBe('2')
})
it('delivers the session pair (useSession identity + sessionId) under SessionProvider', () => {
it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
const cell = h.addSession('s1')
h.addSession('s1')
const seen: AnyProps[] = []
h.add('k.session', { component: (props: object) => { seen.push(props as AnyProps); return null } })
h.add('k.session', {
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
seen.push({ ...props, read: props.useSession!((s) => s.sid) })
return null
},
})
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
<SessionProvider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
@@ -255,10 +294,51 @@ describe('standard-kit synthesis', () => {
))
act(() => { h.current.set('s1') })
const props = seen.at(-1)!
expect(props['useSession']).toBe(cell.useSession)
// The hook is BOUND by the machinery from the cell's bare source: it
// reads the source's snapshot and stays identity-stable across renders
// (per-source cache), which the switch-back cache tests cover.
expect(props['read']).toBe('s1')
expect(props['sessionId']).toBe('s1')
})
it('hands the SessionProvider seat to entries declaring a session-scope child', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
h.declare('k.single', SINGLE_ROOT)
h.addSession('s1')
h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b> })
const rootSeen: AnyProps[] = []
// Root entry uses its INJECTED provider seat (no value import of SessionProvider).
h.add('root', {
component: (props: AnyProps) => {
rootSeen.push(props)
const Provider = props['SessionProvider'] as typeof SessionProvider
const renderSlot = props['renderSlot'] as RenderSlotFn
return (
<Provider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
</Provider>
)
},
children: { 'k.session': SINGLE_SESSION },
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
// Entries whose children are all root-scope get no provider seat.
const h2 = makeHost()
h2.declare('k.single', SINGLE_ROOT)
const seen2: AnyProps[] = []
h2.add('root', {
component: (props: AnyProps) => { seen2.push(props); return null },
children: { 'k.single': SINGLE_ROOT },
})
render(<>{createSlotRenderer().renderRoot(h2.host, {})}</>)
expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
})
it('fails loud when a session slot renders outside SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
@@ -272,10 +352,7 @@ describe('standard-kit synthesis', () => {
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const handle = defineStore({
init: () => ({ n: 0 }),
actions: { inc: (d) => { d.n += 1 } },
})
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
let bump = () => {}
h.add('k.single', {
component: ({ useStore, actions }: {
@@ -298,10 +375,7 @@ describe('standard-kit synthesis', () => {
h.declare('k.session', SINGLE_SESSION)
h.addSession('s1')
h.addSession('s2')
const handle = defineStore({
init: () => ({ draft: '' }),
actions: { setDraft: (d, text: string) => { d.draft = text } },
})
const handle = miniStore(() => ({ draft: '' }), { setDraft: (_s, text: string) => ({ draft: text }) })
let setDraft: (text: string) => void = () => {}
h.add('k.session', {
component: ({ useStore, actions }: {
@@ -369,7 +443,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.declare('k.single', SINGLE_ROOT)
h.declare('k.session', SINGLE_SESSION)
h.addSession('s1')
const handle = defineStore({ init: () => ({ n: 0 }), actions: { inc: (d) => { d.n += 1 } } })
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
const seenRoot: AnyProps[] = []

View File

@@ -57,7 +57,11 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
host,
current,
addSession: (id: string) => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
// Bare source per cell (identity-stable): the machinery binds useSession from it.
const cell: SessionCell = {
sessionId: id,
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
}
cells.set(id, cell)
return cell
},
@@ -121,15 +125,23 @@ describe('SessionProvider', () => {
const h = makeHost({
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const s1 = h.addSession('s1')
const s2 = h.addSession('s2')
h.registerSession({ component: (props: object) => { seen.push(props as Record<string, unknown>); return null }, options: {} })
h.addSession('s1')
h.addSession('s2')
h.registerSession({
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
// The bound hook reads the cell's bare source — asserting through it
// proves the machinery wired THIS session's source, not another's.
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
return null
},
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)!['useSession']).toBe(s1.useSession)
expect(seen.at(-1)!['read']).toBe('s1')
expect(seen.at(-1)!['sessionId']).toBe('s1')
act(() => { h.current.set('s2') })
expect(seen.at(-1)!['useSession']).toBe(s2.useSession)
expect(seen.at(-1)!['read']).toBe('s2')
expect(seen.at(-1)!['sessionId']).toBe('s2')
})

View File

@@ -1,12 +1,12 @@
import { defineConfig } from 'tsdown'
/**
* Root shape plus the store subpath, built as SEPARATE single-entry bundles:
* a multi-entry build emits a hash-named shared chunk that the exact `files`
* whitelist cannot publish (same shape as code-runtime-worker/user-approval).
* Each entry inlines the shared store code instead; the node lib is the
* repo-uniform shape (publint/NodeNext), not an identity-sensitive runtime —
* browser consumers resolve this package through the loader module table.
* Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry
* build emits a hash-named shared chunk that the exact `files` whitelist
* cannot publish (same shape as code-runtime-worker/user-approval). The node
* lib is the repo-uniform shape (publint/NodeNext), not an identity-sensitive
* runtime — browser consumers resolve this package through the loader module
* table.
*/
export default defineConfig([
{
@@ -29,14 +29,4 @@ export default defineConfig([
dts: false,
clean: false,
},
{
entry: { 'store/index': 'lib/types/store/index.js' },
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])

View File

@@ -30,7 +30,6 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -7,7 +7,7 @@
*/
import { useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
import css from './AppRoot.module.css'

View File

@@ -10,7 +10,7 @@
import { Context } from 'cordis'
import { createRoot } from 'react-dom/client'
import type { ReactNode } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
import { AppRoot } from './AppRoot.tsx'

View File

@@ -13,7 +13,6 @@ import * as ReactDomClient from 'react-dom/client'
import * as Cordis from 'cordis'
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
import * as WebReactStore from '@deepseek-ai/dsh-client-web-react/store'
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
/**
@@ -29,7 +28,6 @@ export function seedModules(): Record<string, unknown> {
'cordis': Cordis,
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
'@deepseek-ai/dsh-client-web-react': WebReact,
'@deepseek-ai/dsh-client-web-react/store': WebReactStore,
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
}
}

View File

@@ -9,10 +9,9 @@ import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
afterEach(cleanup)
// createSnapshotStore left the public face (framework-internal engine); the
// status-store stub reaches it through the same ./store channel runtime uses.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-web-react'
// The snapshot-store engine lives with runtime now; the status-store stub
// uses the same channel production code does.
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'

View File

@@ -13,12 +13,13 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from '@testing-library/react'
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
interface BootWindow extends Window {
__DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
DSHClientProxy?: unknown
__TEST_SLOTS_SERVICE__?: unknown
__TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
}
const win = window as unknown as BootWindow
@@ -34,14 +35,14 @@ window.DSHClientProxy.loadPlugin({
id: 'fake-runtime',
factory: (require) => {
const SlotsService = window.__TEST_SLOTS_SERVICE__
const { createSnapshotStore } = require('@deepseek-ai/dsh-client-web-react/store')
const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
return {
apply: (ctx) => {
ctx.plugin(SlotsService)
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
ctx.provide('sessions', {
list,
cell: (id) => (id === 's1' ? { sessionId: 's1', useSelector: (sel) => sel({}) } : undefined),
cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
})
},
}
@@ -55,7 +56,7 @@ window.DSHClientProxy.loadPlugin({
id: 'fake-layout',
factory: (require) => {
const React = require('react')
const { defineStore } = require('@deepseek-ai/dsh-client-web-react')
const { defineStore } = window.__TEST_RUNTIME_STORE__
return {
inject: ['slots'],
apply: (ctx) => {
@@ -130,13 +131,15 @@ afterEach(() => {
delete win.__DSH_BOOT__
delete win.DSHClientProxy
delete win.__TEST_SLOTS_SERVICE__
delete win.__TEST_RUNTIME_STORE__
document.body.innerHTML = ''
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
})
/** Hand the real service class to the stub bundle (runtime is not a seeded library). */
/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
function seedSlotsService(): void {
win.__TEST_SLOTS_SERVICE__ = SlotsService
win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
}
describe('bootWebShell (real loader + real script execution)', () => {