Files
deepseek-harness/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
Yichen Jiang b599d1d0af Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts:
#	.gitignore
#	apps/cli/package.json
#	apps/cli/src/web.ts
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
#	docs/module-graph.md
#	packages/client/connection/README.md
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/src/client/workspaces/service.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/host/apiproxy/src/api-proxy.ts
#	pnpm-lock.yaml
2026-07-27 12:27:30 +08:00

486 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import type { ChangeEvent, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import { ImageLightbox } from './ImageLightbox.tsx'
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, addImages, removeImage, draftImages, stop, renderSlot,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.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 attachments = useMemo(() => draftImages(input.imageIds), [draftImages, input.imageIds])
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
const [dropError, setDropError] = useState<string | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const dragDepthRef = useRef(0)
// 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'
useEffect(() => {
if (attachments.length !== input.imageIds.length) {
inputActions.pruneImages(attachments.map(attachment => attachment.id))
}
}, [attachments, input.imageIds, inputActions])
useEffect(() => {
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
// 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
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 && (e.key === 'z' || e.key === 'Z'))
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)
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.
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
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: ClipboardEvent<HTMLTextAreaElement>): void => {
if (machineBusy || locked) return
const files = [...e.clipboardData.items]
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0) {
setDropError(addImages(files, attachments))
}
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
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
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy) return
dragDepthRef.current += 1
setDropError(null)
setDragActive(true)
}
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked || machineBusy ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
dragDepthRef.current = 0
setDragActive(false)
if (locked || machineBusy) return
const dropped = [...event.dataTransfer.files]
if (dropped.length === 0) return
setDropError(addImages(dropped, attachments))
}
const closePreview = useCallback(() => { setPreview(null) }, [])
// 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>
)}
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
<div
className={clsx(css.card, dragActive && css.dragActive)}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status">松开以添加图片</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} aria-label="待发送图片">
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title="双击查看原图"
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || '待发送图片'} />
</button>
<button
type="button"
className={css.remove}
aria-label={`移除图片 ${attachment.file.name || ''}`}
onClick={() => {
setDropError(null)
removeImage(attachment.id)
}}
>×</button>
</div>
))}
</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={(event) => {
setDropError(null)
onChange(event)
}}
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>
{preview !== null && <ImageLightbox src={preview.previewUrl} alt={preview.file.name || '原图'} onClose={closePreview} />}
</div>
)
}