feat(web): add plan mode controls

This commit is contained in:
fz
2026-07-24 12:55:23 +08:00
parent bc63b5fe00
commit 9a69feeec0
41 changed files with 1047 additions and 27 deletions
@@ -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)}