refactor(web): isolate draft editor implementation without behavior changes

This commit is contained in:
imccyu committed 2026-09-14 22:10:52 +08:00
1 parent 218db70b75
commit b5abe6cf29
18 files changed
+791 -481

No files matched your search

@@ -9,6 +9,7 @@ import { DropOverlay } from '../DropOverlay.tsx'
import { FileCard } from '../FileCard.tsx'
import { ImageLightbox } from '../ImageLightbox.tsx'
import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
import { installDocumentDropEvents } from './drop-events.ts'
import css from './ComposerAttachments.module.css'
/** Rail item retaining its browser-owned attachment for callbacks. */
@@ -29,54 +30,7 @@ export function ComposerAttachments({
}, [attachments, preview])
useEffect(() => {
const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
const dataTransfer = event.dataTransfer
if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
return dataTransfer
}
const reset = (): void => {
dragDepth.current = 0
setDragActive(false)
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (fileTransfer(event) === null) return
event.preventDefault()
dragDepth.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
if (dataTransfer === null) return
event.preventDefault()
dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (fileTransfer(event) === null) return
dragDepth.current = Math.max(0, dragDepth.current - 1)
if (dragDepth.current === 0) setDragActive(false)
const leftViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
if (dataTransfer === null) return
event.preventDefault()
reset()
if (canAcceptDrop) onAddFiles([...dataTransfer.files])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive)
}, [canAcceptDrop, onAddFiles])
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
@@ -0,0 +1,66 @@
/** Document drag-and-drop listeners owned by one mounted attachment view. */
import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
* Install one attachment view's file-drop listeners.
* @param canAcceptDrop - whether this view accepts the dropped files.
* @param onAddFiles - attachment intake callback.
* @param dragDepth - the view's retained nested-drag counter.
* @param setDragActive - publish whether a file drag is active.
* @returns cleanup for exactly these listeners.
*/
export function installDocumentDropEvents(
canAcceptDrop: ComposerAttachmentsProps['canAcceptDrop'],
onAddFiles: ComposerAttachmentsProps['onAddFiles'],
dragDepth: { current: number },
setDragActive: (active: boolean) => void,
): () => void {
const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
const dataTransfer = event.dataTransfer
if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
return dataTransfer
}
const reset = (): void => {
dragDepth.current = 0
setDragActive(false)
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (fileTransfer(event) === null) return
event.preventDefault()
dragDepth.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
if (dataTransfer === null) return
event.preventDefault()
dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (fileTransfer(event) === null) return
dragDepth.current = Math.max(0, dragDepth.current - 1)
if (dragDepth.current === 0) setDragActive(false)
const leftViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
const dataTransfer = fileTransfer(event)
if (dataTransfer === null) return
event.preventDefault()
reset()
if (canAcceptDrop) onAddFiles([...dataTransfer.files])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
}
@@ -0,0 +1,105 @@
/** Editor-facing ranges, reference projections, and the composer keyboard interface. */
import type { LexicalEditor } from 'lexical'
import type { InputState } from './input.ts'
import type { InputSubmitMode } from './composer-submission.ts'
/** Pick-time draft span guarded by the input revision. */
export interface TokenSpan {
readonly start: number
readonly end: number
readonly draftRev: number
}
/** Structured reference inserted by an input-trigger source. */
export interface ReferenceInsert {
readonly source: string
readonly ref: string
readonly label: string
readonly appearance?: 'session' | 'file' | 'folder'
readonly clipboardText: string
}
/** Keyboard keys intercepted by an open trigger menu. */
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab'
/** Trigger-menu keyboard routing result. */
export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
/**
* The InputBar-exclusive keyboard/DOM command face: synchronous
* returns and event-handler semantics that must not enter the public provide
* channel. Handed to the composer-bar entry through its own inject —
* package-internal, never across a plugin boundary. The session shell
* satisfies it structurally. Text editing itself rides the shell's Lexical
* editor (exposed here for the contenteditable binding); the members below
* are the submit-plane and trigger-pipeline verbs the editor does not own.
*/
export interface ComposerKeyboard {
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** The shell-owned Lexical editor the composer binds its contenteditable to. */
readonly editor: LexicalEditor
/** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
submit(mode: InputSubmitMode): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
* button is the same operation applied to the whole queue).
*/
steerQueue(): void
/** Insert pasted plain text over the current editor selection (reference-placeholder-sanitized). */
paste(text: string): void
/**
* The live selection as a detect-coordinate span (menu-launcher synthetic
* hits replace it on pick); an absent selection answers a collapsed span at
* the document end.
*/
caretSpan(): EditSelection
/** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
/** Space adjudication; true = the input applied a claim — caller preventDefaults. */
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/**
* Bind the mounted composer's file action and live intake availability.
* @param picker - availability query and native file-dialog opener.
* @returns the unbind disposer.
*/
bindFilePicker(picker: { available(): boolean; open(): void }): () => void
}
/** Half-open [start, end) range/selection in detect-projection coordinates. */
export interface EditSelection {
readonly start: number
readonly end: number
}
/**
* One reference occurrence projected from the editor's chip nodes, in
* clipboard-text coordinates. Identity is occurrenceId — a stable per-shell
* assignment per chip NodeKey, so same-named references stay independently
* addressable and survive undo. label/appearance/clipboardText are the
* owner's insert-time projections cached on the node (invalid flips instead
* of dropping the occurrence).
*/
export interface Occurrence {
/** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */
readonly occurrenceId: number
/** Owning source name (serializer routing key). */
readonly source: string
/** Owner-scoped reference id. */
readonly ref: string
/** Offset in the clipboard-text projection. */
readonly offset: number
/** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */
readonly length: number
/** Inline display label (insert-time cache). */
readonly label: string
/** Optional domain glyph (insert-time cache). */
readonly appearance?: ReferenceInsert['appearance']
/** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
readonly clipboardText: string
/** Owner-resolution failure flag: the chip renders the failure treatment. */
readonly invalid?: boolean
}
@@ -9,17 +9,10 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { LexicalEditor } from 'lexical'
import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
import type { QueueRow } from './queue.ts'
import type { InputSubmitMode } from './composer-submission.ts'
/** Pick-time draft span guarded by the input revision. */
export interface TokenSpan {
readonly start: number
readonly end: number
readonly draftRev: number
}
/** Attachment payload passed to a claimed command submission. */
export type SubmitAttachment =
| {
@@ -59,15 +52,6 @@ export interface CommandClaim {
submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise<SubmitOutcome>
}
/** Structured reference inserted by an input-trigger source. */
export interface ReferenceInsert {
readonly source: string
readonly ref: string
readonly label: string
readonly appearance?: 'session' | 'file' | 'folder'
readonly clipboardText: string
}
/** Result of trigger-source adjudication. */
export type PickOutcome =
| { readonly claim: CommandClaim }
@@ -76,12 +60,6 @@ export type PickOutcome =
| 'handled'
| undefined
/** Keyboard keys intercepted by an open trigger menu. */
export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' | 'tab'
/** Trigger-menu keyboard routing result. */
export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass'
/** Scoped request to enter command mode. */
export interface BeginCommandRequest {
readonly claim: CommandClaim
@@ -255,91 +233,12 @@ export interface InputNotice {
readonly seq: number
}
/**
* The InputBar-exclusive keyboard/DOM command face: synchronous
* returns and event-handler semantics that must not enter the public provide
* channel. Handed to the composer-bar entry through its own inject —
* package-internal, never across a plugin boundary. The session shell
* satisfies it structurally. Text editing itself rides the shell's Lexical
* editor (exposed here for the contenteditable binding); the members below
* are the submit-plane and trigger-pipeline verbs the editor does not own.
*/
export interface ComposerKeyboard {
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** The shell-owned Lexical editor the composer binds its contenteditable to. */
readonly editor: LexicalEditor
/** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
submit(mode: InputSubmitMode): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
* button is the same operation applied to the whole queue).
*/
steerQueue(): void
/** Insert pasted plain text over the current editor selection (reference-placeholder-sanitized). */
paste(text: string): void
/**
* The live selection as a detect-coordinate span (menu-launcher synthetic
* hits replace it on pick); an absent selection answers a collapsed span at
* the document end.
*/
caretSpan(): EditSelection
/** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
/** Space adjudication; true = the input applied a claim — caller preventDefaults. */
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/**
* Bind the mounted composer's file action and live intake availability.
* @param picker - availability query and native file-dialog opener.
* @returns the unbind disposer.
*/
bindFilePicker(picker: { available(): boolean; open(): void }): () => void
}
/** One independently addressable row projected from the transient queue snapshot. */
export type QueuedMessage = QueueRow
/** Guard union of the scoped consume-token event, checked by the shell. */
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
/** Half-open [start, end) range/selection in detect-projection coordinates. */
export interface EditSelection {
readonly start: number
readonly end: number
}
/**
* One reference occurrence projected from the editor's chip nodes, in
* clipboard-text coordinates. Identity is occurrenceId — a stable per-shell
* assignment per chip NodeKey, so same-named references stay independently
* addressable and survive undo. label/appearance/clipboardText are the
* owner's insert-time projections cached on the node (invalid flips instead
* of dropping the occurrence).
*/
export interface Occurrence {
/** Shell-assigned stable identity (monotonic per shell, keyed by NodeKey). */
readonly occurrenceId: number
/** Owning source name (serializer routing key). */
readonly source: string
/** Owner-scoped reference id. */
readonly ref: string
/** Offset in the clipboard-text projection. */
readonly offset: number
/** Length in the clipboard-text projection; the occurrence occupies exactly [offset, offset+length). */
readonly length: number
/** Inline display label (insert-time cache). */
readonly label: string
/** Optional domain glyph (insert-time cache). */
readonly appearance?: ReferenceInsert['appearance']
/** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
readonly clipboardText: string
/** Owner-resolution failure flag: the chip renders the failure treatment. */
readonly invalid?: boolean
}
/** Published input state (the currency; per-session). */
export interface InputState {
/** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */
@@ -15,9 +15,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { ComposerBlock } from './composer-blocks.ts'
import type {
ComposerKeyboard, DraftAttachmentId, EditSelection, InputActions, InputNotice, InputState,
} from './input.ts'
import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts'
import type { ComposerKeyboard, EditSelection } from './draft-editor.ts'
import type { createConversationStore } from '../stores.ts'
import type { BusyEnterBehavior } from './composer-submission.ts'
import type { ConversationSnapshot } from './snapshot.ts'
@@ -62,11 +62,11 @@ export type {
UseConversationViews,
} from './contract/slots.ts'
export type {
ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,
DraftAttachmentId, InputActions, InputState, InsertReferenceRequest, InsertTextRequest,
PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitAttachment,
SubmitOutcome, TokenSpan,
BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions,
InputState, InsertReferenceRequest, InsertTextRequest, PickOutcome, SessionInput,
SessionInputResolver, SubmitAttachment, SubmitOutcome,
} from './contract/input.ts'
export type { ArbitrateKey, ArbitrateOutcome, ReferenceInsert, TokenSpan } from './contract/draft-editor.ts'
export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'
declare module '@deepseek-ai/cordis' {
@@ -0,0 +1,63 @@
/** Stateless text-area presentation over the InputBar's borrowed editor. */
import type { CSSProperties, KeyboardEventHandler, ReactNode, RefObject } from 'react'
import type { LexicalEditor } from 'lexical'
import clsx from 'clsx'
import type { InputState } from '../../contract/input.ts'
import { ComposerContentEditable } from './ComposerContentEditable.tsx'
import { DecoratorPortals } from './DecoratorPortals.tsx'
/** Text-area values and the scrollport reference retained by InputBar. */
export interface DraftEditorProps {
readonly classNames: Readonly<Record<string, string>>
readonly editor: LexicalEditor | null
readonly scrollRef: RefObject<HTMLDivElement>
readonly editable: boolean
readonly editorDisabled: boolean
readonly phase: InputState['phase'] | 'inert'
readonly placeholderText: string
readonly ariaLabel: string
readonly workspaceTrigger: boolean
readonly workspacePickerOpen: boolean
readonly onWorkspaceKeyDown: KeyboardEventHandler<HTMLDivElement>
readonly hint: string | null
readonly showPlaceholder: boolean
}
/**
* Render the existing scrollport, editable surface, placeholder, and chip portals.
* @param props - borrowed editor and presentation values; this component owns no Hooks.
* @returns the existing text-area DOM without an additional wrapper.
*/
export function DraftEditor({
classNames: css, editor, scrollRef, editable, editorDisabled, phase, placeholderText, ariaLabel,
workspaceTrigger, workspacePickerOpen, onWorkspaceKeyDown, hint, showPlaceholder,
}: DraftEditorProps): ReactNode {
return (
<div ref={scrollRef} className={css.scroll} data-input-scroll>
<div className={css.grow}>
<ComposerContentEditable
editor={workspaceTrigger ? null : editor}
editable={editable}
className={clsx(css.input, editorDisabled && css.inputDisabled)}
data-phase={phase}
aria-disabled={editorDisabled || undefined}
data-placeholder={placeholderText}
// The placeholder was the textarea's accessible name; a div's
// data attribute is not, so the label restores it.
aria-label={ariaLabel}
aria-haspopup={workspaceTrigger ? 'menu' : undefined}
aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
tabIndex={workspaceTrigger ? 0 : undefined}
onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
/>
{showPlaceholder && (
<div aria-hidden className={css.placeholder} data-composer-placeholder>
{placeholderText}
</div>
)}
<DecoratorPortals editor={workspaceTrigger ? null : editor} />
</div>
</div>
)
}
@@ -12,7 +12,7 @@ import type {
EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread,
} from 'lexical'
import { DecoratorNode } from 'lexical'
import type { ReferenceInsert } from '../../contract/input.ts'
import type { ReferenceInsert } from '../../contract/draft-editor.ts'
import { ReferenceChip } from './ReferenceChip.tsx'
/** JSON form of one chip (Lexical node serialization contract). */
@@ -20,7 +20,7 @@ import {
KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND, PASTE_COMMAND,
} from 'lexical'
import { mergeRegister } from '@lexical/utils'
import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/input.ts'
import type { ArbitrateKey, ArbitrateOutcome } from '../../contract/draft-editor.ts'
/** The bar-supplied behavior behind each intercepted gesture. */
export interface ComposerKeymapHandlers {
@@ -11,7 +11,7 @@ import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical'
import {
$getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode,
} from 'lexical'
import type { Occurrence } from '../../contract/input.ts'
import type { Occurrence } from '../../contract/draft-editor.ts'
import { $isReferenceChipNode } from './chip-node.tsx'
/** The detect-projection stand-in for one chip (object replacement character). */
@@ -4,7 +4,7 @@ import {
CLICK_COMMAND, COMMAND_PRIORITY_LOW,
} from 'lexical'
import type { LexicalEditor } from 'lexical'
import type { ReferenceInsert } from '../../contract/input.ts'
import type { ReferenceInsert } from '../../contract/draft-editor.ts'
import { $isReferenceChipNode } from './chip-node.tsx'
import { TextRefNode } from './text-ref.ts'
@@ -0,0 +1,299 @@
/** The Composer model's private Lexical editor, projections, and node operations. */
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { LexicalEditor, NodeKey } from 'lexical'
import {
$addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
} from 'lexical'
import { registerPlainText } from '@lexical/plain-text'
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
import { mergeRegister } from '@lexical/utils'
import type { Occurrence, ReferenceInsert } from '../../contract/draft-editor.ts'
import { registerReferenceActivation } from './reference-activation.ts'
import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx'
import { refreshClaimDecoration, registerClaimDecoration } from './claim-decor.ts'
import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './text-ref.ts'
import type { EditorProjection } from './projection.ts'
import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './projection.ts'
import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './span-map.ts'
import type { DetectSpan } from './span-map.ts'
type Lexicon = ReadonlyMap<'/' | '@', readonly string[]>
/** Model callbacks read at the same editor registration and update points. */
interface DraftEditorRuntimeDeps {
readonly onUpdate: () => void
readonly openReference: (source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>) => boolean
readonly activeClaimToken: () => string | null
readonly lexicon: () => Lexicon
readonly resolveLexicon: () => ObservableSnapshot<Lexicon> | undefined
}
/**
* Detect-projection and legacy reference placeholders stripped from every
* external text entering the document (paste, persisted-draft seed): a chip
* is the only legitimate source of U+FFFC in the detect projection, so a
* literal one in text would forge chip positions.
*/
const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
const HISTORY_MERGE_DELAY_MS = 1000
/** One model-owned editor; registration and disposal remain with its model. */
export class DraftEditorRuntime {
/** The editor bound by the Composer's contenteditable host. */
readonly editor: LexicalEditor
private projected: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
/** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
private readonly occurrenceIds = new Map<NodeKey, number>()
private occurrenceSeq = 0
/** Live lexicon subscription disposer; undefined until the controller resolves. */
private lexiconOff: (() => void) | undefined
/** @param deps - model callbacks used by editor listeners and transforms. */
constructor(private readonly deps: DraftEditorRuntimeDeps) {
this.editor = createEditor({
namespace: 'dsh-composer',
nodes: [ReferenceChipNode, TextRefNode],
onError: (error) => { throw error },
})
}
/**
* Install editor behavior after the model holds this runtime.
* @returns unregister callback that also detaches the editor root.
*/
register(): () => void {
const unregister = mergeRegister(
registerPlainText(this.editor),
registerReferenceActivation(this.editor, (source, reference) =>
this.deps.openReference(source, reference)),
registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
this.editor.registerUpdateListener(() => { this.deps.onUpdate() }),
registerClaimDecoration(this.editor, () => this.deps.activeClaimToken()),
registerTextRefDecoration(this.editor, () => this.deps.lexicon(), () => this.deps.activeClaimToken()),
() => { this.lexiconOff?.() },
)
return () => {
unregister()
this.editor.setRootElement(null)
}
}
/** The latest committed editor projection. */
get projection(): EditorProjection {
return this.projected
}
/**
* Run one editor edit whose result is observable on return. At the top
* level this is a discrete update. Inside this editor's own update —
* command handlers land here synchronously (space/enter picks, paste) —
* $-functions are already legal, and wrapping them in update() would DEFER
* them past the synchronous bail answer (and a nested discrete throws);
* the body runs directly and the outer update commits it.
* @param fn - the $-edit body.
*/
private applyEdit(fn: () => void, tag?: string): void {
if (this.editor._updating) {
// Nested application joins the enclosing update (the PASTE_COMMAND
// dispatch path always lands here), so the tag attaches to that update.
if (tag !== undefined) $addUpdateTag(tag)
fn()
return
}
this.editor.update(fn, { discrete: true, ...(tag === undefined ? {} : { tag }) })
}
/**
* Subscribe the text-ref re-scan to the controller's lexicon once the
* controller resolves. The deps thunk cannot resolve at construction (the
* shell is created inside the sessions provide materialization), so the
* first interactive updates retry until it can.
*/
private ensureLexiconSubscription(): void {
if (this.lexiconOff !== undefined) return
const lexicon = this.deps.resolveLexicon()
if (lexicon === undefined) return
this.lexiconOff = lexicon.subscribe(() => { rescanTextRefs(this.editor) })
}
/**
* Re-project inside the existing editor update callback.
* @returns the projection preceding this read.
*/
refreshProjection(): EditorProjection {
this.ensureLexiconSubscription()
const prev = this.projected
this.projected = this.editor.getEditorState().read(() =>
$projectComposer(key => this.occurrenceIdOf(key)))
return prev
}
private occurrenceIdOf(key: NodeKey): number {
const existing = this.occurrenceIds.get(key)
if (existing !== undefined) return existing
this.occurrenceSeq += 1
this.occurrenceIds.set(key, this.occurrenceSeq)
return this.occurrenceSeq
}
/**
* Replace the whole draft (persisted-draft seed and programmatic writes).
* Placeholder-sanitized; newlines split paragraphs; the caret lands at the
* end. Merged into history so a seed is not an undoable step of its own.
* @param text - the full next draft.
*/
setDraft(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === this.projection.clipboardText) return
this.editor.update(() => {
const root = $getRoot()
root.clear()
for (const line of clean.split('\n')) {
const paragraph = $createParagraphNode()
if (line !== '') paragraph.append($createTextNode(line))
root.append(paragraph)
}
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/**
* Insert pasted plain text over the current editor selection
* (placeholder-sanitized). The paste event's own default is suppressed by
* the caller; PASTE_TAG makes the paste its own history boundary, so one
* undo never removes both the paste and typing inside the merge window.
* @param text - pasted plain text.
*/
paste(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === '') return
this.applyEdit(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
selection.insertText(clean)
return
}
// No selection yet (never-focused surface): land at the document end,
// growing the first paragraph when the tree is empty.
const root = $getRoot()
if (root.getChildrenSize() === 0) root.append($createParagraphNode())
root.selectEnd().insertText(clean)
}, PASTE_TAG)
}
/**
* The live selection as a detect-coordinate span (menu-launcher synthetic
* hits replace it on pick); an absent selection answers a collapsed span at
* the document end.
* @returns the ordered [start, end) span in detect coordinates.
*/
caretSpan(): { start: number; end: number } {
if (this.projection.selection !== null) return this.projection.selection
const at = this.projection.detectText.length
return { start: at, end: at }
}
/**
* Replace a mapped span without applying the model's phase or revision guards.
* @param span - detect-coordinate range.
* @param text - inserted text.
* @returns whether the range mapped and the edit applied.
*/
replaceText(span: DetectSpan, text: string): boolean {
let applied = false
this.applyEdit(() => {
applied = $replaceDetectSpanWithText(span, text)
})
return applied
}
/**
* Insert a reference chip with the existing trailing-space rule.
* @param span - detect-coordinate range.
* @param ref - reference fields.
* @param tail - the character following the range before editing.
* @returns whether the range mapped and the edit applied.
*/
insertReference(span: DetectSpan, ref: ReferenceInsert, tail: string): boolean {
let applied = false
this.applyEdit(() => {
const nodes = tail === ' '
? [$createReferenceChipNode(ref)]
: [$createReferenceChipNode(ref), $createTextNode(' ')]
applied = $replaceDetectSpanWithNodes(span, nodes)
})
return applied
}
/** Refresh claim-token decoration after the model's claim changes. */
refreshClaimDecoration(): void {
refreshClaimDecoration(this.editor)
}
/**
* Clear committed content using the model's suffix decision inside the editor update.
* @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root.
*/
clearCommittedDraft(prefixLength: (clipboardText: string) => number | null): void {
this.editor.update(() => {
const layout = $composerLayout()
const length = prefixLength(layout.clipboardText)
if (length !== null) {
$replaceDetectSpanWithText(
{ start: 0, end: detectOffsetOfClipboardOffset(layout, length) }, '',
)
return
}
const root = $getRoot()
root.clear()
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/**
* Rebuild one model-selected failure snapshot, creating fresh reference nodes.
* @param draft - clipboard text.
* @param occurrences - reference occurrences in clipboard order.
*/
restoreDraft(draft: string, occurrences: readonly Occurrence[]): void {
this.editor.update(() => {
const root = $getRoot()
root.clear()
let paragraph = $createParagraphNode()
root.append(paragraph)
const appendText = (text: string): void => {
const lines = text.split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (line !== '') paragraph.append($createTextNode(line))
if (i < lines.length - 1) {
paragraph = $createParagraphNode()
root.append(paragraph)
}
}
}
let cursor = 0
for (const occurrence of occurrences) {
appendText(draft.slice(cursor, occurrence.offset))
paragraph.append(new ReferenceChipNode({
source: occurrence.source,
ref: occurrence.ref,
label: occurrence.label,
...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }),
clipboardText: occurrence.clipboardText,
}, occurrence.invalid === true))
cursor = occurrence.offset + occurrence.length
}
appendText(draft.slice(cursor))
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}
/** Cut the editor's undo history after a committed clear or restoration. */
clearHistory(): void {
this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
}
}
@@ -0,0 +1,155 @@
/** DOM and keymap bindings installed by the InputBar's existing effects. */
import type { MouseEvent, MutableRefObject, RefObject } from 'react'
import type { LexicalEditor } from 'lexical'
import type { ComposerKeyboard } from '../../contract/draft-editor.ts'
import type { ComposerBarProps } from '../../contract/slots.ts'
import type { BusyEnterBehavior } from '../../contract/composer-submission.ts'
import { resolveSubmitMode } from '../submission-policy.ts'
import { registerComposerKeymap } from './keymap.ts'
interface DraftViewGate {
locked: boolean
machineBusy: boolean
canSteerQueue: boolean
running: boolean
steeringAvailable: boolean
busyEnter: BusyEnterBehavior
intakeFiles: (files: readonly File[]) => void
uploadsPending: boolean
showToast: (text: string) => void
t: ComposerBarProps['t']
canAcceptDrop: boolean
}
/**
* Reveal the DOM selection within the draft's own scrollport.
* @param scrollRef - the InputBar-owned scrollport reference.
*/
export function revealDraftSelection(scrollRef: RefObject<HTMLDivElement>): void {
const scrollEl = scrollRef.current
if (scrollEl === null || scrollEl.scrollHeight <= scrollEl.clientHeight) return
const selection = window.getSelection()
if (selection === null || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
let rect = range.getBoundingClientRect()
if (rect.height === 0 && rect.width === 0) {
// A collapsed caret at an empty line reports a zero rect in some
// engines; the anchor's element box is the line the caret sits on.
const anchor = selection.anchorNode
const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement
if (el === undefined || el === null) return
rect = el.getBoundingClientRect()
}
const box = scrollEl.getBoundingClientRect()
if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom
else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top
}
/**
* Focus the borrowed editor and reveal its restored selection.
* @param editor - the Session-owned editor.
* @param revealSelection - reveal the selection after Lexical restores it.
*/
export function focusDraftEditor(editor: LexicalEditor, revealSelection: () => void): void {
// Lexical's focus() restores the editor selection but never calls the DOM
// focus itself; preventScroll keeps the conversation scrollport still.
editor.getRootElement()?.focus({ preventScroll: true })
editor.focus(() => { revealSelection() })
}
/**
* Forward wheel movement at the draft's edge to its conversation scrollport.
* @param scrollRef - the InputBar-owned scrollport reference.
* @returns the listener cleanup, or undefined when the element is absent.
*/
export function installDraftWheel(scrollRef: RefObject<HTMLDivElement>): (() => void) | undefined {
const el = scrollRef.current
if (el === null) return
const onWheel = (e: WheelEvent): void => {
const host = el.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement) || e.deltaY === 0) return
const atTop = el.scrollTop <= 0
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
e.preventDefault()
host.scrollTop += e.deltaY
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
}
/**
* Bind this view's file dialog through the existing keyboard face.
* @param keyboard - the Session-owned composer operations.
* @param gate - live intake availability retained by InputBar.
* @param fileInputRef - the view's native file input.
* @returns the picker unbind disposer.
*/
export function installDraftFilePicker(
keyboard: ComposerKeyboard,
gate: MutableRefObject<Pick<DraftViewGate, 'canAcceptDrop'>>,
fileInputRef: RefObject<HTMLInputElement>,
): () => void {
return keyboard.bindFilePicker({
available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
open: () => { fileInputRef.current?.click() },
})
}
/**
* Bind editor gestures to the view's live guards and Session operations.
* @param editor - the borrowed Session-owned editor.
* @param keyboard - the existing composer keyboard operations.
* @param gate - live view values read by the installed handlers.
* @returns the keymap disposer.
*/
export function installDraftKeymap(
editor: LexicalEditor,
keyboard: ComposerKeyboard,
gate: MutableRefObject<DraftViewGate>,
): () => void {
return registerComposerKeymap(editor, {
arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
space: () => {
if (gate.current.machineBusy || gate.current.locked) return false
return keyboard.space()
},
dismissPopup: () => { keyboard.dismissPopup() },
canSubmit: () => !gate.current.locked && !gate.current.machineBusy,
submit: (accelerated) => {
const g = gate.current
// Empty-draft accelerated Enter acts on the queue instead of the
// (empty) draft: the machine rejects empty drafts, so the gesture
// steers every still-pending queued message into the running turn.
if (accelerated && g.canSteerQueue) {
keyboard.steerQueue()
return
}
if (g.uploadsPending) {
g.showToast(g.t('file.stillUploading'))
return
}
keyboard.submit(resolveSubmitMode(
g.busyEnter,
g.running,
accelerated ? 'accelerated' : 'enter',
g.steeringAvailable,
))
},
intakeFiles: (files) => { gate.current.intakeFiles(files) },
pasteText: (text) => {
if (gate.current.machineBusy || gate.current.locked) return
keyboard.paste(text)
},
})
}
/**
* Keep a toolbar press from moving focus away from the draft.
* @param event - the toolbar button's mouse event.
* @param editor - the borrowed editor, absent in the inert view.
*/
export function keepDraftFocus(event: MouseEvent<HTMLButtonElement>, editor: LexicalEditor | null): void {
event.preventDefault()
editor?.getRootElement()?.focus({ preventScroll: true })
}
@@ -12,29 +12,19 @@ import type { Context } from '@deepseek-ai/cordis'
import {
createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
} from '@deepseek-ai/dsh-client-store'
import type { LexicalEditor, NodeKey } from 'lexical'
import {
$addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
} from 'lexical'
import { registerPlainText } from '@lexical/plain-text'
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
import { mergeRegister } from '@lexical/utils'
import type { LexicalEditor } from 'lexical'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ComposerKeyboard, ConsumeTokenRequest, DraftAttachmentId,
CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
SubmitOutcome, TokenSpan,
QueuedMessage, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
} from '../contract/input.ts'
import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import { SubmitMachine } from './machine.ts'
import { registerReferenceActivation } from './editor/reference-activation.ts'
import { ReferenceChipNode, $createReferenceChipNode } from './editor/chip-node.tsx'
import { refreshClaimDecoration, registerClaimDecoration } from './editor/claim-decor.ts'
import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './editor/text-ref.ts'
import { DraftEditorRuntime } from './editor/runtime.ts'
import type { EditorProjection } from './editor/projection.ts'
import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './editor/projection.ts'
import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './editor/span-map.ts'
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
export interface PopupDismissFace {
@@ -103,17 +93,6 @@ const EMPTY_QUEUE: readonly QueuedMessage[] = []
/** No-pipeline lexicon: zero text-ref decorations. */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/**
* Detect-projection and legacy reference placeholders stripped from every
* external text entering the document (paste, persisted-draft seed): a chip
* is the only legitimate source of U+FFFC in the detect projection, so a
* literal one in text would forge chip positions.
*/
const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
const HISTORY_MERGE_DELAY_MS = 1000
/** Editor and attachment snapshot owned by one detached default send. */
interface DetachedDraft {
readonly draft: string
@@ -132,7 +111,9 @@ export class SessionInputShell implements SessionInput {
/** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */
readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
/** The shell-owned editor (text + chip truth); the composer binds its contenteditable to it. */
readonly editor: LexicalEditor
get editor(): LexicalEditor {
return this.draftEditor.editor
}
/** The public provide-channel action face (one stable identity per session). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
@@ -143,11 +124,11 @@ export class SessionInputShell implements SessionInput {
}
private readonly core = new SubmitMachine()
private projection: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null }
private readonly draftEditor: DraftEditorRuntime
private get projection(): EditorProjection {
return this.draftEditor.projection
}
private rev = 0
/** Stable occurrence ids per chip NodeKey (undo restores keys, so ids survive it too). */
private readonly occurrenceIds = new Map<NodeKey, number>()
private occurrenceSeq = 0
private readonly unregister: () => void
private noticeSeq = 0
private lastMirroredDraft = ''
@@ -157,8 +138,6 @@ export class SessionInputShell implements SessionInput {
private mirrorFn: ((text: string) => void) | undefined
/** The mounted composer's file-picker opener (scoped pick-files event target). */
private filePicker: Parameters<ComposerKeyboard['bindFilePicker']>[0] | undefined
/** Live lexicon subscription disposer; undefined until the controller resolves. */
private lexiconOff: (() => void) | undefined
/** Default sends retained until admission settles or scope disposal releases their attachments. */
private readonly detachedDrafts = new Map<number, DetachedDraft>()
/** Failed default sends waiting to be restored together in submission order. */
@@ -174,67 +153,24 @@ export class SessionInputShell implements SessionInput {
}>()
constructor(private readonly deps: SessionInputDeps) {
this.editor = createEditor({
namespace: 'dsh-composer',
nodes: [ReferenceChipNode, TextRefNode],
onError: (error) => { throw error },
this.draftEditor = new DraftEditorRuntime({
onUpdate: () => { this.onEditorUpdate() },
openReference: (source, reference) =>
this.deps.inputTriggers?.()?.openReference(source, reference) ?? false,
activeClaimToken: () => this.activeClaimToken(),
lexicon: () => this.lexicon.getSnapshot(),
resolveLexicon: () => this.deps.inputTriggers?.()?.lexicon,
})
this.unregister = mergeRegister(
registerPlainText(this.editor),
registerReferenceActivation(this.editor, (source, reference) =>
this.deps.inputTriggers?.()?.openReference(source, reference) ?? false),
registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
this.editor.registerUpdateListener(() => { this.onEditorUpdate() }),
registerClaimDecoration(this.editor, () => this.activeClaimToken()),
registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot(), () => this.activeClaimToken()),
() => { this.lexiconOff?.() },
)
this.unregister = this.draftEditor.register()
this.state = createSnapshotStore<InputState>(this.compose())
deps.queue?.subscribe(() => { this.publish() })
}
// ---- editor plumbing ----
/**
* Run one editor edit whose result is observable on return. At the top
* level this is a discrete update. Inside this editor's own update —
* command handlers land here synchronously (space/enter picks, paste) —
* $-functions are already legal, and wrapping them in update() would DEFER
* them past the synchronous bail answer (and a nested discrete throws);
* the body runs directly and the outer update commits it.
* @param fn - the $-edit body.
*/
private applyEdit(fn: () => void, tag?: string): void {
if (this.editor._updating) {
// Nested application joins the enclosing update (the PASTE_COMMAND
// dispatch path always lands here), so the tag attaches to that update.
if (tag !== undefined) $addUpdateTag(tag)
fn()
return
}
this.editor.update(fn, { discrete: true, ...(tag === undefined ? {} : { tag }) })
}
/**
* Subscribe the text-ref re-scan to the controller's lexicon once the
* controller resolves. The deps thunk cannot resolve at construction (the
* shell is created inside the sessions provide materialization), so the
* first interactive updates retry until it can.
*/
private ensureLexiconSubscription(): void {
if (this.lexiconOff !== undefined) return
const controller = this.deps.inputTriggers?.()
if (controller === undefined) return
this.lexiconOff = controller.lexicon.subscribe(() => { rescanTextRefs(this.editor) })
}
/** Re-project, run the claim watch, publish, and feed trigger tracking after every editor commit. */
private onEditorUpdate(): void {
this.ensureLexiconSubscription()
const prev = this.projection
this.projection = this.editor.getEditorState().read(() =>
$projectComposer(key => this.occurrenceIdOf(key)))
const prev = this.draftEditor.refreshProjection()
// Selection-only commits advance neither the revision nor the published
// state: menus still track the caret below, while draftRev moves only
// with content so a snapshot-built span (apply.ts) stays CAS-valid across
@@ -255,14 +191,6 @@ export class SessionInputShell implements SessionInput {
}
}
private occurrenceIdOf(key: NodeKey): number {
const existing = this.occurrenceIds.get(key)
if (existing !== undefined) return existing
this.occurrenceSeq += 1
this.occurrenceIds.set(key, this.occurrenceSeq)
return this.occurrenceSeq
}
// ---- SessionInput face ----
/**
@@ -272,18 +200,7 @@ export class SessionInputShell implements SessionInput {
* @param text - the full next draft.
*/
setDraft(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === this.projection.clipboardText) return
this.editor.update(() => {
const root = $getRoot()
root.clear()
for (const line of clean.split('\n')) {
const paragraph = $createParagraphNode()
if (line !== '') paragraph.append($createTextNode(line))
root.append(paragraph)
}
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
this.draftEditor.setDraft(text)
}
/** Append ordered attachment ids unless an admission transaction is locked. */
@@ -341,20 +258,7 @@ export class SessionInputShell implements SessionInput {
* @param text - pasted plain text.
*/
paste(text: string): void {
const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '')
if (clean === '') return
this.applyEdit(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
selection.insertText(clean)
return
}
// No selection yet (never-focused surface): land at the document end,
// growing the first paragraph when the tree is empty.
const root = $getRoot()
if (root.getChildrenSize() === 0) root.append($createParagraphNode())
root.selectEnd().insertText(clean)
}, PASTE_TAG)
this.draftEditor.paste(text)
}
/**
@@ -446,9 +350,7 @@ export class SessionInputShell implements SessionInput {
* @returns the ordered [start, end) span in detect coordinates.
*/
caretSpan(): { start: number; end: number } {
if (this.projection.selection !== null) return this.projection.selection
const at = this.projection.detectText.length
return { start: at, end: at }
return this.draftEditor.caretSpan()
}
/**
@@ -479,10 +381,7 @@ export class SessionInputShell implements SessionInput {
// Leading-trigger contract: only whitespace may precede the span; the
// whitespace prefix is dropped so the claimed watch (startsWith) holds.
if (this.projection.detectText.slice(0, span.start).trim() !== '') return false
let applied = false as boolean
this.applyEdit(() => {
applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token)
})
const applied = this.draftEditor.replaceText({ start: 0, end: span.end }, claim.token)
if (!applied) return false
this.dispatchRun(({ type: 'claim', claim }))
return true
@@ -501,14 +400,7 @@ export class SessionInputShell implements SessionInput {
if (phase !== 'plain' && phase !== 'claimed') return false
if (span.draftRev !== this.rev) return false
const tail = this.projection.detectText.slice(span.end, span.end + 1)
let applied = false
this.applyEdit(() => {
const nodes = tail === ' '
? [$createReferenceChipNode(ref)]
: [$createReferenceChipNode(ref), $createTextNode(' ')]
applied = $replaceDetectSpanWithNodes(span, nodes)
})
return applied
return this.draftEditor.insertReference(span, ref, tail)
}
/**
@@ -521,11 +413,7 @@ export class SessionInputShell implements SessionInput {
consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
if (guard.kind === 'span') {
if (guard.span.draftRev !== this.rev || guard.span.start === guard.span.end) return false
let applied = false
this.applyEdit(() => {
applied = $replaceDetectSpanWithText(guard.span, '')
})
return applied
return this.draftEditor.replaceText(guard.span, '')
}
if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false
this.setDraft('')
@@ -548,11 +436,7 @@ export class SessionInputShell implements SessionInput {
insertText(text: string, span: TokenSpan, keepCompleting = false): boolean {
void keepCompleting
if (span.draftRev !== this.rev) return false
let applied = false
this.applyEdit(() => {
applied = $replaceDetectSpanWithText(span, text)
})
return applied
return this.draftEditor.replaceText(span, text)
}
/**
@@ -585,7 +469,6 @@ export class SessionInputShell implements SessionInput {
this.disposed = true
this.dispatchRun(({ type: 'release' }))
this.unregister()
this.editor.setRootElement(null)
this.detachedDrafts.clear()
this.failedDetached.clear()
this.attachmentFlights.clear()
@@ -656,7 +539,7 @@ export class SessionInputShell implements SessionInput {
private dispatchRun(ev: Parameters<SubmitMachine['dispatch']>[0]): void {
const beforeToken = this.activeClaimToken()
this.run(this.core.dispatch(ev))
if (this.activeClaimToken() !== beforeToken) refreshClaimDecoration(this.editor)
if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
}
private run(effects: readonly InputEffect[]): void {
@@ -696,20 +579,13 @@ export class SessionInputShell implements SessionInput {
* undo history so sent content cannot resurrect.
*/
private commitDraft(retainSuffixOf: string | null): void {
this.editor.update(() => {
const layout = $composerLayout()
const clip = layout.clipboardText
this.draftEditor.clearCommittedDraft((clip) => {
if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) {
$replaceDetectSpanWithText(
{ start: 0, end: detectOffsetOfClipboardOffset(layout, retainSuffixOf.length) }, '',
)
return
return retainSuffixOf.length
}
const root = $getRoot()
root.clear()
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
return null
})
this.draftEditor.clearHistory()
}
/**
@@ -819,38 +695,8 @@ export class SessionInputShell implements SessionInput {
}
this.restoringFailures = true
try {
this.editor.update(() => {
const root = $getRoot()
root.clear()
let paragraph = $createParagraphNode()
root.append(paragraph)
const appendText = (text: string): void => {
const lines = text.split('\n')
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (line !== '') paragraph.append($createTextNode(line))
if (i < lines.length - 1) {
paragraph = $createParagraphNode()
root.append(paragraph)
}
}
}
let cursor = 0
for (const occurrence of occurrences) {
appendText(draft.slice(cursor, occurrence.offset))
paragraph.append(new ReferenceChipNode({
source: occurrence.source,
ref: occurrence.ref,
label: occurrence.label,
...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }),
clipboardText: occurrence.clipboardText,
}, occurrence.invalid === true))
cursor = occurrence.offset + occurrence.length
}
appendText(draft.slice(cursor))
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)
this.draftEditor.restoreDraft(draft, occurrences)
this.draftEditor.clearHistory()
this.failedRestoreRev = this.rev
} finally {
this.restoringFailures = false
@@ -15,9 +15,10 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import { queueReadFaceOf } from './queue-store.ts'
import type {
ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
SessionInputResolver, SessionInput, SubmitOutcome,
} from '../contract/input.ts'
import type { ComposerKeyboard } from '../contract/draft-editor.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
@@ -14,7 +14,7 @@
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'
import clsx from 'clsx'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
@@ -29,9 +29,11 @@ import type {} from '@deepseek-ai/dsh-goal/client'
// api-remotes import already places it in every client program.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { ComposerContentEditable } from '../input/editor/ComposerContentEditable.tsx'
import { DecoratorPortals } from '../input/editor/DecoratorPortals.tsx'
import { registerComposerKeymap } from '../input/editor/keymap.ts'
import { DraftEditor } from '../input/editor/DraftEditor.tsx'
import {
focusDraftEditor, installDraftFilePicker, installDraftKeymap, installDraftWheel,
keepDraftFocus, revealDraftSelection,
} from '../input/editor/view-binding.ts'
import { resolveSubmitMode } from '../input/submission-policy.ts'
import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
@@ -152,23 +154,7 @@ export const InputBar = memo(function InputBar({
// session switches that land the caret off screen). The live DOM selection
// is the ruler; no mirror layer exists to consult.
const revealSelection = (): void => {
const scrollEl = scrollRef.current
if (scrollEl === null || scrollEl.scrollHeight <= scrollEl.clientHeight) return
const selection = window.getSelection()
if (selection === null || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
let rect = range.getBoundingClientRect()
if (rect.height === 0 && rect.width === 0) {
// A collapsed caret at an empty line reports a zero rect in some
// engines; the anchor's element box is the line the caret sits on.
const anchor = selection.anchorNode
const el = anchor instanceof HTMLElement ? anchor : anchor?.parentElement
if (el === undefined || el === null) return
rect = el.getBoundingClientRect()
}
const box = scrollEl.getBoundingClientRect()
if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom
else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top
revealDraftSelection(scrollRef)
}
// Unlock (mount / session switch) returns focus to the box, and owns the
@@ -178,10 +164,7 @@ export const InputBar = memo(function InputBar({
// caret (restored at the draft's end) off screen.
useEffect(() => {
if (locked || editor === null) return
// Lexical's focus() restores the editor selection but never calls the DOM
// focus itself; preventScroll keeps the conversation scrollport still.
editor.getRootElement()?.focus({ preventScroll: true })
editor.focus(() => { revealSelection() })
focusDraftEditor(editor, revealSelection)
}, [locked, sessionId, editor])
// A persisted draft arrives AFTER the unlock effect: ConversationSession
@@ -202,19 +185,7 @@ export const InputBar = memo(function InputBar({
// a short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
useEffect(() => {
const el = scrollRef.current
if (el === null) return
const onWheel = (e: WheelEvent): void => {
const host = el.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement) || e.deltaY === 0) return
const atTop = el.scrollTop <= 0
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
e.preventDefault()
host.scrollTop += e.deltaY
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
return installDraftWheel(scrollRef)
}, [])
// Intake pre-check: an addition that would break a projected image limit is
@@ -270,48 +241,12 @@ export const InputBar = memo(function InputBar({
useEffect(() => {
if (keyboard === undefined) return
return keyboard.bindFilePicker({
available: () => gate.current.canAcceptDrop && fileInputRef.current !== null,
open: () => { fileInputRef.current?.click() },
})
return installDraftFilePicker(keyboard, gate, fileInputRef)
}, [keyboard])
useEffect(() => {
if (editor === null || keyboard === undefined) return
return registerComposerKeymap(editor, {
arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
space: () => {
if (gate.current.machineBusy || gate.current.locked) return false
return keyboard.space()
},
dismissPopup: () => { keyboard.dismissPopup() },
canSubmit: () => !gate.current.locked && !gate.current.machineBusy,
submit: (accelerated) => {
const g = gate.current
// Empty-draft accelerated Enter acts on the queue instead of the
// (empty) draft: the machine rejects empty drafts, so the gesture
// steers every still-pending queued message into the running turn.
if (accelerated && g.canSteerQueue) {
keyboard.steerQueue()
return
}
if (g.uploadsPending) {
g.showToast(g.t('file.stillUploading'))
return
}
keyboard.submit(resolveSubmitMode(
g.busyEnter,
g.running,
accelerated ? 'accelerated' : 'enter',
g.steeringAvailable,
))
},
intakeFiles: (files) => { gate.current.intakeFiles(files) },
pasteText: (text) => {
if (gate.current.machineBusy || gate.current.locked) return
keyboard.paste(text)
},
})
return installDraftKeymap(editor, keyboard, gate)
}, [editor, keyboard])
// Button presses steal focus from the editor; suppress at mousedown so
@@ -319,8 +254,7 @@ export const InputBar = memo(function InputBar({
// restores the previous selection, so no reveal is needed: the caret has
// not moved, and the next keystroke gets the browser's native one.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
editor?.getRootElement()?.focus({ preventScroll: true })
keepDraftFocus(e, editor)
}
const onToggleCommandMenu = (): void => {
@@ -446,32 +380,21 @@ export const InputBar = memo(function InputBar({
thing that scrolls. Chips are decorator portals inside the same
surface, so wrapping, caret geometry, and scrolling are the
browser's own. */}
<div ref={scrollRef} className={css.scroll} data-input-scroll>
<div className={css.grow}>
<ComposerContentEditable
editor={workspaceTrigger ? null : editor}
editable={editable}
className={clsx(css.input, editorDisabled && css.inputDisabled)}
data-phase={input?.phase ?? 'inert'}
aria-disabled={editorDisabled || undefined}
data-placeholder={placeholderText}
// The placeholder was the textarea's accessible name; a div's
// data attribute is not, so the label restores it.
aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText}
aria-haspopup={workspaceTrigger ? 'menu' : undefined}
aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined}
tabIndex={workspaceTrigger ? 0 : undefined}
onKeyDown={workspaceTrigger ? onWorkspaceKeyDown : undefined}
style={hint === null ? undefined : { '--dsh-composer-hint': JSON.stringify(hint) } as CSSProperties}
/>
{draft === '' && attachments.length === 0 && !claimActive && (
<div aria-hidden className={css.placeholder} data-composer-placeholder>
{placeholderText}
</div>
)}
<DecoratorPortals editor={workspaceTrigger ? null : editor} />
</div>
</div>
<DraftEditor
classNames={css}
editor={editor}
scrollRef={scrollRef}
editable={editable}
editorDisabled={editorDisabled}
phase={input?.phase ?? 'inert'}
placeholderText={placeholderText}
ariaLabel={workspaceTrigger ? t('hero.chooseWorkspace') : placeholderText}
workspaceTrigger={workspaceTrigger}
workspacePickerOpen={workspacePickerOpen}
onWorkspaceKeyDown={onWorkspaceKeyDown}
hint={hint}
showPlaceholder={draft === '' && attachments.length === 0 && !claimActive}
/>
<div className={css.row}>
<div className={css.tools}>
<Tooltip label={t('input.commands')} side="top" delayMs={500}>
@@ -12,7 +12,7 @@ import {
$createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection,
$isTextNode, $setSelection,
} from 'lexical'
import type { ReferenceInsert } from '../src/client/contract/input.ts'
import type { ReferenceInsert } from '../src/client/contract/draft-editor.ts'
import {
$createReferenceChipNode, $isReferenceChipNode, ReferenceChipNode,
} from '../src/client/input/editor/chip-node.tsx'
@@ -387,7 +387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:158',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
},
{
key: 'conversation.composer.bar',
@@ -425,7 +425,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175',
},
{
key: 'conversation.composer.dock',
@@ -480,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:170',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169',
},
{
key: 'conversation.hero.agentPreset',
@@ -510,7 +510,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:164',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163',
},
{
key: 'conversation.hero.brand.mark',
@@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
},
{
key: 'conversation.hero.workspace',
@@ -570,7 +570,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:160',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159',
},
{
key: 'conversation.hero.workspace.directoryFlow',
@@ -641,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177',
},
{
key: 'conversation.input.dock',
@@ -703,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:166',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165',
},
{
key: 'conversation.input.left',
@@ -756,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
},
{
key: 'conversation.input.model',
@@ -794,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187',
},
{
key: 'conversation.input.overlay',
@@ -851,7 +851,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167',
},
{
key: 'conversation.input.permission',
@@ -889,7 +889,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185',
},
{
key: 'conversation.input.plan',
@@ -927,7 +927,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183',
},
{
key: 'conversation.input.right',
@@ -980,7 +980,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
},
{
key: 'conversation.message.images',
@@ -1058,7 +1058,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:122',
},
{
key: 'conversation.session.header',
@@ -1094,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124',
},
{
key: 'conversation.session.header.actions',
@@ -1155,7 +1155,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:132',
},
{
key: 'conversation.session.header.corner',
@@ -1193,7 +1193,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149',
},
{
key: 'conversation.session.header.lineage',
@@ -1233,7 +1233,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:126',
},
{
key: 'conversation.session.header.utilities',
@@ -1291,7 +1291,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138',
},
{
key: 'conversation.trajectory.images',
@@ -1393,7 +1393,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155',
},
{
key: 'main',
@@ -1462,7 +1462,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120',
},
{
key: 'rightbar',