feat(web): add session model selector

This commit is contained in:
Yichen Jiang
2026-07-24 14:55:54 +08:00
parent bc7a89b81f
commit 208a44a7ec
87 changed files with 2236 additions and 87 deletions

View File

@@ -10,6 +10,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
The resident composer declares the session-scoped single slot `'conversation.composer.control'` and renders its occupant immediately before the send/stop button. Feature packages own the control and its state; ui-conversation supplies only the placement and standard slot shares. The new-session empty-state composer deliberately has no corresponding control slot.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience

View File

@@ -74,6 +74,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.control': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {

View File

@@ -41,6 +41,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/**
* Optional controls rendered in the resident InputBar immediately before
* its primary send/stop button. The conversation entry declares and owns
* the render site; feature plugins contribute the single occupant.
*/
'conversation.composer.control': {
kind: 'single'
scope: 'session'
owner: ComposerControlOwnerProps
}
}
}
@@ -127,9 +137,13 @@ export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
/** Composer-control owner share; session state and actions arrive through the standard and injected shares. */
export interface ComposerControlOwnerProps {}
/** Full conversation-slot component props: runtime & child-render shares & store & injected shares. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
PropsRuntime<'conversation'>
& PropsRenderSlots<'conversation.view' | 'conversation.composer' | 'conversation.composer.control'>
& PropsStore<ChatStore> & ConversationInjected
/**

View File

@@ -18,8 +18,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -68,6 +68,7 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
control={renderSlot('conversation.composer.control', {})}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}

View File

@@ -124,9 +124,14 @@
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 10px 10px 12px;
}
.control {
min-width: 0;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
white glyph; empty text = 0.4 opacity. */

View File

@@ -26,13 +26,16 @@ export interface InputBarProps {
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
/** Optional resident composer control rendered immediately before the primary button. */
control?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, variant, placeholder, accessory, control,
onDraftChange, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -116,6 +119,7 @@ export function InputBar({
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
{control !== undefined && <div className={css.control}>{control}</div>}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}

View File

@@ -29,6 +29,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}

View File

@@ -41,7 +41,8 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */

View File

@@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}

View File

@@ -26,7 +26,8 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
describe('render branch tails', () => {

View File

@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders the optional composer control immediately before the primary button', () => {
const { view } = setup({ control: <button type="button">model</button> })
const buttons = view.getAllByRole('button')
expect(buttons.map(button => button.textContent)).toEqual(['model', ''])
expect(buttons[1]?.getAttribute('aria-label')).toBe('发送')
})
})

View File

@@ -30,7 +30,8 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
function sessionSource(over?: Partial<ConversationSnapshot>) {
@@ -58,7 +59,9 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
const stubRenderSlot = ((key: string) =>
key === 'conversation.view' ? <div data-testid="view-body" /> : null
) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>

View File

@@ -177,8 +177,11 @@ describe('ConversationRoot', () => {
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
const { chat, send, renderSlot } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.control', {})
expect(screen.getByTestId('view-(all)').getAttribute('data-slot'))
.toBe('conversation.composer.control')
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.