Merge remote-tracking branch 'origin/master' into worktree-guifork

This commit is contained in:
imccyu
2026-07-28 16:12:12 +08:00
237 changed files with 3140 additions and 1659 deletions

View File

@@ -5,8 +5,8 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
const args: unknown[] = []
if (info !== undefined) args.push(info.sessionId)
if (actions !== undefined) args.push(actions)
return (inject as (...args: unknown[]) => InjectedProps)(...args)
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
}
/**
* Bind an inject face's reserved `hooks` compartment (bare observable
* sources, see HooksSources) into `use<Name>` selector hooks — the
* registrant-private twin of the provide-bundle binding in standardKit.
* Runs once per cached inject result; hook identity rides observableHook's
* per-source cache.
*/
function bindInjectHooks(face: InjectedProps): InjectedProps {
const sources = face['hooks']
if (sources === undefined) return face
const { hooks: _hooks, ...rest } = face
const bound: InjectedProps = rest
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
bound[hookName] = observableHook(source)
}
return bound
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {

View File

@@ -123,9 +123,9 @@ const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
const info = observableHook(host.sessions.provideInfo)(s => s)
return (
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
<BindingContext.Provider value={info}>
{children}
</BindingContext.Provider>
)
@@ -140,17 +140,17 @@ export interface SessionProviderProps {
}
/**
* Framework-wired session area: subscribes to the host's current-session
* source, resolves the session cell, and remounts the body under
* `key={sessionId}` so a session switch rebuilds the session subtree. This
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
* branded type at the component boundary.
* Framework-wired session area: subscribes to the host's current provide
* source and remounts the body under `key={sessionId}` so a session switch
* rebuilds the session subtree. This dependency-inverted layer uses plain
* string ids; `PropsRuntime` applies the branded type at the component
* boundary.
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)(s => s)
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
const info = observableHook(host.sessions.provideInfo)(s => s)
const id = info.sessionId
if (id === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={info} key={id}>
{children(id)}

View File

@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
/** Passthrough host over the real core (store/session seats unused here). */
function hostOver(core: SlotCore): SlotRendererHost {
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: key => core.getVersion(key),
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
provideInfo: () => undefined,
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },

View File

@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionProvideInfo,
@@ -85,7 +86,9 @@ function makeHost() {
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const workspaces = observable<{ ids: string[] }>({ ids: [] })
const current = observable<string | undefined>(undefined)
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const bump = (key: string) => {
@@ -123,10 +126,7 @@ function makeHost() {
},
sessions: {
list,
current,
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: {}, props: {} },
provideInfo: provide,
},
workspaces: { list: workspaces },
}
@@ -134,7 +134,14 @@ function makeHost() {
host,
list,
workspaces,
current,
// Same driver surface as the old current cell: set(id) publishes the
// resolved bundle (or the absent projection) through the provide source.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
},
},
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
const entry = entryOf(partial)
@@ -161,6 +168,7 @@ function makeHost() {
props: {},
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
},
}
@@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(inject).toHaveBeenCalledWith()
})
it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const badge = observable('cold')
const seen: Record<string, unknown>[] = []
h.add('k.single', {
component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
return null
},
inject: () => ({ plain: 'kept', hooks: { badge } }),
})
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
// The raw compartment is consumed by the binding; the plain member passes through.
expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
act(() => { badge.set('hot') })
expect(seen.at(-1)!['read']).toBe('hot')
})
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)

View File

@@ -9,7 +9,7 @@
import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider,
type SessionProvideInfo, type SlotRendererHost,
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
}
/**
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
* Minimal host: SessionProvider only reads sessions.provideInfo, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
const current = observable<string | undefined>(undefined)
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
let currentId: string | undefined
const infos = new Map<string, SessionProvideInfo>()
const sessionEntries: StoredEntry[] = []
const rootEntry: StoredEntry = {
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
current,
provideInfo: id => infos.get(id),
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
provideInfo: provide,
},
workspaces: { list: observable<unknown>({ items: [] }) },
}
return {
host,
current,
// Same driver surface as the old current cell: set(id) publishes the
// resolved bundle (or the absent projection) through the provide source.
current: {
set: (id: string | undefined) => {
currentId = id
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
},
},
addSession: (id: string) => {
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
const info: SessionProvideInfo = {
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
props: {},
}
infos.set(id, info)
if (currentId === id) provide.set(info)
return info
},
/** Swap one session's bundle in place (roster-change stand-in); republish when current. */
replaceSession: (info: SessionProvideInfo) => {
infos.set(info.sessionId, info)
if (currentId === info.sessionId) provide.set(info)
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
expect(seen.at(-1)!['sessionId']).toBe('s2')
})
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
const seen: unknown[] = []
const h = makeHost({
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const original = h.addSession('s1')
h.registerSession({
component: (props: { feature?: string }) => {
seen.push(props.feature)
return null
},
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)).toBeUndefined()
// A provider-roster change rematerializes the bundle; the provide source
// must carry it to already-mounted entries without a selection change.
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
expect(seen.at(-1)).toBe('now-live')
})
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(

View File

@@ -21,6 +21,7 @@ function makeHost() {
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -39,9 +40,7 @@ function makeHost() {
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
provideInfo: () => undefined,
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },