From 846da535092fe6bead0d26da4ba7a4b72d8770de Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 06:05:15 +0900 Subject: [PATCH] Extract message scroll controller --- .../chat/message-scroll-controller.ts | 33 ++++++++++++ src/features/chat/view.ts | 51 ++++++++----------- .../chat/message-scroll-controller.test.ts | 31 +++++++++++ 3 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 src/features/chat/message-scroll-controller.ts create mode 100644 tests/features/chat/message-scroll-controller.test.ts diff --git a/src/features/chat/message-scroll-controller.ts b/src/features/chat/message-scroll-controller.ts new file mode 100644 index 00000000..478b2c20 --- /dev/null +++ b/src/features/chat/message-scroll-controller.ts @@ -0,0 +1,33 @@ +import type { ChatStateStore } from "./chat-state"; +import type { ChatMessageScrollIntent } from "./chat-message-renderer"; + +export interface ChatMessageScrollControllerHost { + stateStore: ChatStateStore; + render: () => void; +} + +export class ChatMessageScrollController { + private nextIntent: ChatMessageScrollIntent = "auto"; + + constructor(private readonly host: ChatMessageScrollControllerHost) {} + + consumeIntent(): ChatMessageScrollIntent { + const value = this.nextIntent; + this.nextIntent = "auto"; + return value; + } + + forceBottom(): void { + this.host.stateStore.dispatch({ type: "ui/messages-pinned-set", pinned: true }); + this.nextIntent = "force-bottom"; + } + + preservePosition(): void { + this.nextIntent = "preserve"; + } + + scrollToBottomOnFocus(): void { + this.forceBottom(); + this.host.render(); + } +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 868ce79c..e067ed53 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -39,7 +39,7 @@ import { import { renderToolbar, type ToolbarViewModel } from "./ui/toolbar"; import { renderChatPanelShell, unmountChatPanelShell } from "./ui/shell"; import type { ChatTurnDiffViewState } from "./ui/turn-diff"; -import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer"; +import { ChatMessageRenderer } from "./chat-message-renderer"; import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot"; import type { SharedAppServerMetadata } from "../../runtime/shared-app-server-state"; import { ChatThreadActionController } from "./thread-actions"; @@ -86,6 +86,7 @@ import { resumedThreadAction, type ResumedThreadActionParams } from "./thread-re import { PendingRequestController } from "./pending-request-controller"; import { ToolbarPanelController } from "./toolbar-panel-controller"; import { ChatReconnectController } from "./reconnect-controller"; +import { ChatMessageScrollController } from "./message-scroll-controller"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -122,12 +123,12 @@ export class CodexChatView extends ItemView { private readonly deferredTasks: ChatViewDeferredTasks; private readonly composerController: ChatComposerController; private readonly messageRenderer: ChatMessageRenderer; + private readonly messageScroll: ChatMessageScrollController; private shellRenderVersion = 0; private readonly connectionWork = new ChatConnectionWorkTracker(); private readonly resumeWork: ChatResumeWorkTracker; private opened = false; private closing = false; - private nextMessageScrollIntent: ChatMessageScrollIntent = "auto"; constructor( leaf: WorkspaceLeaf, @@ -138,16 +139,18 @@ export class CodexChatView extends ItemView { this.resumeWork = new ChatResumeWorkTracker(() => { this.history.invalidate(); }); + this.messageScroll = new ChatMessageScrollController({ + stateStore: this.chatState, + render: () => { + this.render(); + }, + }); this.messageRenderer = new ChatMessageRenderer({ app: this.app, owner: this, stateStore: this.chatState, vaultPath: this.plugin.vaultPath, - consumeScrollIntent: () => { - const value = this.nextMessageScrollIntent; - this.nextMessageScrollIntent = "auto"; - return value; - }, + consumeScrollIntent: () => this.messageScroll.consumeIntent(), loadOlderTurns: () => void this.history.loadOlder(), rollbackThread: (threadId) => void this.threadActions.rollbackThread(threadId), implementPlan: (item) => void this.implementPlan(item), @@ -170,7 +173,7 @@ export class CodexChatView extends ItemView { this.plugin.refreshThreadsViewLiveState(); }, onComposerResize: () => { - if (this.state.messagesPinnedToBottom) this.queueMessagesBottomScroll(); + if (this.state.messagesPinnedToBottom) this.messageScroll.forceBottom(); }, onSubmit: () => void this.submitComposerAction(), onNewThread: () => void this.startNewThread(), @@ -242,7 +245,7 @@ export class CodexChatView extends ItemView { currentClient: () => this.connection.currentClient(), runtimeSnapshot: () => this.runtimeSnapshot(), forceMessagesToBottom: () => { - this.queueMessagesBottomScroll(); + this.messageScroll.forceBottom(); }, publishAppServerMetadata: (metadata) => { this.plugin.publishAppServerMetadata(metadata); @@ -258,10 +261,10 @@ export class CodexChatView extends ItemView { this.addSystemMessage(text); }, forceMessagesToBottom: () => { - this.queueMessagesBottomScroll(); + this.messageScroll.forceBottom(); }, keepCurrentScrollPosition: () => { - this.preserveMessagesScrollPosition(); + this.messageScroll.preservePosition(); }, setThreadTurnPresence: (hadTurns) => { this.threadRename.resetThreadTurnPresence(hadTurns); @@ -485,7 +488,7 @@ export class CodexChatView extends ItemView { if (threadId && this.isRestoredThreadPending(threadId)) { await this.ensureRestoredThreadLoaded(); } - this.scrollMessagesToBottomOnFocus(); + this.messageScroll.scrollToBottomOnFocus(); this.focusComposer(); } @@ -533,7 +536,7 @@ export class CodexChatView extends ItemView { }); this.registerEvent( this.app.workspace.on("active-leaf-change", (leaf) => { - if (leaf === this.leaf) this.scrollMessagesToBottomOnFocus(); + if (leaf === this.leaf) this.messageScroll.scrollToBottomOnFocus(); }), ); this.applyCachedSharedAppServerState(); @@ -625,7 +628,7 @@ export class CodexChatView extends ItemView { this.clearActiveThreadContext(); this.chatState.dispatch({ type: "ui/panel-set", panel: null }); this.setStatus("New chat."); - this.queueMessagesBottomScroll(); + this.messageScroll.forceBottom(); this.render(); this.focusComposer(); } @@ -685,7 +688,7 @@ export class CodexChatView extends ItemView { if (this.isStaleResumeWork(resume)) return; if (this.state.displayItems.length === 0) { this.addSystemMessage(`Resumed thread ${response.thread.id}`); - this.queueMessagesBottomScroll(); + this.messageScroll.forceBottom(); this.render(); } this.plugin.refreshThreadsViewLiveState(); @@ -799,7 +802,7 @@ export class CodexChatView extends ItemView { item: optimistic.item, pendingTurnStart: optimistic.pendingTurnStart, }); - this.queueMessagesBottomScroll(); + this.messageScroll.forceBottom(); this.composerController.setDraft(""); this.render(); @@ -863,7 +866,7 @@ export class CodexChatView extends ItemView { codexInput, }), }); - this.queueMessagesBottomScroll(); + this.messageScroll.forceBottom(); this.setStatus("Steered current turn."); } catch (error) { this.composerController.setDraft(text, { focus: true }); @@ -1266,20 +1269,6 @@ export class CodexChatView extends ItemView { return runtimeSnapshotForChatState({ state }); } - private queueMessagesBottomScroll(): void { - this.dispatch({ type: "ui/messages-pinned-set", pinned: true }); - this.nextMessageScrollIntent = "force-bottom"; - } - - private preserveMessagesScrollPosition(): void { - this.nextMessageScrollIntent = "preserve"; - } - - private scrollMessagesToBottomOnFocus(): void { - this.queueMessagesBottomScroll(); - this.render(); - } - private clearActiveThreadContext(): void { this.invalidateResumeWork(); this.clearRestoredThreadLifecycle(); diff --git a/tests/features/chat/message-scroll-controller.test.ts b/tests/features/chat/message-scroll-controller.test.ts new file mode 100644 index 00000000..e9718bb2 --- /dev/null +++ b/tests/features/chat/message-scroll-controller.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state"; +import { ChatMessageScrollController } from "../../../src/features/chat/message-scroll-controller"; + +describe("ChatMessageScrollController", () => { + it("consumes one-shot scroll intents", () => { + const controller = new ChatMessageScrollController({ + stateStore: createChatStateStore(createChatState()), + render: vi.fn(), + }); + + controller.preservePosition(); + + expect(controller.consumeIntent()).toBe("preserve"); + expect(controller.consumeIntent()).toBe("auto"); + }); + + it("pins messages and renders when focusing the view", () => { + const stateStore = createChatStateStore(createChatState()); + stateStore.dispatch({ type: "ui/messages-pinned-set", pinned: false }); + const render = vi.fn(); + const controller = new ChatMessageScrollController({ stateStore, render }); + + controller.scrollToBottomOnFocus(); + + expect(stateStore.getState().messagesPinnedToBottom).toBe(true); + expect(controller.consumeIntent()).toBe("force-bottom"); + expect(render).toHaveBeenCalledOnce(); + }); +});