Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 );',
);
});
});
45 changes: 38 additions & 7 deletions apps/desktop/src/renderer/components/new-chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2031,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(() => {
Expand Down Expand Up @@ -2092,6 +2122,8 @@ export function ChatInput({
// 目录级禁用同判(ghostWorkdirFilter):被禁用的意识胶囊不亮——渲染层
// 绝不比发送层乐观;禁用变更会广播 ghosts:changed,清单引用变化时重滤。
const installedGhosts = useInstalledGhosts();
const installedGhostsRef = useRef(installedGhosts);
installedGhostsRef.current = installedGhosts;
const pluginsForMenu = useMemo(
() =>
installedGhosts.filter(
Expand Down Expand Up @@ -3001,13 +3033,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) =>
Expand All @@ -3017,7 +3048,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(
Expand Down Expand Up @@ -3421,10 +3452,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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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,
});
});

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);
});
});
Loading