fix(ui): re-render composer when input history recall mutates draft (#112634)

* fix(ui): re-render composer when input history recall mutates draft

* test(chat-state): add unit tests for composer history invalidation
This commit is contained in:
lee-xydt
2026-07-27 16:55:55 +08:00
committed by GitHub
parent 65cf9c1d65
commit 2c30b7c0de
2 changed files with 123 additions and 0 deletions
@@ -102,6 +102,9 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
const result = navigateInputHistory(input);
if (result.handled) {
this.composerPersistence.schedule();
// A history recall mutates chatMessage directly; without invalidating,
// the composer textarea stays empty until an unrelated event re-renders.
state.renderLifecycle.invalidate();
}
return result;
};
+120
View File
@@ -485,6 +485,126 @@ describe("ChatStateController render lifecycle", () => {
expect(cancelAnimationFrame).toHaveBeenCalledWith(2);
expect(painted).not.toHaveBeenCalled();
});
it("invalidates the render lifecycle when input history recall mutates the draft", () => {
const requestUpdate = vi.fn();
const host = {
addController: () => undefined,
removeController: () => undefined,
requestUpdate,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const renderLifecycle = controller.createRenderLifecycle();
const navigateHistory = vi.fn().mockReturnValue({
handled: true,
preventDefault: true,
restoreCaret: "up" as const,
decision: "handled:history-up" as const,
historyNavigationActiveBefore: false,
historyNavigationActiveAfter: true,
selectionStart: 0,
selectionEnd: 0,
valueLength: 10,
});
const state = {
settings: undefined,
assistantAgentId: null,
agentsList: null,
hello: null,
sessionKey: "agent:main:current",
chatLoading: false,
chatMessages: [],
chatQueue: [],
renderLifecycle,
handleSendChat: vi.fn().mockResolvedValue(undefined),
handleChatDraftChange: vi.fn(),
handleChatInputHistoryKey: navigateHistory,
} as unknown as ChatPageHost;
controller.attach(state);
const input = {
key: "ArrowUp" as const,
selectionStart: 0,
selectionEnd: 0,
valueLength: 0,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
isComposing: false,
keyCode: 0,
};
const result = state.handleChatInputHistoryKey!(input);
expect(result.handled).toBe(true);
expect(navigateHistory).toHaveBeenCalledWith(input);
expect(requestUpdate).toHaveBeenCalled();
});
it("does not invalidate the render lifecycle when input history key is not handled", () => {
const requestUpdate = vi.fn();
const host = {
addController: () => undefined,
removeController: () => undefined,
requestUpdate,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const renderLifecycle = controller.createRenderLifecycle();
const navigateHistory = vi.fn().mockReturnValue({
handled: false,
preventDefault: false,
restoreCaret: null,
decision: "blocked:modifier-or-composition" as const,
historyNavigationActiveBefore: false,
historyNavigationActiveAfter: false,
selectionStart: 0,
selectionEnd: 0,
valueLength: 10,
});
const state = {
settings: undefined,
assistantAgentId: null,
agentsList: null,
hello: null,
sessionKey: "agent:main:current",
chatLoading: false,
chatMessages: [],
chatQueue: [],
renderLifecycle,
handleSendChat: vi.fn().mockResolvedValue(undefined),
handleChatDraftChange: vi.fn(),
handleChatInputHistoryKey: navigateHistory,
} as unknown as ChatPageHost;
controller.attach(state);
const input = {
key: "ArrowUp" as const,
selectionStart: 5,
selectionEnd: 5,
valueLength: 10,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
isComposing: false,
keyCode: 0,
};
const result = state.handleChatInputHistoryKey!(input);
expect(result.handled).toBe(false);
expect(navigateHistory).toHaveBeenCalledWith(input);
expect(requestUpdate).not.toHaveBeenCalled();
});
});
describe("session pull request refresh", () => {