From a4d4c6498af6f903793881d64f637b97d63469a1 Mon Sep 17 00:00:00 2001 From: Shao-Wu Hu <271197399+hushaowu-rh@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:07:27 -0700 Subject: [PATCH 1/3] fix(desktop): remove composer ghost sync IPC Signed-off-by: Shao-Wu Hu <271197399+hushaowu-rh@users.noreply.github.com> --- .../__tests__/chatInputGhostSnapshot.test.ts | 32 +++++ .../components/new-chat/ChatInput.tsx | 35 ++++-- .../composerInputLatencyProbe.test.ts | 114 ++++++++++++++++++ .../renderer/lib/composerInputLatencyProbe.ts | 99 +++++++++++++++ 4 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/renderer/__tests__/chatInputGhostSnapshot.test.ts create mode 100644 apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts create mode 100644 apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts diff --git a/apps/desktop/src/renderer/__tests__/chatInputGhostSnapshot.test.ts b/apps/desktop/src/renderer/__tests__/chatInputGhostSnapshot.test.ts new file mode 100644 index 00000000000..283659bc561 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/chatInputGhostSnapshot.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync( + resolve(__dirname, '..', 'components', 'new-chat', 'ChatInput.tsx'), + 'utf8', +).replace(/\r\n?/g, '\n'); + +describe('ChatInput Ghost snapshot contract', () => { + it('never performs a synchronous Ghost list IPC in the composer', () => { + expect(source).not.toContain('ghosts.listSync()'); + }); + + it('derives the $ palette from the workdir-filtered installed snapshot', () => { + expect(source).toContain( + 'const ghostsForCommand = useMemo(\n () => filterGhostsForWorkdir(installedGhosts, workingDir),', + ); + expect(source).toContain( + 'const ghostCommandItems = useMemo(() => {\n if (!isGhostSigil) return [];\n return ghostsForCommand', + ); + expect(source).toContain('}, [ghostsForCommand, isGhostSigil, t]);'); + }); + + it('uses the latest installed snapshot and workdir at send time', () => { + expect(source).toContain('const installedGhostsRef = useRef(installedGhosts);'); + expect(source).toContain('installedGhostsRef.current = installedGhosts;'); + expect(source).toContain( + 'const eligibleGhosts = filterGhostsForWorkdir(\n installedGhostsRef.current,\n workingDirRef.current,\n );', + ); + }); +}); diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index dbd56d68c8d..2027c51213a 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -175,6 +175,7 @@ import { getAppShortcutCombos } from '@/lib/appShortcutStore'; import { getNextPermissionMode } from '@/lib/permissionModeCycle'; import { matchesKeyboardEvent } from '../../../shared/appShortcuts'; import { createLogger } from '@/lib/logger'; +import { createComposerInputLatencyProbe } from '@/lib/composerInputLatencyProbe'; import { serializeEditorContent, serializeEditorSlice } from './composerContentSerialization'; import { composerDocumentContainsList, @@ -232,6 +233,7 @@ const log = createLogger('ChatInput'); // chat-input:commit 量化每次会话切换时 ChatInput 子树(Lexical 初始化 + 草稿恢复 // + 工具栏)的首次 commit 主线程占用;<30ms 不打,避免噪音。 const perfLog = createLogger('perf/session-switch'); +const composerPerfLog = createLogger('perf/composer-input'); const VOICE_INPUT_LONG_PRESS_MS = 450; const VOICE_INPUT_SHORTCUT_DEDUPE_MS = 250; @@ -1370,6 +1372,14 @@ export function ChatInput({ // atomic chips, and only the list nodes needed to preserve Markdown list // structure while editing. It does not use StarterKit, whose headings and // marks are not part of the chat input contract. + const composerInputLatencyProbeRef = useRef< + ReturnType | null + >(null); + composerInputLatencyProbeRef.current ??= createComposerInputLatencyProbe({ + log: composerPerfLog, + }); + useEffect(() => () => composerInputLatencyProbeRef.current?.dispose(), []); + const editor = useEditor({ // Match the legacy textarea's `autoFocus` prop — on mount, focus the // editor at the end so the user can continue typing after restored text. @@ -1920,6 +1930,11 @@ export function ChatInput({ }); } setTick((t) => t + 1); + composerInputLatencyProbeRef.current?.markUpdate({ + kind: 'document', + composing: ed.view.composing, + docSize: ed.state.doc.content.size, + }); if (!composerMentionDragActiveRef.current) { lastComposerSelectionFromRef.current = ed.state.selection.from; } @@ -1961,6 +1976,11 @@ export function ChatInput({ }, onSelectionUpdate: ({ editor: ed }) => { setTick((t) => t + 1); + composerInputLatencyProbeRef.current?.markUpdate({ + kind: 'selection', + composing: ed.view.composing, + docSize: ed.state.doc.content.size, + }); if (!composerMentionDragActiveRef.current) { lastComposerSelectionFromRef.current = ed.state.selection.from; } @@ -2092,6 +2112,8 @@ export function ChatInput({ // 目录级禁用同判(ghostWorkdirFilter):被禁用的意识胶囊不亮——渲染层 // 绝不比发送层乐观;禁用变更会广播 ghosts:changed,清单引用变化时重滤。 const installedGhosts = useInstalledGhosts(); + const installedGhostsRef = useRef(installedGhosts); + installedGhostsRef.current = installedGhosts; const pluginsForMenu = useMemo( () => installedGhosts.filter( @@ -3001,13 +3023,12 @@ export function ChatInput({ useEffect(() => { setSlashCommandRoster(editor, mergedCommands); }, [editor, mergedCommands]); - // 意识指令源($ 触发):已唤醒且声明了 command 的意识,现查现报(同步 - // IPC 极小);构造成 UnifiedCommand 形状喂同一个面板(交互与 / 完全一致)。 + // 意识指令源($ 触发):复用窗口级已装意识快照,避免输入触发符时同步扫盘。 // 目录级禁用同判:被禁用的意识不进 $ 菜单(与胶囊 / 发送期展开同源)。 const isGhostSigil = trigger.kind === 'slash' && trigger.sigil === '$'; const ghostCommandItems = useMemo(() => { if (!isGhostSigil) return []; - return filterGhostsForWorkdir(window.electronAPI.ghosts.listSync().ghosts, workingDir) + return ghostsForCommand .filter((g) => g.enabled && g.manifest.command !== undefined) .map( (g) => @@ -3017,7 +3038,7 @@ export function ChatInput({ description: `${g.manifest.name} · ${t('settings.ghosts.commandPaletteTag')}`, }) as UnifiedCommand, ); - }, [isGhostSigil, t, workingDir]); + }, [ghostsForCommand, isGhostSigil, t]); // 面板显示与键盘导航共用同一份命令源:$ 只列意识,/ 只列技能/命令。 const paletteCommands = isGhostSigil ? ghostCommandItems : mergedCommands; const filteredCommands = useMemo( @@ -3421,10 +3442,10 @@ export function ChatInput({ const mentionsToSend = mentions.length > 0 ? mentions : undefined; // 意识 $指令展开(C3d 双触发):`$画图 ...` 开头且命中已唤醒意识时, // 追加"必须走 cindy 总机"的机器指令;未命中原样发送。 - // listSync 是既有同步 IPC(首帧同款,极小),每次发送现查,装/卸即时反映; - // 目录级禁用同判(与胶囊 / main 侧生效点同源),被禁用 = 原样发送。 + // 读取 useInstalledGhosts 的最新窗口级快照。ghosts:changed 会原子更新 + // 该快照;发送路径无需同步 IPC,仍按当前工作目录执行同一禁用判定。 const eligibleGhosts = filterGhostsForWorkdir( - window.electronAPI.ghosts.listSync().ghosts, + installedGhostsRef.current, workingDirRef.current, ); const ghostCommandWord = parseGhostCommandWord(text); diff --git a/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts b/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts new file mode 100644 index 00000000000..edbb1aaedbf --- /dev/null +++ b/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + __composerInputLatencyProbeDefaultsForTest, + createComposerInputLatencyProbe, +} from '@/lib/composerInputLatencyProbe'; + +type ScheduledFrame = { + handle: number; + callback: FrameRequestCallback; +}; + +function harness(options?: { thresholdMs?: number; maxLogsPerWindow?: number }) { + let now = 0; + let nextHandle = 1; + const frames: ScheduledFrame[] = []; + const cancelled: number[] = []; + const debug = vi.fn(); + const probe = createComposerInputLatencyProbe({ + now: () => now, + requestFrame: (callback) => { + const handle = nextHandle++; + frames.push({ handle, callback }); + return handle; + }, + cancelFrame: (handle) => cancelled.push(handle), + log: { debug }, + thresholdMs: options?.thresholdMs ?? 100, + windowMs: 1_000, + maxLogsPerWindow: options?.maxLogsPerWindow ?? 2, + }); + + return { + probe, + debug, + frames, + cancelled, + setNow(value: number) { + now = value; + }, + runNextFrame(timestamp: number) { + const frame = frames.shift(); + expect(frame).toBeDefined(); + frame?.callback(timestamp); + }, + }; +} + +describe('createComposerInputLatencyProbe', () => { + it('logs only slow frames and never includes editor content', () => { + const h = harness(); + + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 42 }); + h.runNextFrame(80); + expect(h.debug).not.toHaveBeenCalled(); + + h.setNow(100); + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 57 }); + h.runNextFrame(250); + expect(h.debug).toHaveBeenCalledWith('composer:input-slow kind=document dur=150ms docSize=57'); + }); + + it('coalesces updates in one frame and ignores IME composition', () => { + const h = harness(); + + h.probe.markUpdate({ kind: 'document', composing: true, docSize: 10 }); + expect(h.frames).toHaveLength(0); + + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 20 }); + h.setNow(25); + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 99 }); + expect(h.frames).toHaveLength(1); + + h.runNextFrame(125); + expect(h.debug).toHaveBeenCalledWith('composer:input-slow kind=document dur=125ms docSize=99'); + }); + + it('rate-limits slow logs within a window', () => { + const h = harness({ maxLogsPerWindow: 2 }); + + for (let i = 0; i < 3; i += 1) { + h.setNow(i * 200); + h.probe.markUpdate({ kind: 'document', composing: false, docSize: i + 1 }); + h.runNextFrame(i * 200 + 150); + } + expect(h.debug).toHaveBeenCalledTimes(2); + + h.setNow(1_200); + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 4 }); + h.runNextFrame(1_350); + expect(h.debug).toHaveBeenCalledTimes(3); + }); + + it('cancels a pending frame on dispose', () => { + const h = harness(); + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 8 }); + + h.probe.dispose(); + expect(h.cancelled).toEqual([1]); + + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 9 }); + expect(h.frames).toHaveLength(1); + h.runNextFrame(200); + expect(h.debug).not.toHaveBeenCalled(); + }); + + it('keeps conservative production defaults', () => { + expect(__composerInputLatencyProbeDefaultsForTest).toEqual({ + thresholdMs: 100, + windowMs: 10_000, + maxLogsPerWindow: 20, + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts b/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts new file mode 100644 index 00000000000..758dd26d983 --- /dev/null +++ b/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts @@ -0,0 +1,99 @@ +import type { Logger } from '@/lib/logger'; + +type FrameHandle = number; + +export interface ComposerInputLatencyProbe { + /** Mark a committed editor update and measure until the next animation frame. */ + markUpdate(input: { kind: 'document' | 'selection'; composing: boolean; docSize: number }): void; + dispose(): void; +} + +export interface ComposerInputLatencyProbeOptions { + now?: () => number; + requestFrame?: (callback: FrameRequestCallback) => FrameHandle; + cancelFrame?: (handle: FrameHandle) => void; + log?: Pick; + thresholdMs?: number; + windowMs?: number; + maxLogsPerWindow?: number; +} + +const DEFAULT_THRESHOLD_MS = 100; +const DEFAULT_WINDOW_MS = 10_000; +const DEFAULT_MAX_LOGS_PER_WINDOW = 20; + +/** + * Low-volume composer transaction probe. + * + * It intentionally records only timing and document size. The probe does not + * retain editor text, paths, session ids, or any other user content. Multiple + * updates in one frame are coalesced; an IME composition update is ignored. + */ +export function createComposerInputLatencyProbe( + options: ComposerInputLatencyProbeOptions = {}, +): ComposerInputLatencyProbe { + const now = options.now ?? (() => performance.now()); + const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)); + const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle)); + const thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS; + const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS; + const maxLogsPerWindow = options.maxLogsPerWindow ?? DEFAULT_MAX_LOGS_PER_WINDOW; + const log = options.log; + + let pendingFrame: FrameHandle | null = null; + let pendingStartedAt = 0; + let pendingDocSize = 0; + let pendingKind: 'document' | 'selection' = 'document'; + let windowStartedAt = 0; + let windowCount = 0; + let disposed = false; + + const admit = (timestamp: number): boolean => { + if (timestamp - windowStartedAt >= windowMs) { + windowStartedAt = timestamp; + windowCount = 0; + } + if (windowCount >= maxLogsPerWindow) return false; + windowCount += 1; + return true; + }; + + const flush = (frameTimestamp: number): void => { + pendingFrame = null; + if (disposed) return; + const durationMs = frameTimestamp - pendingStartedAt; + if (durationMs < thresholdMs || !log || !admit(frameTimestamp)) return; + log.debug( + `composer:input-slow kind=${pendingKind} dur=${Math.round(durationMs)}ms docSize=${pendingDocSize}`, + ); + }; + + return { + markUpdate({ kind, composing, docSize }): void { + if (disposed || composing) return; + const timestamp = now(); + if (pendingFrame !== null) { + if (kind === 'document') pendingKind = 'document'; + pendingDocSize = Math.max(0, Math.round(docSize)); + return; + } + pendingStartedAt = timestamp; + pendingKind = kind; + pendingDocSize = Math.max(0, Math.round(docSize)); + pendingFrame = requestFrame(flush); + }, + dispose(): void { + disposed = true; + if (pendingFrame !== null) { + cancelFrame(pendingFrame); + pendingFrame = null; + } + }, + }; +} + +export const __composerInputLatencyProbeDefaultsForTest = { + thresholdMs: DEFAULT_THRESHOLD_MS, + windowMs: DEFAULT_WINDOW_MS, + maxLogsPerWindow: DEFAULT_MAX_LOGS_PER_WINDOW, +}; From 8c7ef7fc7220f2bf2fd7a0f24b63c716f3660c1c Mon Sep 17 00:00:00 2001 From: Shao-Wu Hu <271197399+hushaowu-rh@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:21:11 -0700 Subject: [PATCH 2/3] perf(desktop): disable inactive composer probe Signed-off-by: Shao-Wu Hu <271197399+hushaowu-rh@users.noreply.github.com> --- .../composerInputLatencyProbe.test.ts | 53 +++++++++++++++++++ .../renderer/lib/composerInputLatencyProbe.ts | 14 ++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts b/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts index edbb1aaedbf..1668e12c986 100644 --- a/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts +++ b/apps/desktop/src/renderer/lib/__tests__/composerInputLatencyProbe.test.ts @@ -111,4 +111,57 @@ describe('createComposerInputLatencyProbe', () => { maxLogsPerWindow: 20, }); }); + + it('is a true no-op and never reads the clock or schedules a frame when no log sink is provided', () => { + const now = vi.fn<() => number>(() => 0); + const requestFrame = vi.fn<(callback: FrameRequestCallback) => number>(() => 1); + const cancelFrame = vi.fn(); + + const probe = createComposerInputLatencyProbe({ now, requestFrame, cancelFrame }); + + probe.markUpdate({ kind: 'document', composing: false, docSize: 10 }); + probe.dispose(); + + expect(now).not.toHaveBeenCalled(); + expect(requestFrame).not.toHaveBeenCalled(); + expect(cancelFrame).not.toHaveBeenCalled(); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'is a true no-op and never reads the clock, schedules a frame, or logs when maxLogsPerWindow is %s', + (maxLogsPerWindow) => { + const now = vi.fn<() => number>(() => 0); + const requestFrame = vi.fn<(callback: FrameRequestCallback) => number>(() => 1); + const cancelFrame = vi.fn(); + const debug = vi.fn(); + + const probe = createComposerInputLatencyProbe({ + now, + requestFrame, + cancelFrame, + log: { debug }, + maxLogsPerWindow, + }); + + probe.markUpdate({ kind: 'document', composing: false, docSize: 10 }); + probe.dispose(); + + expect(now).not.toHaveBeenCalled(); + expect(requestFrame).not.toHaveBeenCalled(); + expect(debug).not.toHaveBeenCalled(); + }, + ); + + it('still logs at the smallest positive maxLogsPerWindow boundary', () => { + const h = harness({ maxLogsPerWindow: 1 }); + + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 5 }); + h.runNextFrame(150); + expect(h.debug).toHaveBeenCalledTimes(1); + + h.setNow(10); + h.probe.markUpdate({ kind: 'document', composing: false, docSize: 6 }); + h.runNextFrame(160); + expect(h.debug).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts b/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts index 758dd26d983..0fc67a44420 100644 --- a/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts +++ b/apps/desktop/src/renderer/lib/composerInputLatencyProbe.ts @@ -32,13 +32,23 @@ const DEFAULT_MAX_LOGS_PER_WINDOW = 20; export function createComposerInputLatencyProbe( options: ComposerInputLatencyProbeOptions = {}, ): ComposerInputLatencyProbe { + const log = options.log; + const maxLogsPerWindow = options.maxLogsPerWindow ?? DEFAULT_MAX_LOGS_PER_WINDOW; + + // Without a log sink or a positive, finite log budget nothing can ever be + // recorded, so skip touching the clock and RAF entirely. + if (!log || !Number.isFinite(maxLogsPerWindow) || maxLogsPerWindow <= 0) { + return { + markUpdate(): void {}, + dispose(): void {}, + }; + } + const now = options.now ?? (() => performance.now()); const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)); const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle)); const thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS; const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS; - const maxLogsPerWindow = options.maxLogsPerWindow ?? DEFAULT_MAX_LOGS_PER_WINDOW; - const log = options.log; let pendingFrame: FrameHandle | null = null; let pendingStartedAt = 0; From 386a445e264cf09eab6f7f21e4416d671106a8f8 Mon Sep 17 00:00:00 2001 From: Shao-Wu Hu <271197399+hushaowu-rh@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:44:13 -0700 Subject: [PATCH 3/3] fix(desktop): isolate composer latency probe wiring Signed-off-by: Shao-Wu Hu <271197399+hushaowu-rh@users.noreply.github.com> --- .../components/new-chat/ChatInput.tsx | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index 2027c51213a..b2c1edb9c87 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -28,6 +28,7 @@ import History from '@tiptap/extension-history'; import Placeholder from '@tiptap/extension-placeholder'; import HardBreak from '@tiptap/extension-hard-break'; import type { Editor, JSONContent } from '@tiptap/core'; +import { createComposerInputLatencyProbe } from '@/lib/composerInputLatencyProbe'; import { CjkPunctDecoration } from './CjkPunctDecoration'; import { ComposerListIndentDecoration } from './ComposerListIndentDecoration'; import { @@ -175,7 +176,6 @@ import { getAppShortcutCombos } from '@/lib/appShortcutStore'; import { getNextPermissionMode } from '@/lib/permissionModeCycle'; import { matchesKeyboardEvent } from '../../../shared/appShortcuts'; import { createLogger } from '@/lib/logger'; -import { createComposerInputLatencyProbe } from '@/lib/composerInputLatencyProbe'; import { serializeEditorContent, serializeEditorSlice } from './composerContentSerialization'; import { composerDocumentContainsList, @@ -1372,14 +1372,6 @@ export function ChatInput({ // atomic chips, and only the list nodes needed to preserve Markdown list // structure while editing. It does not use StarterKit, whose headings and // marks are not part of the chat input contract. - const composerInputLatencyProbeRef = useRef< - ReturnType | null - >(null); - composerInputLatencyProbeRef.current ??= createComposerInputLatencyProbe({ - log: composerPerfLog, - }); - useEffect(() => () => composerInputLatencyProbeRef.current?.dispose(), []); - const editor = useEditor({ // Match the legacy textarea's `autoFocus` prop — on mount, focus the // editor at the end so the user can continue typing after restored text. @@ -1930,11 +1922,6 @@ export function ChatInput({ }); } setTick((t) => t + 1); - composerInputLatencyProbeRef.current?.markUpdate({ - kind: 'document', - composing: ed.view.composing, - docSize: ed.state.doc.content.size, - }); if (!composerMentionDragActiveRef.current) { lastComposerSelectionFromRef.current = ed.state.selection.from; } @@ -1976,11 +1963,6 @@ export function ChatInput({ }, onSelectionUpdate: ({ editor: ed }) => { setTick((t) => t + 1); - composerInputLatencyProbeRef.current?.markUpdate({ - kind: 'selection', - composing: ed.view.composing, - docSize: ed.state.doc.content.size, - }); if (!composerMentionDragActiveRef.current) { lastComposerSelectionFromRef.current = ed.state.selection.from; } @@ -2051,6 +2033,34 @@ export function ChatInput({ editorRef.current = editor; }, [editor]); + useEffect(() => { + if (!editor) return; + + const probe = createComposerInputLatencyProbe({ log: composerPerfLog }); + const markDocumentUpdate = ({ editor: activeEditor }: { editor: Editor }): void => { + probe.markUpdate({ + kind: 'document', + composing: activeEditor.view.composing, + docSize: activeEditor.state.doc.content.size, + }); + }; + const markSelectionUpdate = ({ editor: activeEditor }: { editor: Editor }): void => { + probe.markUpdate({ + kind: 'selection', + composing: activeEditor.view.composing, + docSize: activeEditor.state.doc.content.size, + }); + }; + + editor.on('update', markDocumentUpdate); + editor.on('selectionUpdate', markSelectionUpdate); + return () => { + editor.off('update', markDocumentUpdate); + editor.off('selectionUpdate', markSelectionUpdate); + probe.dispose(); + }; + }, [editor]); + // Message action menu “Add to chat”: reuse the exact session-chip insertion // path used by clipboard paste, at the last composer caret position. useEffect(() => {