feat(web): add session model selector
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('发送')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}</>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user