mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract message scroll controller
This commit is contained in:
parent
d69233307a
commit
846da53509
3 changed files with 84 additions and 31 deletions
33
src/features/chat/message-scroll-controller.ts
Normal file
33
src/features/chat/message-scroll-controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
31
tests/features/chat/message-scroll-controller.test.ts
Normal file
31
tests/features/chat/message-scroll-controller.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue