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", () => {