Extract message scroll controller

This commit is contained in:
murashit 2026-05-29 06:05:15 +09:00
parent d69233307a
commit 846da53509
3 changed files with 84 additions and 31 deletions

View file

@ -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();
}
}

View file

@ -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();

View file

@ -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();
});
});