Extract thread identity controller

This commit is contained in:
murashit 2026-05-29 06:25:33 +09:00
parent 7ec151b0ea
commit d5fba854ca
3 changed files with 189 additions and 40 deletions

View file

@ -0,0 +1,61 @@
import type { ChatStateStore } from "./chat-state";
import type { RestoredThreadController } from "./restored-thread-controller";
export interface ThreadIdentityControllerHost {
stateStore: ChatStateStore;
restoredThread: RestoredThreadController;
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshTabHeader: () => void;
refreshLiveState: () => void;
render: () => void;
}
export class ThreadIdentityController {
constructor(private readonly host: ThreadIdentityControllerHost) {}
clearActiveThreadContext(): void {
this.host.invalidateResumeWork();
this.host.restoredThread.clear();
this.host.clearDeferredRestoredThreadHydration();
this.host.stateStore.dispatch({ type: "thread/active-cleared" });
this.host.resetThreadTurnPresence(false);
this.host.notifyActiveThreadIdentityChanged();
this.host.refreshLiveState();
}
notifyThreadArchived(threadId: string): void {
if (this.state.activeThreadId !== threadId) return;
this.clearActiveThreadContext();
this.host.render();
}
notifyThreadRenamed(threadId: string, name: string | null): void {
let changed = false;
const listedThreads = this.state.listedThreads.map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
this.host.stateStore.dispatch({ type: "thread/list-applied", threads: listedThreads });
const restoredThread = this.host.restoredThread.placeholder();
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
this.host.restoredThread.rename(threadId, name);
changed = true;
}
const activeThreadChanged = this.state.activeThreadId === threadId || this.host.restoredThread.isPending(threadId);
if (!changed && !activeThreadChanged) return;
if (activeThreadChanged) {
this.host.notifyActiveThreadIdentityChanged();
} else {
this.host.refreshTabHeader();
}
this.host.render();
}
private get state() {
return this.host.stateStore.getState();
}
}

View file

@ -65,6 +65,7 @@ import { TurnSubmissionController } from "./turn-submission-controller";
import { SlashCommandController } from "./slash-command-controller";
import { ComposerSubmissionController } from "./composer-submission-controller";
import { ChatConnectionController } from "./connection-controller";
import { ThreadIdentityController } from "./thread-identity-controller";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
@ -93,6 +94,7 @@ export class CodexChatView extends ItemView {
private readonly threadActions: ChatThreadActionController;
private readonly runtimeSettings: ChatRuntimeSettingsController;
private readonly restoredThread: RestoredThreadController;
private readonly threadIdentity: ThreadIdentityController;
private readonly threadRename: ThreadRenameController;
private readonly pendingRequests: PendingRequestController;
private readonly toolbarPanels: ToolbarPanelController;
@ -458,6 +460,31 @@ export class CodexChatView extends ItemView {
this.refreshTabHeader();
},
});
this.threadIdentity = new ThreadIdentityController({
stateStore: this.chatState,
restoredThread: this.restoredThread,
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
clearDeferredRestoredThreadHydration: () => {
this.clearDeferredRestoredThreadHydration();
},
resetThreadTurnPresence: (hadTurns) => {
this.threadRename.resetThreadTurnPresence(hadTurns);
},
notifyActiveThreadIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
refreshLiveState: () => {
this.plugin.refreshThreadsViewLiveState();
},
render: () => {
this.render();
},
});
this.threadRename = new ThreadRenameController({
stateStore: this.chatState,
vaultPath: this.plugin.vaultPath,
@ -585,32 +612,11 @@ export class CodexChatView extends ItemView {
}
notifyThreadArchived(threadId: string): void {
if (this.clearArchivedActiveThread(threadId)) {
this.render();
}
this.threadIdentity.notifyThreadArchived(threadId);
}
notifyThreadRenamed(threadId: string, name: string | null): void {
let changed = false;
const listedThreads = this.state.listedThreads.map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
this.dispatch({ type: "thread/list-applied", threads: listedThreads });
const restoredThread = this.restoredThreadPlaceholder();
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
this.restoredThread.rename(threadId, name);
changed = true;
}
const activeThreadChanged = this.state.activeThreadId === threadId || this.isRestoredThreadPending(threadId);
if (!changed && !activeThreadChanged) return;
if (activeThreadChanged) {
this.notifyActiveThreadIdentityChanged();
} else {
this.refreshTabHeader();
}
this.render();
this.threadIdentity.notifyThreadRenamed(threadId, name);
}
override async onOpen(): Promise<void> {
@ -672,7 +678,7 @@ export class CodexChatView extends ItemView {
async startNewThread(): Promise<void> {
if (this.turnBusy) return;
this.clearActiveThreadContext();
this.threadIdentity.clearActiveThreadContext();
this.chatState.dispatch({ type: "ui/panel-set", panel: null });
this.setStatus("New chat.");
this.messageScroll.forceBottom();
@ -1095,22 +1101,6 @@ export class CodexChatView extends ItemView {
return runtimeSnapshotForChatState({ state });
}
private clearActiveThreadContext(): void {
this.invalidateResumeWork();
this.clearRestoredThreadLifecycle();
this.clearDeferredRestoredThreadHydration();
this.chatState.dispatch({ type: "thread/active-cleared" });
this.threadRename.resetThreadTurnPresence(false);
this.notifyActiveThreadIdentityChanged();
this.plugin.refreshThreadsViewLiveState();
}
private clearArchivedActiveThread(threadId: string): boolean {
if (this.state.activeThreadId !== threadId) return false;
this.clearActiveThreadContext();
return true;
}
private renderMessages(parent: HTMLElement): void {
this.messageRenderer.render(parent);
}

View file

@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import { ThreadIdentityController } from "../../../src/features/chat/thread-identity-controller";
import type { RestoredThreadController } from "../../../src/features/chat/restored-thread-controller";
import type { RestoredThreadPlaceholderState } from "../../../src/features/chat/view-lifecycle";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
function thread(id: string, name: string | null = null): Thread {
return {
id,
sessionId: id,
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 0,
updatedAt: 0,
status: { type: "idle" },
path: null,
cwd: "/vault",
cliVersion: "test",
source: "appServer",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name,
turns: [],
};
}
function createController() {
const stateStore = createChatStateStore(createChatState());
const restoredPlaceholder = vi.fn<() => RestoredThreadPlaceholderState | null>(() => null);
const restoredRename = vi.fn();
const restoredThread = {
clear: vi.fn(),
isPending: vi.fn(() => false),
placeholder: restoredPlaceholder,
rename: restoredRename,
} as unknown as RestoredThreadController;
const host = {
stateStore,
restoredThread,
invalidateResumeWork: vi.fn(),
clearDeferredRestoredThreadHydration: vi.fn(),
resetThreadTurnPresence: vi.fn(),
notifyActiveThreadIdentityChanged: vi.fn(),
refreshTabHeader: vi.fn(),
refreshLiveState: vi.fn(),
render: vi.fn(),
};
return { controller: new ThreadIdentityController(host), host, restoredPlaceholder, restoredRename, stateStore };
}
describe("ThreadIdentityController", () => {
it("clears the active thread when it is archived", () => {
const { controller, host, stateStore } = createController();
stateStore.dispatch({
type: "thread/resumed",
thread: thread("thread"),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalPolicy: null,
approvalsReviewer: null,
activePermissionProfile: null,
});
controller.notifyThreadArchived("thread");
expect(stateStore.getState().activeThreadId).toBeNull();
expect(host.invalidateResumeWork).toHaveBeenCalledOnce();
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
expect(host.render).toHaveBeenCalledOnce();
});
it("updates listed and restored thread titles on rename notifications", () => {
const { controller, host, restoredPlaceholder, restoredRename, stateStore } = createController();
stateStore.dispatch({ type: "thread/list-applied", threads: [thread("thread", "Old")] });
restoredPlaceholder.mockReturnValue({
kind: "placeholder",
threadId: "thread",
title: "Old",
explicitName: "Old",
loading: null,
});
controller.notifyThreadRenamed("thread", "New");
expect(stateStore.getState().listedThreads[0]?.name).toBe("New");
expect(restoredRename).toHaveBeenCalledWith("thread", "New");
expect(host.render).toHaveBeenCalledOnce();
});
});