refactor(gui): slot system standard — single register, four props shares, framework store seat
The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:
- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
authorization + runtime spec in one options object; misconfiguration fails
loud at load (duplicate declaration, undeclared contribution, one store
handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
(owner params + session/global standard kits via declare-merge),
PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
read = useStore, write = baked actions only; store scope derives from the
mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
React-free; ownership ledger keyed to the single entry axis closes the
stale-authority window (StaleAuthorizationError probes).
Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.
Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).
docs(ui-sidebar): point contract reference at the committed slot standard RFC
missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
@@ -2,20 +2,18 @@
|
||||
* Client plugin body: provide the conversation service and toolview registry,
|
||||
* register the conversation/details slot occupants and the no-session empty
|
||||
* state, and mount the chat view with its samples. Assembly only — components
|
||||
* receive everything through inject factories; nothing here renders directly.
|
||||
* receive everything through props: the framework standard kit and store
|
||||
* faces arrive automatically from the declarations below; the inject
|
||||
* factories contribute the plain-data-and-callbacks business face (design §5).
|
||||
*/
|
||||
import { createElement, Fragment, type ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { scopedSlots, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionsService, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
|
||||
import type { SelectionTarget } from './contract/views.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
import { childSessionScope, registerChat } from './chat/register.ts'
|
||||
@@ -37,20 +35,13 @@ function need<T>(ctx: Context, name: string): T {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */
|
||||
const cwdsCache = new WeakMap<SessionListState, readonly string[]>()
|
||||
function cwdsOf(state: SessionListState): readonly string[] {
|
||||
let cached = cwdsCache.get(state)
|
||||
if (cached === undefined) {
|
||||
const seen = new Set<string>()
|
||||
for (const id of state.ids) {
|
||||
const cwd = state.byId[id]?.cwd
|
||||
if (cwd !== undefined && cwd !== '') seen.add(cwd)
|
||||
}
|
||||
cached = [...seen]
|
||||
cwdsCache.set(state, cached)
|
||||
}
|
||||
return cached
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
|
||||
const conversation = scoped.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
|
||||
return conversation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,106 +71,64 @@ export function apply(ctx: Context): void {
|
||||
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
|
||||
'ui-conversation: bash toolview samples')
|
||||
|
||||
// ConvViewProps.slots is ScopedSlots<never>: a real outlet with an empty
|
||||
// whitelist (uncallable by type, correct runtime shape for future grants).
|
||||
const emptySlots = scopedSlots<never>(slots.core)
|
||||
// Shared store handle, constructed here so its identity lives and dies with
|
||||
// this fiber (a module-level handle would be a de-facto singleton). Both
|
||||
// session-slot registrations declare it; same scope key = same instance, so
|
||||
// conversation writes and details reads meet in one store.
|
||||
const chat = createChatStore()
|
||||
|
||||
/** conversation slot: skeleton surface assembled once per (entry x session). */
|
||||
const conversationInject = (b: SessionBinding): ConversationInjected => {
|
||||
const bctx = b.ctx as Context
|
||||
const scoped = need<ConversationService>(bctx, 'conversation')
|
||||
const id = b.sessionId as SessionId
|
||||
const useSession = b.session.useSelector as UseSession
|
||||
const selectionStore = scoped.selection
|
||||
const draftsStore = scoped.drafts
|
||||
const session = sessions.manager.get(id)
|
||||
// Watch-driven history pull: assembling the surface IS the watch signal
|
||||
// (once per entry x session; open() is idempotent and self-recovers).
|
||||
void session.open()
|
||||
|
||||
const viewProps: Omit<ConvViewProps, 'slots'> = {
|
||||
sessionId: id,
|
||||
useSession,
|
||||
useSelection: selectionStore.useSelector,
|
||||
actions: {
|
||||
openDetails: (target: SelectionTarget) => { scoped.openDetails(target) },
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
},
|
||||
}
|
||||
|
||||
const injected: ConversationInjected = {
|
||||
useAncestry: () => sessions.list.useSelector(
|
||||
() => sessions.ancestry(id),
|
||||
(a, b) => shallowEqual(a, b)),
|
||||
views: {
|
||||
list: () => conversation.views(),
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
},
|
||||
// layout's viewFor value type is its own looser ViewId; the registry is
|
||||
// the runtime validator (unknown ids fall back to the first view).
|
||||
useActiveView: () => layout.current.useSelector(s => s.viewFor[id]) as ViewId | undefined,
|
||||
composer: {
|
||||
useDraft: () => draftsStore.useSelector(s => s),
|
||||
setDraft: (text) => { draftsStore.set(text) },
|
||||
send: (mode) => {
|
||||
const text = draftsStore.getSnapshot().trim()
|
||||
if (text === '') return
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
store: chat,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
|
||||
const session = sessions.manager.get(sessionId)
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
// Watch-driven history pull: assembling the surface IS the watch signal
|
||||
// (once per entry x session; open() is idempotent and self-recovers).
|
||||
void session.open()
|
||||
return {
|
||||
views: {
|
||||
list: () => conversation.views(),
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
// Optimistic clear with failure restore (choreography lives with the
|
||||
// sender; the business failure also lands in snapshot.promptError).
|
||||
draftsStore.set('')
|
||||
void scoped.send(text, mode).catch(() => {
|
||||
if (draftsStore.getSnapshot() === '') draftsStore.set(text)
|
||||
})
|
||||
// The store write path stays inside the declared actions set:
|
||||
// restoreDraft itself no-ops once the user typed something new.
|
||||
actions.clearDraft()
|
||||
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
|
||||
},
|
||||
stop: () => {
|
||||
scoped.cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
openView: (view: ViewId) => { layout.openView(id, view) },
|
||||
open: (target: SessionId) => { layout.open(target) },
|
||||
},
|
||||
renderView: (entry: ViewEntry): ReactNode => {
|
||||
const children: ReactNode[] = []
|
||||
if (entry.chrome?.header !== undefined) {
|
||||
children.push(createElement(entry.chrome.header, { key: 'header', sessionId: id, useSession }))
|
||||
}
|
||||
children.push(createElement(entry.component, { key: 'view', ...viewProps, slots: emptySlots }))
|
||||
if (entry.chrome?.footer !== undefined) {
|
||||
children.push(createElement(entry.chrome.footer, { key: 'footer', sessionId: id, useSession }))
|
||||
}
|
||||
return createElement(Fragment, null, ...children)
|
||||
},
|
||||
}
|
||||
return injected
|
||||
}
|
||||
openDetails: (target: SelectionTarget) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
open: (target: SessionId) => { sessions.open(target) },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
|
||||
/** details slot: minimal selection-driven panel. */
|
||||
const detailsInject = (b: SessionBinding): DetailsInjected => {
|
||||
const bctx = b.ctx as Context
|
||||
const scoped = need<ConversationService>(bctx, 'conversation')
|
||||
const injected: DetailsInjected = {
|
||||
useSelection: scoped.selection.useSelector,
|
||||
actions: { closeDetails: () => { layout.closeDetails() } },
|
||||
}
|
||||
return injected
|
||||
}
|
||||
slots.register({
|
||||
name: 'details',
|
||||
store: chat,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
}),
|
||||
}, DetailsPanel)
|
||||
|
||||
/** conversation.empty root slot: the NEW SESSION hero. */
|
||||
const emptyInject = (): EmptyStateInjected => {
|
||||
const useCwds: SnapshotSelectorHook<readonly string[]> = (sel, eq) =>
|
||||
sessions.list.useSelector(s => sel(cwdsOf(s)), eq)
|
||||
const injected: EmptyStateInjected = {
|
||||
useCwds,
|
||||
actions: { startSession: opts => conversation.startSession(opts) },
|
||||
}
|
||||
return injected
|
||||
}
|
||||
|
||||
slots.register('conversation', ConversationRoot, { inject: conversationInject })
|
||||
slots.register('details', DetailsPanel, { inject: detailsInject })
|
||||
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
inject: (): EmptyStateInjected => ({
|
||||
startSession: opts => conversation.startSession(opts),
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const { toolviews, t } = deps
|
||||
|
||||
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
|
||||
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
|
||||
const useSession = useSessionWide as UseConversation
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
@@ -131,7 +131,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useSelection((sel) => sel?.callId)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
|
||||
// (uSES over the registry version so unload falls back live) and renders it
|
||||
// behind a per-row error boundary. GenericToolCard is the render-side
|
||||
// fallback for both a registry miss and a crashed custom row. A registrant
|
||||
// inject factory is called once per (registration x binding) and cached,
|
||||
// mirroring the scoped-slots injection discipline.
|
||||
// fallback for both a registry miss and a crashed custom row. Pure props
|
||||
// machinery, zero React context: a registrant inject factory receives the
|
||||
// sessionId this outlet already holds, is called once per (registration x
|
||||
// session) and cached, mirroring the slot injection discipline.
|
||||
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import { useSessionBinding } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Component, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -19,19 +18,21 @@ export interface ToolViewOutletProps {
|
||||
viewProps: ToolViewProps
|
||||
}
|
||||
|
||||
/** Inject cache: per inject-factory (stable per registration) x binding object. */
|
||||
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
|
||||
/** Inject cache: per inject-factory (stable per registration) x session id.
|
||||
* The inner Map lives and dies with its factory (WeakMap entry), so entries
|
||||
* are bounded by the session count over the registration's lifetime. */
|
||||
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
|
||||
|
||||
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
|
||||
let perBinding = injectCache.get(inject)
|
||||
if (!perBinding) {
|
||||
perBinding = new WeakMap()
|
||||
injectCache.set(inject, perBinding)
|
||||
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
|
||||
let perSession = injectCache.get(inject)
|
||||
if (!perSession) {
|
||||
perSession = new Map()
|
||||
injectCache.set(inject, perSession)
|
||||
}
|
||||
let props = perBinding.get(binding)
|
||||
let props = perSession.get(sessionId)
|
||||
if (!props) {
|
||||
props = inject(binding)
|
||||
perBinding.set(binding, props)
|
||||
props = inject(sessionId)
|
||||
perSession.set(sessionId, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
@@ -61,16 +62,6 @@ class RowErrorBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
/** Split component: only inject-carrying registrations need the session
|
||||
* binding hook (keeps injectless rendering free of the Provider requirement). */
|
||||
function InjectedRow({ Row, inject, viewProps }: {
|
||||
Row: FC<ToolViewProps & object>; inject: ToolViewInject<object>; viewProps: ToolViewProps
|
||||
}) {
|
||||
const binding = useSessionBinding()
|
||||
const injected = cachedInject(inject, binding)
|
||||
return <Row {...{ ...injected, ...viewProps }} />
|
||||
}
|
||||
|
||||
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
|
||||
const version = useSyncExternalStore(
|
||||
(fn) => registry.subscribe(fn),
|
||||
@@ -83,7 +74,7 @@ export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: Too
|
||||
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
|
||||
{resolved.inject === undefined
|
||||
? <Row {...viewProps} />
|
||||
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
|
||||
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
|
||||
</RowErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,67 @@
|
||||
/**
|
||||
* Slot-ring contract for the conversation package: the composed props shapes
|
||||
* its registrants mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty — the SlotMap declarations live with ui-layout, the
|
||||
* slot owner). Per the share-ownership rule, the owner share is REFERENCED
|
||||
* from ui-layout and each registrant's injected share is declared here, next
|
||||
* to the component that receives it; full component props = owner share &
|
||||
* standard share & own injected share.
|
||||
* conversation.empty). Terminal slot design (§3): full component props are the
|
||||
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
|
||||
* (declared store's read/write faces) & the injected business face declared
|
||||
* here. No renderSlot share: none of the three registrations declares
|
||||
* children, so the zero-renderSlot inference applies.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { SelectionTarget, ViewEntry } from './views.ts'
|
||||
|
||||
/** Injected share of the conversation slot (assembled by apply's inject factory). */
|
||||
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
/**
|
||||
* Injected share of the conversation slot: plain data and callbacks only
|
||||
* (design §5 — hooks are framework-made). The store lines that used to ride
|
||||
* here live in the declared {@link ChatStore} now; ancestry derives from the
|
||||
* standard useSessions hook in-component; view rendering moved into the
|
||||
* component, which holds every share a view needs.
|
||||
*/
|
||||
export interface ConversationInjected {
|
||||
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
|
||||
useAncestry: () => readonly SessionSummary[]
|
||||
/** View registry read face (uSES triple from the conversation service). */
|
||||
views: {
|
||||
list(): readonly ViewEntry[]
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
}
|
||||
/** Active view accessor (layout.viewFor backed; undefined falls to 'chat'). */
|
||||
useActiveView: () => ViewId | undefined
|
||||
/** Composer surface: draft store hook pair + send/stop choreography. */
|
||||
composer: {
|
||||
useDraft: () => string
|
||||
setDraft(text: string): void
|
||||
send(mode: 'queue' | 'steer'): void
|
||||
stop(): void
|
||||
}
|
||||
actions: {
|
||||
openView(view: ViewId): void
|
||||
open(id: SessionId): void
|
||||
}
|
||||
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
|
||||
renderView: (entry: ViewEntry) => ReactNode
|
||||
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: owner share & standard share & injected share. */
|
||||
export type ConversationSlotProps = ConvOwnerProps & { useSession: UseSession } & ConversationInjected
|
||||
/** Full conversation-slot component props: runtime share & store share & injected share. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsStore<ChatStore> & ConversationInjected
|
||||
|
||||
/** Injected share of the details slot. */
|
||||
/**
|
||||
* Injected share of the details slot: the panel is otherwise a pure reader of
|
||||
* the shared chat store, but its close button is a layout orchestration call.
|
||||
*/
|
||||
export interface DetailsInjected {
|
||||
useSelection: SnapshotSelectorHook<SelectionTarget | null>
|
||||
actions: { closeDetails(): void }
|
||||
/** Close the details panel (layout geometry stays with ctx.layout). */
|
||||
closeDetails(): void
|
||||
}
|
||||
|
||||
/** Full details-slot component props. */
|
||||
export type DetailsSlotProps = DetailsOwnerProps & { useSession: UseSession } & DetailsInjected
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
|
||||
|
||||
/** Injected share of the no-session empty-state slot (root slot: no standard share). */
|
||||
/** Injected share of the no-session empty-state slot. */
|
||||
export interface EmptyStateInjected {
|
||||
/** cwd options derived from sessions.list (deduped; assembled by the inject factory). */
|
||||
useCwds: SnapshotSelectorHook<readonly string[]>
|
||||
actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> }
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
}
|
||||
|
||||
/** Full empty-state component props. */
|
||||
export type EmptyStateSlotProps = EmptyOwnerProps & EmptyStateInjected
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* implementation files import this, never each other.
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CallId, Translate } from './views.ts'
|
||||
@@ -28,11 +27,13 @@ export interface ToolViewProps {
|
||||
|
||||
/**
|
||||
* Toolview inject factory: produces the registrant's private injected share
|
||||
* `I`, called once per (registration x session binding) and cached by the
|
||||
* render outlet. Session-bound by nature — tool rows always render inside a
|
||||
* session subtree.
|
||||
* `I`, called once per (registration x session) and cached by the render
|
||||
* outlet. Mirrors the slot inject shape (parameters derive from the
|
||||
* declaration): toolviews are session-domain by nature, so the factory
|
||||
* receives the session id only — service access goes through the
|
||||
* registrant's own apply-closure ctx (design §5; binding objects retired).
|
||||
*/
|
||||
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
|
||||
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
|
||||
|
||||
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
|
||||
export interface ToolViewOptions<I extends object = object> {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* View-ring contract: the typed conversation view table and the props
|
||||
* surfaces handed to registered views. Shared face between the skeleton
|
||||
* domain (ConversationRoot renders views) and the chat domain (registers the
|
||||
* chat view); domain implementation files import this, never each other.
|
||||
* View-ring contract: the typed conversation view table, the chat store state
|
||||
* shared through it, and the props surfaces handed to registered views.
|
||||
* Shared face between the skeleton domain (ConversationRoot renders views)
|
||||
* and the chat domain (registers the chat view); domain implementation files
|
||||
* import this, never each other.
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -57,12 +57,33 @@ export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
|
||||
/** Selection target for the details linkage channel (toolcall is the step special case). */
|
||||
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
|
||||
|
||||
/** Props handed to registered conversation views. */
|
||||
/**
|
||||
* Chat store state (slot terminal design §4): the per-session store shared by
|
||||
* the conversation and details registrations. `createChatStore` implements
|
||||
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
|
||||
* `view` may carry a stale persisted id after a view plugin unloads — the
|
||||
* registry is the runtime validator (unknown ids fall back to the first view).
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
/** Details-linkage channel (conversation writes, details reads). */
|
||||
selection: SelectionTarget | null
|
||||
/** Composer draft (persisted; survives session switches and reloads). */
|
||||
draft: string
|
||||
/** Active conversation view id; null falls back to the first registered view. */
|
||||
view: ViewId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Props handed to registered conversation views. `useSession` and `useStore`
|
||||
* are the framework hooks ConversationRoot received as a slot registrant,
|
||||
* passed through unchanged (hook transfer is plain props passing; no
|
||||
* business-made subscription exists on this path). No renderSlot share: the
|
||||
* view ring delegates no sub-slots.
|
||||
*/
|
||||
export interface ConvViewProps {
|
||||
sessionId: SessionId
|
||||
useSession: UseSession
|
||||
useSelection: SnapshotSelectorHook<SelectionTarget | null>
|
||||
/** Chat store read face (selection is the only slice views consume today). */
|
||||
useStore: SnapshotSelectorHook<ChatStoreState>
|
||||
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
|
||||
/** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */
|
||||
slots: ScopedSlots<never>
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ export { ConversationService } from './service.ts'
|
||||
export { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, ConvViewPropsOf,
|
||||
SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
|
||||
CallId, ChatStoreState, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps,
|
||||
ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
|
||||
} from './contract/views.ts'
|
||||
export type {
|
||||
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
|
||||
} from './contract/toolview.ts'
|
||||
export type {
|
||||
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* ConversationService implementation: scope-addressed send/cancel, per-scope
|
||||
* selection/draft stores booked on the session scope fiber, view registry
|
||||
* with a uSES read face, openDetails orchestration, and the empty-state
|
||||
* startSession chain. Contract: api-contracts v3 section 7.
|
||||
* ConversationService implementation: scope-addressed send/cancel, view
|
||||
* registry with a uSES read face, and the empty-state startSession chain.
|
||||
* Contract: api-contracts v3 section 7. Selection/draft state moved to the
|
||||
* declared chat store (slot terminal design §4) — the per-scope store maps,
|
||||
* lazy construction, and prune bookkeeping this service used to carry are
|
||||
* retired; what remains is the send/stop orchestration face.
|
||||
*
|
||||
* Scope addressing rides the cordis Service tracker: property access through
|
||||
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
||||
@@ -20,11 +22,8 @@ import type { Context } from 'cordis'
|
||||
// SessionsService tags contexts with — scopeOf then always returns undefined
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SelectionTarget, ViewEntry, ViewId } from './index.ts'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ViewEntry, ViewId } from './index.ts'
|
||||
|
||||
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
|
||||
interface ViewsState {
|
||||
@@ -37,8 +36,6 @@ interface ViewsState {
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
private readonly selections = new Map<SessionId, SnapshotStore<SelectionTarget | null>>()
|
||||
private readonly draftStores = new Map<SessionId, SnapshotStore<string>>()
|
||||
private readonly viewsState: ViewsState = {
|
||||
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
|
||||
}
|
||||
@@ -71,37 +68,6 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/** Per-scope selection channel (details linkage); root access throws. */
|
||||
get selection(): SnapshotStore<SelectionTarget | null> {
|
||||
return this.scopeStore(this.selections, 'selection',
|
||||
() => createSnapshotStore<SelectionTarget | null>(null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope draft store, persisted per session id; root access throws.
|
||||
* Persistence is hand-rolled (raw string per key): the snapshot-store
|
||||
* engine's persist middleware object-spreads state on save, corrupting
|
||||
* primitive-state stores.
|
||||
*/
|
||||
get drafts(): SnapshotStore<string> {
|
||||
return this.scopeStore(this.draftStores, 'drafts', (id) => {
|
||||
const key = `dsh.conversation.draft.${id}`
|
||||
const store = createSnapshotStore<string>(loadDraft(key))
|
||||
store.subscribe(() => { saveDraft(key, store.getSnapshot()) })
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the scoped selection and open the details panel. Orchestration
|
||||
* only — panel geometry stays with ctx.layout.
|
||||
* @param target - selection target.
|
||||
*/
|
||||
openDetails(target: SelectionTarget): void {
|
||||
this.selection.set(target)
|
||||
this.requireLayout().openDetails()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a conversation view. Duplicate ids throw; the registration is an
|
||||
* effect on the caller's fiber (plugin unload collects it).
|
||||
@@ -170,9 +136,9 @@ export class ConversationService extends Service {
|
||||
const sessions = this.requireSessions()
|
||||
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
|
||||
// The manager notifier flushes per microtask; one await guarantees the
|
||||
// list-store projection landed before layout.open validates against it.
|
||||
// list-store projection landed before sessions.open validates against it.
|
||||
await Promise.resolve()
|
||||
this.requireLayout().open(id)
|
||||
sessions.open(id)
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
|
||||
// ctx.get, not scoped.conversation: property access walks the fiber
|
||||
@@ -185,34 +151,11 @@ export class ConversationService extends Service {
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag; root contexts fail loud. */
|
||||
private scopeId(op: string): SessionId {
|
||||
const id = scopeOf(this.ctx)
|
||||
if (id === undefined) {
|
||||
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scope store account: lazily created, booked on the scope fiber so the
|
||||
* scope teardown (SessionsService prune) collects the entry.
|
||||
*/
|
||||
private scopeStore<T>(
|
||||
map: Map<SessionId, SnapshotStore<T>>, op: string,
|
||||
make: (id: SessionId) => SnapshotStore<T>): SnapshotStore<T> {
|
||||
const id = this.scopeId(op)
|
||||
let store = map.get(id)
|
||||
if (store === undefined) {
|
||||
store = make(id)
|
||||
map.set(id, store)
|
||||
this.ctx.effect(() => () => { map.delete(id) }, `conversation.${op} scope account`)
|
||||
}
|
||||
return store
|
||||
return this.requireSessions().manager.get(id)
|
||||
}
|
||||
|
||||
private requireSessions(): SessionsService {
|
||||
@@ -223,12 +166,6 @@ export class ConversationService extends Service {
|
||||
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
|
||||
return sessions
|
||||
}
|
||||
|
||||
private requireLayout(): LayoutService {
|
||||
const layout = this.ctx.get('layout')
|
||||
if (layout === undefined) throw new Error('conversation: layout service unavailable')
|
||||
return layout
|
||||
}
|
||||
}
|
||||
|
||||
function bumpViews(state: ViewsState): void {
|
||||
@@ -236,16 +173,3 @@ function bumpViews(state: ViewsState): void {
|
||||
state.tick += 1
|
||||
for (const fn of [...state.listeners]) fn()
|
||||
}
|
||||
|
||||
function loadDraft(key: string): string {
|
||||
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
return localStorage.getItem(key) ?? ''
|
||||
}
|
||||
|
||||
function saveDraft(key: string, text: string): void {
|
||||
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (text === '') localStorage.removeItem(key)
|
||||
else localStorage.setItem(key, text)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,82 @@
|
||||
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
|
||||
// Tab_Group + view area + composer). Zero framework imports — everything
|
||||
// arrives via props from the inject factory: breadcrumb feed, view registry
|
||||
// read face, per-view render, and the composer's draft/send choreography.
|
||||
// The active view id lives in layout.viewFor (shell viewing state), read and
|
||||
// written through injected accessors.
|
||||
// Tab_Group + view area + composer). Pure component — everything arrives via
|
||||
// props: the framework standard kit (useSession/sessionId/useSessions), the
|
||||
// declared chat store's useStore/actions, and the injected business face.
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
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'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/**
|
||||
* Full props = owner share (sessionId) & standard share (useSession) &
|
||||
* injected share — composed by reference from the contract, never re-typed
|
||||
* here (share-ownership rule).
|
||||
*/
|
||||
/** Full props = the automatic shares & injected share — composed by reference
|
||||
* from the contract, never re-typed here (share-ownership rule). */
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
|
||||
* empty when unknown; a broken link stops the walk). Pure twin of the
|
||||
* sessions service's ancestry — components derive, they don't subscribe. */
|
||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = list.byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, send, stop, openDetails, loadOlder, open,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
const activeId = useActiveView() ?? 'chat'
|
||||
// The store's persisted view id may be stale (view plugin unloaded); the
|
||||
// registry is the runtime validator — unknown ids fall to the first view.
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = list.find(v => v.id === activeId) ?? list[0]
|
||||
|
||||
const ancestry = useAncestry()
|
||||
const draft = composer.useDraft()
|
||||
const running = useSession(s => (s as { running: boolean }).running)
|
||||
const removed = useSession(s => (s as { removed: boolean }).removed)
|
||||
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
|
||||
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const draft = useStore(s => s.draft)
|
||||
const running = useSession(s => s.running)
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
|
||||
// Views receive the shares this component already holds (hook transfer is
|
||||
// plain props passing); the callback slice is referentially stable per
|
||||
// injected identity so memoized view rows hold.
|
||||
const viewProps = useMemo<ConvViewProps>(() => ({
|
||||
sessionId, useSession, useStore,
|
||||
actions: { openDetails, loadOlder },
|
||||
}), [sessionId, useSession, useStore, openDetails, loadOlder])
|
||||
|
||||
const renderView = (entry: ViewEntry): ReactNode => {
|
||||
const Header = entry.chrome?.header
|
||||
const Footer = entry.chrome?.footer
|
||||
const View = entry.component
|
||||
return (
|
||||
<>
|
||||
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
|
||||
<View {...viewProps} />
|
||||
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<header className={css.header}>
|
||||
@@ -52,7 +91,7 @@ export function ConversationRoot({
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { actions.open(s.id) }}
|
||||
onClick={() => { open(s.id) }}
|
||||
>
|
||||
{s.title}
|
||||
</button>
|
||||
@@ -74,7 +113,7 @@ export function ConversationRoot({
|
||||
role="tab"
|
||||
aria-selected={v.id === active?.id}
|
||||
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.openView(v.id) }}
|
||||
onClick={() => { actions.setView(v.id) }}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
@@ -93,9 +132,9 @@ export function ConversationRoot({
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
onDraftChange={composer.setDraft}
|
||||
onSend={composer.send}
|
||||
onStop={composer.stop}
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onStop={stop}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Subscribes to the per-scope selection and
|
||||
// derives the call material from the session snapshot — no data of its own.
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
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'
|
||||
|
||||
/** Full props composed by reference from the contract (owner & standard & injected shares). */
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
@@ -41,13 +43,13 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanelProps) {
|
||||
const selection = useSelection(s => s)
|
||||
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
const material = useSession(
|
||||
s => (callId === undefined ? null : materialFor(s as ConversationSnapshot, callId)),
|
||||
s => (callId === undefined ? null : materialFor(s, callId)),
|
||||
(a, b) => shallowEqual(a, b))
|
||||
|
||||
return (
|
||||
@@ -58,7 +60,7 @@ export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanel
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.close} aria-label="关闭详情"
|
||||
onClick={() => { actions.closeDetails() }}
|
||||
onClick={() => { closeDetails() }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// EmptyState (figma NEW SESSION screen): centered hero card built around the
|
||||
// SAME InputBar component the resident composer uses (the empty→content
|
||||
// transition is one component changing position, never a swap). Project
|
||||
// picker: cwd set derived from sessions.list plus a free-form new-directory
|
||||
// input; submit runs the startSession chain (create → open → send) in one
|
||||
// service call.
|
||||
// picker: cwd set derived in-component from the standard useSessions hook
|
||||
// (subscription is the framework's, derivation is a pure function — design
|
||||
// §6) plus a free-form new-directory input; submit runs the startSession
|
||||
// chain (create → open → send) in one service call.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
@@ -15,11 +17,22 @@ import css from './EmptyState.module.css'
|
||||
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
|
||||
const NEW_DIR = '::new-directory'
|
||||
|
||||
/** Full props composed by reference from the contract (owner & injected shares; root slot has no standard share). */
|
||||
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||
export type EmptyStateProps = EmptyStateSlotProps
|
||||
|
||||
export function EmptyState({ useCwds, actions }: EmptyStateProps) {
|
||||
const cwds = useCwds(s => s)
|
||||
/** Deduped cwd set in list order (pure derivation over the sessions list). */
|
||||
function deriveCwds(state: SessionListState): readonly string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const id of state.ids) {
|
||||
const cwd = state.byId[id]?.cwd
|
||||
if (cwd !== undefined && cwd !== '') seen.add(cwd)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
@@ -35,14 +48,14 @@ export function EmptyState({ useCwds, actions }: EmptyStateProps) {
|
||||
setSending(true)
|
||||
setError(null)
|
||||
const chosen = cwd.trim()
|
||||
actions.startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
.catch((reason: unknown) => {
|
||||
// The empty state survives failure with the draft intact (no session
|
||||
// exists to carry promptError; this is the only local error surface).
|
||||
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
|
||||
setSending(false)
|
||||
})
|
||||
// Success needs no cleanup: layout.open swaps this slot out for the session body.
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
const picker = (
|
||||
|
||||
40
packages/client/ui-conversation/src/client/stores.ts
Normal file
40
packages/client/ui-conversation/src/client/stores.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Chat store factory (slot terminal design §4): selection + draft + active
|
||||
* view for one session, shared by the conversation and details registrations
|
||||
* (apply constructs one handle and passes it to both). Session-scope
|
||||
* derivation: both mount slots are scope=session, so the framework creates
|
||||
* one instance per session; the persist key is scope-suffixed by the
|
||||
* framework, aligning with the previous per-session draft persistence.
|
||||
*
|
||||
* 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 type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
|
||||
|
||||
/**
|
||||
* Declare the per-session chat store. `selection` is the details-linkage
|
||||
* channel (conversation writes, details reads); `draft` is the composer text
|
||||
* (persisted so it survives session switches and reloads); `view` is the
|
||||
* active conversation view id (previously layout.viewFor — store seat is the
|
||||
* cross-remount survival channel, null falls back to the first registered view).
|
||||
* @returns the store handle (spec + identity + factory in one value).
|
||||
*/
|
||||
export function createChatStore() {
|
||||
return defineStore({
|
||||
// Anchored to the contract shape: views consume the store through
|
||||
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
|
||||
// contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, target: SelectionTarget | null) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
// Optimistic-send failure restore: only when the user typed nothing new
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: ViewId) => { d.view = view },
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user