Files
deepseek-harness/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
imccyu cdd4d59ea0 chore(lint): clear the semantic .tsx backlog
Hand fixes for the findings --fix cannot touch, mirroring the fixes
already applied on the fe-docs feature branch (same file, same shape)
so its eventual rebase resolves cleanly:

- restore the return the no-confusing-void-expression autofix ate in
  useAbsentSnapshot (typed S | undefined; hook call kept for hook-order
  stability, undefined returned explicitly);
- re-type DOM queries the no-unnecessary-type-assertion autofix broke:
  getByRole<HTMLButtonElement>(...) generics instead of the removed
  as-casts (the eslint program and the client tsconfig aggregate
  disagree about these casts; the generic form satisfies both);
- justified eslint-disable for the deliberate legacy paths: keyCode 229
  IME-composition detection, execCommand clipboard fallbacks, lib.dom
  clipboard optionality, and the any-typed Reflect.get/this probes in
  test fakes;
- drop the dead react/no-danger directive (eslint-plugin-react is not
  loaded, so the rule never applied) keeping its shiki rationale;
- delete the tautological 'Z' comparison and the renameTarget null
  check already implied by renameBlocked;
- css-module non-null assertions replaced by type widening
  (Button className, TAG_CLASS Record) per the established pattern;
- misc: max-len comment wraps, void generic drop in the deferred test
  helper, unused type imports, floating selectWorkspace promises voided,
  member-delimiter newlines in inline type literals.
2026-07-27 22:23:41 +08:00

400 lines
17 KiB
TypeScript

