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
127 changes: 92 additions & 35 deletions apps/desktop/src/renderer/components/new-chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
composerHistoryEntryToDocument,
type ComposerHistoryEntry,
} from '@/lib/composerQuoteDocument';
import { deriveStableComposerHistory } from './composerHistoryProjection';
import type { PastedTextRange, SlashCommandRange } from '@/lib/imageRef';
import {
pastedSessionChipAttrs,
Expand Down Expand Up @@ -175,6 +176,13 @@ import { getAppShortcutCombos } from '@/lib/appShortcutStore';
import { getNextPermissionMode } from '@/lib/permissionModeCycle';
import { matchesKeyboardEvent } from '../../../shared/appShortcuts';
import { createLogger } from '@/lib/logger';
import { createComposerDraftSaveScheduler } from '@/lib/composerDraftSaveScheduler';
import {
composerRenderSnapshot,
shouldRefreshComposerRender,
type ComposerRenderSnapshot,
} from './composerRenderGate';
import { createComposerFrameScheduler } from './composerFrameScheduler';
import { serializeEditorContent, serializeEditorSlice } from './composerContentSerialization';
import {
composerDocumentContainsList,
Expand Down Expand Up @@ -988,19 +996,8 @@ export function ChatInput({
const effectiveCompactToolbar = compactToolbar || autoCompactToolbar;

// ── User message history for ↑/↓ navigation ──────────────────────
const userHistory = useMemo(
() =>
(messages ?? [])
.filter((m) => m.role === 'user' && m.content.trim())
.map((m): ComposerHistoryEntry => ({
content: m.content,
...(m.quotesEncoded === true ? { quotesEncoded: true } : {}),
}))
.reverse(), // newest first
[messages],
);
const userHistoryRef = useRef(userHistory);
userHistoryRef.current = userHistory;
const userHistoryRef = useRef<ComposerHistoryEntry[]>([]);
userHistoryRef.current = deriveStableComposerHistory(messages, userHistoryRef.current);
const historyIndexRef = useRef(-1); // -1 = current draft (not browsing)
const draftRef = useRef<JSONContent | null>(null); // saves draft doc JSON when user starts browsing (preserves marks)
const hydratedHistoryDocumentRef = useRef<ProseMirrorNode | null>(null);
Expand Down Expand Up @@ -1370,7 +1367,36 @@ 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 [, setTick] = useState(0);
const refreshComposerRef = useRef<(() => void) | null>(null);
refreshComposerRef.current = () => setTick((t) => t + 1);
const renderSnapshotRef = useRef<ComposerRenderSnapshot | null>(null);
const draftSaveSchedulerRef = useRef<ReturnType<
typeof createComposerDraftSaveScheduler
> | null>(null);
draftSaveSchedulerRef.current ??= createComposerDraftSaveScheduler();
const caretScrollEditorRef = useRef<Editor | null>(null);
const caretScrollSchedulerRef = useRef<ReturnType<
typeof createComposerFrameScheduler
> | null>(null);
caretScrollSchedulerRef.current ??= createComposerFrameScheduler(() => {
const current = caretScrollEditorRef.current;
if (current) scrollCaretIntoView(current);
});
const scheduleCaretScroll = (ed: Editor): void => {
caretScrollEditorRef.current = ed;
caretScrollSchedulerRef.current?.schedule();
};
useEffect(
() => () => {
caretScrollSchedulerRef.current?.cancel();
},
[],
);
const editor = useEditor({
// React receives only the narrow snapshots above; Tiptap keeps ordinary
// transactions inside its editor view instead of rerendering ChatInput.
shouldRerenderOnTransaction: false,
// Match the legacy textarea's `autoFocus` prop — on mount, focus the
// editor at the end so the user can continue typing after restored text.
// Tiptap treats boolean `true` as `focus('start')`; its deferred mount
Expand Down Expand Up @@ -1919,7 +1945,14 @@ export function ChatInput({
}
});
}
setTick((t) => t + 1);
const nextRenderSnapshot = composerRenderSnapshot(
detectTrigger(ed),
!composerDocIsEmpty(ed.state.doc),
);
if (shouldRefreshComposerRender(renderSnapshotRef.current, nextRenderSnapshot)) {
renderSnapshotRef.current = nextRenderSnapshot;
refreshComposerRef.current?.();
}
if (!composerMentionDragActiveRef.current) {
lastComposerSelectionFromRef.current = ed.state.selection.from;
}
Expand All @@ -1929,45 +1962,68 @@ export function ChatInput({
// recurse on rapid switches).
if (isRestoringRef.current) {
// 即便是 restore,也要补一次滚动——切换 session 后光标常落在末尾
requestAnimationFrame(() => scrollCaretIntoView(ed));
scheduleCaretScroll(ed);
return;
}
// composer-draft-mount-race 修复 (issue #40):hydration 还没跑过 → 这次
// onUpdate 是 Tiptap mount 期间的初始触发(空 editor),不能写 store。
if (!hasHydratedRef.current) {
requestAnimationFrame(() => scrollCaretIntoView(ed));
scheduleCaretScroll(ed);
return;
}
const sk = storageKeyForDraftRef.current;
if (!sk) {
requestAnimationFrame(() => scrollCaretIntoView(ed));
scheduleCaretScroll(ed);
return;
}
const existing = getComposerDraft(sk);
// silent: 自己写自己——不通知 subscribeComposerDraft 监听器,避免回灌
// setContent 把光标位置/IME 组合状态打乱。
saveComposerDraft(
sk,
{
text: ed.getJSON(),
attachments: existing?.attachments ?? [],
quotes: existing?.quotes ?? [],
browserComments: existing?.browserComments ?? [],
},
{ silent: true },
);
// setContent 把光标位置/IME 组合状态打乱。把 JSON 序列化和写入都放进短
// debounce,生命周期边界由 flush 强制落最后一版。
//
// voice-input session-switch 草稿串味修复:`ed.getJSON()` 故意延后到
// debounce 触发那一刻才读(保住上面这条 perf 优化——不在每次按键都同步
// 序列化整份文档)。但 storageKeyForDraftRef 在语音输入 stop/refine/send
// 的 async 等待期间会「故意滞后」于 storageKey prop(见该 ref 声明处注释),
// 期间若这条 debounce 定时器还没触发,restoreNextDraft 就可能先跑完
// setContent 把编辑器换成下一个 session 的文档、再把 ref 切到新 key —
// 定时器这时才触发的话,`ed.getJSON()` 读到的已经是下一个 session 的内容,
// 却仍会存进这里捕获的旧 `sk` 下,串味覆盖旧会话草稿。任务真正执行时重新核对
// ref 是否还等于调度时捕获的 `sk`,不等就说明编辑器内容已经不再属于它,直接
// 跳过这次写入(旧会话的最终内容已由 saveCurrentEditorDraft 在切换前存妥)。
draftSaveSchedulerRef.current?.schedule(() => {
if (storageKeyForDraftRef.current !== sk) return;
const existing = getComposerDraft(sk);
saveComposerDraft(
sk,
{
text: ed.getJSON(),
Comment thread
hushaowu-rh marked this conversation as resolved.
attachments: existing?.attachments ?? [],
quotes: existing?.quotes ?? [],
browserComments: existing?.browserComments ?? [],
},
{ silent: true },
);
});
// chat-input-autoscroll fix: 输入超过 max-h 后,让光标随内容追底
requestAnimationFrame(() => scrollCaretIntoView(ed));
scheduleCaretScroll(ed);
},
onSelectionUpdate: ({ editor: ed }) => {
setTick((t) => t + 1);
const nextRenderSnapshot = composerRenderSnapshot(
detectTrigger(ed),
!composerDocIsEmpty(ed.state.doc),
);
if (shouldRefreshComposerRender(renderSnapshotRef.current, nextRenderSnapshot)) {
renderSnapshotRef.current = nextRenderSnapshot;
refreshComposerRef.current?.();
}
if (!composerMentionDragActiveRef.current) {
lastComposerSelectionFromRef.current = ed.state.selection.from;
}
// 方向键移动光标也要跟随(例如 ↓ 把光标从可见区移到 doc 末尾)
requestAnimationFrame(() => scrollCaretIntoView(ed));
scheduleCaretScroll(ed);
},
onBlur: () => {
draftSaveSchedulerRef.current?.flush();
// Focus left the editor. Spec F1/F2 require the palette to close on
// blur. We defer by a microtask so mouse-click selections on the
// palette (which also blur the editor momentarily) still register.
Expand Down Expand Up @@ -2573,6 +2629,7 @@ export function ChatInput({
useEffect(() => {
if (!editor) return;
return () => {
draftSaveSchedulerRef.current?.flush();
const editorStorageKey = storageKeyForDraftRef.current;
if (!editorStorageKey) return;
const existing = getComposerDraft(editorStorageKey);
Expand Down Expand Up @@ -2657,6 +2714,7 @@ export function ChatInput({

const transitionSeq = storageKeyTransitionSeqRef.current + 1;
storageKeyTransitionSeqRef.current = transitionSeq;
draftSaveSchedulerRef.current?.flush();
const saveCurrentEditorDraft = () => {
if (!prevEditorKey) return;
if (!hasHydratedRef.current) return;
Expand Down Expand Up @@ -2946,9 +3004,6 @@ export function ChatInput({
[syncPaletteHover],
);

// Bump to force trigger recompute (editor state is mutable, not React state)
const [, setTick] = useState(0);

// ── Slash / At panel state ─────────────────────────────────────────
const trigger: TriggerState = editor ? detectTrigger(editor) : { kind: 'none' };

Expand Down Expand Up @@ -3340,6 +3395,7 @@ export function ChatInput({
if (!editor) return;
if (disabled) return;
if (dispatchSendInFlightRef.current) return;
draftSaveSchedulerRef.current?.flush();
dispatchSendInFlightRef.current = true;
setSendDispatchInFlight(true);
try {
Expand Down Expand Up @@ -4738,6 +4794,7 @@ export function ChatInput({
);

const hasMessage = !isEditorEmpty(editor);
renderSnapshotRef.current = composerRenderSnapshot(trigger, hasMessage);
const canSend = hasMessage || hasAttachments || browserComments.length > 0;
const hasVoiceDraftText = voiceInput.draftText.trim().length > 0;
const [voiceReleaseToSendActive, setVoiceReleaseToSendActive] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';

import { createComposerFrameScheduler } from '../composerFrameScheduler';

describe('createComposerFrameScheduler', () => {
it('keeps only the latest pending frame', () => {
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
const cancelled: number[] = [];
let runs = 0;
const scheduler = createComposerFrameScheduler(
() => {
runs += 1;
},
{
requestFrame: (callback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
},
cancelFrame: (handle) => {
cancelled.push(handle);
callbacks.delete(handle);
},
},
);

scheduler.schedule();
scheduler.schedule();
expect(cancelled).toEqual([1]);
expect(callbacks.size).toBe(1);
callbacks.get(2)?.(16);
expect(runs).toBe(1);
});

it('cancels pending work on teardown', () => {
let callback: FrameRequestCallback | null = null;
let cancelled = false;
const scheduler = createComposerFrameScheduler(() => undefined, {
requestFrame: (next) => {
callback = next;
return 1;
},
cancelFrame: () => {
cancelled = true;
callback = null;
},
});

scheduler.schedule();
scheduler.cancel();
expect(cancelled).toBe(true);
expect(callback).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { deriveStableComposerHistory } from '../composerHistoryProjection';

describe('deriveStableComposerHistory', () => {
it('keeps the same array while only assistant streaming content changes', () => {
const previous = deriveStableComposerHistory(
[
{ role: 'user', content: 'first' },
{ role: 'assistant', content: 'a' },
],
[],
);
const next = deriveStableComposerHistory(
[
{ role: 'user', content: 'first' },
{ role: 'assistant', content: 'a growing response' },
],
previous,
);

expect(next).toBe(previous);
});

it('returns a new newest-first projection when user rows change', () => {
const previous = deriveStableComposerHistory([{ role: 'user', content: 'first' }], []);
const next = deriveStableComposerHistory(
[
{ role: 'user', content: 'first' },
{ role: 'user', content: 'second', quotesEncoded: true },
],
previous,
);

expect(next).not.toBe(previous);
expect(next).toEqual([{ content: 'second', quotesEncoded: true }, { content: 'first' }]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';

import {
__composerRenderGateDefaultsForTest,
composerRenderSnapshot,
shouldRefreshComposerRender,
} from '../composerRenderGate';

describe('composer render gate', () => {
it('skips ordinary text updates when trigger and send state are stable', () => {
const none = composerRenderSnapshot({ kind: 'none' }, true);
expect(shouldRefreshComposerRender(none, composerRenderSnapshot({ kind: 'none' }, true))).toBe(
false,
);
});

it('refreshes when text becomes sendable or empty', () => {
expect(
shouldRefreshComposerRender(
composerRenderSnapshot({ kind: 'none' }, false),
composerRenderSnapshot({ kind: 'none' }, true),
),
).toBe(true);
expect(
shouldRefreshComposerRender(
composerRenderSnapshot({ kind: 'none' }, true),
composerRenderSnapshot({ kind: 'none' }, false),
),
).toBe(true);
});

it('refreshes when a palette trigger changes', () => {
const before = composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true);
expect(
shouldRefreshComposerRender(
before,
composerRenderSnapshot({ kind: 'slash', sigil: '/', query: 'he', from: 1 }, true),
),
).toBe(true);
expect(
shouldRefreshComposerRender(
before,
composerRenderSnapshot({ kind: 'slash', sigil: '$', query: '', from: 1 }, true),
),
).toBe(true);
});

it('refreshes when the trigger kind itself changes', () => {
// 'none' -> a palette trigger opening.
expect(
shouldRefreshComposerRender(
composerRenderSnapshot({ kind: 'none' }, true),
composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true),
),
).toBe(true);
// A palette trigger closing back to 'none'.
expect(
shouldRefreshComposerRender(
composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true),
composerRenderSnapshot({ kind: 'none' }, true),
),
).toBe(true);
// One palette trigger kind swapping directly for the other.
expect(
shouldRefreshComposerRender(
composerRenderSnapshot({ kind: 'slash', sigil: '/', query: '', from: 1 }, true),
composerRenderSnapshot({ kind: 'at', query: '', from: 1 }, true),
),
).toBe(true);
});

it('keeps the ordinary update default explicit', () => {
expect(__composerRenderGateDefaultsForTest.ordinaryTextUpdatesRefresh).toBe(false);
});
});
Loading
Loading