# 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
486 lines
20 KiB
TypeScript
486 lines
20 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 { 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>
|
||
)
|
||
}
|