/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import css from './InputBar.module.css'
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
message: string
}
export type InputBarProps = ComposerBarProps
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
{ id: 'readonly', label: 'Read-only' },
{ id: 'readwrite', label: 'Read-write' },
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
// Prompt failures are ordinary failures (no create/attach transaction
// exists anymore): the strip renders promptError, the draft stays in the
// machine, and the user resubmits.
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const draft = input.draft
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
const onCompositionStart = (): void => {
composingRef.current = true
}
const onCompositionEnd = (): void => {
setTimeout(() => {
composingRef.current = false
}, 10)
}
// Placeholder chrome: Access selection stays local until its seam lands
// (plan/model are real seats now — the named single slots below).
const [readonlyId, setReadonlyId] = useState('readonly')
// Queue cut 1: running input stays free; locked = session disabled only.
// The transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const locked = disabled
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
return
}
if (e.key === 'Escape') {
// Escape layering: an open overlay closes; claimed without an overlay
// does NOT release (backspacing the token is the only exit gesture).
keyboard.dismissPopup()
if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault()
return
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
// The machine owns the undo/redo log (chip transactions have semantics
// the browser stack cannot represent); never let the native stack run.
e.preventDefault()
if (machineBusy || locked) return
const redo = e.key === 'y' || e.shiftKey
if (redo) keyboard.redo()
else keyboard.undo()
return
}
if (e.key === ' ') {
if (composing) return
if (keyboard.space()) e.preventDefault() // claim token already carries the trailing separator
return
}
if (e.key !== 'Enter') return
if (composing) return
// Menu-open Enter picks the highlight through arbitration; a no-highlight
// menu passes down to the machine's own adjudication.
const arbitrated = keyboard.arbitrate('enter', composing)
if (arbitrated !== 'pass') {
e.preventDefault()
return
}
if (e.ctrlKey || e.metaKey) {
// Newline as a machine transaction (the machine owns undo history; an
// execCommand write would fork a second, browser-owned history).
e.preventDefault()
if (!machineBusy && !locked) {
const el = e.currentTarget
const sel = selectionOf(el)
keyboard.newline(sel)
const caret = sel.start + 1
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
}
return
}
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
inputActions.submit('queue')
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
keyboard.track(next, e.target.selectionStart ?? next.length)
}
// ---- chip atomicity (DOM layer; the machine sees only transactions) ----
// Placeholders occupy exactly one char, so caret positions are always
// BETWEEN them — what needs normalizing is deletion (whole chip per
// Backspace/Delete via native single-char semantics, which U+FFFC already
// gives us) and selection endpoints: Shift-extension snapping is native
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
const slice = draft.slice(start, end)
const touched = input.occurrences.filter(o => o.offset >= start && o.offset < end)
if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine
e.preventDefault()
// Expand placeholders to their owner clipboard projections.
let text = ''
let cursor = start
for (const o of touched) {
text += draft.slice(cursor, o.offset) + o.clipboardText
cursor = o.offset + 1
}
text += draft.slice(cursor, end)
e.clipboardData.setData('text/plain', text)
if (cut && !machineBusy && !locked) {
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
requestAnimationFrame(() => { el.setSelectionRange(start, start) })
}
void slice
}
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
if (machineBusy || locked) return
const text = e.clipboardData.getData('text/plain')
if (text === '') return
e.preventDefault()
const el = e.currentTarget
const sel = selectionOf(el)
// Sync components stay empty at this layer: hot-snapshot matching needs
// the Slash roster, which lives behind keyboard.track — the paste attempt
// opens in the machine and the controller upgrades tokens as matches
// land (paste-upgrade). The DOM layer only starts the transaction.
keyboard.pasteBegin(text, sel)
const caret = sel.start + text.length
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
keyboard.track(keyboard.snapshot.draft, caret)
}
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
void e
}
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
inputRef.current?.focus()
}
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
if (running) {
stop()
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
}
// Access placeholder select (the one remaining local-chrome control).
const accessSelect: ReactNode = (
<select
className={css.select}
aria-label="Access mode"
value={readonlyId}
disabled={locked}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
>
{READONLY_OPTIONS.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, keyboard.lexicon())
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
// text-ref range (decision 21) — merged in draft order (the sources never
// overlap: chips sit on placeholders, text-refs on plain tokens, the
// claim token only leads).
let cursor = 0
const pushPlain = (upTo: number): void => {
if (upTo > cursor) backdrop.push(draft.slice(cursor, upTo))
cursor = upTo
}
if (deco.token !== null) {
backdrop.push(
<mark key="token" className={css.hlToken} data-decoration="token">
{draft.slice(deco.token.start, deco.token.end)}
</mark>,
)
cursor = deco.token.end
}
type Boundary =
| { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] }
| { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number] }
const boundaries: Boundary[] = [
...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })),
...deco.textRefs.map(ref => ({ at: ref.start, kind: 'text-ref' as const, ref })),
].sort((a, b) => a.at - b.at)
for (const b of boundaries) {
if (b.at < cursor) continue // claim-token overlap: the leading mark wins
pushPlain(b.at)
if (b.kind === 'chip') {
const chip = b.chip
backdrop.push(
// The cell's ::before renders U+FFFC itself so its advance equals the
// textarea's placeholder exactly (same char, same font); the label is
// a clipped overlay that never affects layout.
<span
key={`chip-${chip.occurrenceId}`}
className={clsx(css.chip, chip.invalid && css.chipInvalid)}
data-decoration="chip"
data-occurrence={chip.occurrenceId}
data-invalid={chip.invalid || undefined}
title={chip.label}
>
<span className={css.chipLabel}>{chip.label}</span>
</span>,
)
cursor = chip.offset + 1 // the placeholder char the chip stands for
} else {
// Plain-range highlight (decision 21): the glyphs stay the
// textarea's (advance untouched); the mark paints the chip look.
backdrop.push(
<mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
{draft.slice(b.ref.start, b.ref.end)}
</mark>,
)
cursor = b.ref.end
}
}
pushPlain(draft.length)
if (deco.hint !== null) {
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{deco.hint}</span>)
}
}
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
<div className={css.error} role="alert">
{error.message}
</div>
)}
{notice !== null && (
<div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
{notice.text}
</div>
)}
<div className={css.card}>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input.phase}
placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
<div className={css.tools}>
<button
type="button"
className={css.add}
aria-label={addLabel}
title={addLabel}
disabled={locked}
onMouseDown={keepFocus}
onClick={onAdd}
>
<IconPlusOutline16 size={14} />
</button>
<div className={css.modes}>
{renderSlot('conversation.input.plan', { locked })}
{accessSelect}
</div>
{leftItems}
</div>
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.model', { locked })}
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={primaryLabel}
disabled={!running && (empty || disabled || machineBusy)}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>
)
}