refactor(web): isolate draft editor implementation without behavior changes
This commit is contained in:
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',
|
||||
|
||||
Reference in new issue
Block a user