Extract chat thread actions

This commit is contained in:
murashit 2026-05-26 09:58:41 +09:00
parent bc0e642f5c
commit bf43f15c09
3 changed files with 168 additions and 97 deletions

View file

@ -0,0 +1,125 @@
import { Notice } from "obsidian";
import type { AppServerClient } from "../../app-server/client";
import { exportArchivedThreadMarkdown } from "../../domain/threads/export";
import { inheritedForkThreadName, upsertThread } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
import type { ArchiveExportAdapter } from "../../domain/threads/export";
import type { ChatState } from "./chat-state";
import type { ThreadHistoryLoader } from "./thread-history";
import { rollbackCandidateFromItems } from "./rollback";
export interface ChatThreadActionControllerHost {
state: ChatState;
vaultPath: string;
settings: () => CodexPanelSettings;
archiveAdapter: () => ArchiveExportAdapter;
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
history: ThreadHistoryLoader;
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
}
export class ChatThreadActionController {
constructor(private readonly host: ChatThreadActionControllerHost) {}
async archiveThread(threadId: string): Promise<void> {
if (this.host.state.busy) {
this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return;
}
const client = this.host.currentClient();
if (!client) return;
try {
const settings = this.host.settings();
if (settings.archiveExportEnabled) {
const response = await client.readThread(threadId, true);
const result = await exportArchivedThreadMarkdown(response.thread, settings, this.host.archiveAdapter());
new Notice(`Saved archived thread to ${result.path}.`);
}
await client.archiveThread(threadId);
this.host.notifyThreadArchived(threadId);
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async forkThread(threadId: string): Promise<void> {
if (this.host.state.busy) {
this.host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
}
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return;
try {
const sourceName = inheritedForkThreadName(threadId, this.host.state.listedThreads);
const response = await client.forkThread(threadId, this.host.vaultPath);
const forkedThreadId = response.thread.id;
if (sourceName) {
try {
await client.setThreadName(forkedThreadId, sourceName);
this.host.notifyThreadRenamed(forkedThreadId, sourceName);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
}
}
try {
await this.host.openThreadInNewView(forkedThreadId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
}
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async rollbackThread(threadId: string): Promise<void> {
if (this.host.state.busy) {
this.host.addSystemMessage("Interrupt the current turn before rolling back.");
return;
}
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return;
const candidate = rollbackCandidateFromItems(this.host.state.displayItems);
if (!candidate) {
this.host.addSystemMessage("No completed turn to roll back.");
return;
}
try {
this.host.setStatus("Rolling back latest turn...");
const response = await client.rollbackThread(threadId);
this.host.state.activeThreadId = response.thread.id;
this.host.state.activeThreadCwd = response.thread.cwd;
this.host.state.activeTurnId = null;
this.host.state.tokenUsage = null;
this.host.state.historyCursor = null;
this.host.state.turnDiffs.clear();
this.host.state.listedThreads = upsertThread(this.host.state.listedThreads, response.thread);
await this.host.history.loadLatest(response.thread.id);
this.host.setComposerText(candidate.text);
this.host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
this.host.setStatus("Rolled back latest turn.");
this.host.notifyActiveThreadIdentityChanged();
await this.host.refreshThreads();
this.host.refreshSharedThreadListFromOpenSurface();
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
this.host.setStatus("Rollback failed.");
}
}
}

View file

@ -172,7 +172,9 @@ export class ThreadRenameController {
threadId,
readTurns: (id, cursor, limit, sortDirection) => client.threadTurnsList(id, cursor, limit, sortDirection),
});
return context ?? (this.host.state.activeThreadId === threadId ? firstNamingContextFromDisplayItems(this.host.state.displayItems) : null);
return (
context ?? (this.host.state.activeThreadId === threadId ? firstNamingContextFromDisplayItems(this.host.state.displayItems) : null)
);
}
private async generateTitle(context: ThreadNamingContext): Promise<string | null> {

View file

@ -24,7 +24,6 @@ import {
} from "../../runtime/collaboration-mode";
import { ChatController } from "./chat-controller";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "./diagnostics";
import { rollbackCandidateFromItems } from "./rollback";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view";
import {
autoReviewActive,
@ -58,8 +57,7 @@ import { questionDefaultAnswer, type PendingUserInput } from "./user-input/model
import { ChatComposerController } from "./chat-composer-controller";
import { attachHookRunsToTurn } from "./hook-display";
import { clearActiveThreadState, clearConnectionScopedState, createChatState, type ChatState } from "./chat-state";
import { codexPanelDisplayTitle, getThreadTitle, inheritedForkThreadName, upsertThread } from "../../domain/threads/model";
import { exportArchivedThreadMarkdown } from "../../domain/threads/export";
import { codexPanelDisplayTitle, getThreadTitle, upsertThread } from "../../domain/threads/model";
import {
referencedThreadDisplay,
referencedThreadPrompt,
@ -74,6 +72,7 @@ import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer";
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
import type { SharedSessionMetadata } from "../../runtime/shared-app-server-state";
import { ChatThreadActionController } from "./thread-actions";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
@ -103,6 +102,7 @@ export class CodexChatView extends ItemView {
private readonly controller: ChatController;
private readonly session: ChatSessionController;
private readonly history: ThreadHistoryLoader;
private readonly threadActions: ChatThreadActionController;
private readonly threadRename: ThreadRenameController;
private readonly state: ChatState = createChatState();
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
@ -144,7 +144,7 @@ export class CodexChatView extends ItemView {
return value;
},
loadOlderTurns: () => void this.history.loadOlder(),
rollbackThread: (threadId) => void this.rollbackThread(threadId),
rollbackThread: (threadId) => void this.threadActions.rollbackThread(threadId),
implementPlan: (item) => void this.implementPlan(item),
openTurnDiff: (state) => void this.plugin.openTurnDiff(state),
pendingRequestsSignature: () => this.pendingRequestsSignature(),
@ -243,6 +243,38 @@ export class CodexChatView extends ItemView {
this.threadRename.resetThreadTurnPresence(hadTurns);
},
});
this.threadActions = new ChatThreadActionController({
state: this.state,
vaultPath: this.plugin.vaultPath,
settings: () => this.plugin.settings,
archiveAdapter: () => this.app.vault.adapter,
ensureConnected: () => this.ensureConnected(),
currentClient: () => this.client,
history: this.history,
addSystemMessage: (text) => {
this.addSystemMessage(text);
},
setStatus: (status) => {
this.setStatus(status);
},
setComposerText: (text) => {
this.setComposerText(text);
},
openThreadInNewView: (threadId) => this.plugin.openThreadInNewView(threadId),
notifyThreadArchived: (threadId) => {
this.plugin.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
this.plugin.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
refreshThreads: () => this.refreshThreads(),
refreshSharedThreadListFromOpenSurface: () => {
this.plugin.refreshSharedThreadListFromOpenSurface();
},
});
this.threadRename = new ThreadRenameController({
state: this.state,
vaultPath: this.plugin.vaultPath,
@ -777,12 +809,12 @@ export class CodexChatView extends ItemView {
startNewThread: () => this.startNewThread(),
resumeThread: (threadId) => this.selectThread(threadId),
referThread: (thread, message) => this.referencedThreadInput(thread, message),
forkThread: (threadId) => this.forkThread(threadId),
rollbackThread: (threadId) => this.rollbackThread(threadId),
forkThread: (threadId) => this.threadActions.forkThread(threadId),
rollbackThread: (threadId) => this.threadActions.rollbackThread(threadId),
compactThread: async (threadId) => {
await this.client?.compactThread(threadId);
},
archiveThread: (threadId) => this.archiveThread(threadId),
archiveThread: (threadId) => this.threadActions.archiveThread(threadId),
busy: this.state.busy,
toggleFastMode: () => this.toggleFastMode(),
toggleCollaborationMode: () => this.toggleCollaborationMode(),
@ -1183,7 +1215,7 @@ export class CodexChatView extends ItemView {
this.state.openDetails.delete("history");
void this.selectThread(threadId);
},
archiveThread: (threadId) => void this.archiveThread(threadId),
archiveThread: (threadId) => void this.threadActions.archiveThread(threadId),
startRenameThread: (threadId) => {
this.threadRename.start(threadId);
},
@ -1519,94 +1551,6 @@ export class CodexChatView extends ItemView {
);
}
private async archiveThread(threadId: string): Promise<void> {
if (this.state.busy) {
this.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return;
}
if (!this.client) return;
try {
if (this.plugin.settings.archiveExportEnabled) {
const response = await this.client.readThread(threadId, true);
const result = await exportArchivedThreadMarkdown(response.thread, this.plugin.settings, this.app.vault.adapter);
new Notice(`Saved archived thread to ${result.path}.`);
}
await this.client.archiveThread(threadId);
this.plugin.notifyThreadArchived(threadId);
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
private async forkThread(threadId: string): Promise<void> {
if (this.state.busy) {
this.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
}
await this.ensureConnected();
if (!this.client) return;
try {
const sourceName = inheritedForkThreadName(threadId, this.state.listedThreads);
const response = await this.client.forkThread(threadId, this.plugin.vaultPath);
const forkedThreadId = response.thread.id;
if (sourceName) {
try {
await this.client.setThreadName(forkedThreadId, sourceName);
this.plugin.notifyThreadRenamed(forkedThreadId, sourceName);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
}
}
try {
await this.plugin.openThreadInNewView(forkedThreadId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
}
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
private async rollbackThread(threadId: string): Promise<void> {
if (this.state.busy) {
this.addSystemMessage("Interrupt the current turn before rolling back.");
return;
}
await this.ensureConnected();
if (!this.client) return;
const candidate = rollbackCandidateFromItems(this.state.displayItems);
if (!candidate) {
this.addSystemMessage("No completed turn to roll back.");
return;
}
try {
this.setStatus("Rolling back latest turn...");
const response = await this.client.rollbackThread(threadId);
this.state.activeThreadId = response.thread.id;
this.state.activeThreadCwd = response.thread.cwd;
this.state.activeTurnId = null;
this.state.tokenUsage = null;
this.state.historyCursor = null;
this.state.turnDiffs.clear();
this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread);
await this.history.loadLatest(response.thread.id);
this.setComposerText(candidate.text);
this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
this.setStatus("Rolled back latest turn.");
this.notifyActiveThreadIdentityChanged();
await this.refreshThreads();
this.plugin.refreshSharedThreadListFromOpenSurface();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
this.setStatus("Rollback failed.");
}
}
private queueMessagesBottomScroll(): void {
this.state.messagesPinnedToBottom = true;
this.nextMessageScrollIntent = "force-bottom";