From 2279fd19b0fb051fd41860bfbfbe7e634bc473a7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 16:46:57 +0800 Subject: [PATCH 01/26] chore(ui-conversation): add lexical dependencies and jsdom spike --- THIRD_PARTY_NOTICES.md | 6 + packages/client/ui-conversation/package.json | 12 +- .../tests/lexical-spike.client.spec.tsx | 199 ++++++++++++ pnpm-lock.yaml | 305 +++++++++++++++++- 4 files changed, 509 insertions(+), 13 deletions(-) create mode 100644 packages/client/ui-conversation/tests/lexical-spike.client.spec.tsx diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 295eb1868f..f7fcb09283 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,10 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | +| [`@lexical/history`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/utils`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | @@ -63,6 +67,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | +| [`lexical`](https://github.com/facebook/lexical) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | @@ -122,6 +127,7 @@ External packages **directly declared** only by repository tooling, test infrast | Package | License | | --- | --- | | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | +| [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index a8596f1209..8b94dcd936 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,7 +49,12 @@ "license": "MIT", "dependencies": { "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@lexical/history": "^0.49.0", + "@lexical/plain-text": "^0.49.0", + "@lexical/text": "^0.49.0", + "@lexical/utils": "^0.49.0", + "lexical": "^0.49.0" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -105,7 +110,10 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@lexical/headless": "^0.49.0", + "react-dom": "^18.2.0", + "@types/react-dom": "~18.3.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/tests/lexical-spike.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-spike.client.spec.tsx new file mode 100644 index 0000000000..d95e45f031 --- /dev/null +++ b/packages/client/ui-conversation/tests/lexical-spike.client.spec.tsx @@ -0,0 +1,199 @@ +// @vitest-environment jsdom +/** + * SPIKE (Phase 0): validates the jsdom driving model for the Lexical composer + * before the real implementation lands. Answers three questions: (1) can a + * shell-owned headful editor render into jsdom and accept update-driven + * edits; (2) can synthetic beforeinput/keyboard events drive Lexical in + * jsdom, or must component tests drive through the command layer; (3) do + * DecoratorNode portals render and keep DOM identity when text is inserted + * before them. Deleted/absorbed into the real suites at the end of Phase 4. + */ +import { describe, expect, it } from 'vitest' +import * as React from 'react' +import { createPortal } from 'react-dom' +import { act, render } from '@testing-library/react' +import type { EditorConfig, LexicalEditor, NodeKey, SerializedLexicalNode } from 'lexical' +import { + $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, + createEditor, DecoratorNode, +} from 'lexical' +import { registerPlainText } from '@lexical/plain-text' + +/** Minimal inline atomic chip for the spike (real node lands in Phase 1). */ +class SpikeChipNode extends DecoratorNode { + static getType(): string { + return 'spike-chip' + } + + static clone(node: SpikeChipNode): SpikeChipNode { + return new SpikeChipNode(node.__key) + } + + static importJSON(): SpikeChipNode { + return new SpikeChipNode() + } + + exportJSON(): SerializedLexicalNode { + return { type: 'spike-chip', version: 1 } + } + + createDOM(_config: EditorConfig): HTMLElement { + const el = document.createElement('span') + el.dataset['spikeChip'] = 'true' + return el + } + + updateDOM(): boolean { + return false + } + + isInline(): boolean { + return true + } + + getTextContent(): string { + return '/chip-clipboard' + } + + decorate(): React.JSX.Element { + return + } +} + +function makeEditor(): { editor: LexicalEditor; rootEl: HTMLDivElement } { + const editor = createEditor({ + namespace: 'spike', + nodes: [SpikeChipNode], + onError: (error) => { throw error }, + }) + const rootEl = document.createElement('div') + rootEl.contentEditable = 'true' + document.body.appendChild(rootEl) + editor.setRootElement(rootEl) + registerPlainText(editor) + return { editor, rootEl } +} + +describe('lexical jsdom spike', () => { + it('renders update-driven text into the DOM and reports projections', async () => { + const { editor, rootEl } = makeEditor() + editor.update(() => { + const p = $createParagraphNode() + p.append($createTextNode('hello world')) + $getRoot().append(p) + }, { discrete: true }) + await Promise.resolve() // reconciliation is sync inside update; flush microtasks anyway + expect(rootEl.textContent).toBe('hello world') + const text = editor.getEditorState().read(() => $getRoot().getTextContent()) + expect(text).toBe('hello world') + }) + + it('answers whether synthetic beforeinput insertText drives Lexical under jsdom', () => { + const { editor, rootEl } = makeEditor() + editor.update(() => { + const p = $createParagraphNode() + p.append($createTextNode('ab')) + $getRoot().append(p) + }, { discrete: true }) + // Place a real DOM selection at the end of the text node. + const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT) + const textDom = walker.nextNode() + const selectable = textDom !== null && textDom !== undefined + if (selectable) { + const sel = document.getSelection() + const range = document.createRange() + range.setStart(textDom, 2) + range.collapse(true) + sel?.removeAllRanges() + sel?.addRange(range) + document.dispatchEvent(new Event('selectionchange')) + } + const event = new InputEvent('beforeinput', { + inputType: 'insertText', data: 'X', bubbles: true, cancelable: true, + }) + rootEl.dispatchEvent(event) + const after = editor.getEditorState().read(() => $getRoot().getTextContent()) + // Record the verdict either way — the spike's job is the answer, not a pass. + console.log(`SPIKE beforeinput verdict: selectable=${selectable} after=${JSON.stringify(after)}`) + expect(typeof after).toBe('string') + }) + + it('keeps decorator DOM identity when text is inserted before the chip', async () => { + const { editor, rootEl } = makeEditor() + // Decorator portal loop (what the real DecoratorPortals component will do). + function Portals(): React.JSX.Element { + const [decorators, setDecorators] = React.useState>( + () => editor.getDecorators(), + ) + React.useLayoutEffect( + () => editor.registerDecoratorListener((next) => { setDecorators(next) }), + [], + ) + return ( + <> + {Object.entries(decorators).map(([key, jsx]) => { + const el = editor.getElementByKey(key) + return el === null ? null : createPortal(jsx, el, key) + })} + + ) + } + render() + + let chipKey = '' + act(() => { + editor.update(() => { + const p = $createParagraphNode() + const chip = new SpikeChipNode() + chipKey = chip.getKey() + p.append($createTextNode('before '), chip, $createTextNode(' after')) + $getRoot().append(p) + }, { discrete: true }) + }) + await Promise.resolve() + const chipButton = rootEl.querySelector('[data-chip-button]') + expect(chipButton).not.toBeNull() + const chipSpan = rootEl.querySelector('[data-spike-chip]') + + // Insert text before the chip through the node API (the transaction path). + act(() => { + editor.update(() => { + const p = $getRoot().getFirstChild() + if (p === null) throw new Error('paragraph missing') + const first = (p as ReturnType).getFirstChild() + if (first === null) throw new Error('text missing') + ;(first as ReturnType).spliceText(0, 0, '@') + }, { discrete: true }) + }) + await Promise.resolve() + // Chip DOM node identity survives (bug #2793's structural fix). + expect(rootEl.querySelector('[data-spike-chip]')).toBe(chipSpan) + expect(rootEl.querySelector('[data-chip-button]')).toBe(chipButton) + // Chip node identity survives in the tree (bug #2813's structural fix). + const stillThere = editor.getEditorState().read(() => { + const node = editor.getEditorState()._nodeMap.get(chipKey) + return node !== undefined + }) + expect(stillThere).toBe(true) + // Text content projection sees the clipboard form. + const text = editor.getEditorState().read(() => $getRoot().getTextContent()) + expect(text).toBe('@before /chip-clipboard after') + }) + + it('reports whether selection APIs let update-driven caret placement work', () => { + const { editor } = makeEditor() + editor.update(() => { + const p = $createParagraphNode() + const t = $createTextNode('abc') + p.append(t) + $getRoot().append(p) + t.select(1, 1) + }, { discrete: true }) + const verdict = editor.getEditorState().read(() => { + const sel = $getSelection() + return $isRangeSelection(sel) ? `range@${sel.anchor.offset}` : String(sel) + }) + console.log(`SPIKE selection verdict: ${verdict}`) + expect(verdict).toBe('range@1') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74c628dd01..2237b86d9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,7 +122,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/cli: dependencies: @@ -411,7 +411,7 @@ importers: version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) examples: dependencies: @@ -1858,9 +1858,24 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@lexical/history': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) + '@lexical/plain-text': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) + '@lexical/text': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) + '@lexical/utils': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) clsx: specifier: ^2.0.0 version: 2.1.1 + lexical: + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1943,12 +1958,21 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@lexical/headless': + specifier: ^0.49.0 + version: 0.49.0(typescript@6.0.3) '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-deliverables: devDependencies: @@ -7982,7 +8006,7 @@ importers: version: link:../loader-smoke vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -8031,7 +8055,7 @@ importers: version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -10450,6 +10474,102 @@ packages: cpu: [x64] os: [win32] + '@lexical/clipboard@0.49.0': + resolution: {integrity: sha512-AVKj21xH1qU7JAFA/v0hCoafa+Yti1I7cHG+JQIgc/EqCtP8ePeJyIfTPNN/tXskoCdq++HewQoiSXRwwlocVg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/dragon@0.49.0': + resolution: {integrity: sha512-62/4DP5qyX/l4Yf5qRyyQrs9BV725eRU3OmLUW6g7T5xrcHXxAo7tia/NvqjqvXdpvQzyHjWgsy7dMitGITUsw==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/extension@0.49.0': + resolution: {integrity: sha512-Wv0VsuqxorbxHCK4ms1PwAu6cXIGNLtflq66auF+zwxOtBprkSFV8VzzzdKLOYD2admP0VK6EqVCeGXFK1GggA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/headless@0.49.0': + resolution: {integrity: sha512-3fQiKMAZ+K5/YlnGli9MId4LM8TPuOdLXIy1eUszfA/8hTZyPMcAxLjG99940UClgcoW9XxP03T37PJpFvpqIA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/history@0.49.0': + resolution: {integrity: sha512-uQdtEd34gIJklXNSdHS2Wko1zxx1xUMVXbiodcLO6a3GeFTE5bKnx6af1zGX78KpYhUQYnHWorKuUvtdZlJPaA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/html@0.49.0': + resolution: {integrity: sha512-NQqAydKzRjQl7Jx+bTTyC2iuz5uhuDaQX0SSPrdfgaFDCPjqQEejcBkCt1QXnu1CkbVHutZKVGVzljx40Y6y+Q==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/internal@0.49.0': + resolution: {integrity: sha512-s+XjPC7Qb39A/Xx9ahcz1s69CPix4ultaqyW+MDG0AXYW2quDr7m0USqReKIAiuoLIWtGN5HW8BwV2Y6t4qr6Q==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/list@0.49.0': + resolution: {integrity: sha512-zs6wYkxakDRcJO0KmwrPPHgeLLIxzjD+P2CRu+scJCHRuA5e2iSw2iFjH5n/LlWBrlnPMSjeQse4QqsRy9nnqA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/plain-text@0.49.0': + resolution: {integrity: sha512-l7IuUj9n9CtFfz4Fz6zU6mmO+VkcnvyyebABx+lHevvTa7B6YI5PPVgABFfzdyPanyW/FGs7qpKBf59inAqbkg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/selection@0.49.0': + resolution: {integrity: sha512-08Vd1+VoC6YnztWOFWVsqF/Hxw5EP8qeL1c7t3+rVCV8revLzXxdQw+vbPX3tgM4fmjOBDUXk6Ws1+rTtsqjcQ==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/text@0.49.0': + resolution: {integrity: sha512-mowedvbvx0HDaW+ymVdYmWVhueqgauhhqIWaCtbJj33tPk8pOu1BB0pZuJQbOB+1bDnFiZhkJV8W/CvR2M9lEA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/utils@0.49.0': + resolution: {integrity: sha512-Jaa6DERBqxiFOFa49VPRV1WOb7mzRbMZ5U+v+RFagjzTmBhfxDmEkbQQ9nYTU3n3DPfdT8Hkyffextmw04etXg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + '@mermaid-js/mermaid-mindmap@9.3.0': resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} @@ -11003,6 +11123,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -11716,6 +11839,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -12068,6 +12194,10 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -12811,6 +12941,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.11.2: + resolution: {integrity: sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -13180,6 +13314,14 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexical@0.49.0: + resolution: {integrity: sha512-9V1ZIzGpJEd8rIN+nN7veL4fW4fFWbS66Un4JNqSZB4D5t9euzN9+3+jEXy83FjNjNy0MiiUI3+DQaGCLYko0w==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -14566,6 +14708,10 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -15853,6 +15999,111 @@ snapshots: '@koromix/koffi-win32-x64@3.1.1': optional: true + '@lexical/clipboard@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/extension': 0.49.0(typescript@6.0.3) + '@lexical/html': 0.49.0(typescript@6.0.3) + '@lexical/internal': 0.49.0(typescript@6.0.3) + '@lexical/list': 0.49.0(typescript@6.0.3) + '@lexical/selection': 0.49.0(typescript@6.0.3) + '@lexical/utils': 0.49.0(typescript@6.0.3) + '@types/trusted-types': 2.0.7 + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/dragon@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/extension': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/extension@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/internal': 0.49.0(typescript@6.0.3) + '@lexical/utils': 0.49.0(typescript@6.0.3) + '@preact/signals-core': 1.14.4 + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/headless@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/internal': 0.49.0(typescript@6.0.3) + happy-dom: 20.11.2 + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@lexical/history@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/extension': 0.49.0(typescript@6.0.3) + '@lexical/utils': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/html@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/extension': 0.49.0(typescript@6.0.3) + '@lexical/internal': 0.49.0(typescript@6.0.3) + '@lexical/selection': 0.49.0(typescript@6.0.3) + '@lexical/utils': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/internal@0.49.0(typescript@6.0.3)': + optionalDependencies: + typescript: 6.0.3 + + '@lexical/list@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/extension': 0.49.0(typescript@6.0.3) + '@lexical/html': 0.49.0(typescript@6.0.3) + '@lexical/internal': 0.49.0(typescript@6.0.3) + '@lexical/utils': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/plain-text@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/clipboard': 0.49.0(typescript@6.0.3) + '@lexical/dragon': 0.49.0(typescript@6.0.3) + '@lexical/extension': 0.49.0(typescript@6.0.3) + '@lexical/selection': 0.49.0(typescript@6.0.3) + '@lexical/utils': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/selection@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/internal': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/text@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/internal': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@lexical/utils@0.49.0(typescript@6.0.3)': + dependencies: + '@lexical/internal': 0.49.0(typescript@6.0.3) + '@lexical/selection': 0.49.0(typescript@6.0.3) + lexical: 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + '@mermaid-js/mermaid-mindmap@9.3.0': dependencies: '@braintree/sanitize-url': 6.0.4 @@ -16254,6 +16505,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@preact/signals-core@1.14.4': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -16863,8 +17116,7 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/turndown@5.0.6': {} @@ -16872,6 +17124,8 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 @@ -16914,7 +17168,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -17263,6 +17517,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.20.0 + builtin-modules@3.3.0: {} bundle-name@4.1.0: @@ -18124,6 +18382,19 @@ snapshots: hachure-fill@0.5.2: {} + happy-dom@20.11.2: + dependencies: + '@types/node': 22.20.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -18483,6 +18754,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.49.0(typescript@6.0.3): + dependencies: + '@lexical/internal': 0.49.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -20042,7 +20319,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -20068,11 +20345,12 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 22.20.0 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + happy-dom: 20.11.2 jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -20098,11 +20376,12 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 25.9.3 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + happy-dom: 20.11.2 jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -20128,11 +20407,12 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.0 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + happy-dom: 20.11.2 jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.2)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -20158,6 +20438,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.3 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + happy-dom: 20.11.2 jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -20195,6 +20476,8 @@ snapshots: webidl-conversions@8.0.1: {} + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1: From 477615162ae1f7294e17378a1be64d57afbe62cf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 16:57:56 +0800 Subject: [PATCH 02/26] feat(ui-conversation): lexical chip node, projections, span map --- .../input/editor/ReferenceChip.module.css | 41 ++ .../src/client/input/editor/ReferenceChip.tsx | 35 ++ .../src/client/input/editor/chip-node.tsx | 208 +++++++++ .../src/client/input/editor/projection.ts | 202 +++++++++ .../src/client/input/editor/span-map.ts | 109 +++++ .../tests/lexical-editor-core.client.spec.tsx | 428 ++++++++++++++++++ .../tests/reference-chip.client.spec.tsx | 34 ++ 7 files changed, 1057 insertions(+) create mode 100644 packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css create mode 100644 packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx create mode 100644 packages/client/ui-conversation/src/client/input/editor/chip-node.tsx create mode 100644 packages/client/ui-conversation/src/client/input/editor/projection.ts create mode 100644 packages/client/ui-conversation/src/client/input/editor/span-map.ts create mode 100644 packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx create mode 100644 packages/client/ui-conversation/tests/reference-chip.client.spec.tsx diff --git a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css new file mode 100644 index 0000000000..84b0ed08ac --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css @@ -0,0 +1,41 @@ +/* Inline reference chip: a real DOM capsule (the three-layer backdrop trick + and its advance-preserving constraints are gone — background, padding, + radius, and label truncation are ordinary styles here). Vertical metrics + stay inside the composer's 24px line so a chip never changes line height. */ + +.chip { + display: inline-flex; + align-items: center; + gap: 3px; + max-width: 240px; + padding: 0 6px; + border-radius: 6px; + vertical-align: bottom; + line-height: 22px; + height: 22px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-state-business-primary); + user-select: none; + cursor: default; +} + +.marker { + flex: none; + font-weight: 500; +} + +.icon { + flex: none; +} + +.label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.invalid { + color: var(--dsw-alias-state-error-primary); + text-decoration: line-through; + opacity: 0.7; +} diff --git a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx new file mode 100644 index 0000000000..7c82036fb8 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx @@ -0,0 +1,35 @@ +/** + * Visual body of one inline reference chip: the DecoratorNode's React + * face. Pure display — identity, invalidation, and lifecycle live on the + * ReferenceChipNode; this component renders whatever the node carries. + */ +import clsx from 'clsx' +import type { ReactNode } from 'react' +import { ReferenceIcon } from '../../reference/ReferenceIcon.tsx' +import type { ReferenceIconKind } from '../../reference/ReferenceIcon.tsx' +import css from './ReferenceChip.module.css' + +/** Display inputs of one chip (the node's cached owner projections). */ +export interface ReferenceChipProps { + readonly label: string + /** Domain glyph; absent renders the trigger marker instead of an icon. */ + readonly appearance?: ReferenceIconKind | undefined + /** Owner-resolution failure styling bit. */ + readonly invalid: boolean +} + +/** + * Render one inline reference chip. + * @param props - label, optional domain glyph, and the invalid bit. + * @returns the chip body (icon + truncating label). + */ +export function ReferenceChip({ label, appearance, invalid }: ReferenceChipProps): ReactNode { + return ( + + {appearance === undefined + ? @ + : } + {label} + + ) +} 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 new file mode 100644 index 0000000000..d91098a574 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx @@ -0,0 +1,208 @@ +/** + * ReferenceChipNode: one inline reference as an atomic Lexical decorator. + * The node IS the occurrence — NodeKey carries identity, the node carries + * the owner's insert-time projections (label/appearance/clipboardText), and + * `getTextContent()` answers the clipboard/persistence projection so native + * copy and the draft mirror stay correct without expansion code. The detect + * projection (trigger scanning and TokenSpan coordinates) counts every chip + * as one U+FFFC instead; see projection.ts. + */ +import type { JSX } from 'react' +import type { + EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread, +} from 'lexical' +import { DecoratorNode } from 'lexical' +import type { ReferenceInsert } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { ReferenceChip } from './ReferenceChip.tsx' + +/** JSON form of one chip (Lexical node serialization contract). */ +export type SerializedReferenceChipNode = Spread<{ + source: string + ref: string + label: string + appearance?: ReferenceInsert['appearance'] + clipboardText: string + invalid: boolean +}, SerializedLexicalNode> + +/** One inline reference occurrence as an atomic decorator node. */ +export class ReferenceChipNode extends DecoratorNode { + /** Owning source name (serializer routing key). */ + __source: string + /** Owner-scoped reference id. */ + __ref: string + /** Inline display label (insert-time cache). */ + __label: string + /** Optional domain glyph (insert-time cache). */ + __appearance: ReferenceInsert['appearance'] + /** Clipboard / persistence projection, e.g. `/name` (never the model form). */ + __clipboardText: string + /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ + __invalid: boolean + + /** Lexical node registry type tag. */ + static override getType(): string { + return 'reference-chip' + } + + /** + * Clone with identity (Lexical writable-copy contract). + * @param node - node to clone. + * @returns a copy carrying the same NodeKey. + */ + static override clone(node: ReferenceChipNode): ReferenceChipNode { + return new ReferenceChipNode( + { + source: node.__source, + ref: node.__ref, + label: node.__label, + appearance: node.__appearance, + clipboardText: node.__clipboardText, + }, + node.__invalid, + node.__key, + ) + } + + /** + * Rebuild one chip from its JSON form. + * @param json - serialized chip. + * @returns a fresh node (new key). + */ + static override importJSON(json: SerializedReferenceChipNode): ReferenceChipNode { + return new ReferenceChipNode( + { + source: json.source, + ref: json.ref, + label: json.label, + appearance: json.appearance, + clipboardText: json.clipboardText, + }, + json.invalid, + ) + } + + /** + * @param insert - the owner's reference insertion (display projections included). + * @param invalid - owner-resolution failure bit (defaults valid). + * @param key - Lexical clone-path key; absent for fresh nodes. + */ + constructor(insert: Omit & { appearance?: ReferenceInsert['appearance'] }, invalid = false, key?: NodeKey) { + super(key) + this.__source = insert.source + this.__ref = insert.ref + this.__label = insert.label + this.__appearance = insert.appearance + this.__clipboardText = insert.clipboardText + this.__invalid = invalid + } + + /** Serialize to the JSON node form. */ + override exportJSON(): SerializedReferenceChipNode { + return { + ...super.exportJSON(), + type: 'reference-chip', + version: 1, + source: this.__source, + ref: this.__ref, + label: this.__label, + ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), + clipboardText: this.__clipboardText, + invalid: this.__invalid, + } + } + + /** + * Mount the chip's host element; the decorator portal renders into it. + * @returns an inline, non-editable span carrying the test/e2e anchor. + */ + override createDOM(_config: EditorConfig): HTMLElement { + const el = document.createElement('span') + el.setAttribute('data-composer-chip', this.__source) + el.contentEditable = 'false' + return el + } + + /** Host element never changes shape. */ + override updateDOM(): boolean { + return false + } + + /** Chips sit in the text line. */ + override isInline(): boolean { + return true + } + + /** Arrow keys and Backspace address the chip as one unit. */ + override isKeyboardSelectable(): boolean { + return true + } + + /** Clipboard / persistence projection (native copy reads this). */ + override getTextContent(): string { + return this.__clipboardText + } + + /** + * Flip the owner-resolution failure bit (set-invalid application). + * @param invalid - next bit; no-op writes are the caller's concern. + */ + setInvalid(invalid: boolean): void { + const writable = this.getWritable() + writable.__invalid = invalid + } + + /** Owner-resolution failure bit. */ + isInvalid(): boolean { + return this.getLatest().__invalid + } + + /** Owning source name. */ + getSource(): string { + return this.getLatest().__source + } + + /** Owner-scoped reference id. */ + getReference(): string { + return this.getLatest().__ref + } + + /** Inline display label. */ + getLabel(): string { + return this.getLatest().__label + } + + /** Optional domain glyph. */ + getAppearance(): ReferenceInsert['appearance'] { + return this.getLatest().__appearance + } + + /** React face rendered into the host element by the decorator portal. */ + override decorate(): JSX.Element { + return ( + + ) + } +} + +/** + * Mint one chip node from a reference insertion. + * @param insert - the owner's reference insertion. + * @returns the fresh node. + */ +export function $createReferenceChipNode(insert: ReferenceInsert): ReferenceChipNode { + return new ReferenceChipNode(insert) +} + +/** + * Chip type guard. + * @param node - any node or nullish. + * @returns whether the node is a ReferenceChipNode. + */ +export function $isReferenceChipNode(node: LexicalNode | null | undefined): node is ReferenceChipNode { + return node instanceof ReferenceChipNode +} diff --git a/packages/client/ui-conversation/src/client/input/editor/projection.ts b/packages/client/ui-conversation/src/client/input/editor/projection.ts new file mode 100644 index 0000000000..798e95c42e --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts @@ -0,0 +1,202 @@ +/** + * Composer editor projections: one EditorState, three pure text views. + * detectText feeds trigger detection and TokenSpan coordinates (every chip + * counts as one U+FFFC — the opaque-reference invariant); clipboardText + * feeds persistence, the InputState draft, and submit-plane decisions + * (chips expand to their clipboard projection); the model form is not a + * text view here — submit serializes chip nodes through their owner codec. + * All $-functions must run inside `editor.read()` / `editor.update()`. + */ +import type { ElementNode, LexicalNode, NodeKey, Point } from 'lexical' +import { + $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, +} from 'lexical' +import type { Occurrence } from '../contract.ts' +import { $isReferenceChipNode } from './chip-node.tsx' + +/** The detect-projection stand-in for one chip (object replacement character). */ +export const ATOMIC_CHAR = '' + +/** One leaf (or gap) of the composer document in projection coordinates. */ +export interface ComposerSegment { + /** text/linebreak carry a node; chip is atomic; gap is the newline between block elements. */ + readonly kind: 'text' | 'chip' | 'linebreak' | 'gap' + /** The backing node; null only for gap. */ + readonly node: LexicalNode | null + readonly detectStart: number + readonly detectLength: number + readonly clipboardStart: number + readonly clipboardLength: number + /** gap only: the block elements this newline separates. */ + readonly gapBetween?: { readonly before: NodeKey; readonly after: NodeKey } +} + +/** One walk's product: segments plus the indexes point mapping needs. */ +export interface ComposerLayout { + readonly segments: readonly ComposerSegment[] + readonly detectLength: number + readonly detectText: string + readonly clipboardText: string + /** Leaf node key → its segment (text/chip/linebreak). */ + readonly byKey: ReadonlyMap + /** Element key → ordered child keys (root and every block element). */ + readonly children: ReadonlyMap + /** Element key → detect bounds of its content (gaps excluded). */ + readonly bounds: ReadonlyMap +} + +/** + * Walk the composer document once, producing every projection segment in + * document order. Blocks (paragraphs) contribute a one-newline gap between + * one another in both text projections. + * @returns the layout for this EditorState. + */ +export function $composerLayout(): ComposerLayout { + const segments: ComposerSegment[] = [] + const byKey = new Map() + const children = new Map() + const bounds = new Map() + let detect = '' + let clipboard = '' + + const pushLeaf = (kind: 'text' | 'chip' | 'linebreak', node: LexicalNode, detectPiece: string, clipboardPiece: string): void => { + const segment: ComposerSegment = { + kind, + node, + detectStart: detect.length, + detectLength: detectPiece.length, + clipboardStart: clipboard.length, + clipboardLength: clipboardPiece.length, + } + segments.push(segment) + byKey.set(node.getKey(), segment) + detect += detectPiece + clipboard += clipboardPiece + } + + const walkElement = (element: ElementNode): void => { + const start = detect.length + const kids = element.getChildren() + children.set(element.getKey(), kids.map(kid => kid.getKey())) + for (const kid of kids) { + if ($isReferenceChipNode(kid)) { + pushLeaf('chip', kid, ATOMIC_CHAR, kid.getTextContent()) + } else if ($isTextNode(kid)) { + const text = kid.getTextContent() + pushLeaf('text', kid, text, text) + } else if ($isLineBreakNode(kid)) { + pushLeaf('linebreak', kid, '\n', '\n') + } else if ($isElementNode(kid)) { + /* v8 ignore next 4 -- plain-text composition nests no block elements today; the walk stays total for imported states. */ + walkElement(kid) + } + // Unknown inline decorators contribute nothing: this composer registers + // no other decorator type, so the arm is unreachable by construction. + } + bounds.set(element.getKey(), { start, end: detect.length }) + } + + const root = $getRoot() + const blocks = root.getChildren() + children.set(root.getKey(), blocks.map(block => block.getKey())) + const rootStart = detect.length + blocks.forEach((block, index) => { + const previous = blocks[index - 1] + if (index > 0 && previous !== undefined) { + segments.push({ + kind: 'gap', + node: null, + detectStart: detect.length, + detectLength: 1, + clipboardStart: clipboard.length, + clipboardLength: 1, + gapBetween: { before: previous.getKey(), after: block.getKey() }, + }) + detect += '\n' + clipboard += '\n' + } + if ($isElementNode(block)) walkElement(block) + }) + bounds.set(root.getKey(), { start: rootStart, end: detect.length }) + + return { + segments, + detectLength: detect.length, + detectText: detect, + clipboardText: clipboard, + byKey, + children, + bounds, + } +} + +/** The published projection product consumed by the shell every update. */ +export interface EditorProjection { + /** Trigger/TokenSpan coordinate text (chip = one U+FFFC). */ + readonly detectText: string + /** Persistence/InputState draft text (chip = clipboardText). */ + readonly clipboardText: string + /** InputState-compatible occurrence view (clipboardText coordinates). */ + readonly occurrences: readonly Occurrence[] + /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */ + readonly caret: number | null +} + +/** + * Fold one selection point to a detect offset. + * @param layout - the current walk product. + * @param point - selection anchor/focus point. + * @returns detect offset, or null when the point references an unknown node. + */ +export function $detectOffsetOfPoint(layout: ComposerLayout, point: Point): number | null { + if (point.type === 'text') { + const segment = layout.byKey.get(point.key) + return segment === undefined ? null : segment.detectStart + Math.min(point.offset, segment.detectLength) + } + const kids = layout.children.get(point.key) + const elementBounds = layout.bounds.get(point.key) + if (kids === undefined || elementBounds === undefined) return null + if (point.offset >= kids.length) return elementBounds.end + const childKey = kids[point.offset] + if (childKey === undefined) return elementBounds.end + const childSegment = layout.byKey.get(childKey) + if (childSegment !== undefined) return childSegment.detectStart + const childBounds = layout.bounds.get(childKey) + return childBounds === undefined ? null : childBounds.start +} + +/** + * Project the composer document and its caret. + * @param idOf - stable occurrence-id assignment per chip NodeKey (the shell + * owns the map so ids survive across projections of the same node). + * @returns the three-view projection product. + */ +export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjection { + const layout = $composerLayout() + const occurrences: Occurrence[] = [] + for (const segment of layout.segments) { + if (segment.kind !== 'chip' || !$isReferenceChipNode(segment.node)) continue + const chip = segment.node + occurrences.push({ + occurrenceId: idOf(chip.getKey()), + source: chip.getSource(), + ref: chip.getReference(), + offset: segment.clipboardStart, + length: segment.clipboardLength, + label: chip.getLabel(), + ...(chip.getAppearance() === undefined ? {} : { appearance: chip.getAppearance() }), + clipboardText: chip.getTextContent(), + ...(chip.isInvalid() ? { invalid: true } : {}), + }) + } + const selection = $getSelection() + const caret = $isRangeSelection(selection) && selection.isCollapsed() + ? $detectOffsetOfPoint(layout, selection.anchor) + : null + return { + detectText: layout.detectText, + clipboardText: layout.clipboardText, + occurrences, + caret, + } +} diff --git a/packages/client/ui-conversation/src/client/input/editor/span-map.ts b/packages/client/ui-conversation/src/client/input/editor/span-map.ts new file mode 100644 index 0000000000..7a8bc50f05 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/span-map.ts @@ -0,0 +1,109 @@ +/** + * Detect-coordinate span application: the one place that maps a TokenSpan's + * numeric [start, end) back onto Lexical points and applies an edit there. + * Every slash/input-* event (begin-command, insert-reference, insert-text, + * consume-token) and the paste-upgrade path land through here; revision CAS + * stays with the caller — this module only maps and edits. All functions + * must run inside `editor.update()`. + */ +import type { LexicalNode, RangeSelection } from 'lexical' +import { $createRangeSelection, $getRoot, $setSelection } from 'lexical' +import type { ComposerLayout } from './projection.ts' +import { $composerLayout } from './projection.ts' + +/** Half-open [start, end) range in detect coordinates (TokenSpan's plane). */ +export interface DetectSpan { + readonly start: number + readonly end: number +} + +/** One resolved selection point (set() argument triple). */ +interface ResolvedPoint { + readonly key: string + readonly offset: number + readonly type: 'text' | 'element' +} + +/** + * Resolve one detect offset to a selection point. Ownership rule: the first + * segment whose half-open [start, end) contains the offset resolves it; the + * document end falls to the last block. Atomic segments (chip, linebreak) + * resolve to element points beside them, so a span can only ever address a + * chip as a whole; a gap offset is the end of the block before it. + * @param layout - current walk product. + * @param offset - detect offset in [0, detectLength]. + * @returns the point, or null when the offset is out of bounds. + */ +function resolvePoint(layout: ComposerLayout, offset: number): ResolvedPoint | null { + if (offset < 0 || offset > layout.detectLength) return null + for (const segment of layout.segments) { + if (offset >= segment.detectStart + segment.detectLength) continue + if (segment.kind === 'text' && segment.node !== null) { + return { key: segment.node.getKey(), offset: offset - segment.detectStart, type: 'text' } + } + if (segment.kind === 'gap' && segment.gapBetween !== undefined) { + const before = segment.gapBetween.before + return { key: before, offset: layout.children.get(before)?.length ?? 0, type: 'element' } + } + // Atomic leaf (chip / linebreak): the element point on its leading side. + /* v8 ignore next -- non-gap segments always carry their node. */ + if (segment.node === null) return null + const element = segment.node.getParent() + /* v8 ignore next -- a walked leaf always has a parent element. */ + if (element === null) return null + return { key: element.getKey(), offset: segment.node.getIndexWithinParent(), type: 'element' } + } + // offset === detectLength: the end of the last block (or the empty root). + const blocks = $getRoot().getChildren() + const last = blocks[blocks.length - 1] + if (last === undefined) return { key: $getRoot().getKey(), offset: 0, type: 'element' } + return { key: last.getKey(), offset: layout.children.get(last.getKey())?.length ?? 0, type: 'element' } +} + +/** + * Build and apply a live RangeSelection over one detect span. + * @param layout - current walk product. + * @param span - detect span. + * @returns the applied selection, or null when either endpoint fails to map. + */ +function selectSpan(layout: ComposerLayout, span: DetectSpan): RangeSelection | null { + if (span.start < 0 || span.start > span.end || span.end > layout.detectLength) return null + const anchor = resolvePoint(layout, span.start) + const focus = resolvePoint(layout, span.end) + /* v8 ignore next -- bounds were checked above; resolvePoint only fails out of bounds. */ + if (anchor === null || focus === null) return null + const selection = $createRangeSelection() + selection.anchor.set(anchor.key, anchor.offset, anchor.type) + selection.focus.set(focus.key, focus.offset, focus.type) + $setSelection(selection) + return selection +} + +/** + * Replace one detect span with plain text (empty text deletes the span). + * The caret lands after the insertion. + * @param span - detect span to replace. + * @param text - replacement text. + * @returns whether the span mapped and the edit applied. + */ +export function $replaceDetectSpanWithText(span: DetectSpan, text: string): boolean { + const selection = selectSpan($composerLayout(), span) + if (selection === null) return false + if (text === '' && !selection.isCollapsed()) selection.removeText() + else selection.insertText(text) + return true +} + +/** + * Replace one detect span with nodes (chip insertion path). The caret lands + * after the last inserted node. + * @param span - detect span to replace. + * @param nodes - replacement nodes in order. + * @returns whether the span mapped and the edit applied. + */ +export function $replaceDetectSpanWithNodes(span: DetectSpan, nodes: readonly LexicalNode[]): boolean { + const selection = selectSpan($composerLayout(), span) + if (selection === null) return false + selection.insertNodes([...nodes]) + return true +} 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 new file mode 100644 index 0000000000..994a9445e9 --- /dev/null +++ b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx @@ -0,0 +1,428 @@ +// @vitest-environment jsdom +/** + * Composer editor pure core: ReferenceChipNode semantics, the three-view + * projections, and detect-span application. Headless editors drive every + * case — no React tree, no contenteditable; the chip's visual face has its + * own component spec. + */ +import { describe, expect, it } from 'vitest' +import { createHeadlessEditor } from '@lexical/headless' +import type { LexicalEditor, NodeKey, ParagraphNode } from 'lexical' +import { + $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection, + $isTextNode, $setSelection, +} from 'lexical' +import type { ReferenceInsert } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { + $createReferenceChipNode, $isReferenceChipNode, ReferenceChipNode, +} from '../src/client/input/editor/chip-node.tsx' +import type { SerializedReferenceChipNode } from '../src/client/input/editor/chip-node.tsx' +import { + $composerLayout, $detectOffsetOfPoint, $projectComposer, ATOMIC_CHAR, +} from '../src/client/input/editor/projection.ts' +import { + $replaceDetectSpanWithNodes, $replaceDetectSpanWithText, +} from '../src/client/input/editor/span-map.ts' + +const SESSION_REF: ReferenceInsert = { + source: 'session-reference', + ref: 'session-a', + label: '随意回复不调用工具', + appearance: 'session', + clipboardText: '@session:随意回复不调用工具', +} + +const SKILL_REF: ReferenceInsert = { + source: 'skill', + ref: 'commit-helper', + label: 'commit-helper', + clipboardText: '/commit-helper', +} + +function makeEditor(): LexicalEditor { + return createHeadlessEditor({ + namespace: 'core-spec', + nodes: [ReferenceChipNode], + onError: (error) => { throw error }, + }) +} + +/** Ids per NodeKey the way the shell will assign them (stable, monotonic). */ +function idAssigner(): (key: NodeKey) => number { + const ids = new Map() + let seq = 0 + return (key) => { + const existing = ids.get(key) + if (existing !== undefined) return existing + seq += 1 + ids.set(key, seq) + return seq + } +} + +/** Seed one paragraph of mixed content and return the chip key. */ +function seedMixed(editor: LexicalEditor): NodeKey { + let chipKey = '' as NodeKey + editor.update(() => { + const p = $createParagraphNode() + const chip = $createReferenceChipNode(SESSION_REF) + chipKey = chip.getKey() + p.append($createTextNode('ask '), chip, $createTextNode(' now')) + $getRoot().append(p) + }, { discrete: true }) + return chipKey +} + +describe('ReferenceChipNode', () => { + it('carries the owner projections and answers the clipboard text', () => { + const editor = makeEditor() + editor.update(() => { + const chip = $createReferenceChipNode(SESSION_REF) + expect(chip.getSource()).toBe('session-reference') + expect(chip.getReference()).toBe('session-a') + expect(chip.getLabel()).toBe('随意回复不调用工具') + expect(chip.getAppearance()).toBe('session') + expect(chip.getTextContent()).toBe('@session:随意回复不调用工具') + expect(chip.isInvalid()).toBe(false) + expect(chip.isInline()).toBe(true) + expect(chip.isKeyboardSelectable()).toBe(true) + expect($isReferenceChipNode(chip)).toBe(true) + expect($isReferenceChipNode($createTextNode('x'))).toBe(false) + }, { discrete: true }) + }) + + it('round-trips JSON with and without an appearance', () => { + const editor = makeEditor() + editor.update(() => { + const withIcon = $createReferenceChipNode(SESSION_REF).exportJSON() + expect(withIcon.appearance).toBe('session') + const backWithIcon = ReferenceChipNode.importJSON(withIcon) + expect(backWithIcon.getAppearance()).toBe('session') + expect(backWithIcon.getTextContent()).toBe(SESSION_REF.clipboardText) + + const bare = $createReferenceChipNode(SKILL_REF).exportJSON() + expect('appearance' in bare).toBe(false) + const backBare = ReferenceChipNode.importJSON(bare) + expect(backBare.getAppearance()).toBeUndefined() + expect(backBare.getLabel()).toBe('commit-helper') + }, { discrete: true }) + }) + + it('imports the invalid bit from JSON', () => { + const editor = makeEditor() + editor.update(() => { + const json: SerializedReferenceChipNode = { + ...$createReferenceChipNode(SKILL_REF).exportJSON(), + invalid: true, + } + expect(ReferenceChipNode.importJSON(json).isInvalid()).toBe(true) + }, { discrete: true }) + }) + + it('flips the invalid bit through the writable transaction', () => { + const editor = makeEditor() + const chipKey = seedMixed(editor) + editor.update(() => { + const node = $getRoot().getAllTextNodes() // force nothing; address via key map below + void node + }, { discrete: true }) + editor.update(() => { + const chip = [...$getRoot().getChildren()].flatMap(block => + 'getChildren' in block ? (block as ParagraphNode).getChildren() : []).find($isReferenceChipNode) + expect(chip?.getKey()).toBe(chipKey) + chip?.setInvalid(true) + }, { discrete: true }) + editor.read(() => { + const chip = [...$getRoot().getChildren()].flatMap(block => + 'getChildren' in block ? (block as ParagraphNode).getChildren() : []).find($isReferenceChipNode) + expect(chip?.isInvalid()).toBe(true) + }) + }) + + it('mounts a non-editable inline host with the composer anchor', () => { + const editor = makeEditor() + seedMixed(editor) + // Headless editors never call createDOM; exercise it directly. + editor.read(() => { + const chip = [...$getRoot().getChildren()].flatMap(block => + 'getChildren' in block ? (block as ParagraphNode).getChildren() : []).find($isReferenceChipNode) + expect(chip).toBeDefined() + if (chip === undefined) return + const el = chip.createDOM() + expect(el.getAttribute('data-composer-chip')).toBe('session-reference') + expect(el.contentEditable).toBe('false') + expect(chip.updateDOM()).toBe(false) + }) + }) +}) + +describe('$projectComposer', () => { + it('projects the empty document', () => { + const editor = makeEditor() + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.detectText).toBe('') + expect(projection.clipboardText).toBe('') + expect(projection.occurrences).toEqual([]) + expect(projection.caret).toBeNull() + }) + }) + + it('projects chips atomically in detect text and expanded in clipboard text', () => { + const editor = makeEditor() + seedMixed(editor) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.detectText).toBe(`ask ${ATOMIC_CHAR} now`) + expect(projection.clipboardText).toBe('ask @session:随意回复不调用工具 now') + expect(projection.occurrences).toHaveLength(1) + const occurrence = projection.occurrences[0] + expect(occurrence).toMatchObject({ + occurrenceId: 1, + source: 'session-reference', + ref: 'session-a', + offset: 4, + length: SESSION_REF.clipboardText.length, + label: SESSION_REF.label, + appearance: 'session', + clipboardText: SESSION_REF.clipboardText, + }) + expect(occurrence !== undefined && 'invalid' in occurrence).toBe(false) + }) + }) + + it('marks invalid chips and keeps ids stable across projections', () => { + const editor = makeEditor() + const chipKey = seedMixed(editor) + const idOf = idAssigner() + editor.read(() => { + expect($projectComposer(idOf).occurrences[0]?.occurrenceId).toBe(1) + }) + editor.update(() => { + const layout = $composerLayout() + const chip = layout.segments.find(segment => segment.kind === 'chip')?.node + if ($isReferenceChipNode(chip)) chip.setInvalid(true) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idOf) + expect(projection.occurrences[0]?.occurrenceId).toBe(1) + expect(projection.occurrences[0]?.invalid).toBe(true) + void chipKey + }) + }) + + it('projects paragraph gaps and line breaks as newlines in both views', () => { + const editor = makeEditor() + editor.update(() => { + const first = $createParagraphNode() + first.append($createTextNode('a'), $createLineBreakNode(), $createTextNode('b')) + const second = $createParagraphNode() + second.append($createTextNode('c')) + $getRoot().append(first, second) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.detectText).toBe('a\nb\nc') + expect(projection.clipboardText).toBe('a\nb\nc') + }) + }) + + it('folds a collapsed text-point selection to a detect caret', () => { + const editor = makeEditor() + seedMixed(editor) + editor.update(() => { + const text = $getRoot().getAllTextNodes()[0] + if ($isTextNode(text)) text.select(2, 2) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).caret).toBe(2) + }) + }) + + it('reports null caret for ranged and absent selections', () => { + const editor = makeEditor() + seedMixed(editor) + editor.update(() => { + const text = $getRoot().getAllTextNodes()[0] + if ($isTextNode(text)) text.select(0, 2) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).caret).toBeNull() + }) + editor.update(() => { $setSelection(null) }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).caret).toBeNull() + }) + }) + + it('folds element points: chip-adjacent and paragraph-end positions', () => { + const editor = makeEditor() + seedMixed(editor) + editor.update(() => { + // Element point at child index 1 = right before the chip (detect 4). + const p = $getRoot().getFirstChild() + if (p === null) throw new Error('paragraph missing') + const selection = $getSelection() + void selection + const paragraph = p as ParagraphNode + paragraph.select(1, 1) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).caret).toBe(4) + }) + editor.update(() => { + const p = $getRoot().getFirstChild() as ParagraphNode + p.select(3, 3) // after the trailing text child = paragraph end (detect 9) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).caret).toBe(`ask ${ATOMIC_CHAR} now`.length) + }) + }) + + it('returns null for points that reference unknown nodes', () => { + const editor = makeEditor() + seedMixed(editor) + editor.read(() => { + const layout = $composerLayout() + expect($detectOffsetOfPoint(layout, { key: 'missing', offset: 0, type: 'text' } as never)).toBeNull() + expect($detectOffsetOfPoint(layout, { key: 'missing', offset: 0, type: 'element' } as never)).toBeNull() + }) + }) +}) + +describe('detect-span application', () => { + it('replaces a mid-text span with text and lands the caret after it', () => { + const editor = makeEditor() + editor.update(() => { + const p = $createParagraphNode() + p.append($createTextNode('hello world')) + $getRoot().append(p) + }, { discrete: true }) + editor.update(() => { + expect($replaceDetectSpanWithText({ start: 6, end: 11 }, 'there')).toBe(true) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.clipboardText).toBe('hello there') + expect(projection.caret).toBe(11) + }) + }) + + it('replaces a trigger token span with a chip node', () => { + const editor = makeEditor() + editor.update(() => { + const p = $createParagraphNode() + p.append($createTextNode('see @ses please')) + $getRoot().append(p) + }, { discrete: true }) + editor.update(() => { + expect($replaceDetectSpanWithNodes({ start: 4, end: 8 }, [$createReferenceChipNode(SESSION_REF)])).toBe(true) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.detectText).toBe(`see ${ATOMIC_CHAR} please`) + expect(projection.occurrences).toHaveLength(1) + expect(projection.caret).toBe(5) + }) + }) + + it('deletes a span with empty replacement text (consume-token shape)', () => { + const editor = makeEditor() + editor.update(() => { + const p = $createParagraphNode() + p.append($createTextNode('/goal keep')) + $getRoot().append(p) + }, { discrete: true }) + editor.update(() => { + expect($replaceDetectSpanWithText({ start: 0, end: 6 }, '')).toBe(true) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).clipboardText).toBe('keep') + }) + }) + + it('removes a chip whose range the span covers (claim over a chip)', () => { + const editor = makeEditor() + seedMixed(editor) + editor.update(() => { + // [0, 5) covers 'ask ' plus the chip. + expect($replaceDetectSpanWithText({ start: 0, end: 5 }, '/goal ')).toBe(true) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.detectText).toBe('/goal now') + expect(projection.occurrences).toEqual([]) + }) + }) + + it('merges paragraphs when the span crosses a gap', () => { + const editor = makeEditor() + editor.update(() => { + const first = $createParagraphNode() + first.append($createTextNode('one')) + const second = $createParagraphNode() + second.append($createTextNode('two')) + $getRoot().append(first, second) + }, { discrete: true }) + editor.update(() => { + expect($replaceDetectSpanWithText({ start: 2, end: 5 }, '-')).toBe(true) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).clipboardText).toBe('on-wo') + }) + }) + + it('inserts into the empty document and grows a paragraph', () => { + const editor = makeEditor() + editor.update(() => { + expect($replaceDetectSpanWithText({ start: 0, end: 0 }, 'seed')).toBe(true) + }, { discrete: true }) + editor.read(() => { + expect($projectComposer(idAssigner()).clipboardText).toBe('seed') + }) + }) + + it('inserts at a chip boundary without touching the chip', () => { + const editor = makeEditor() + seedMixed(editor) + editor.update(() => { + expect($replaceDetectSpanWithText({ start: 4, end: 4 }, '@')).toBe(true) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.detectText).toBe(`ask @${ATOMIC_CHAR} now`) + expect(projection.occurrences).toHaveLength(1) + }) + }) + + it('rejects out-of-bounds and inverted spans', () => { + const editor = makeEditor() + seedMixed(editor) + editor.update(() => { + expect($replaceDetectSpanWithText({ start: 0, end: 99 }, 'x')).toBe(false) + expect($replaceDetectSpanWithText({ start: 5, end: 4 }, 'x')).toBe(false) + expect($replaceDetectSpanWithText({ start: -1, end: 2 }, 'x')).toBe(false) + expect($replaceDetectSpanWithNodes({ start: 0, end: 99 }, [])).toBe(false) + }, { discrete: true }) + }) + + it('selects the whole document across blocks (submit-clear shape)', () => { + const editor = makeEditor() + editor.update(() => { + const first = $createParagraphNode() + first.append($createTextNode('a'), $createReferenceChipNode(SKILL_REF)) + const second = $createParagraphNode() + second.append($createTextNode('b')) + $getRoot().append(first, second) + }, { discrete: true }) + editor.update(() => { + const layout = $composerLayout() + expect($replaceDetectSpanWithText({ start: 0, end: layout.detectLength }, '')).toBe(true) + }, { discrete: true }) + editor.read(() => { + const projection = $projectComposer(idAssigner()) + expect(projection.clipboardText).toBe('') + expect(projection.occurrences).toEqual([]) + }) + }) +}) diff --git a/packages/client/ui-conversation/tests/reference-chip.client.spec.tsx b/packages/client/ui-conversation/tests/reference-chip.client.spec.tsx new file mode 100644 index 0000000000..63e2ef171c --- /dev/null +++ b/packages/client/ui-conversation/tests/reference-chip.client.spec.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +/** + * ReferenceChip visual face: icon selection per appearance, the trigger + * marker fallback, label truncation container, and invalid styling. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { ReferenceChip } from '../src/client/input/editor/ReferenceChip.tsx' + +afterEach(cleanup) + +describe('ReferenceChip', () => { + it('renders the domain icon and the label', () => { + const { container, getByTitle } = render( + , + ) + expect(getByTitle('Research notes')).toBeTruthy() + expect(container.querySelector('svg')).not.toBeNull() + expect(container.textContent).toBe('Research notes') + }) + + it('falls back to the trigger marker without an appearance', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeNull() + expect(container.textContent).toBe('@commit-helper') + }) + + it('applies the invalid styling bit', () => { + const { container } = render() + const chip = container.firstElementChild + expect(chip).not.toBeNull() + expect([...(chip?.classList ?? [])].some(name => name.includes('invalid'))).toBe(true) + }) +}) From b519cb87b022fa22b0f9f49c8fc4c108a0d44491 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 17:58:52 +0800 Subject: [PATCH 03/26] feat(ui-conversation): lexical composer replaces the textarea stack The editor (shell-owned, per-session) is the draft + chip truth; the machine slims to the submit plane. Chips are atomic decorator nodes with NodeKey identity; TokenSpan coordinates ride the detect projection (chip = one U+FFFC), persistence and InputState.draft ride the clipboard projection. The mirror/backdrop layers, Safari soft-wrap repair, manual undo log, boundary occurrence deletion, and clipboard expansion all retire; the producerless paste-attempt and set-invalid planes go with them. --- .../src/client/input/contract.ts | 171 ++-- .../src/client/input/decorations.ts | 65 +- .../input/editor/ComposerContentEditable.tsx | 50 + .../client/input/editor/DecoratorPortals.tsx | 41 + .../src/client/input/editor/chip-node.tsx | 2 +- .../src/client/input/editor/claim-decor.ts | 69 ++ .../input/editor/composer-editor.module.css | 11 + .../src/client/input/editor/keymap.ts | 149 +++ .../src/client/input/editor/projection.ts | 36 +- .../src/client/input/editor/span-map.ts | 10 + .../src/client/input/editor/text-ref.ts | 138 +++ .../src/client/input/facade.ts | 456 ++++++--- .../src/client/input/machine.ts | 474 +-------- .../src/client/skeleton/InputBar.module.css | 187 +--- .../src/client/skeleton/InputBar.tsx | 585 +++-------- .../tests/assembly-surfaces.client.spec.tsx | 44 +- .../tests/input-bar.client.spec.tsx | 576 ++++------- .../tests/input-machine.client.spec.ts | 927 ------------------ .../tests/input-matrix.client.spec.tsx | 55 +- .../input-reference-submit.client.spec.ts | 27 +- .../tests/input-scenarios.client.spec.tsx | 35 +- .../tests/keydown-probe.client.spec.tsx | 32 + .../tests/lexical-editor-core.client.spec.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 39 +- .../tests/submit-machine.client.spec.ts | 356 +++++++ 25 files changed, 1830 insertions(+), 2707 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx create mode 100644 packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx create mode 100644 packages/client/ui-conversation/src/client/input/editor/claim-decor.ts create mode 100644 packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css create mode 100644 packages/client/ui-conversation/src/client/input/editor/keymap.ts create mode 100644 packages/client/ui-conversation/src/client/input/editor/text-ref.ts delete mode 100644 packages/client/ui-conversation/tests/input-machine.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/keydown-probe.client.spec.tsx create mode 100644 packages/client/ui-conversation/tests/submit-machine.client.spec.ts diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index b9ace8b863..a832359ad8 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -2,11 +2,13 @@ * Frozen input-machine contract. Types * only. Three-tier visibility: business packages see InputState via the * InputZone currency; the scoped input events carry the mutation verbs; the - * conversation wiring layer alone sees the full SessionInput. InputMachine - * (machine.ts) is package-private and never exported. + * conversation wiring layer alone sees the full SessionInput. The draft text + * and its reference chips live in the shell's Lexical editor; the machine + * here is the submit plane (phase, claim, attempt) alone. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { Branded } from '@deepseek-ai/dsh-brand' +import type { LexicalEditor } from 'lexical' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -19,19 +21,19 @@ export type DraftAttachmentId = Branded<'DraftAttachmentId'> /** * The scoped-event application verbs: the hub's bail listeners call these, - * and the boolean answer IS the event's bail value (true ⟺ the machine - * accepted after phase and span/bare-token guards). + * and the boolean answer IS the event's bail value (true ⟺ the editor + * applied the edit after phase and span guards). */ export interface InputTarget { /** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */ beginCommand(claim: CommandClaim, span: TokenSpan): boolean - /** Replace the trigger span with one reference occurrence (span-CAS'd). */ + /** Replace the trigger span with one reference chip (span-CAS'd). */ insertReference(ref: ReferenceInsert, span: TokenSpan): boolean } /** Per-session input facade owned by the conversation wiring layer. */ export interface SessionInput extends InputTarget { - /** Single write path for draft text (all mutation rides machine events). */ + /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean @@ -66,12 +68,12 @@ export interface SessionInputResolver { /** * The public input action face provided to every session-scope slot - * component: two stable-identity void callbacks, mirroring the - * useStore+actions convention. Command-style handles (track/arbitrate/space/ - * undo/paste/…) stay InputBar-private and never ride this face. + * component: stable-identity void callbacks, mirroring the + * useStore+actions convention. Command-style handles (arbitrate/space/ + * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { - /** Single public draft write path (full next draft; occurrence math via diff scan). */ + /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned image ids; busy admission phases refuse. */ addImages(ids: readonly DraftAttachmentId[]): boolean @@ -95,13 +97,15 @@ export interface InputNotice { * 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. + * 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 - /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ - setDraft(text: string, editRange?: EditRange): void + /** The shell-owned Lexical editor the composer binds its contenteditable to. */ + readonly editor: LexicalEditor /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void /** @@ -110,14 +114,14 @@ export interface ComposerKeyboard { * button is the same operation applied to the whole queue). */ steerQueue(): void - undo(): void - redo(): void - /** Paste over the selection (sync components ride the same transaction). */ - pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void - /** Caret/selection gestures the machine cannot observe end the paste attempt. */ - invalidatePaste(): void - /** Feed a draft/caret change through trigger detection (guard derived from phase). */ - track(draft: string, caret: number): 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. */ @@ -129,42 +133,33 @@ export interface ComposerKeyboard { /** 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 machine. */ +/** Guard union of the scoped consume-token event, checked by the shell. */ export type ConsumeTokenGuard = ConsumeTokenRequest['guard'] -/** Half-open [start, end) range/selection in draft character coordinates. */ +/** Half-open [start, end) range/selection in detect-projection coordinates. */ export interface EditSelection { readonly start: number readonly end: number } /** - * One edit applied to the previous draft: [start, end) in the PREVIOUS - * draft's coordinates was replaced by insertedLength characters. Supplied by - * the wiring layer when the DOM event exposes the edit shape; absent, the - * machine recovers it with a prefix/suffix common-scan diff. - */ -export interface EditRange extends EditSelection { - readonly insertedLength: number -} - -/** - * One reference occurrence backed by its complete inline display text in the - * draft. Identity is occurrenceId — same-named - * references stay independently addressable. label/appearance/clipboardText are the - * owner's insert-time projections, cached so the chip survives owner loss - * (invalid flips instead of dropping the occurrence). + * 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 { - /** Machine-minted stable identity (monotonic per machine). */ + /** 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 - /** Display-text offset in the draft. */ + /** Offset in the clipboard-text projection. */ readonly offset: number - /** Display-text length; the occurrence occupies exactly [offset, offset+length). */ + /** 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 @@ -176,53 +171,19 @@ export interface Occurrence { readonly invalid?: boolean } -/** One sync-matched paste component; start/end are relative to the pasted text. */ -export interface PasteComponent extends EditSelection { - readonly reference: ReferenceInsert -} - -/** - * Live paste-match attempt published while async matching may still upgrade - * pasted tokens (the clipboard round-trip). Any non-paste transaction, - * submit start, invalidate-paste, or release ends it; a paste-upgrade keeps - * it current (later tokens re-CAS against the advanced draftRev). - */ -export interface PasteAttemptState { - /** Machine-minted attempt identity (paste-upgrade must match it). */ - readonly attemptId: number - /** Pasted range in the draft as of the paste transaction. */ - readonly insertedRange: EditSelection - /** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */ - readonly generation: number -} - -/** - * InputMachine construction knobs. The machine never reads an ambient clock: - * `now` is the only time source, injected by the shell (tests inject a - * fake). The default clock is constant, i.e. consecutive single-char typing - * always coalesces until a non-typing transaction intervenes. - */ -export interface InputMachineOptions { - /** Single-char typing undo-merge window in ms (default 1000). */ - readonly mergeWindowMs?: number - /** Monotonic clock for typing-merge decisions (default: constant 0). */ - readonly now?: () => number -} - /** Published input state (the currency; per-session). */ export interface InputState { + /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */ readonly draft: string /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */ readonly imageIds: readonly DraftAttachmentId[] - /** Monotonic draft revision (span CAS compares against this). */ + /** Monotonic editor revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } - /** Reference occurrence table, sorted by offset. */ + /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] - /** Live paste-match attempt (absent when no paste is matchable). */ - readonly paste?: PasteAttemptState /** Read-only transient inbox projection (`session/queue`, including pending steering). */ readonly queue: readonly QueuedMessage[] } @@ -236,56 +197,46 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; settlement clears it only after acceptance. */ + /** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode } /** - * InputMachine input events (the machine's single write path). Every draft - * mutation is one transaction: draft edit, occurrence reconciliation, and - * undo-log push are atomic inside dispatch(). Events carrying `at` stamp the - * injected clock reading; only single-char typing coalescing reads it. + * Submit-machine input events (the machine's single write path). Text + * mutation lives in the editor; the machine only observes the draft through + * event payloads (claim integrity, enter snapshots, settlement decisions). */ export type InputEvent = - /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ - | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } - | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } - /** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */ - | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } - /** Delete a settled command token; success is observable as a draftRev advance. */ - | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } - /** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */ - | { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] } - | { readonly type: 'undo' } - | { readonly type: 'redo' } - /** - * Paste text replacing the selection, one transaction. Hot-snapshot sync - * matches ride in as components (chips minted inside the SAME transaction: - * one undo returns to pre-paste); a PasteMatchAttempt opens for the async - * remainder. Component ranges must be disjoint and inside the pasted text. - */ - | { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number } - /** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */ - | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } - /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ - | { readonly type: 'invalidate-paste' } - | { readonly type: 'enter'; readonly mode: InputSubmitMode } + /** Clipboard projection changed: the claimed integrity watch runs (zero effects). */ + | { readonly type: 'draft-changed'; readonly draft: string } + /** The editor applied a claim-token replacement: enter claimed. */ + | { readonly type: 'claim'; readonly claim: CommandClaim } + /** Enter submission with the current clipboard projection. */ + | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } - | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } + /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */ + | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } /** - * InputMachine output effects (executed by the SessionInput shell; the - * machine stays pure). Draft/occurrence mutations carry no effect — the - * shell publishes the state store after every dispatch. + * Submit-machine output effects (executed by the SessionInput shell; the + * machine stays pure). */ export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } + /** + * Clear the committed draft in the editor and cut undo history. A string + * snapshot keeps a pure suffix typed during the Host round-trip (content + * appended after the sent snapshot survives; interleaved edits cannot be + * separated and clear whole); null clears unconditionally (image-only + * sends have no draft to retain). + */ + | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 1ae5e9404b..f8ebddb214 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -1,33 +1,11 @@ /** - * Draft decoration pure core (references render from occurrence ranges; the - * claim token renders as a mirror-layer - * highlight, the claim hint as ghost text). Zero React — the skeleton renders - * the instructions; tests drive this directly. + * Plain-text reference scan (the plain-text-reference decision; + * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * a `/name` or `@name` token whose name is on the trigger's lexicon, and + * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's + * text-ref entity transform consumes these ranges; editing the text out of + * match shape simply drops the range next scan. */ -import type { InputState } from './contract.ts' - -/** The claim-token highlight range (always draft-leading while the watch holds). */ -export interface TokenRange { - readonly start: number - readonly end: number -} - -/** One structured inline-reference render instruction. */ -export interface ChipRender { - /** Stable render key (same-labeled chips stay independent). */ - readonly occurrenceId: number - /** Display-text offset in the draft. */ - readonly offset: number - /** Display-text length in the draft. */ - readonly length: number - /** Exact inline text whose native glyph metrics determine layout. */ - readonly text: string - readonly label: string - /** Optional domain glyph beside the label. */ - readonly appearance?: 'session' | 'file' | 'folder' - /** Owner-resolution failure styling bit. */ - readonly invalid: boolean -} /** * One plain-text reference range (the plain-text-reference decision; @@ -98,34 +76,3 @@ export function scanTextRefs( } return out.sort((left, right) => left.start - right.start) } - -/** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */ -const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() - -/** - * Derive the mirror-layer decorations from the input state. - * @param state - published input state. - * @param lexicon - optional per-trigger reference lexicons (plain-text-reference scan). - * @returns token range, chip instructions, text-ref ranges, and the ghost hint. - */ -export function deriveDecorations( - state: InputState, lexicon: ReadonlyMap<'/' | '@', readonly string[]> = EMPTY_LEXICON, -): DraftDecorations { - const { draft, claim, phase, occurrences } = state - const claimActive = (phase === 'claimed' || phase === 'submitting') - && claim !== undefined && draft.startsWith(claim.token) - const token: TokenRange | null = claimActive ? { start: 0, end: claim.token.length } : null - const chips = occurrences.map(o => ({ - occurrenceId: o.occurrenceId, - offset: o.offset, - length: o.length, - text: draft.slice(o.offset, o.offset + o.length), - label: o.label, - ...o.appearance === undefined ? {} : { appearance: o.appearance }, - invalid: o.invalid === true, - })) - const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === '' - ? claim.hint - : null - return { token, chips, textRefs: scanTextRefs(draft, lexicon), hint } -} diff --git a/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx b/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx new file mode 100644 index 0000000000..b975ee1f2c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx @@ -0,0 +1,50 @@ +/** + * The composer's contenteditable host: binds one shell-owned Lexical editor + * to a resident div. Session-maybe by design — a null editor renders the + * same DOM inert (the no-session Workspace-trigger state), so switching + * between the two never swaps the element tree. Editability has ONE writer: + * this component reflects the `editable` prop onto the editor; nothing else + * calls setEditable. + */ +import { useLayoutEffect, useRef } from 'react' +import type { HTMLAttributes, ReactNode } from 'react' +import type { LexicalEditor } from 'lexical' + +/** Host props: the editor binding plus the div passthroughs the bar owns. */ +export interface ComposerContentEditableProps extends HTMLAttributes { + /** The shell-owned editor; null renders the same div unbound and inert. */ + readonly editor: LexicalEditor | null + /** Whether the user may edit (readOnly/disabled states fold in here). */ + readonly editable: boolean +} + +/** + * Render the composer's editable surface. + * @param props - editor binding, editability, and div passthroughs. + * @returns the resident contenteditable div. + */ +export function ComposerContentEditable({ editor, editable, ...rest }: ComposerContentEditableProps): ReactNode { + const ref = useRef(null) + useLayoutEffect(() => { + const el = ref.current + if (editor === null || el === null) return + editor.setRootElement(el) + return () => { editor.setRootElement(null) } + }, [editor]) + useLayoutEffect(() => { + if (editor !== null) editor.setEditable(editable) + }, [editor, editable]) + return ( +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx b/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx new file mode 100644 index 0000000000..08a054698c --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx @@ -0,0 +1,41 @@ +/** + * Decorator render loop: portals every decorator node's React face into its + * host element (what @lexical/react's composer does internally, scoped to + * this composer's needs). Chip DOM identity rides the NodeKey — text edits + * around a chip never remount its portal. + */ +import * as React from 'react' +import { createPortal } from 'react-dom' +import type { ReactNode } from 'react' +import type { LexicalEditor, NodeKey } from 'lexical' + +/** Portal-loop props. */ +export interface DecoratorPortalsProps { + /** The bound editor; null (no-session) renders nothing. */ + readonly editor: LexicalEditor | null +} + +/** + * Render every decorator's React face into its editor host element. + * @param props - the editor to observe. + * @returns the live portal set. + */ +export function DecoratorPortals({ editor }: DecoratorPortalsProps): ReactNode { + const [decorators, setDecorators] = React.useState>( + () => editor === null ? {} : editor.getDecorators(), + ) + React.useLayoutEffect(() => { + if (editor === null) return + setDecorators(editor.getDecorators()) + return editor.registerDecoratorListener((next) => { setDecorators(next) }) + }, [editor]) + if (editor === null) return null + return ( + <> + {Object.entries(decorators).map(([key, jsx]) => { + const el = editor.getElementByKey(key) + return el === null ? null : createPortal(jsx, el, key) + })} + + ) +} 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 d91098a574..dab8fe51f3 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 @@ -119,7 +119,7 @@ export class ReferenceChipNode extends DecoratorNode { override createDOM(_config: EditorConfig): HTMLElement { const el = document.createElement('span') el.setAttribute('data-composer-chip', this.__source) - el.contentEditable = 'false' + el.setAttribute('contenteditable', 'false') return el } diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts new file mode 100644 index 0000000000..0da08fbc21 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -0,0 +1,69 @@ +/** + * Claim-token highlight: while a command claim holds, the draft's leading + * token renders in the warn color. A TextNode transform keeps the token in + * its own styled node (splitting when typing merges text into it), and the + * shell nudges the first leaf dirty when the claim flips so entering and + * leaving claimed restyles without a text edit. + */ +import type { LexicalEditor, TextNode as TextNodeType } from 'lexical' +import { $getRoot, $isElementNode, $isTextNode, TextNode } from 'lexical' + +/** Inline style carried by the claim-token node (the old backdrop's hlToken color). */ +const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)' + +/** The document's first text leaf, or null (empty document / leading chip). */ +function firstTextLeaf(): TextNodeType | null { + const block = $getRoot().getFirstChild() + if (!$isElementNode(block)) return null + const leaf = block.getFirstChild() + return $isTextNode(leaf) ? leaf : null +} + +/** + * Register the claim-token styling transform. + * @param editor - the shell-owned editor. + * @param activeToken - live claim token accessor; null while unclaimed. + * @returns the unregister disposer. + */ +export function registerClaimDecoration(editor: LexicalEditor, activeToken: () => string | null): () => void { + return editor.registerNodeTransform(TextNode, (node) => { + const first = firstTextLeaf() + if (first === null || node.getKey() !== first.getKey()) { + // Off the token seat: clear a stale token style (a node can move here + // by paragraph merges). + if (node.getStyle() === TOKEN_STYLE && (first === null || node.getKey() !== first.getKey())) { + node.setStyle('') + } + return + } + const token = activeToken() + const text = node.getTextContent() + if (token === null || !text.startsWith(token)) { + if (node.getStyle() === TOKEN_STYLE) node.setStyle('') + return + } + if (text.length > token.length) { + // Typing at the token boundary lands in the styled node; split the + // overflow back out so only the token itself carries the color. + const [tokenNode] = node.splitText(token.length) + if (tokenNode !== undefined && tokenNode.getStyle() !== TOKEN_STYLE) tokenNode.setStyle(TOKEN_STYLE) + return + } + if (node.getStyle() !== TOKEN_STYLE) node.setStyle(TOKEN_STYLE) + }) +} + +/** + * Nudge the token seat dirty so the transform restyles after a claim flip + * (claims change phase without a text edit; transforms only run on dirty + * nodes). + * @param editor - the shell-owned editor. + */ +export function refreshClaimDecoration(editor: LexicalEditor): void { + // Not discrete: a refresh can fire from inside an update listener, where a + // synchronous nested commit would recurse; the queued update lands on the + // next flush. + editor.update(() => { + firstTextLeaf()?.markDirty() + }) +} diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css new file mode 100644 index 0000000000..3e97afb3fb --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css @@ -0,0 +1,11 @@ +/* Editor-internal decoration styles: nodes Lexical mounts inside the + contenteditable (chip hosts get their look from ReferenceChip.module.css; + this sheet covers text-level decorations). */ + +/* Plain-text reference: chip family colors over the draft's own glyphs. + clone keeps rounded ends on soft-wrap fragments. */ +.textRef { + color: var(--dsw-alias-state-business-primary); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts new file mode 100644 index 0000000000..4df587f2ef --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -0,0 +1,149 @@ +/** + * Composer keymap over the Lexical command layer: menu arbitration + * (arrows/escape/enter), space adjudication, the Enter submit gesture, and + * paste routing. Registered at CRITICAL priority so it decides before + * @lexical/plain-text's own Enter/paste defaults; a handler returning false + * falls through to those defaults (Shift+Enter's line break, ordinary + * spaces, text paste the bar routes itself). + * + * IME guard: a composition-closing Enter/Space must not submit or adjudicate. + * KeyboardEvent.isComposing covers most engines; Safari delivers the closing + * keydown AFTER compositionend, so a root-element composition watch holds the + * guard for 10ms more (the old textarea's proven window); keyCode + * 229 is the legacy signal engines emit without isComposing. + */ +import type { LexicalEditor } from 'lexical' +import { + COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, KEY_SPACE_COMMAND, PASTE_COMMAND, +} from 'lexical' +import { mergeRegister } from '@lexical/utils' +import type { ArbitrateKey, ArbitrateOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' + +/** The bar-supplied behavior behind each intercepted gesture. */ +export interface ComposerKeymapHandlers { + /** Keyboard arbitration while the menu is open ('pass' when no pipeline). */ + arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome + /** Space adjudication; true = a claim was applied — the keystroke is consumed. */ + space(): boolean + /** Dismiss the popupSelect shell (Escape layering: an open overlay closes first). */ + dismissPopup(): void + /** Whether Enter may submit right now (locked/busy states refuse). */ + canSubmit(): boolean + /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ + submit(accelerated: boolean): void + /** Pasted files (image intake). */ + intakeFiles(files: readonly File[]): void + /** Pasted plain text (sanitized insertion through the shell). */ + pasteText(text: string): void +} + +/** Composition state a keydown can trust (see the module doc's Safari note). */ +function isComposingEvent(event: KeyboardEvent, recentlyComposing: () => boolean): boolean { + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + return event.isComposing || event.keyCode === 229 || recentlyComposing() +} + +/** + * Register the composer keymap on one editor. + * @param editor - the shell-owned editor. + * @param handlers - bar-supplied behavior. + * @returns the unregister disposer. + */ +export function registerComposerKeymap(editor: LexicalEditor, handlers: ComposerKeymapHandlers): () => void { + // Composition watch: true through composition and for one tick after + // compositionend (Safari's late closing keydown). The listener rides the + // root element and re-arms on root swaps. + let composing = false + let composingUntil = 0 + const onCompositionStart = (): void => { + composing = true + } + const onCompositionEnd = (): void => { + composing = false + composingUntil = Date.now() + 10 + } + const recentlyComposing = (): boolean => composing || Date.now() < composingUntil + + const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => { + const inComposition = event !== null && isComposingEvent(event, recentlyComposing) + if (handlers.arbitrate(key, inComposition) === 'consumed') { + event?.preventDefault() + return true + } + return false + } + + return mergeRegister( + editor.registerRootListener((root, prevRoot) => { + prevRoot?.removeEventListener('compositionstart', onCompositionStart) + prevRoot?.removeEventListener('compositionend', onCompositionEnd) + root?.addEventListener('compositionstart', onCompositionStart) + root?.addEventListener('compositionend', onCompositionEnd) + }), + editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => { + // Escape layering: an open overlay closes; claimed without an overlay + // does NOT release (backspacing the token is the only exit gesture). + handlers.dismissPopup() + const inComposition = event !== null && isComposingEvent(event, recentlyComposing) + if (handlers.arbitrate('escape', inComposition) === 'consumed') { + event?.preventDefault() + return true + } + return false + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_SPACE_COMMAND, (event) => { + if (isComposingEvent(event, recentlyComposing)) return false + const consumed = handlers.space() + console.log('[probe] space() ->', consumed) + if (consumed) { + event.preventDefault() // claim token already carries the trailing separator + return true + } + return false + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(KEY_ENTER_COMMAND, (event) => { + // Shift+Enter is the native line break UNCONDITIONALLY — decided before + // the IME guard so a composition-closing Shift+Enter still breaks the line. + if (event?.shiftKey === true) return false + if (event !== null && isComposingEvent(event, recentlyComposing)) { + // The IME consumes this Enter (candidate pick); neither submit nor + // break the line. No preventDefault: the browser owns the gesture. + return true + } + // Menu-open Enter picks the highlight through arbitration; a + // no-highlight menu passes down to the submit gesture. + if (handlers.arbitrate('enter', false) !== 'pass') { + event?.preventDefault() + return true + } + event?.preventDefault() + if (event?.repeat === true) return true // held-down Enter must not machine-gun sends + if (!handlers.canSubmit()) return true + handlers.submit(event?.ctrlKey === true || event?.metaKey === true) + return true + }, COMMAND_PRIORITY_CRITICAL), + editor.registerCommand(PASTE_COMMAND, (event) => { + // Duck-typed: the payload union includes InputEvent, and test engines + // deliver clipboardData on plain events. + const clipboardData = (event as ClipboardEvent).clipboardData ?? null + if (clipboardData === null) return false + const files = Array.from(clipboardData.items) + .filter(item => item.kind === 'file') + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + if (files.length > 0) handlers.intakeFiles(files) + const text = clipboardData.getData('text/plain') + if (text === '') { + if (files.length === 0) return false + event.preventDefault() + return true + } + event.preventDefault() + handlers.pasteText(text) + return true + }, COMMAND_PRIORITY_CRITICAL), + ) +} 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 798e95c42e..ef167bc566 100644 --- a/packages/client/ui-conversation/src/client/input/editor/projection.ts +++ b/packages/client/ui-conversation/src/client/input/editor/projection.ts @@ -130,6 +130,26 @@ export function $composerLayout(): ComposerLayout { } } +/** + * Fold one clipboard-projection offset to its detect-projection twin. + * Offsets inside a chip's clipboard expansion snap to the chip's trailing + * edge; callers only pass boundaries that were once a document end (submit + * snapshots), which never split a chip. + * @param layout - the current walk product. + * @param clipboardOffset - offset into the clipboard projection. + * @returns the detect offset covering the same document position. + */ +export function detectOffsetOfClipboardOffset(layout: ComposerLayout, clipboardOffset: number): number { + for (const segment of layout.segments) { + const end = segment.clipboardStart + segment.clipboardLength + if (clipboardOffset > end) continue + if (clipboardOffset === end) return segment.detectStart + segment.detectLength + if (segment.kind === 'chip') return segment.detectStart + segment.detectLength + return segment.detectStart + (clipboardOffset - segment.clipboardStart) + } + return layout.detectLength +} + /** The published projection product consumed by the shell every update. */ export interface EditorProjection { /** Trigger/TokenSpan coordinate text (chip = one U+FFFC). */ @@ -138,6 +158,8 @@ export interface EditorProjection { readonly clipboardText: string /** InputState-compatible occurrence view (clipboardText coordinates). */ readonly occurrences: readonly Occurrence[] + /** Range selection in detect coordinates (ordered); null while absent or non-range. */ + readonly selection: { readonly start: number; readonly end: number } | null /** Collapsed caret in detect coordinates; null while the selection is absent or ranged. */ readonly caret: number | null } @@ -190,13 +212,19 @@ export function $projectComposer(idOf: (key: NodeKey) => number): EditorProjecti }) } const selection = $getSelection() - const caret = $isRangeSelection(selection) && selection.isCollapsed() - ? $detectOffsetOfPoint(layout, selection.anchor) - : null + let range: { start: number; end: number } | null = null + if ($isRangeSelection(selection)) { + const anchor = $detectOffsetOfPoint(layout, selection.anchor) + const focus = $detectOffsetOfPoint(layout, selection.focus) + if (anchor !== null && focus !== null) { + range = { start: Math.min(anchor, focus), end: Math.max(anchor, focus) } + } + } return { detectText: layout.detectText, clipboardText: layout.clipboardText, occurrences, - caret, + selection: range, + caret: range !== null && range.start === range.end ? range.start : null, } } diff --git a/packages/client/ui-conversation/src/client/input/editor/span-map.ts b/packages/client/ui-conversation/src/client/input/editor/span-map.ts index 7a8bc50f05..e3e99e4740 100644 --- a/packages/client/ui-conversation/src/client/input/editor/span-map.ts +++ b/packages/client/ui-conversation/src/client/input/editor/span-map.ts @@ -79,6 +79,16 @@ function selectSpan(layout: ComposerLayout, span: DetectSpan): RangeSelection | return selection } +/** + * Select one detect span (collapsed spans place the caret). Exposed for the + * shell's caret placement and tests; the replace helpers below build on it. + * @param span - detect span to select. + * @returns whether both endpoints mapped. + */ +export function $selectDetectSpan(span: DetectSpan): boolean { + return selectSpan($composerLayout(), span) !== null +} + /** * Replace one detect span with plain text (empty text deletes the span). * The caret lands after the insertion. diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts new file mode 100644 index 0000000000..e40473df11 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -0,0 +1,138 @@ +/** + * Plain-text reference decoration (the plain-text-reference decision; + * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * a `/name` or `@name` token whose name is on the trigger's lexicon, and + * syntax-recognizable `@dir/` folder tokens, render in the chip family + * colors. Pure derivation as before — the entity transform converts matching + * text into TextRefNode and back as edits move it in and out of match shape; + * no occurrence identity exists. + */ +import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical' +import { TextNode } from 'lexical' +import { registerLexicalTextEntity } from '@lexical/text' +import { mergeRegister } from '@lexical/utils' +import { $getRoot } from 'lexical' +import { scanTextRefs } from '../decorations.ts' +import css from './composer-editor.module.css' + +/** JSON form of one text-ref node. */ +export type SerializedTextRefNode = Spread<{ + appearance?: 'folder' +}, SerializedTextNode> + +/** One matched plain-text reference as a styled, fully editable text node. */ +export class TextRefNode extends TextNode { + /** Optional icon domain for syntax-recognizable plain references. */ + __appearance: 'folder' | undefined + + /** Lexical node registry type tag. */ + static override getType(): string { + return 'composer-text-ref' + } + + /** + * Clone with identity (Lexical writable-copy contract). + * @param node - node to clone. + * @returns a copy carrying the same NodeKey. + */ + static override clone(node: TextRefNode): TextRefNode { + return new TextRefNode(node.__text, node.__appearance, node.__key) + } + + /** + * Rebuild one text-ref from its JSON form. + * @param json - serialized node. + * @returns a fresh node. + */ + static override importJSON(json: SerializedTextRefNode): TextRefNode { + const node = new TextRefNode(json.text, json.appearance) + node.setFormat(json.format) + node.setDetail(json.detail) + node.setMode(json.mode) + node.setStyle(json.style) + return node + } + + /** + * @param text - the matched token text. + * @param appearance - optional icon domain (folder tokens). + * @param key - Lexical clone-path key; absent for fresh nodes. + */ + constructor(text: string, appearance?: 'folder', key?: NodeKey) { + super(text, key) + this.__appearance = appearance + } + + /** Serialize to the JSON node form. */ + override exportJSON(): SerializedTextRefNode { + return { + ...super.exportJSON(), + type: 'composer-text-ref', + ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), + } + } + + /** Style the span the base TextNode mounts. */ + override createDOM(config: EditorConfig): HTMLElement { + const el = super.createDOM(config) + el.classList.add(css.textRef ?? 'textRef') + el.setAttribute('data-composer-text-ref', '') + if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance) + return el + } + + /** Entity nodes never merge with plain siblings (the transform owns their bounds). */ + override isTextEntity(): true { + return true + } + + /** Editing continues inside; the transform re-evaluates match shape per edit. */ + override canInsertTextBefore(): boolean { + return true + } +} + +/** + * Folder-shape probe for one matched token (the appearance bit). + * @param token - matched token text. + * @returns 'folder' for `@dir/` shapes; undefined otherwise. + */ +function appearanceOf(token: string): 'folder' | undefined { + return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined +} + +/** + * Register the plain-text reference entity transform. + * @param editor - the shell-owned editor. + * @param lexiconOf - live per-trigger name-roll accessor (the controller's aggregated store). + * @returns the unregister disposer. + */ +export function registerTextRefDecoration( + editor: LexicalEditor, + lexiconOf: () => ReadonlyMap<'/' | '@', readonly string[]>, +): () => void { + const getMatch = (text: string): { start: number; end: number } | null => { + const first = scanTextRefs(text, lexiconOf())[0] + return first === undefined ? null : { start: first.start, end: first.end } + } + return mergeRegister( + ...registerLexicalTextEntity( + editor, + getMatch, + TextRefNode, + node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())), + ), + ) +} + +/** + * Force a re-scan of the whole document (transforms only visit dirty nodes; + * a lexicon roll change dirties nothing on its own). Queued, not discrete — + * the caller may sit inside an update listener. + * @param editor - the shell-owned editor. + */ +export function rescanTextRefs(editor: LexicalEditor): void { + editor.update(() => { + for (const node of $getRoot().getAllTextNodes()) node.markDirty() + }) +} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index cc42d0c1ad..fa79aae80e 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -1,23 +1,39 @@ /** - * SessionInput shell over the pure input machine: the sole machine caller - * and effect executor. Owns the InputState store (machine state + the queue - * overlay), the notice channel, and the submit transaction plumbing + * SessionInput shell: owns the per-session Lexical editor (text + chip + * truth) and the pure SubmitMachine (phase/claim/attempt), and choreographs + * everything between them — projections and InputState publication, the + * scoped-event application verbs, the submit transaction plumbing * (adjudicate via the session's InputTriggerController; claim.submit; default - * sink). Package-private; the hub alone constructs it and wires the scoped - * event listeners onto it. + * sink), the notice channel, and the draft persistence mirror. + * Package-private; the hub alone constructs it and wires the scoped event + * listeners onto it. */ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { LexicalEditor, NodeKey } from 'lexical' +import { + $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, + CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, +} from 'lexical' +import { registerPlainText } from '@lexical/plain-text' +import { createEmptyHistoryState, registerHistory } from '@lexical/history' +import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { - DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, - PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, + DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, + QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' -import { InputMachine, projectClipboard } from './machine.ts' +import { SubmitMachine } from './machine.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 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 { @@ -76,15 +92,29 @@ 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 + /** * The per-session input facade: scoped-event application verbs + - * setDraft/submit + the published InputState store. + * setDraft/submit + the published InputState store, over a shell-owned + * Lexical editor. */ export class SessionInputShell implements SessionInput { - /** Published machine state + queue overlay (the InputZone currency source). */ + /** Published editor projection + submit-plane state + queue overlay (the InputZone currency source). */ readonly state: SnapshotStore /** 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 /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, @@ -94,33 +124,119 @@ export class SessionInputShell implements SessionInput { submit: () => { this.submit('queue') }, } - // Real wall clock: the typing-run merge window must actually expire in - // production (the machine's no-clock default is a constant for pure tests). - private readonly core = new InputMachine({ now: () => Date.now() }) + private readonly core = new SubmitMachine() + private projection: EditorProjection = { detectText: '', clipboardText: '', occurrences: [], selection: null, caret: null } + 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 = '' private imageIds: readonly DraftAttachmentId[] = [] /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */ + /** Draft persistence mirror (chat store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined + /** Live lexicon subscription disposer; undefined until the controller resolves. */ + private lexiconOff: (() => void) | undefined constructor(private readonly deps: SessionInputDeps) { + this.editor = createEditor({ + namespace: 'dsh-composer', + nodes: [ReferenceChipNode, TextRefNode], + onError: (error) => { throw error }, + }) + this.unregister = mergeRegister( + registerPlainText(this.editor), + 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.lexiconOff?.() }, + ) 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): void { + if (this.editor._updating) { + fn() + return + } + this.editor.update(fn, { discrete: true }) + } + + + /** + * 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() + this.rev += 1 + this.projection = this.editor.getEditorState().read(() => + $projectComposer(key => this.occurrenceIdOf(key))) + this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText })) + const caret = this.projection.caret + if (caret !== null) { + this.deps.inputTriggers?.()?.track( + this.projection.detectText, caret, { tier: guardOf(this.core.state.phase) }, this.rev, + ) + } + } + + 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 ---- /** - * Single draft write path (all mutation rides machine events). + * 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. - * @param editRange - the DOM-observed edit shape, when the caller knows it - * (narrows the machine's occurrence math; absent → diff scan). */ - setDraft(text: string, editRange?: EditRange): void { - this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) + 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 }) } /** Append ordered image ids unless an admission transaction is locked. */ @@ -158,46 +274,38 @@ export class SessionInputShell implements SessionInput { } /** - * Clear the draft as a successful-send commit: no undo unit is recorded and - * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content - * (the command path gets the same discipline from submit-settled success). + * Clear the draft as a successful-send commit: the editor empties (no undo + * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent + * content (the command path gets the same discipline from submit-settled). * @param imageIds - admitted image ids to remove from this draft. */ commitSend(imageIds: readonly DraftAttachmentId[]): void { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) - this.run(this.core.dispatch({ type: 'send-committed' })) - } - - /** Undo the latest transaction (InputBar intercepts the platform chord). */ - undo(): void { - this.run(this.core.dispatch({ type: 'undo' })) - } - - /** Redo the latest undone transaction. */ - redo(): void { - this.run(this.core.dispatch({ type: 'redo' })) + this.dispatchRun(({ type: 'send-committed' })) } /** - * Paste text over the selection in one transaction, with any hot-snapshot - * sync matches componentized inside it. + * Insert pasted plain text over the current editor selection + * (placeholder-sanitized). The paste event's own default is suppressed by + * the caller; history groups the paste as one undoable step. * @param text - pasted plain text. - * @param selection - replaced selection in draft coordinates. - * @param components - sync-matched reference components (disjoint, inside `text`). - * @param generation - projection generation for late async-upgrade guards. */ - pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void { - this.run(this.core.dispatch({ - type: 'paste-begin', text, selection, - ...(components !== undefined ? { components } : {}), - ...(generation !== undefined ? { generation } : {}), - })) - } - - /** End the live paste-match attempt (caret/selection ops and Slash updates the machine cannot see). */ - invalidatePaste(): void { - this.run(this.core.dispatch({ type: 'invalidate-paste' })) + 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) + }) } /** @@ -232,24 +340,14 @@ export class SessionInputShell implements SessionInput { this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) return } - this.run(this.core.dispatch({ type: 'enter', mode })) + this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() - this.deps.inputTriggers?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev) + this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev) } } - /** - * Feed a draft/caret change through trigger detection (guard derived from - * the machine phase). - * @param draft - live draft text. - * @param caret - caret position in draft coordinates. - */ - track(draft: string, caret: number): void { - this.deps.inputTriggers?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev) - } - /** * Keyboard arbitration while the menu is open. * @param key - the intercepted key. @@ -277,15 +375,9 @@ export class SessionInputShell implements SessionInput { space(): boolean { const inputTriggers = this.deps.inputTriggers?.() if (inputTriggers === undefined) return false - const consumed = inputTriggers.onSpace() - // Machine-driven draft replacement never passes through onChange, so - // re-track: the caret lands after the token, where detection sees - // whitespace and closes the menu. - if (consumed) { - const next = this.snapshot - inputTriggers.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev) - } - return consumed + return inputTriggers.onSpace() + // No re-track here: applying the claim/insert mutates the editor, and the + // update listener re-tracks at the settled caret on its own. } /** Dismiss the popupSelect shell (any interaction outside the box). */ @@ -294,9 +386,18 @@ export class SessionInputShell implements SessionInput { } /** - * Hot plain-text reference lexicon source for the decoration scan - * (the plain-text-reference decision; - * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): + * 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(): { start: number; end: number } { + if (this.projection.selection !== null) return this.projection.selection + const at = this.projection.detectText.length + return { start: at, end: at } + } + + /** + * Hot plain-text reference lexicon source for the decoration scan: * delegates to the controller's aggregated store. Stable * identity per shell; without a pipeline the snapshot is the empty Map and * subscribers never fire. @@ -306,28 +407,53 @@ export class SessionInputShell implements SessionInput { subscribe: fn => this.deps.inputTriggers?.()?.lexicon.subscribe(fn) ?? (() => {}), } + // ---- scoped-event application verbs ---- + /** - * Apply one command claim (scoped begin-command event listener body). + * Apply one command claim (scoped begin-command event listener body): the + * editor replaces [0, span.end) with the claim token, then the machine + * enters claimed. * @param claim - the command claim from the pick path. - * @param span - pick-time span snapshot. - * @returns whether the machine accepted (phase + span CAS passed and the draft mutated). + * @param span - pick-time span snapshot (detect coordinates). + * @returns whether the edit applied (phase, span CAS, and leading guard passed). */ beginCommand(claim: CommandClaim, span: TokenSpan): boolean { - const before = this.core.state.draftRev - this.run(this.core.dispatch({ type: 'begin-command', claim, span })) - return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before + const phase = this.core.state.phase + if (phase !== 'plain' && phase !== 'claimed') return false + if (span.draftRev !== this.rev) return false + // 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 + this.applyEdit(() => { + applied = $replaceDetectSpanWithText({ start: 0, end: span.end }, claim.token) + }) + if (!applied) return false + this.dispatchRun(({ type: 'claim', claim })) + return true } /** - * Apply one reference insertion (scoped insert-reference event listener body). + * Apply one reference insertion (scoped insert-reference event listener + * body): the editor replaces the span with one chip node, followed by a + * separating space unless one is already next. * @param ref - the reference insertion from the pick path. - * @param span - pick-time span snapshot. - * @returns whether the machine accepted. + * @param span - pick-time span snapshot (detect coordinates). + * @returns whether the edit applied. */ insertReference(ref: ReferenceInsert, span: TokenSpan): boolean { - const before = this.core.state.draftRev - this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span })) - return this.core.state.draftRev !== before + const phase = this.core.state.phase + 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 } /** @@ -338,43 +464,40 @@ export class SessionInputShell implements SessionInput { * @returns whether the token was consumed. */ consumeToken(guard: ConsumeTokenRequest['guard']): boolean { - const snapshot = this.core.state if (guard.kind === 'span') { - if (guard.span.draftRev !== snapshot.draftRev) return false - const draft = snapshot.draft - this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end)) - return true + 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 } - if (snapshot.draft.trim() !== guard.token) return false + if (guard.token === '' || this.projection.clipboardText.trim() !== guard.token) return false this.setDraft('') return true } /** * Insert plain reference text over the pick-time span (scoped insert-text - * event listener body; plain-text-reference decision, web-input-machine - * note). Same CAS-then-splice shape as the - * consume-token span branch: the machine sees an ordinary draft-changed - * transaction (one undo step), no occurrence is minted — the chip look is - * a scan-derived decoration, never state. + * event listener body; the plain-text reference path). The editor gains + * ordinary characters — no chip node; the chip look is a scan-derived + * decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). - * @param span - pick-time span snapshot (draftRev CAS). - * @param keepCompleting - re-track at the caret after the splice so an open - * token (a directory pick's trailing slash) reopens the menu. + * @param span - pick-time span snapshot (detect coordinates). + * @param keepCompleting - contract passenger; completion re-opening is + * automatic here (the update listener re-tracks at the settled caret, so an + * open token — a directory pick's trailing slash — reopens the menu without + * an explicit re-track). * @returns whether the text was applied. */ insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { - const snapshot = this.core.state - if (span.draftRev !== snapshot.draftRev) return false - const draft = snapshot.draft - this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) - if (keepCompleting) { - // Machine-driven draft replacement never passes through onChange, so - // re-track at the caret inside the still-open token (see space()). - const next = this.snapshot - this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) - } - return true + void keepCompleting + if (span.draftRev !== this.rev) return false + let applied = false + this.applyEdit(() => { + applied = $replaceDetectSpanWithText(span, text) + }) + return applied } /** @@ -389,13 +512,15 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- - /** Teardown: abort any in-flight attempt and stop accepting async settlements. */ + /** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */ dispose(): void { this.disposed = true - this.run(this.core.dispatch({ type: 'release' })) + this.dispatchRun(({ type: 'release' })) + this.unregister() + this.editor.setRootElement(null) } - /** Read the live machine state (guard derivation reads here). */ + /** Read the live input state (guard derivation reads here). */ get snapshot(): InputState { return this.state.getSnapshot() } @@ -403,7 +528,7 @@ export class SessionInputShell implements SessionInput { /** * Bind the draft persistence mirror (chat store write). Adopt-on-bind: the * store draft may hold a persisted value from a previous mount; the caller - * seeds it via setDraft BEFORE binding, and afterwards every machine-adopted + * seeds it via setDraft BEFORE binding, and afterwards every editor-adopted * draft mirrors out. * @param write - store draft write. * @returns the unbind disposer. @@ -417,6 +542,21 @@ export class SessionInputShell implements SessionInput { // ---- effect executor ---- + /** The claim token the decoration transform styles; null while unclaimed. */ + private activeClaimToken(): string | null { + const core = this.core.state + return (core.phase === 'claimed' || core.phase === 'submitting') && core.claim !== undefined + ? core.claim.token + : null + } + + /** Dispatch + execute, refreshing the claim decoration when the styled token flips. */ + private dispatchRun(ev: Parameters[0]): void { + const beforeToken = this.activeClaimToken() + this.run(this.core.dispatch(ev)) + if (this.activeClaimToken() !== beforeToken) refreshClaimDecoration(this.editor) + } + private run(effects: readonly InputEffect[]): void { for (const fx of effects) this.execute(fx) this.publish() @@ -441,21 +581,45 @@ export class SessionInputShell implements SessionInput { this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } - default: - return // machine-internal effects (mirror rides publish) + case 'commit-draft': { + this.commitDraft(fx.retainSuffixOf) + return + } } } /** - * Prompt serialization before the sink: expand each - * inline reference range to its owner's model form via the session controller's - * codec routing. Owner missing / serialize failure / disposal blocks the - * send — notice + draft and chips retained, never a silent downgrade to - * the clipboard text. Chip-free drafts skip the async detour. + * Execute the commit-draft effect: clear the committed content (retaining + * a pure typed-during-flight suffix when the snapshot allows) and cut the + * undo history so sent content cannot resurrect. + */ + private commitDraft(retainSuffixOf: string | null): void { + this.editor.update(() => { + const layout = $composerLayout() + const clip = layout.clipboardText + if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) { + $replaceDetectSpanWithText( + { start: 0, end: detectOffsetOfClipboardOffset(layout, retainSuffixOf.length) }, '', + ) + return + } + const root = $getRoot() + root.clear() + root.selectEnd() + }, { discrete: true, tag: HISTORY_MERGE_TAG }) + this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined) + } + + /** + * Prompt serialization before the sink: expand each chip occurrence to its + * owner's model form via the session controller's codec routing. Owner + * missing / serialize failure / disposal blocks the send — notice + draft + * and chips retained, never a silent downgrade to the clipboard text. + * Chip-free drafts skip the async detour. */ private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { const imageIds = [...this.imageIds] - const occurrences = this.core.state.occurrences + const occurrences = this.projection.occurrences if (occurrences.length === 0) { this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return @@ -472,8 +636,9 @@ export class SessionInputShell implements SessionInput { })).then( (parts) => { if (this.disposed) return - // Splice model forms over their display ranges (offsets are draft-time; - // parts arrive offset-sorted since the table is). + // Splice model forms over their clipboard ranges (offsets are + // clipboard-projection; parts arrive offset-sorted since chips walk in + // document order). let out = '' let cursor = 0 for (const part of parts) { @@ -487,7 +652,9 @@ export class SessionInputShell implements SessionInput { controller.abort() if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + this.dispatchRun(({ + type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message, + })) }, ) } @@ -505,19 +672,21 @@ export class SessionInputShell implements SessionInput { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) } - this.run(this.core.dispatch({ + this.dispatchRun(({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', + draft: this.projection.clipboardText, outcome, })) }, (error: unknown) => { if (this.dead(attempt)) return - this.run(this.core.dispatch({ + this.dispatchRun(({ type: 'submit-settled', attempt, ok: false, + draft: this.projection.clipboardText, message: error instanceof Error ? error.message : String(error), })) }, @@ -529,18 +698,18 @@ export class SessionInputShell implements SessionInput { const inputTriggers = this.deps.inputTriggers?.() if (inputTriggers === undefined) { // No pipeline mounted: the '/' line is an ordinary message. - this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) + this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined })) return } inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return - this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome })) + this.dispatchRun(({ type: 'adjudicated', attempt, outcome })) }, (error: unknown) => { if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message })) + this.dispatchRun(({ type: 'adjudication-failed', attempt, message })) }, ) } @@ -570,15 +739,19 @@ export class SessionInputShell implements SessionInput { this.imageIds = this.imageIds.filter(id => !submitted.has(id)) this.deps.commandImages.release(imageIds) } - this.run(this.core.dispatch({ - type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, + this.dispatchRun(({ + type: 'submit-settled', attempt, ok: outcome.kind === 'success', + draft: this.projection.clipboardText, outcome, ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}), })) }, (error: unknown) => { if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + this.dispatchRun(({ + type: 'submit-settled', attempt, ok: false, + draft: this.projection.clipboardText, message, + })) }, ) } @@ -590,16 +763,23 @@ export class SessionInputShell implements SessionInput { private compose(): InputState { const core = this.core.state - return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE } + return { + draft: this.projection.clipboardText, + imageIds: this.imageIds, + draftRev: this.rev, + phase: core.phase, + ...(core.claim !== undefined ? { claim: core.claim } : {}), + occurrences: this.projection.occurrences, + queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE, + } } private publish(): void { const next = this.compose() this.state.set(next) - const mirroredDraft = projectClipboard(next) - if (mirroredDraft !== this.lastMirroredDraft) { - this.lastMirroredDraft = mirroredDraft - this.mirrorFn?.(mirroredDraft) + if (next.draft !== this.lastMirroredDraft) { + this.lastMirroredDraft = next.draft + this.mirrorFn?.(next.draft) } } } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index a1a49004a4..ec308d461d 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -1,47 +1,19 @@ /** - * InputMachine: the pure per-session input state machine. - * Events in, effects out; zero React / DOM / cordis / ambient - * clock. Package-private — the SessionInput shell is the only caller and the - * sole executor of the returned effects. + * SubmitMachine: the pure per-session submit-plane state machine. + * Events in, effects out; zero React / DOM / cordis. Package-private — the + * SessionInput shell is the only caller and the sole executor of the + * returned effects. * - * Draft truth: the draft string holds each reference's complete inline display - * text; the occurrence table carries identity, range, and the owner's cached projections. Every - * draft mutation is one transaction — draft edit, occurrence reconciliation, - * and undo-log push are atomic inside dispatch() — and bumps draftRev, which - * is what lets span CAS reduce to a revision-equality check: equal rev ⟹ - * identical draft ⟹ identical span content. Callers observe mutation success - * as a draftRev advance (begin-command / insert-ref / consume-token / - * paste-upgrade all answer their bail events this way). + * The machine owns phase, claim, and the in-flight SubmitAttempt; it never + * holds the draft. Text truth lives in the shell's Lexical editor, and every + * decision that needs the draft reads it from the event payload (claim + * integrity watch, enter snapshots, settlement suffix/re-entry decisions). */ -import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { CommandClaim } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { InputSubmitMode } from '../contract/composer-submission.ts' -import type { - ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions, - InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, -} from './contract.ts' +import type { InputEffect, InputEvent, InputState, SubmitAttempt } from './contract.ts' -/** Legacy fixed-width object replacement character rejected from pasted text. */ -export const PLACEHOLDER = '' - -const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu - -/** - * Build the inline draft text whose leading marker is decorated as the - * reference icon in the backdrop. - * @param reference - reference insertion with its cached display projection. - * @returns display text with one marker glyph followed by the complete label. - */ -export function referenceDraftText(reference: Pick): string { - return `@${reference.label}` -} - -/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */ -const EMPTY_QUEUE: InputState['queue'] = [] - -/** Undo ring depth (bounded self-managed transaction log). */ -const LOG_LIMIT = 100 - -/** Exhaustiveness backstop for the closed InputEvent / guard unions. */ +/** Exhaustiveness backstop for the closed InputEvent union. */ function unreachable(value: never): never { throw new Error(`unreachable input event: ${JSON.stringify(value)}`) } @@ -64,88 +36,33 @@ function argsAfter(draft: string, token: string): string { return '' } -/** - * Prefix/suffix common-scan recovering the edit range between two drafts - * (used when the wiring layer cannot supply one from the DOM event). - */ -function diffEdit(prev: string, next: string): EditRange { - let p = 0 - const maxCommon = Math.min(prev.length, next.length) - while (p < maxCommon && prev[p] === next[p]) p += 1 - let s = 0 - const maxSuffix = maxCommon - p - while (s < maxSuffix && prev[prev.length - 1 - s] === next[next.length - 1 - s]) s += 1 - return { start: p, end: prev.length - s, insertedLength: next.length - s - p } +/** The submit-plane slice of the published InputState. */ +export interface SubmitSnapshot { + readonly phase: InputState['phase'] + readonly claim?: InputState['claim'] } /** - * Expand the draft's reference ranges into their occurrences' clipboard text - * for persistence and clipboard projection. Table order is offset order, so - * one linear walk pairs ranges with entries. - * @param state - published input state. - * @returns the plain-text projection of the draft. - */ -export function projectClipboard(state: Pick): string { - const { draft, occurrences } = state - if (occurrences.length === 0) return draft - let out = '' - let cursor = 0 - for (const o of occurrences) { - out += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + o.length - } - return out + draft.slice(cursor) -} - -/** One undo unit: snapshots taken before the transaction applied. */ -interface Transaction { - readonly draftBefore: string - readonly occurrencesBefore: readonly Occurrence[] - /** Pre-edit selection when the triggering event carried one (shell caret restore on undo). */ - readonly selectionBefore?: EditSelection -} - -/** - * Pure input machine, one instance per session (per-session isolation is by + * Pure submit machine, one instance per session (per-session isolation is by * construction). The machine constructs one AbortController per SubmitAttempt * at enter time and aborts it itself on release; the shell never aborts, it * only observes attempt.signal on its adjudicate/submit promises. Stale * attempts (any adjudicated / adjudication-failed / submit-settled whose seq * is not the in-flight one) are dropped: same state, zero effects. */ -export class InputMachine { - private draft = '' - private draftRev = 0 +export class SubmitMachine { private phase: InputState['phase'] = 'plain' private claim: CommandClaim | undefined - private occurrences: readonly Occurrence[] = [] - private occurrenceSeq = 0 private seq = 0 private inflight: { readonly attempt: SubmitAttempt readonly controller: AbortController } | undefined - private log: Transaction[] = [] - private redoStack: Transaction[] = [] - /** Open single-char typing run: the next contiguous char within the window coalesces. */ - private typingRun: { readonly end: number; readonly at: number } | undefined - private paste: PasteAttemptState | undefined - private pasteSeq = 0 - private readonly mergeWindowMs: number - private readonly now: () => number - constructor(options: InputMachineOptions = {}) { - this.mergeWindowMs = options.mergeWindowMs ?? 1000 - this.now = options.now ?? (() => 0) - } - - /** Read-only snapshot of the machine state (queue always empty at this tier). */ - get state(): InputState { + /** Read-only snapshot of the submit-plane state. */ + get state(): SubmitSnapshot { const c = this.claim return { - draft: this.draft, - imageIds: [], - draftRev: this.draftRev, phase: this.phase, ...(c ? { @@ -156,33 +73,19 @@ export class InputMachine { }, } : {}), - occurrences: this.occurrences, - ...(this.paste !== undefined ? { paste: this.paste } : {}), - queue: EMPTY_QUEUE, } } /** * Feed one event through the machine. - * @param ev - Input event; the single write path for all input state. + * @param ev - Input event; the single write path for all submit-plane state. * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events. */ dispatch(ev: InputEvent): readonly InputEffect[] { switch (ev.type) { - case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange) - case 'begin-command': return this.onBeginCommand(ev.claim, ev.span) - case 'insert-ref': return this.onInsertRef(ev.reference, ev.span) - case 'consume-token': return this.onConsumeToken(ev.guard) - case 'set-invalid': return this.onSetInvalid(ev.invalidIds) - case 'undo': return this.onUndo() - case 'redo': return this.onRedo() - case 'paste-begin': return this.onPasteBegin(ev.text, ev.selection, ev.components, ev.generation) - case 'paste-upgrade': return this.onPasteUpgrade(ev.attemptId, ev.span, ev.reference) - case 'invalidate-paste': { - this.paste = undefined - return [] - } - case 'enter': return this.onEnter(ev.mode) + case 'draft-changed': return this.onDraftChanged(ev.draft) + case 'claim': return this.onClaim(ev.claim) + case 'enter': return this.onEnter(ev.mode, ev.draft) case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) @@ -192,313 +95,51 @@ export class InputMachine { } } - // ---- transaction plumbing ---- - - /** Adopt a new draft: bump the revision (the span-CAS invalidation point). */ - private adopt(draft: string): void { - this.draft = draft - this.draftRev += 1 - } - - /** Push one undo unit (before-state), trim the ring, and cut the redo chain. */ - private pushTxn(selectionBefore?: EditSelection): void { - this.log.push({ - draftBefore: this.draft, - occurrencesBefore: this.occurrences, - ...(selectionBefore !== undefined ? { selectionBefore } : {}), - }) - if (this.log.length > LOG_LIMIT) this.log.shift() - this.redoStack = [] - } - - /** - * Reconcile the occurrence table with one edit (old-draft coordinates): - * entries past the range shift by the length delta; an edit that intersects - * a reference range removes its structured occurrence and leaves the edited - * characters as ordinary draft text. - */ - private reconcile(range: EditRange): void { - const delta = range.insertedLength - (range.end - range.start) - const kept: Occurrence[] = [] - for (const o of this.occurrences) { - if (o.offset + o.length <= range.start) kept.push(o) - else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta }) - } - this.occurrences = kept - } - - /** Claimed integrity watch: any mutation that breaks the token prefix releases the claim. */ - private watchClaim(): void { - if (this.phase === 'claimed' && this.claim !== undefined && !this.draft.startsWith(this.claim.token)) { + /** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */ + private onDraftChanged(draft: string): InputEffect[] { + if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) { this.phase = 'plain' this.claim = undefined } - } - - /** Mint one occurrence at a draft offset. */ - private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence { - this.occurrenceSeq += 1 - return { - occurrenceId: this.occurrenceSeq, - source: reference.source, - ref: reference.ref, - offset, - length, - label: reference.label, - ...reference.appearance === undefined ? {} : { appearance: reference.appearance }, - clipboardText: reference.clipboardText, - } - } - - /** Splice minted entries into the offset-sorted table. */ - private withMinted(minted: readonly Occurrence[]): void { - if (minted.length === 0) return - this.occurrences = [...this.occurrences, ...minted].sort((a, b) => a.offset - b.offset) - } - - // ---- draft transactions ---- - - private onDraftChanged(draft: string, editRange?: EditRange): InputEffect[] { - if (draft === this.draft) return [] - const range = editRange ?? diffEdit(this.draft, draft) - // Single-char typing coalesces into the open run while contiguous and - // inside the merge window; anything else opens its own transaction. - const typing = range.start === range.end && range.insertedLength === 1 - const at = this.now() - const run = this.typingRun - const merges = typing && run !== undefined && run.end === range.start && at - run.at <= this.mergeWindowMs - if (!merges) this.pushTxn({ start: range.start, end: range.end }) - this.typingRun = typing ? { end: range.start + 1, at } : undefined - this.reconcile(range) - this.adopt(draft) - this.watchClaim() - this.paste = undefined return [] } - /** Span CAS: revision equality (content identity follows) plus bounds sanity. */ - private casOk(span: TokenSpan): boolean { - return span.draftRev === this.draftRev - && span.start >= 0 && span.start <= span.end && span.end <= this.draft.length - } - - private onBeginCommand(claim: CommandClaim, span: TokenSpan): InputEffect[] { + /** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */ + private onClaim(claim: CommandClaim): InputEffect[] { if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - // Leading-trigger contract: only whitespace may precede the span; the - // whitespace prefix is dropped so the claimed watch (startsWith) holds. - if (!this.casOk(span) || this.draft.slice(0, span.start).trim() !== '') return [] - this.pushTxn() - this.typingRun = undefined - this.reconcile({ start: 0, end: span.end, insertedLength: claim.token.length }) - this.adopt(claim.token + this.draft.slice(span.end)) this.claim = claim this.phase = 'claimed' - this.paste = undefined - return [] - } - - private onInsertRef(reference: ReferenceInsert, span: TokenSpan): InputEffect[] { - if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - if (!this.casOk(span)) return [] - this.replaceSpanWithChip(reference, span) - this.paste = undefined - return [] - } - - /** - * Shared reference-insertion transaction: replace [span) with one inline - * occurrence (insert-ref and paste-upgrade both land here). A separating - * space follows the reference unless one is already next. - * @returns the inserted length (display text plus optional gap). - */ - private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number { - this.pushTxn() - this.typingRun = undefined - const tail = this.draft.slice(span.end) - const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : '' - const displayText = referenceDraftText(reference) - const inserted = displayText + gap - this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length }) - this.withMinted([this.mint(reference, span.start, displayText.length)]) - this.adopt(this.draft.slice(0, span.start) + inserted + tail) - this.watchClaim() - return inserted.length - } - - /** - * Guarded token deletion after business success (popup settle / menu-pick - * execute). No effect signals success: the caller reads the draftRev - * advance off the published state (same currency as the other bail verbs). - */ - private onConsumeToken(guard: ConsumeTokenGuard): InputEffect[] { - if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - switch (guard.kind) { - case 'span': { - const span = guard.span - if (!this.casOk(span) || span.start === span.end) return [] - this.pushTxn() - this.typingRun = undefined - this.reconcile({ start: span.start, end: span.end, insertedLength: 0 }) - this.adopt(this.draft.slice(0, span.start) + this.draft.slice(span.end)) - this.watchClaim() - this.paste = undefined - return [] - } - case 'bare-token': { - if (guard.token === '' || this.draft.trim() !== guard.token) return [] - this.pushTxn() - this.typingRun = undefined - this.occurrences = [] - this.adopt('') - this.watchClaim() - this.paste = undefined - return [] - } - default: return unreachable(guard) - } - } - - /** - * Owner-resolution style bits: exactly the listed occurrences render - * invalid. Not a transaction — the draft, revision, and undo log are - * untouched (invalidation never deletes or rewrites chips). - */ - private onSetInvalid(invalidIds: readonly number[]): InputEffect[] { - const ids = new Set(invalidIds) - if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return [] - this.occurrences = this.occurrences.map((o) => { - const invalid = ids.has(o.occurrenceId) - if ((o.invalid === true) === invalid) return o - const { invalid: _drop, ...rest } = o - return invalid ? { ...rest, invalid: true } : rest - }) - return [] - } - - // ---- undo / redo ---- - - private onUndo(): InputEffect[] { - const entry = this.log.pop() - if (entry === undefined) return [] - this.redoStack.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences }) - this.occurrences = entry.occurrencesBefore - this.adopt(entry.draftBefore) - this.watchClaim() - this.typingRun = undefined - this.paste = undefined - return [] - } - - private onRedo(): InputEffect[] { - const entry = this.redoStack.pop() - if (entry === undefined) return [] - // Manual log push: pushTxn would cut the redo chain being walked. - this.log.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences }) - if (this.log.length > LOG_LIMIT) this.log.shift() - this.occurrences = entry.occurrencesBefore - this.adopt(entry.draftBefore) - this.watchClaim() - this.typingRun = undefined - this.paste = undefined - return [] - } - - // ---- paste plane ---- - - /** - * Paste as one transaction: the text (reference-placeholder-sanitized) replaces the - * selection; hot-snapshot sync matches componentize inside the SAME - * transaction (one undo returns to pre-paste); a match attempt opens for - * the async remainder while the phase still accepts reference mutations. - */ - private onPasteBegin( - rawText: string, selection: EditSelection, - components: readonly PasteComponent[] = [], generation = 0, - ): InputEffect[] { - const { start, end } = selection - if (start < 0 || start > end || end > this.draft.length) return [] - const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '') - this.pushTxn(selection) - this.typingRun = undefined - // Componentize: replace each matched token range (paste-text coordinates, - // disjoint by contract) with inline display text while assembling the insert. - const sorted = [...components].sort((a, b) => a.start - b.start) - const minted: Occurrence[] = [] - let inserted = '' - let cursor = 0 - for (const c of sorted) { - inserted += text.slice(cursor, c.start) - const displayText = referenceDraftText(c.reference) - minted.push(this.mint(c.reference, start + inserted.length, displayText.length)) - inserted += displayText - cursor = c.end - } - inserted += text.slice(cursor) - this.reconcile({ start, end, insertedLength: inserted.length }) - this.withMinted(minted) - this.adopt(this.draft.slice(0, start) + inserted + this.draft.slice(end)) - this.watchClaim() - if (this.phase === 'plain' || this.phase === 'claimed') { - this.pasteSeq += 1 - this.paste = { - attemptId: this.pasteSeq, - insertedRange: { start, end: start + inserted.length }, - generation, - } - } else { - this.paste = undefined - } - return [] - } - - /** - * Async match landed: upgrade one pasted token to a chip as an INDEPENDENT - * transaction (undo #1 → the token text, undo #2 → pre-paste). The attempt - * stays current — later tokens re-CAS against the advanced draftRev. - */ - private onPasteUpgrade(attemptId: number, span: TokenSpan, reference: ReferenceInsert): InputEffect[] { - const attempt = this.paste - if (attempt === undefined || attempt.attemptId !== attemptId) return [] - if (this.phase !== 'plain' && this.phase !== 'claimed') return [] - if (!this.casOk(span) || span.start === span.end) return [] - const insertedLength = this.replaceSpanWithChip(reference, span) - this.paste = { - ...attempt, - insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) }, - } return [] } // ---- submit plane ---- /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(mode: InputSubmitMode): SubmitAttempt { + private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { const controller = new AbortController() this.seq += 1 - const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode } + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode } this.inflight = { attempt, controller } return attempt } - private onEnter(mode: InputSubmitMode): InputEffect[] { + private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt(mode, draft) this.phase = 'submitting' - this.paste = undefined - return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] + return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }] } - const trimmed = this.draft.trim() + const trimmed = draft.trim() if (trimmed === '') return [] - this.paste = undefined if (trimmed.startsWith('/')) { - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt(mode, draft) this.phase = 'adjudicating' - return [{ type: 'adjudicate', attempt, draft: this.draft }] + return [{ type: 'adjudicate', attempt, draft }] } - const attempt = this.beginAttempt(mode) + const attempt = this.beginAttempt(mode, draft) this.phase = 'submitting' - return [{ type: 'default-sink', attempt, draft: this.draft, mode }] + return [{ type: 'default-sink', attempt, draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -514,7 +155,7 @@ export class InputMachine { args: argsAfter(attempt.draftSnapshot, outcome.claim.token), }] } - // 'handled' (source dealt internally), {insert} (no enter-time span + // 'handled' (source dealt internally), {insert}/{text} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. if (outcome === undefined) { this.phase = 'submitting' @@ -545,30 +186,19 @@ export class InputMachine { if (ev.ok) { this.phase = 'plain' this.claim = undefined - this.occurrences = [] - // Text appended after the sent snapshot during the Host round-trip - // survives the commit; edits interleaved with committed content cannot - // be separated from it, so only a pure suffix is retained. - const snapshot = flight.attempt.draftSnapshot - this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) - ? this.draft.slice(snapshot.length) - : '') - // Committed content is gone for good: undo must not resurrect a sent draft. - this.log = [] - this.redoStack = [] - this.typingRun = undefined - this.paste = undefined - return ev.outcome?.text !== undefined - ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] - : [] + const effects: InputEffect[] = [{ type: 'commit-draft', retainSuffixOf: flight.attempt.draftSnapshot }] + if (ev.outcome?.text !== undefined) { + effects.push({ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }) + } + return effects } const text = ev.message ?? ev.outcome?.text // Keep the same command claim only while the live draft still equals the // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. - if (this.draft === flight.attempt.draftSnapshot - && this.claim !== undefined && this.draft.startsWith(this.claim.token)) { + if (ev.draft === flight.attempt.draftSnapshot + && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) { this.phase = 'claimed' return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } @@ -577,17 +207,11 @@ export class InputMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Cut undo state after an accepted image-only send. */ + /** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */ private onSendCommitted(): InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined - this.occurrences = [] - this.adopt('') - this.log = [] - this.redoStack = [] - this.typingRun = undefined - this.paste = undefined - return [] + return [{ type: 'commit-draft', retainSuffixOf: null }] } private onRelease(): InputEffect[] { @@ -597,8 +221,6 @@ export class InputMachine { } this.phase = 'plain' this.claim = undefined - this.typingRun = undefined - this.paste = undefined return [] } } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index e5b85df3e9..f62cace994 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -116,57 +116,20 @@ height: 0; } -/* The draft's scrollport, and the ONLY scrolling box in the composer: the - caret is the textarea's and every visible glyph is the backdrop's, so the two - layers stay together only by riding one offset the browser applies to both at - once. Scrolling one box and assigning the offset to the other cannot hold — - a wheel gesture is composited off the main thread, so the assignment lands - frames late and the words visibly trail the caret. The 14-line cap lives here +/* The draft's scrollport, and the ONLY scrolling box in the composer. The + contenteditable grows with its content inside; the 14-line cap lives here because this is the box the cap describes. */ .scroll { max-height: var(--dsh-composer-text-max-height); overflow-y: auto; } -/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft - height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are - as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea - MUST share font, line-height, padding and wrapping rules or heights diverge. */ +/* Auto-grow anchor: the contenteditable is in normal flow and sets the + draft's height; the placeholder rides it absolutely. */ .grow { position: relative; } -/* Decoration backdrop: same metrics as the transparent-text textarea. It owns - every visible glyph plus the range colors and ghost hint; the textarea above - retains the native selection and caret. */ -.backdrop { - position: absolute; - inset: 0; - overflow: hidden; - color: var(--dsw-alias-label-primary); - pointer-events: none; -} - -.backdropDisabled, -.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) { - color: var(--dsw-alias-label-tertiary); -} - -.hlToken { - background-color: transparent; - color: var(--dsw-alias-state-warn-label); -} - -.hlSegment { - border-radius: 4px; - background-color: transparent; - color: transparent; -} - -.hint { - color: var(--dsw-alias-label-caption); -} - /* Machine pending dot (adjudicating / submitting). */ .pending { width: 8px; @@ -181,70 +144,48 @@ to { opacity: 1; } } +/* The contenteditable draft surface (grows with its content; .scroll caps + and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */ .input { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - resize: none; - /* Never a scroller of its own: it is as tall as the draft, so it has no - scrollable overflow to hold an offset that could differ from the glyphs'. - The browser still reveals the caret — the scroll-into-view walks up to - .scroll and moves both layers together. */ - overflow: hidden; - border: none; - outline: none; - background: transparent; - color: transparent; - -webkit-text-fill-color: transparent; - /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ - caret-color: var(--dsw-alias-state-business-primary); -} - -.input, -.mirror, -.backdrop { - /* Textareas default to content-box (unlike buttons/inputs): without this the - width:100% textarea gains its padding OUTSIDE the card and text runs past - the right padding — and wraps 28px later than the mirror/backdrop layers. */ box-sizing: border-box; - /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these - metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; font-family: var(--dsw-font-family); font-size: inherit; - /* Three consumers, not two: the mirror sizes the stack, the layers must break - lines identically, and the caret reveal parses this value to step one line - down for a caret that sits after a newline. That parse needs a length, so a - theme resolving this to `normal` would make the reveal a silent no-op. */ line-height: inherit; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; - /* These three MUST wrap at one width: the mirror decides the box height - the other two are laid out in, and a glyph layer that breaks lines - elsewhere than the textarea puts the words under the wrong caret. They do - so by construction now that all three sit INSIDE .scroll — a scrollbar - that consumes layout space narrows the scrollport, which is their shared - containing block, so it costs all three the same width on every engine. - A textarea that scrolls itself would break this, and no property fixes - it: WebKit reserves gutter space for an overflow-y:auto textarea and not - for the overflow:hidden layers beside it, leaving them 8px apart - (768 against 776) — worth 2 to 5 wrapped lines on a long draft. */ + outline: none; + color: var(--dsw-alias-label-primary); + /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ + caret-color: var(--dsw-alias-state-business-primary); +} + +/* Lexical paragraphs are

blocks: strip the UA margins so the surface + keeps the textarea's line rhythm. */ +.input p { + margin: 0; +} + +/* Claim ghost hint as generated content after the last paragraph: the bar + sets --dsh-composer-hint (a quoted string) while the claim's args are + blank; without the variable the declaration is invalid and nothing shows. */ +.input p:last-child::after { + content: var(--dsh-composer-hint); + color: var(--dsw-alias-label-caption); } /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ -.input::placeholder { +.placeholder { + position: absolute; + inset: 4px 12px auto 16px; color: var(--dsw-alias-label-caption); - -webkit-text-fill-color: var(--dsw-alias-label-caption); + pointer-events: none; user-select: none; } -/* The backdrop owns disabled draft color; the textarea remains caret-only so - its marker glyphs cannot cover the reference icons beneath it. */ -.input:disabled { - color: transparent; - -webkit-text-fill-color: transparent; +.inputDisabled { + color: var(--dsw-alias-label-tertiary); cursor: not-allowed; } @@ -252,14 +193,9 @@ cursor: pointer; } -.mirror { - visibility: hidden; - pointer-events: none; -} - /* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24 line + 4pt); the docked composer collapses to the content height. */ -.hero .mirror { +.hero .input { min-height: 52px; } @@ -410,64 +346,3 @@ font-size: 12px; cursor: pointer; } - -/* Plain-text reference highlight: a pure range mark over the - draft's own glyphs — advance untouched, so the two layers cannot drift. - Chip family colors; clone keeps rounded ends on soft-wrap fragments. */ -.textRef { - background-color: transparent; - color: var(--dsw-alias-state-business-primary); - box-decoration-break: clone; - -webkit-box-decoration-break: clone; -} -.textRef:after { - display: none; -} - -.textRefTrigger { - position: relative; -} - -.textRefTriggerGlyph { - color: transparent; -} - -.textRefIcon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -/* Structured references use the same inline-backdrop technique as /skill: - their complete display text remains in the textarea, so wrapping and caret - geometry come from the browser's native glyph metrics. The leading marker - reserves the icon's advance while the backdrop paints the domain glyph. */ -.chip { - position: relative; - color: var(--dsw-alias-state-business-primary); - background: transparent; - -webkit-box-decoration-break: clone; - box-decoration-break: clone; -} - -.chipTrigger { - position: relative; -} - -.chipTriggerGlyph { - color: transparent; -} - -.chipIcon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -.chipInvalid { - text-decoration: line-through; - opacity: 0.7; - color: var(--dsw-alias-state-error-primary); -} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 2d96e3bf33..55f4ef9eff 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -4,10 +4,17 @@ * through this entry's own inject, whose hooks compartment binds * useNotices/useLexicon; layout-phase inputs (variant, placeholder, * region-slot content) ride the owner props. Session facts - * (running/removed/promptError) are self-selected via useSession. */ + * (running/removed/promptError) are self-selected via useSession. + * + * The text surface is the shell-owned Lexical editor bound here through + * ComposerContentEditable; chips render as decorator portals, and the + * keymap registers submit/menu/paste gestures on the editor command layer. + * The no-session state renders the SAME div inert as the Workspace-picker + * trigger instead of a parallel tree. + */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, @@ -22,18 +29,14 @@ 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 { deriveDecorations } from '../input/decorations.ts' -import type { DraftDecorations } from '../input/decorations.ts' +import { ComposerContentEditable } from '../input/editor/ComposerContentEditable.tsx' +import { DecoratorPortals } from '../input/editor/DecoratorPortals.tsx' +import { registerComposerKeymap } from '../input/editor/keymap.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' -import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' -import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' import css from './InputBar.module.css' -/** Decoration product of the no-session state (no machine, empty draft). */ -const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } - export type InputBarProps = ComposerBarProps export function InputBar({ @@ -46,13 +49,13 @@ export function InputBar({ }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) - const lexicon = useLexicon(s => s) + void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const subagent = useSession(s => s.subagent) ?? null const removed = useSession(s => s.removed) ?? false - // Plan mode swaps the textarea placeholder (the projection is the folded + // Plan mode swaps the composer placeholder (the projection is the folded // host value; owner-prop placeholders — hero, session-unavailable — win). const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. @@ -61,6 +64,7 @@ export function InputBar({ // current; the bar renders the same DOM inert instead of a parallel tree. const live = input !== undefined && keyboard !== undefined && inputActions !== undefined const draft = input?.draft ?? '' + const editor = keyboard?.editor ?? null const attachments = useMemo( () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds), [draftImages, input?.imageIds], @@ -95,23 +99,8 @@ export function InputBar({ useEffect(() => { if (notice?.level === 'error') showToast(notice.text) }, [notice, showToast]) - const inputRef = useRef(null) const cardRef = useRef(null) const scrollRef = useRef(null) - const mirrorRef = useRef(null) - const safari = useMemo(() => isSafariBrowser(navigator), []) - const safariNativeShrinkRef = useRef(false) - // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; - // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. - const composingRef = useRef(false) - const onCompositionStart = (): void => { - composingRef.current = true - } - const onCompositionEnd = (): void => { - setTimeout(() => { - composingRef.current = false - }, 10) - } // The Access seat's data: the host-computed permissions projection // (undefined = capability absent → the chip renders nothing). @@ -133,12 +122,13 @@ export function InputBar({ // be disabled do lock it — there is no session to choose a model for. const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - // The no-workspace textarea remains the resident DOM node but acts as the + // The no-workspace surface remains the resident DOM node but acts as the // existing picker trigger. Message controls stay locked until a Session // exists; the trigger itself is read-only rather than disabled so pointer // and keyboard users can reach the recovery action. const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined - const textareaDisabled = removed || (locked && !workspaceTrigger) + const editorDisabled = removed || (locked && !workspaceTrigger) + const editable = live && !locked && !machineBusy const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') @@ -149,77 +139,43 @@ export function InputBar({ } }, [attachments, input?.imageIds, inputActions]) - // A native Safari edit that shortens the draft may leave the previous - // soft-wrap layout behind after the mirror shrinks. The native-change signal - // keeps ordinary typing and programmatic draft updates from reading layout; - // the helper then repairs only measured overflow before paint while - // preserving native editing state. See - // .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md. - useLayoutEffect(() => { - const nativeShrink = safariNativeShrinkRef.current - safariNativeShrinkRef.current = false - if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current) - }, [draft, safari]) - // Scroll the draft scrollport the minimum that brings `caret` into view — the - // browser's own behavior for typing, performed for the paths where it does - // not act. - // - // The mirror is the caret's ruler: it renders the same draft at the same - // metrics and the same wrap width in the same stack (that is what makes it - // the height authority), so a Range collapsed at the caret's index reports - // where the caret is without a caret API. - const revealCaret = (caret: number): void => { + // Scroll the draft scrollport the minimum that brings the selection focus + // into view — the browser's own behavior for typing, performed for the + // paths where it does not act (programmatic focus with preventScroll, and + // 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 - const mirrorEl = mirrorRef.current - const text = mirrorEl?.firstChild - if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return - // A box that cannot scroll has nothing to reveal: the draft fits, so every - // caret is already in view and the assignment below would clamp to itself. - if (scrollEl.scrollHeight <= scrollEl.clientHeight) return - const at = Math.min(caret, text.data.length) - // A caret straight after a newline sits on a line with nothing on it to - // measure — the shape a trailing-newline draft ends in — and the engines - // disagree there: chromium returns NO client rects at all (an all-zero box, - // which would scroll the wrong way), firefox reports the line above, WebKit - // the right one. Measure the newline itself instead, which is the line the - // caret just left, and step one line down; that they all agree on. - const afterNewline = at > 0 && text.data[at - 1] === '\n' - const range = document.createRange() - range.setStart(text, afterNewline ? at - 1 : at) - if (afterNewline) range.setEnd(text, at) - else range.collapse(true) - const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0 - const rect = range.getBoundingClientRect() + 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 + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom - else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line - } - - // Reveal the focus end of the current selection. Today's entry paths leave a - // collapsed selection, but honoring direction keeps a future range-preserving - // path from revealing its anchor instead of its focus. - const revealSelectionFocus = (el: HTMLTextAreaElement): void => { - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd - // oxlint-disable-next-line typescript/no-unnecessary-condition - revealCaret(caret ?? el.value.length) + if (rect.bottom > box.bottom) scrollEl.scrollTop += rect.bottom - box.bottom + else if (rect.top < box.top) scrollEl.scrollTop -= box.top - rect.top } // Unlock (mount / session switch) returns focus to the box, and owns the - // reveal that comes with it. `preventScroll` because this focus is ours, not - // a gesture: the textarea is as tall as the draft, so the browser's reveal - // would walk up to the conversation scrollport and move the transcript under - // a user who only switched session. That leaves the caret to us — the DOM is - // reused across sessions, so switching to a longer draft keeps the previous - // offset while the value swap puts the caret at the new draft's end, which is - // off screen (measured on all three engines: offset 0 with the caret 940px - // down). Suppress the walk, then reveal in our own box. + // reveal that comes with it. Lexical's focus() suppresses the browser's + // scroll walk (preventScroll inside), so the reveal in our own scrollport + // is ours to perform — switching to a longer draft otherwise leaves the + // caret (restored at the draft's end) off screen. useEffect(() => { - const el = inputRef.current - if (locked || el === null) return - el.focus({ preventScroll: true }) - revealSelectionFocus(el) - }, [locked, sessionId]) + 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() }) + }, [locked, sessionId, editor]) // A persisted draft arrives AFTER the unlock effect: ConversationSession // adopts it in its own mount effect, and a parent's mount effect runs after @@ -228,25 +184,10 @@ export function InputBar({ // not focus: send-clear, failed-send restore, and first-character transitions // must not steal focus from another control the user moved to. useEffect(() => { - const el = inputRef.current - if (locked || draft === '' || el === null) return - revealSelectionFocus(el) + if (locked || draft === '') return + revealSelection() }, [draft !== '']) - // Caret restore after an edit the composer performs itself. The machine owns - // the draft and the undo log, so paste and cut suppress the native edit and - // write the value through the machine — and a - // programmatic selection change reveals nothing: measured in chromium and - // WebKit, pasting a long block leaves the view where it was while the caret - // sits at the end of the draft. Native typing gets its reveal from the - // browser; these two have to ask for it, so they share one restore. - const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => { - requestAnimationFrame(() => { - el.setSelectionRange(caret, caret) - revealCaret(caret) - }) - } - // Wheel chaining on the draft scrollport, one lifetime (it is never // unmounted — the inert state renders the same element disabled). While the // capped box can still move in this direction, keep the native scroll; only @@ -269,169 +210,6 @@ export function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - /* oxlint-disable typescript/no-unnecessary-condition */ - const selectionOf = (el: HTMLTextAreaElement) => ({ - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }) - /* oxlint-enable typescript/no-unnecessary-condition */ - - const onKeyDown = (e: KeyboardEvent): void => { - if (workspaceTrigger) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onRequestWorkspace() - } - return - } - // Absent machine without a Workspace recovery action stays disabled; the - // guard narrows the faces for the paths below. - if (input === undefined || keyboard === undefined || inputActions === undefined) return - // Shift+Enter is the native newline UNCONDITIONALLY — decided before the - // IME guard so a composition-closing Shift+Enter still breaks the line. - if (e.key === 'Enter' && e.shiftKey) return - // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. - // oxlint-disable-next-line typescript/no-deprecated - const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 - if (!composing && !machineBusy && !locked - && (e.key === 'Backspace' || e.key === 'Delete')) { - const selection = selectionOf(e.currentTarget) - if (selection.start === selection.end) { - const occurrence = input.occurrences.find(o => e.key === 'Backspace' - ? o.offset + o.length === selection.start - : o.offset === selection.start) - if (occurrence !== undefined) { - e.preventDefault() - const start = occurrence.offset - const end = occurrence.offset + occurrence.length - keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) - restoreCaret(e.currentTarget, start) - keyboard.track(keyboard.snapshot.draft, start) - return - } - } - } - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() - return - } - if (e.key === 'Escape') { - // Escape layering: an open overlay closes; claimed without an overlay - // does NOT release (backspacing the token is the only exit gesture). - keyboard.dismissPopup() - if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault() - return - } - if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) { - // The machine owns the undo/redo log (chip transactions have semantics - // the browser stack cannot represent); never let the native stack run. - e.preventDefault() - if (machineBusy || locked) return - const redo = e.key === 'y' || e.shiftKey - if (redo) keyboard.redo() - else keyboard.undo() - return - } - if (e.key === ' ') { - if (composing) return - if (keyboard.space()) e.preventDefault() // claim token already carries the trailing separator - return - } - if (e.key !== 'Enter') return - if (composing) return - // Menu-open Enter picks the highlight through arbitration; a no-highlight - // menu passes down to the machine's own adjudication. - const arbitrated = keyboard.arbitrate('enter', composing) - if (arbitrated !== 'pass') { - e.preventDefault() - return - } - e.preventDefault() - if (e.repeat) return // held-down Enter must not machine-gun sends - if (locked || machineBusy) return - const accelerated = e.ctrlKey || e.metaKey - // 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 (the dock's per-row - // steer button applied to the whole queue). Steering needs the same - // window as the per-row button: a running ordinary session. - if (accelerated && canSteerQueue) { - keyboard.steerQueue() - return - } - keyboard.submit(resolveSubmitMode( - running, - accelerated ? 'accelerated' : 'enter', - subagent === null, - )) - } - - const onChange = (e: ChangeEvent): void => { - if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft - if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock - const next = e.target.value - safariNativeShrinkRef.current = safari && next.length < draft.length - keyboard.setDraft(next) - // selectionStart is number|null in lib.dom; the type-aware lint program narrows it. - // oxlint-disable-next-line typescript/no-unnecessary-condition - keyboard.track(next, e.target.selectionStart ?? next.length) - } - - const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { - if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut - const el = e.currentTarget - const { start, end } = selectionOf(el) - if (start === end) return - const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start) - if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine - e.preventDefault() - const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start) - const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end) - // Expand structured ranges to their owner clipboard projections. - let text = '' - let cursor = copyStart - for (const o of touched) { - text += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + o.length - } - text += draft.slice(cursor, copyEnd) - e.clipboardData.setData('text/plain', text) - if (cut && !machineBusy && !locked) { - keyboard.setDraft( - draft.slice(0, copyStart) + draft.slice(copyEnd), - { start: copyStart, end: copyEnd, insertedLength: 0 }, - ) - restoreCaret(el, copyStart) - } - } - - const onPaste = (e: React.ClipboardEvent): void => { - if (keyboard === undefined) return // absent machine: no draft can accept a paste - if (machineBusy || locked) return - const files = Array.from(e.clipboardData.items) - .filter(item => item.kind === 'file') - .map(item => item.getAsFile()) - .filter((file): file is File => file !== null) - if (files.length > 0) intakeImages(files) - const text = e.clipboardData.getData('text/plain') - if (text === '') { - if (files.length > 0) e.preventDefault() - return - } - e.preventDefault() - const el = e.currentTarget - const sel = selectionOf(el) - // Sync components stay empty at this layer: hot-snapshot matching needs - // the Slash roster, which lives behind keyboard.track — the paste attempt - // opens in the machine and the controller upgrades tokens as matches - // land (paste-upgrade). The DOM layer only starts the transaction. - keyboard.pasteBegin(text, sel) - const caret = sel.start + text.length - restoreCaret(el, caret) - keyboard.track(keyboard.snapshot.draft, caret) - } - // Intake pre-check (DeepSeek Chat semantics): an addition that would break // a projected limit is refused as a whole batch, announced immediately, and // never enters the rail — no more submit-time failure rolling the rail @@ -466,25 +244,67 @@ export function InputBar({ const canAcceptDrop = !locked && !machineBusy && addImages !== undefined - const onSelect = (e: React.SyntheticEvent): void => { - // Any caret/selection gesture ends a live paste attempt (the machine - // cannot observe DOM selection). Cheap no-op when none is live. - if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste() - void e - } + // The keymap handlers read live bar state through this ref so the editor + // registration survives re-renders without re-arming per keystroke. + const gate = useRef({ + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages, + }) + gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages } - // Button presses steal focus from the textarea; suppress at mousedown so - // typing continues seamlessly. `preventScroll` for the same reason as the - // unlock effect, and with no reveal of its own: the caret has not moved, and - // the next keystroke gets the browser's native one. + 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 + } + keyboard.submit(g.resolveSubmitMode( + g.running, + accelerated ? 'accelerated' : 'enter', + g.subagent === null, + )) + }, + intakeFiles: (files) => { gate.current.intakeImages(files) }, + pasteText: (text) => { + if (gate.current.machineBusy || gate.current.locked) return + keyboard.paste(text) + }, + }) + }, [editor, keyboard]) + + // Button presses steal focus from the editor; suppress at mousedown so + // typing continues seamlessly. Lexical's focus() carries preventScroll and + // 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() - inputRef.current?.focus({ preventScroll: true }) + editor?.getRootElement()?.focus({ preventScroll: true }) } const onToggleCommandMenu = (): void => { - const el = inputRef.current - if (el !== null) toggleCommandMenu?.(selectionOf(el)) + if (keyboard !== undefined) toggleCommandMenu?.(keyboard.caretSpan()) + } + + // The no-session Workspace trigger: the resident editable div acts as the + // picker trigger for keyboard users (no editor is bound in this state). + const onWorkspaceKeyDown = (e: KeyboardEvent): void => { + if (!workspaceTrigger) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onRequestWorkspace() + } } // Ordinary sessions retain their primary Send/Stop toggle. A continuable @@ -510,102 +330,36 @@ export function InputBar({ ? null : - // Mirror-layer decorations: a visible backdrop with transparent textarea - // text. Claim tokens and references retain the draft's own glyph metrics, - // so their decoration cannot drift from wrapping, selection, or the caret. - const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon) - const backdrop: ReactNode[] = [] - { - // Segment boundaries: the token range end, every structured-reference - // offset, and every text-ref range — merged in draft order (the sources never - // overlap: structured references own their ranges, text-refs own plain tokens, the - // claim token only leads). - let cursor = 0 - const pushPlain = (upTo: number): void => { - if (upTo > cursor) backdrop.push(draft.slice(cursor, upTo)) - cursor = upTo - } - if (deco.token !== null) { - backdrop.push( - - {draft.slice(deco.token.start, deco.token.end)} - , - ) - cursor = deco.token.end - } - type Boundary = - | { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] } - | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number } - const boundaries: Boundary[] = [ - ...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })), - ...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })), - ].sort((a, b) => a.at - b.at) - for (const b of boundaries) { - if (b.at < cursor) continue // claim-token overlap: the leading mark wins - pushPlain(b.at) - if (b.kind === 'chip') { - const chip = b.chip - backdrop.push( - - {chip.appearance === undefined - ? chip.text[0] - : ( - - {chip.text[0]} - - - )} - {chip.text.slice(1)} - , - ) - cursor = chip.offset + chip.length - } else { - // Plain-range highlight: the glyphs stay the - // textarea's (advance untouched); the mark paints the chip look. - // The key is the draft-order ordinal: a fresh scan derives these - // ranges every render, so none of them carries identity past its - // position, and a draft-offset key would unmount the mark and its - // icon for every character typed ahead of it. Structured references - // key by occurrenceId, the identity their occurrence table owns. - const text = draft.slice(b.ref.start, b.ref.end) - backdrop.push( - - {b.ref.appearance === 'folder' - ? ( - <> - - {text[0]} - - - {text.slice(1)} - - ) - : text} - , - ) - cursor = b.ref.end - } - } - pushPlain(draft.length) - if (deco.hint !== null) { - // Claim tokens have the `/name ` format (trailing space); trim to the bare name. - const commandName = input?.claim?.token.slice(1).trim() ?? '' - const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` - // Dynamic lookup by claimed command name: unknown commands miss the - // dictionary and keep the machine's own hint, so the call is wide. - const translated = (t as Translate)(hintKey) - const displayHint = translated !== hintKey ? translated : deco.hint - backdrop.push({displayHint}) - } - } + // Claim ghost hint: rendered by CSS as generated content after the last + // paragraph while the claim's args are blank (a hint implies a single-line + // token draft). The translated per-command hint wins over the claim's own. + const claimActive = (input?.phase === 'claimed' || input?.phase === 'submitting') + && input.claim !== undefined && draft.startsWith(input.claim.token) + const rawHint = claimActive && input.claim?.hint !== undefined + && draft.slice(input.claim.token.length).trim() === '' + ? input.claim.hint + : null + const hint = ((): string | null => { + if (rawHint === null) return null + // Claim tokens have the `/name ` format (trailing space); trim to the bare name. + const commandName = input?.claim?.token.slice(1).trim() ?? '' + const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` + // Dynamic lookup by claimed command name: unknown commands miss the + // dictionary and keep the machine's own hint, so the call is wide. + const translated = (t as Translate)(hintKey) + return translated !== hintKey ? translated : rawHint + })() + + const placeholderText = placeholder ?? (parentOffline + ? t('placeholder.parentOffline') + : disabled + ? t('placeholder.unavailable') + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. + : canSteerQueue + ? t('placeholder.steerQueue') + : planActive ? t('placeholder.plan') : t('placeholder.default')) return (

@@ -623,7 +377,7 @@ export function InputBar({ {notice.text}
)} - {/* Trigger clicks land on the card, not the textarea: the toolbar row's + {/* Trigger clicks land on the card, not the editor: the toolbar row's disabled controls swallow clicks otherwise (the CSS state disarms their pointer events), so the WHOLE capsule is the pick target. pointerdown stops here so the Menu's outside-close cannot race the @@ -647,54 +401,33 @@ export function InputBar({ size: imageSizeText(imageLimits.maxImageBytes), }, })} - {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the - stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the - absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14 - lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the - glyphs to the backdrop, so they can only stay together by moving together: one scroll - offset the browser applies to both layers at once, never a JS mirror between two boxes, - which a compositor-driven gesture outruns and leaves the words trailing the caret. */} + {/* One scrollport, one text surface: the contenteditable grows with + its content and .scroll — capped at 14 lines in CSS — is the only + thing that scrolls. Chips are decorator portals inside the same + surface, so wrapping, caret geometry, and scrolling are the + browser's own. */}
-
- {backdrop} -
-