From 5f1eca58eaaaf5bf604b64a27cbd25a8d38e5095 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:05:27 +0800 Subject: [PATCH] perf: InputBar use immutable props --- .../ui-conversation/src/client/apply.ts | 8 +++--- .../src/client/contract/slots.ts | 23 ++++++--------- .../src/client/skeleton/ConversationRoot.tsx | 5 ---- .../src/client/skeleton/InputBar.tsx | 28 ++++++++++++------- .../tests/input-bar.client.spec.tsx | 22 ++++++++++----- .../tests/skeleton.client.spec.tsx | 19 +++++++++++++ 6 files changed, 64 insertions(+), 41 deletions(-) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 87623ca3c3..4c8527c1ee 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1f84c8e3f1..5107c319bf 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 & 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' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index eb9a3be4fd..dfb515bde6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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 = ( diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6c8c46cad4..c223459c51 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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 &&
{overlay}
} + {sessionId !== undefined && ( +
{renderSlot('conversation.input.overlay', {})}
+ )} {accessory !== undefined &&
{accessory}
} {renderSlot('conversation.input.attachments', { attachments, @@ -454,10 +456,14 @@ export function InputBar({ {accessSelect} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} - {leftItems} + {input === undefined || sessionId === undefined + ? null + : renderSlot('conversation.input.left', {})}
- {rightItems} + {input === undefined || sessionId === undefined + ? null + : renderSlot('conversation.input.right', {})} {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} {interruptible && ( @@ -499,7 +505,9 @@ export function InputBar({
- {footer} + {variant === 'composer' && input !== undefined && sessionId !== undefined + ? renderSlot('conversation.composer.dock', {}) + : null} ) -} +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 94aec70bbf..0edc8b9ef6 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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() const textarea = view.container.querySelector('[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: , leftItems: , rightItems: , + footer: , }) 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) }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 3abf6f1381..b431a51ff8 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -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' },