feat(web): add plan mode controls
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 default composer's bottom row exposes the session-scoped `'conversation.composer.controls'` list slot to the left of the primary action. Mode and policy features contribute controls through that slot; whole-composer takeovers such as questions remain selector-routed entries of the separate `'conversation.composer'` chain.
|
||||
|
||||
`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
|
||||
|
||||
@@ -69,11 +69,12 @@ export function apply(ctx: Context): void {
|
||||
// ConversationRoot is the only component authorized to render the ring.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
// The composer chain rides the same declaration table: takeover plugins
|
||||
// register selector-routed replacements of the InputBar.
|
||||
// Composer controls are additive bottom-row entries; the chain carries
|
||||
// selector-routed replacements of the whole InputBar.
|
||||
children: {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'conversation.composer.controls': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
|
||||
|
||||
@@ -41,6 +41,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* zero owner changes.
|
||||
*/
|
||||
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
|
||||
/**
|
||||
* Additive controls in the default composer's bottom-left row. Entries
|
||||
* receive the session standard kit and no owner payload.
|
||||
*/
|
||||
'conversation.composer.controls': { kind: 'list'; scope: 'session'; owner: ComposerControlOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +132,17 @@ export interface ComposerChainProps {
|
||||
interactions: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
|
||||
/** Owner seat for additive composer controls; the render site supplies no payload. */
|
||||
export interface ComposerControlOwnerProps {}
|
||||
|
||||
/** Full props of an additive composer-control entry. */
|
||||
export type ComposerControlProps = PropsRuntime<'conversation.composer.controls'>
|
||||
|
||||
/** Full conversation-slot component props: runtime & child-render (view ring, controls, composer chain) & store & injected shares. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<
|
||||
'conversation.view' | 'conversation.composer' | 'conversation.composer.controls'
|
||||
>
|
||||
& PropsStore<ChatStore> & ConversationInjected
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,8 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
|
||||
ComposerControlProps, ConversationInjected,
|
||||
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// 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, the injected business face, and the
|
||||
// renderSlot share for the declared 'conversation.view' child slot (views are
|
||||
// slot entries; the active one renders via the list `only` filter) plus the
|
||||
// renderSlot share for the declared view and composer-control child slots
|
||||
// (views are slot entries; the active one renders via the list `only` filter) plus the
|
||||
// renderSlotChain share for the 'conversation.composer' takeover chain.
|
||||
// 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).
|
||||
@@ -58,6 +58,7 @@ export function ConversationRoot({
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
const controls = renderSlot('conversation.composer.controls', {})
|
||||
|
||||
// The default composer doubles as the chain's all-decline fallback: a
|
||||
// pending wait with no registered takeover must still leave the input usable.
|
||||
@@ -68,6 +69,7 @@ export function ConversationRoot({
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
controls={controls}
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onStop={stop}
|
||||
|
||||
@@ -119,12 +119,19 @@
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */
|
||||
/* figma Frame 1123 (34:11463): session controls sit bottom-left and the
|
||||
primary action bottom-right. */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10px 10px 12px;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px 10px 16px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
|
||||
|
||||
@@ -26,13 +26,15 @@ export interface InputBarProps {
|
||||
placeholder?: string
|
||||
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
|
||||
accessory?: ReactNode
|
||||
/** Optional bottom-row controls, left of the primary button. */
|
||||
controls?: 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, controls, onDraftChange, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
@@ -116,6 +118,7 @@ export function InputBar({
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<div className={css.controls}>{controls}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('apply wiring', () => {
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
expect(b.slots.spec('conversation.composer.controls')).toEqual({ kind: 'list', scope: 'session' })
|
||||
})
|
||||
|
||||
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
|
||||
@@ -122,6 +123,7 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.spec('conversation.composer.controls')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
|
||||
expect(b.ctx.get('conversation')).toBeUndefined()
|
||||
|
||||
@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
|
||||
expect(view.getByTestId('acc')).toBeTruthy()
|
||||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders additive controls left of the primary action', () => {
|
||||
const { view, button } = setup({ controls: <span data-testid="mode-control">计划</span> })
|
||||
const control = view.getByTestId('mode-control')
|
||||
expect(control).toBeTruthy()
|
||||
expect(control.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,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.composer.controls'
|
||||
? null
|
||||
: <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
|
||||
|
||||
@@ -119,9 +119,9 @@ describe('ConversationRoot', () => {
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
// the ring key carrying the active-id filter (a Mock cannot satisfy the
|
||||
// generic method type directly — cast once at the prop seam).
|
||||
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
|
||||
))
|
||||
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => key === 'conversation.composer.controls'
|
||||
? <span data-testid="composer-controls" />
|
||||
: <div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />)
|
||||
const ui = render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
@@ -173,7 +173,9 @@ describe('ConversationRoot', () => {
|
||||
const { renderSlot } = bench([tab('chat', 'Chat')])
|
||||
// No owner share: views take everything from the standard kit (contract).
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.controls', {})
|
||||
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
|
||||
expect(screen.getByTestId('composer-controls')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
||||
@@ -195,6 +197,7 @@ describe('ConversationRoot', () => {
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
|
||||
expect(screen.getByText('question takeover')).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
||||
expect(screen.queryByTestId('composer-controls')).toBeNull()
|
||||
// The owner dispatches the raw pending list (chain currency); routing
|
||||
// lives in entry selectors, not here.
|
||||
expect(renderSlotChain).toHaveBeenCalledWith(
|
||||
|
||||
Reference in New Issue
Block a user