murashit_codex-panel/src/features/chat/application/threads/thread-management-actions.ts

281 lines
12 KiB
TypeScript

import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model";
import { activeThreadRuntimeState } from "../../domain/runtime/state";
import { effectCompletedInCurrentContext } from "../effect-outcome";
import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy";
import { resumedThreadActionFromActiveRuntime } from "../state/actions";
import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer";
import {
capturePanelTargetLease,
type PanelTargetLease,
panelTargetLeaseIsCurrent,
} from "../state/panel-target";
import type { ChatStateStore } from "../state/store";
import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream";
import { chatTurnBusy } from "../turns/turn-state";
import type { ThreadMutationTransport } from "./thread-mutation-transport";
const STATUS_COMPACTION_REQUESTED = "Compaction requested.";
const STATUS_ROLLBACK_STARTING = "Rolling back the latest turn...";
const STATUS_ROLLBACK_COMPLETE = "Rolled back the latest turn.";
const STATUS_ROLLBACK_FAILED = "Could not roll back the latest turn.";
export interface ThreadManagementActionsHost {
stateStore: ChatStateStore;
operations: ThreadManagementOperations;
threadTransport: ThreadMutationTransport;
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<void>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyActiveThreadIdentityChanged: () => void;
refreshAfterThreadMutation: () => Promise<void>;
recordForkedThread: (thread: Thread) => void;
}
interface ThreadManagementOperations {
renameThread(threadId: string, value: string): Promise<boolean>;
archiveThread(threadId: string, options?: { saveMarkdown?: boolean }): Promise<boolean>;
}
export interface ThreadManagementActions {
compactActiveThread: () => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<boolean>;
rollbackThread: (threadId: string) => Promise<void>;
}
interface ThreadManagementPanelScope {
targetThreadId: string;
initialActiveThreadId: string | null;
initialTurnLifecycle: ChatState["turn"]["lifecycle"];
panelTarget: PanelTargetLease;
}
export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
return {
compactActiveThread: () => compactActiveThread(host),
compactThread: (threadId) => compactThread(host, threadId),
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
forkThread: (threadId) => forkThread(host, threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource),
renameThread: (threadId, name) => renameThread(host, threadId, name),
rollbackThread: (threadId) => rollbackThread(host, threadId),
};
}
async function compactActiveThread(host: ThreadManagementActionsHost): Promise<void> {
const threadId = activeThreadId(threadManagementState(host));
if (!threadId) {
host.addSystemMessage("No active thread to compact.");
return;
}
await compactThread(host, threadId);
}
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
if (activePanelOperationBlocked(host, threadId, "compact")) return;
const scope = captureThreadManagementPanelScope(host, threadId);
try {
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const effect = await host.threadTransport.compactThread(threadId);
if (!effectCompletedInCurrentContext(effect)) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(STATUS_COMPACTION_REQUESTED);
host.setStatus(STATUS_COMPACTION_REQUESTED);
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async function archiveThread(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<void> {
await archiveThreadFromPanel(host, threadId, saveMarkdown);
}
async function archiveThreadFromPanel(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<boolean> {
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return false;
}
try {
const options = saveMarkdown === undefined ? {} : { saveMarkdown };
return await host.operations.archiveThread(threadId, options);
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
function forkThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
return forkThreadFromTurn(host, threadId, null, false);
}
async function forkThreadFromTurn(
host: ThreadManagementActionsHost,
threadId: string,
turnId: string | null,
archiveSource: boolean,
): Promise<void> {
if (activePanelOperationBlocked(host, threadId, "fork")) return;
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
}
const scope = captureThreadManagementPanelScope(host, threadId);
const selectedTurnDistanceFromEnd = turnId ? threadStreamTurnsAfterTurnId(threadManagementState(host).threadStream, turnId) : 0;
if (selectedTurnDistanceFromEnd === null) {
host.addSystemMessage("Could not find the selected turn to fork.");
return;
}
try {
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const effect = turnId ? await host.threadTransport.forkThread(threadId, turnId) : await host.threadTransport.forkThread(threadId);
if (!effectCompletedInCurrentContext(effect)) return;
const forkedThread = effect.value;
const forkedThreadId = forkedThread.id;
host.recordForkedThread(forkedThread);
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
if (sourceName) {
try {
if (!(await host.operations.renameThread(forkedThreadId, sourceName))) return;
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
}
}
if (archiveSource) {
if (!(await archiveThreadFromPanel(host, threadId))) return;
try {
await host.openThreadInCurrentPanel(forkedThreadId);
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
}
return;
}
try {
await host.openThreadInNewView(forkedThreadId);
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
}
} catch (error) {
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async function renameThread(host: ThreadManagementActionsHost, threadId: string, value: string): Promise<boolean> {
try {
const result = await host.operations.renameThread(threadId, value);
if (!result) return false;
return true;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
if (activePanelOperationBlocked(host, threadId, "rollback")) return;
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage("Interrupt the current turn before rolling back.");
return;
}
const scope = captureThreadManagementPanelScope(host, threadId);
const candidate = threadStreamRollbackCandidate(threadManagementState(host).threadStream);
if (!candidate) {
host.addSystemMessage("No completed turn to roll back.");
return;
}
try {
host.setStatus(STATUS_ROLLBACK_STARTING);
if (!(await host.threadTransport.ensureConnected())) return;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
const effect = await host.threadTransport.rollbackThread(threadId);
if (!effectCompletedInCurrentContext(effect)) return;
const snapshot = effect.value;
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
threadManagementDispatch(
host,
resumedThreadActionFromActiveRuntime({
thread: snapshot.thread,
cwd: snapshot.cwd,
runtime: activeThreadRuntimeState(threadManagementState(host).runtime),
listedThreads: threadManagementState(host).threadList.listedThreads,
expectedPanelTargetRevision: scope.panelTarget.revision,
}),
);
threadManagementDispatch(host, {
type: "thread-stream/items-replaced",
items: snapshot.items,
historyCursor: null,
loadingHistory: false,
});
host.setComposerText(candidate.text);
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus(STATUS_ROLLBACK_COMPLETE);
host.notifyActiveThreadIdentityChanged();
await host.refreshAfterThreadMutation();
} catch (error) {
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus(STATUS_ROLLBACK_FAILED);
}
}
function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean {
const state = threadManagementState(host);
if (activeThreadId(state) !== threadId) return false;
const decision = activePanelOperationDecision(state, operation);
if (decision.kind !== "blocked") return false;
host.addSystemMessage(decision.message);
return true;
}
function threadManagementState(host: ThreadManagementActionsHost): ChatState {
return host.stateStore.getState();
}
function threadManagementDispatch(host: ThreadManagementActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}
function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, targetThreadId: string): ThreadManagementPanelScope {
return {
targetThreadId,
initialActiveThreadId: activeThreadId(threadManagementState(host)),
initialTurnLifecycle: threadManagementState(host).turn.lifecycle,
panelTarget: capturePanelTargetLease(threadManagementState(host)),
};
}
function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean {
const state = threadManagementState(host);
return (
panelTargetLeaseIsCurrent(state, scope.panelTarget) &&
activeThreadId(state) === scope.targetThreadId &&
state.turn.lifecycle === scope.initialTurnLifecycle
);
}
function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean {
const state = threadManagementState(host);
if (!panelTargetLeaseIsCurrent(state, scope.panelTarget)) return false;
if (!scope.initialActiveThreadId) return true;
return scope.initialActiveThreadId === scope.targetThreadId && activeThreadId(state) === scope.targetThreadId;
}