Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
115 changes: 80 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,56 @@ 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 强制落最后一版。
draftSaveSchedulerRef.current?.schedule(() => {
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 +2617,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 +2702,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 +2992,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 +3383,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 +4782,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,51 @@
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('keeps the ordinary update default explicit', () => {
expect(__composerRenderGateDefaultsForTest.ordinaryTextUpdatesRefresh).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Creates a coalesced requestAnimationFrame scheduler. Scheduling again before
* the frame runs cancels the stale callback; teardown cancels pending work.
*/
export function createComposerFrameScheduler(
run: () => void,
options: {
requestFrame?: (callback: FrameRequestCallback) => number;
cancelFrame?: (handle: number) => void;
} = {},
): { schedule(): void; cancel(): void } {
const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback));
const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle));
let pending: number | null = null;

return {
schedule(): void {
if (pending !== null) cancelFrame(pending);
pending = requestFrame(() => {
pending = null;
run();
});
},
cancel(): void {
if (pending === null) return;
cancelFrame(pending);
pending = null;
},
};
}
Loading
Loading