From 2c30b7c0deff1578e5cb57dc33c3cbf449cc251e Mon Sep 17 00:00:00 2001
From: lee-xydt
Date: Mon, 27 Jul 2026 16:55:55 +0800
Subject: [PATCH] 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
---
ui/src/pages/chat/chat-state-controller.ts | 3 +
ui/src/pages/chat/chat-state.test.ts | 120 +++++++++++++++++++++
2 files changed, 123 insertions(+)
diff --git a/ui/src/pages/chat/chat-state-controller.ts b/ui/src/pages/chat/chat-state-controller.ts
index b37e05de9dda..b623bdb02bc3 100644
--- a/ui/src/pages/chat/chat-state-controller.ts
+++ b/ui/src/pages/chat/chat-state-controller.ts
@@ -102,6 +102,9 @@ export class ChatStateController 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;
};
diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts
index c78a8f62dae5..cd7fa87f0ab7 100644
--- a/ui/src/pages/chat/chat-state.test.ts
+++ b/ui/src/pages/chat/chat-state.test.ts
@@ -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(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(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", () => {