murashit_codex-panel/src/features/threads-view/session.ts

368 lines
13 KiB
TypeScript
Raw Normal View History

2026-06-13 09:26:15 +00:00
import { Notice } from "obsidian";
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
import type { AppServerObservedQueryResult } from "../../app-server/query/cache";
2026-06-21 06:55:02 +00:00
import { observedQueryData, observedQueryInitialError, observedQueryInitialLoading } from "../../app-server/query/observed-result";
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
2026-06-13 09:26:15 +00:00
import type { Thread } from "../../domain/threads/model";
import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator";
import type { ThreadCatalogActiveReader, ThreadCatalogThreadManagementEvents } from "../../workspace/thread-catalog";
import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service";
2026-06-13 09:26:15 +00:00
import { renderThreadsView, unmountThreadsView } from "./renderer";
import {
threadRows,
transitionThreadsRenameState,
2026-06-13 09:26:15 +00:00
type ThreadsGeneratingRenameState,
type ThreadsRenameLifecycleEvent,
2026-06-13 09:26:15 +00:00
type ThreadsRenameState,
} from "./state";
import {
createThreadsViewDeferredTasks,
transitionThreadsViewRefreshLifecycle,
type ActiveThreadsViewRefresh,
type ThreadsViewDeferredTasks,
type ThreadsViewRefreshLifecycleState,
} from "./view-lifecycle";
export interface CodexThreadsHost {
readonly settings: CodexThreadsSettingsAccess;
2026-06-13 09:26:15 +00:00
readonly vaultPath: string;
readonly clientAccess: AppServerClientAccess;
2026-06-15 06:00:14 +00:00
readonly threadCatalog: ThreadsThreadCatalog;
2026-06-13 09:26:15 +00:00
openNewPanel(): Promise<unknown>;
openThreadInAvailableView(threadId: string): Promise<void>;
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[];
}
type ThreadsThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogThreadManagementEvents;
2026-06-15 06:00:14 +00:00
export interface CodexThreadsSettingsAccess {
archiveExportEnabled(): boolean;
codexPath(): string;
threadNamingModel(): string | null;
threadNamingEffort(): ReasoningEffort | null;
archiveExportSettings(): ArchiveExportSettings;
}
2026-06-13 09:26:15 +00:00
export interface CodexThreadsSessionEnvironment {
root: HTMLElement;
host: CodexThreadsHost;
registerPointerDown(handler: (event: PointerEvent) => void): void;
archiveAdapter(): ArchiveExportAdapter;
viewWindow(): Window | null;
}
type ThreadsViewStatus =
| { kind: "idle" }
| { kind: "loading"; message: string }
| { kind: "empty"; message: string }
| { kind: "log"; message: string }
| { kind: "error"; message: string };
export class CodexThreadsSession {
private readonly operations: ThreadOperations;
private readonly titleService: ThreadTitleService;
2026-06-13 09:26:15 +00:00
private readonly deferredTasks: ThreadsViewDeferredTasks;
private refreshLifecycle: ThreadsViewRefreshLifecycleState = { kind: "idle" };
private status: ThreadsViewStatus = { kind: "idle" };
private threads: readonly Thread[] = [];
2026-06-21 06:55:02 +00:00
private threadsLoaded = false;
2026-06-13 09:26:15 +00:00
private readonly renameStates = new Map<string, ThreadsRenameState>();
private nextRenameGenerationToken = 1;
2026-06-15 08:44:34 +00:00
private unsubscribeThreads: (() => void) | null = null;
2026-06-13 09:26:15 +00:00
private archiveConfirmThreadId: string | null = null;
constructor(private readonly environment: CodexThreadsSessionEnvironment) {
this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow());
this.operations = createThreadOperations({
clientAccess: this.host.clientAccess,
archiveExport: {
settings: () => this.host.settings.archiveExportSettings(),
enabled: () => this.host.settings.archiveExportEnabled(),
vaultPath: this.host.vaultPath,
},
archiveAdapter: () => this.environment.archiveAdapter(),
2026-06-15 06:00:14 +00:00
catalog: this.host.threadCatalog,
notice: (message) => {
new Notice(message);
},
});
this.titleService = createThreadTitleService({
codexPath: () => this.host.settings.codexPath(),
vaultPath: this.host.vaultPath,
threadNamingModel: () => this.host.settings.threadNamingModel(),
threadNamingEffort: () => this.host.settings.threadNamingEffort(),
clientAccess: this.host.clientAccess,
});
2026-06-15 00:30:30 +00:00
}
2026-06-13 09:26:15 +00:00
open(): void {
this.environment.registerPointerDown((event) => {
this.cancelArchiveConfirmOnOutsidePointer(event);
});
const activeThreadsSnapshot = this.host.threadCatalog.activeSnapshot();
2026-06-15 08:44:34 +00:00
if (activeThreadsSnapshot) {
this.threads = activeThreadsSnapshot;
2026-06-21 06:55:02 +00:00
this.threadsLoaded = true;
2026-06-13 09:26:15 +00:00
}
this.unsubscribeThreads = this.host.threadCatalog.observeActive((result) => {
this.receiveObservedThreadsResult(result);
2026-06-15 08:44:34 +00:00
});
2026-06-13 09:26:15 +00:00
this.render();
void this.refresh();
}
close(): void {
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" });
this.deferredTasks.clearAll();
2026-06-15 08:44:34 +00:00
this.unsubscribeThreads?.();
this.unsubscribeThreads = null;
2026-06-13 09:26:15 +00:00
unmountThreadsView(this.environment.root);
}
async refresh(): Promise<void> {
const refresh = this.startRefresh();
2026-06-21 06:55:02 +00:00
if (!this.currentThreadsData()) {
this.status = { kind: "loading", message: "Loading threads..." };
}
2026-06-13 09:26:15 +00:00
this.render();
try {
const threads = await this.host.threadCatalog.refreshActive();
2026-06-13 09:26:15 +00:00
if (this.isStaleRefresh(refresh)) return;
this.threads = threads;
2026-06-21 06:55:02 +00:00
this.threadsLoaded = true;
2026-06-13 09:26:15 +00:00
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
} catch (error) {
if (isStaleAppServerSharedQueryContextError(error)) return;
2026-06-21 06:55:02 +00:00
if (!this.currentThreadsData()) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
}
2026-06-13 09:26:15 +00:00
} finally {
this.finishRefresh(refresh);
}
}
refreshLiveState(): void {
this.scheduleRender();
}
2026-06-15 08:44:34 +00:00
private receiveObservedThreads(threads: readonly Thread[]): void {
2026-06-13 09:26:15 +00:00
this.threads = threads;
2026-06-21 06:55:02 +00:00
this.threadsLoaded = true;
2026-06-13 09:26:15 +00:00
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
this.render();
}
private receiveObservedThreadsResult(result: AppServerObservedQueryResult<readonly Thread[]>): void {
2026-06-21 06:55:02 +00:00
const data = observedQueryData(result);
if (data) {
this.receiveObservedThreads(data);
return;
}
2026-06-21 06:55:02 +00:00
const currentData = this.currentThreadsData();
if (observedQueryInitialLoading(result, currentData)) {
this.status = { kind: "loading", message: "Loading threads..." };
this.render();
return;
}
2026-06-21 06:55:02 +00:00
const initialError = observedQueryInitialError(result, currentData);
if (initialError) {
this.status = { kind: "error", message: initialError.message };
this.render();
}
}
2026-06-21 06:55:02 +00:00
private currentThreadsData(): readonly Thread[] | null {
return this.threadsLoaded ? this.threads : null;
}
2026-06-13 09:26:15 +00:00
private get host(): CodexThreadsHost {
return this.environment.host;
}
private startRefresh(): ActiveThreadsViewRefresh {
const refresh: ActiveThreadsViewRefresh = { kind: "loading" };
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "started", refresh });
return refresh;
}
private finishRefresh(refresh: ActiveThreadsViewRefresh): void {
if (this.isStaleRefresh(refresh)) return;
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "finished", refresh });
this.render();
}
private isStaleRefresh(refresh: ActiveThreadsViewRefresh): boolean {
return this.refreshLifecycle !== refresh;
}
private render(): void {
renderThreadsView(
this.environment.root,
{
status: threadsViewStatusText(this.status),
loading: this.refreshLifecycle.kind === "loading",
rows: threadRows(
this.threads,
this.host.getOpenPanelSnapshots(),
this.renameStates,
this.archiveConfirmThreadId,
this.host.settings.archiveExportEnabled(),
2026-06-13 09:26:15 +00:00
),
},
{
refresh: () => void this.refresh(),
openNewPanel: () => void this.openNewPanel(),
openThread: (threadId) => void this.openThread(threadId),
startRename: (threadId, value) => {
this.startRename(threadId, value);
},
updateRename: (threadId, value) => {
this.updateRename(threadId, value);
},
saveRename: (threadId, value) => void this.saveRename(threadId, value),
cancelRename: (threadId) => {
this.cancelRename(threadId);
},
autoNameThread: (threadId) => void this.autoNameThread(threadId),
startArchive: (threadId) => {
this.startArchive(threadId);
},
archiveThread: (threadId, saveMarkdown) => void this.archiveThread(threadId, saveMarkdown),
},
);
}
private scheduleRender(): void {
this.deferredTasks.scheduleRender(() => {
this.render();
});
}
private async openThread(threadId: string): Promise<void> {
this.archiveConfirmThreadId = null;
await this.host.openThreadInAvailableView(threadId);
}
private async openNewPanel(): Promise<void> {
await this.host.openNewPanel();
}
private startRename(threadId: string, value: string): void {
this.archiveConfirmThreadId = null;
this.transitionRenameState(threadId, { type: "started", draft: value });
2026-06-13 09:26:15 +00:00
this.render();
}
private updateRename(threadId: string, value: string): void {
this.transitionRenameState(threadId, { type: "draft-updated", draft: value });
2026-06-13 09:26:15 +00:00
this.render();
}
private cancelRename(threadId: string): void {
this.transitionRenameState(threadId, { type: "cancelled" });
2026-06-13 09:26:15 +00:00
this.render();
}
private async saveRename(threadId: string, value: string): Promise<void> {
const editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
try {
if (this.renameStates.get(threadId) !== editingState) return;
const result = await this.operations.renameThread(threadId, value);
2026-06-21 08:24:04 +00:00
if (this.renameStates.get(threadId) !== editingState) return;
if (!result) {
this.cancelRename(threadId);
return;
}
2026-06-13 09:26:15 +00:00
this.renameStates.delete(threadId);
2026-06-21 08:24:04 +00:00
this.render();
2026-06-13 09:26:15 +00:00
} catch (error) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
private async autoNameThread(threadId: string): Promise<void> {
const previousState = this.renameStates.get(threadId);
const generatingState = this.transitionRenameState(threadId, {
type: "auto-name-started",
generationToken: this.nextRenameGenerationToken,
});
if (generatingState === previousState || generatingState?.kind !== "generating") return;
this.nextRenameGenerationToken += 1;
2026-06-13 09:26:15 +00:00
this.render();
try {
if (this.renameStates.get(threadId) !== generatingState) return;
const title = await this.titleService.generateTitle(threadId);
this.transitionRenameState(threadId, { type: "auto-name-generated", generatingState, title });
2026-06-13 09:26:15 +00:00
} catch (error) {
if (this.renameStates.get(threadId) === generatingState) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
}
} finally {
this.finishAutoNameThread(threadId, generatingState);
}
}
private startArchive(threadId: string): void {
this.archiveConfirmThreadId = threadId;
this.render();
}
private cancelArchiveConfirmOnOutsidePointer(event: PointerEvent): void {
if (!this.archiveConfirmThreadId) return;
const target = event.target;
const viewWindow = this.viewWindow() as Window & { Element: typeof Element };
if (target instanceof viewWindow.Element) {
const archiveConfirm = target.closest(".codex-panel-threads__archive-confirm");
if (archiveConfirm && this.environment.root.contains(archiveConfirm)) return;
}
this.archiveConfirmThreadId = null;
this.render();
}
private async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
try {
const result = await this.operations.archiveThread(threadId, {
saveMarkdown,
closeOpenPanels: true,
});
if (!result) return;
2026-06-13 09:26:15 +00:00
if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null;
this.renameStates.delete(threadId);
} catch (error) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
private finishAutoNameThread(threadId: string, generatingState: ThreadsGeneratingRenameState): void {
const previousState = this.renameStates.get(threadId);
const nextState = this.transitionRenameState(threadId, { type: "auto-name-finished", generatingState });
if (nextState !== previousState) this.render();
}
private transitionRenameState(threadId: string, event: ThreadsRenameLifecycleEvent): ThreadsRenameState | undefined {
const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event);
if (nextState) {
this.renameStates.set(threadId, nextState);
} else {
this.renameStates.delete(threadId);
}
return nextState;
2026-06-13 09:26:15 +00:00
}
private viewWindow(): Window {
return this.environment.viewWindow() ?? window;
}
}
function threadsViewStatusText(status: ThreadsViewStatus): string | null {
return status.kind === "idle" ? null : status.message;
}