perf: InputBar use immutable props
This commit is contained in:
parent
a731536ecc
commit
5f1eca58ea
6 changed files with 64 additions and 41 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue