mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract thread identity controller
This commit is contained in:
parent
7ec151b0ea
commit
d5fba854ca
3 changed files with 189 additions and 40 deletions
61
src/features/chat/thread-identity-controller.ts
Normal file
61
src/features/chat/thread-identity-controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
98
tests/features/chat/thread-identity-controller.test.ts
Normal file
98
tests/features/chat/thread-identity-controller.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue