perf: InputBar use immutable props

This commit is contained in:
imccyu 2026-09-01 19:05:27 +08:00
parent a731536ecc
commit 5f1eca58ea
6 changed files with 64 additions and 41 deletions

View file

@ -202,11 +202,7 @@ export function apply(ctx: Context): void {
'conversation.session.header': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.hero.brand.mark': { kind: 'single', scope: 'root' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
@ -277,8 +273,12 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
},
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
if (sessionId === undefined) {

View file

@ -128,11 +128,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Floating entries rendered inside the resident composer card. */
'conversation.input.overlay': { kind: 'list'; scope: 'session' }
/** Ambient entries below the composer card. */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
'conversation.composer.dock': { kind: 'list'; scope: 'session' }
/** Compact controls at the left of the composer tool row. */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
'conversation.input.left': { kind: 'list'; scope: 'session' }
/** Compact controls before the composer submit action. */
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
'conversation.input.right': { kind: 'list'; scope: 'session' }
/** Resident composer body, including the no-Session inert state. */
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/** Optional draft-image rail and drop target. */
@ -255,14 +255,6 @@ export interface ComposerBarOwnerProps {
placeholder?: string
/** Optional content rendered above the composer surface. */
accessory?: ReactNode
/** Floating overlay content rendered inside the composer card. */
overlay?: ReactNode
/** Left-side input controls. */
leftItems?: ReactNode
/** Right-side input controls. */
rightItems?: ReactNode
/** Ambient content below the card. */
footer?: ReactNode
}
/** Package-private operations injected into the resident composer bar. */
@ -296,7 +288,10 @@ export interface InputControlOwnerProps {
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<
'conversation.input.attachments' | 'conversation.input.plan' | 'conversation.input.model'
| 'conversation.input.attachments' | 'conversation.input.overlay'
| 'conversation.input.left' | 'conversation.input.plan'
| 'conversation.input.right' | 'conversation.input.model'
| 'conversation.composer.dock'
>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
@ -325,9 +320,7 @@ export type ConversationSlotProps =
& PropsRenderSlots<
| 'conversation.session' | 'conversation.session.header'
| 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.input.dock'
| 'conversation.hero.brand.mark'
| 'conversation.hero.workspace'
| 'conversation.hero.agentPreset'

View file

@ -341,11 +341,6 @@ export function ConversationRoot({
// user clears it.
? { blocked: composerBlock, placeholder: composerBlock.reason }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: sessionId === undefined ? undefined : renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Ambient dock under the card shares the composer's width constraint.
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
const composerBar = (

View file

@ -2,8 +2,8 @@
* Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* useNotices/useLexicon; layout-phase inputs (variant and placeholder) ride
* the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession.
*
* The text surface is the shell-owned Lexical editor bound here through
@ -13,7 +13,7 @@
* trigger instead of a parallel tree.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
@ -39,13 +39,13 @@ import css from './InputBar.module.css'
export type InputBarProps = ComposerBarProps
export function InputBar({
export const InputBar = memo(function InputBar({
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
resolveSubmitMode, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, blocked,
workspacePickerOpen = false, onRequestWorkspace,
placeholder, accessory, overlay, leftItems, rightItems, footer,
placeholder, accessory,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
@ -391,7 +391,9 @@ export function InputBar({
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{sessionId !== undefined && (
<div className={css.overlayAnchor}>{renderSlot('conversation.input.overlay', {})}</div>
)}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{renderSlot('conversation.input.attachments', {
attachments,
@ -454,10 +456,14 @@ export function InputBar({
{accessSelect}
{sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })}
</div>
{leftItems}
{input === undefined || sessionId === undefined
? null
: renderSlot('conversation.input.left', {})}
</div>
<div className={css.trailing}>
{rightItems}
{input === undefined || sessionId === undefined
? null
: renderSlot('conversation.input.right', {})}
{sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{interruptible && (
@ -499,7 +505,9 @@ export function InputBar({
</div>
</div>
</div>
{footer}
{variant === 'composer' && input !== undefined && sessionId !== undefined
? renderSlot('conversation.composer.dock', {})
: null}
</div>
)
}
})

View file

@ -85,6 +85,7 @@ interface BenchOptions {
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
footer?: React.ReactNode
attachments?: readonly ComposerAttachment[]
addImages?: (files: readonly File[]) => string | null
commandMenuOpen?: boolean
@ -145,6 +146,10 @@ function bench(over?: BenchOptions) {
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
if (key === 'conversation.input.overlay') return over?.overlay ?? null
if (key === 'conversation.input.left') return over?.leftItems ?? null
if (key === 'conversation.input.right') return over?.rightItems ?? null
if (key === 'conversation.composer.dock') return over?.footer ?? null
if (key === 'conversation.input.plan') return over?.planEntry ?? null
if (key === 'conversation.input.model') return over?.modelEntry ?? null
return null
@ -196,9 +201,6 @@ function bench(over?: BenchOptions) {
...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}),
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
@ -1278,15 +1280,17 @@ describe('strips and variants', () => {
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders overlay anchor and left/right slot items', () => {
it('renders overlay, left/right, and footer slots at their layout positions', () => {
const { view } = bench({
overlay: <i data-testid="ov" />,
leftItems: <i data-testid="li" />,
rightItems: <i data-testid="ri" />,
footer: <i data-testid="foot" />,
})
expect(view.getByTestId('ov')).toBeTruthy()
expect(view.getByTestId('li')).toBeTruthy()
expect(view.getByTestId('ri')).toBeTruthy()
expect(view.getByTestId('foot')).toBeTruthy()
})
})
@ -1299,7 +1303,10 @@ describe('command launcher chrome and control seats', () => {
// Every seat dispatched, nothing rendered (render passes may repeat; the
// seat set is the contract).
expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
'conversation.input.overlay', 'conversation.input.attachments',
'conversation.input.plan', 'conversation.input.left',
'conversation.input.right', 'conversation.input.model',
'conversation.composer.dock',
])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()
@ -1465,12 +1472,13 @@ describe('command launcher chrome and control seats', () => {
expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry.
const controls = slotCalls.filter(call => call.key !== 'conversation.input.attachments')
const controlKeys = new Set(['conversation.input.plan', 'conversation.input.model'])
const controls = slotCalls.filter(call => controlKeys.has(call.key))
expect(controls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
cleanup()
const live = bench({ running: true })
const liveControls = live.slotCalls.filter(call => call.key !== 'conversation.input.attachments')
const liveControls = live.slotCalls.filter(call => controlKeys.has(call.key))
expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
})

View file

@ -328,6 +328,25 @@ describe('Hero chrome', () => {
})
describe('ConversationRoot resident composer', () => {
it('does not redispatch composer child slots for an unrelated Session publication', () => {
const b = mount(sessionSnapshotOf())
const childKeys = new Set([
'conversation.input.overlay',
'conversation.input.left',
'conversation.input.right',
'conversation.composer.dock',
])
const dispatchCount = () => b.slotCalls.filter(key => childKeys.has(key)).length
const before = dispatchCount()
act(() => {
const current = b.session.getSnapshot()
b.session.set({ ...current, hasMore: !current.hasMore })
})
expect(dispatchCount()).toBe(before)
})
it('renders the composer inert with the blocker\u2019s own reason', () => {
const b = mount(sessionSnapshotOf(), undefined, undefined, {
composerBlock: { reason: 'select a model first' },