From b5abe6cf2969c273d7e1ac7470637bdf2eb057c1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:04:29 +0800 Subject: [PATCH] refactor(web): isolate draft editor implementation without behavior changes --- .../src/client/ComposerAttachments.tsx | 50 +-- .../ui-attachment/src/client/drop-events.ts | 66 ++++ .../src/client/contract/draft-editor.ts | 105 ++++++ .../src/client/contract/input.ts | 103 +----- .../src/client/contract/slots.ts | 5 +- .../ui-conversation/src/client/index.ts | 8 +- .../src/client/input/editor/DraftEditor.tsx | 63 ++++ .../src/client/input/editor/chip-node.tsx | 2 +- .../src/client/input/editor/keymap.ts | 2 +- .../src/client/input/editor/projection.ts | 2 +- .../input/editor/reference-activation.ts | 2 +- .../src/client/input/editor/runtime.ts | 299 ++++++++++++++++++ .../src/client/input/editor/view-binding.ts | 155 +++++++++ .../src/client/input/facade.ts | 230 +++----------- .../ui-conversation/src/client/input/hub.ts | 3 +- .../src/client/skeleton/InputBar.tsx | 131 ++------ .../tests/lexical-editor-core.client.spec.tsx | 2 +- .../src/client/slot-catalog.ts | 44 +-- 18 files changed, 791 insertions(+), 481 deletions(-) create mode 100644 packages/client/ui-attachment/src/client/drop-events.ts create mode 100644 packages/client/ui-conversation/src/client/contract/draft-editor.ts create mode 100644 packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx create mode 100644 packages/client/ui-conversation/src/client/input/editor/runtime.ts create mode 100644 packages/client/ui-conversation/src/client/input/editor/view-binding.ts diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index fd469195bf..ca2e1e1aa7 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -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(() => attachments.map(attachment => ({ diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts new file mode 100644 index 0000000000..e04cbd43d0 --- /dev/null +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -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) + } +} diff --git a/packages/client/ui-conversation/src/client/contract/draft-editor.ts b/packages/client/ui-conversation/src/client/contract/draft-editor.ts new file mode 100644 index 0000000000..f4dc49246f --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/draft-editor.ts @@ -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 +} diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index e70136736e..c6820f08a0 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -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 } -/** 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). */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a131b63a0d..20fd568256 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 2b94139936..5f765b9ada 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.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' { diff --git a/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx b/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx new file mode 100644 index 0000000000..d03ff6232c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx @@ -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> + readonly editor: LexicalEditor | null + readonly scrollRef: RefObject + 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 + 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 ( +
+
+ + {showPlaceholder && ( +
+ {placeholderText} +
+ )} + +
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx index 16de06e878..b2abf13158 100644 --- a/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx +++ b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx @@ -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). */ diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index f11592ba5a..354de6aba7 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -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 { diff --git a/packages/client/ui-conversation/src/client/input/editor/projection.ts b/packages/client/ui-conversation/src/client/input/editor/projection.ts index 1a25c95884..f48a5b595d 100644 --- a/packages/client/ui-conversation/src/client/input/editor/projection.ts +++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts @@ -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). */ diff --git a/packages/client/ui-conversation/src/client/input/editor/reference-activation.ts b/packages/client/ui-conversation/src/client/input/editor/reference-activation.ts index c58cef382a..d1d377e331 100644 --- a/packages/client/ui-conversation/src/client/input/editor/reference-activation.ts +++ b/packages/client/ui-conversation/src/client/input/editor/reference-activation.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts new file mode 100644 index 0000000000..2054624342 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.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) => boolean + readonly activeClaimToken: () => string | null + readonly lexicon: () => Lexicon + readonly resolveLexicon: () => ObservableSnapshot | 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() + 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) + } +} diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts new file mode 100644 index 0000000000..8dcd3d8c63 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -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): 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): (() => 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>, + fileInputRef: RefObject, +): () => 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, +): () => 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, editor: LexicalEditor | null): void { + event.preventDefault() + editor?.getRootElement()?.focus({ preventScroll: true }) +} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 80509a6ac7..a30edb016c 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -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 = createSnapshotStore(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() - 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[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() /** 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(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[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 diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 3a862753e1..b09db9bf60 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -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' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ddc8b665ce..4bae3819a4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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): 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. */} -
-
- - {draft === '' && attachments.length === 0 && !claimActive && ( -
- {placeholderText} -
- )} - -
-
+
diff --git a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx index 92bfa3ce15..2d63aaefa9 100644 --- a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx +++ b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx @@ -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' diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1b11b9eb27..66c1f295eb 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -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',