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

435 lines
17 KiB
TypeScript
Raw Normal View History

2026-06-13 09:26:15 +00:00
import { Notice } from "obsidian";
import { type AppServerQueryContext, appServerQueryContextRawEquals } from "../../app-server/query/keys";
import type { ObservedResult } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
2026-06-13 09:26:15 +00:00
import type { Thread } from "../../domain/threads/model";
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
import { DeferredTask } from "../../shared/runtime/deferred-task";
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
import type { ThreadOperationsTransport, ThreadTitleTransport } from "../threads/workflows/ports";
import type { ThreadNameMutationCoordinator } from "../threads/workflows/thread-name-mutation-coordinator";
2026-06-27 12:25:13 +00:00
import { createThreadOperations, type ThreadOperations } from "../threads/workflows/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../threads/workflows/thread-title-service";
2026-06-27 02:36:00 +00:00
import { isThreadsArchiveConfirmPointer, renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom";
import { type ThreadsRenameState, type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState } from "./state";
2026-06-26 11:23:37 +00:00
export interface ThreadsViewHost {
readonly settings: ThreadsViewSettingsAccess;
2026-06-13 09:26:15 +00:00
readonly vaultPath: string;
2026-06-26 11:23:37 +00:00
readonly threadCatalog: ThreadsViewThreadCatalog;
readonly threadNameMutations: ThreadNameMutationCoordinator;
readonly threadOperationsTransport: ThreadOperationsTransport;
readonly threadTitleTransport: ThreadTitleTransport;
2026-06-13 09:26:15 +00:00
openNewPanel(): Promise<unknown>;
openThreadInAvailableView(threadId: string): Promise<void>;
openPanelActivities(): readonly ThreadsViewPanelActivity[];
closeOpenPanelsForThread(threadId: string): void;
2026-06-13 09:26:15 +00:00
}
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
2026-06-15 06:00:14 +00:00
2026-06-26 11:23:37 +00:00
export interface ThreadsViewSettingsAccess {
archiveExportEnabled(): boolean;
codexPath(): string;
threadNamingModel(): string | null;
threadNamingEffort(): ReasoningEffort | null;
archiveExportSettings(): ArchiveExportSettings;
}
2026-06-26 11:23:37 +00:00
export interface ThreadsViewSessionEnvironment {
2026-06-13 09:26:15 +00:00
root: HTMLElement;
2026-06-26 11:23:37 +00:00
host: ThreadsViewHost;
2026-06-13 09:26:15 +00:00
registerPointerDown(handler: (event: PointerEvent) => void): void;
archiveDestination(): ArchiveExportDestination;
2026-06-21 08:38:55 +00:00
vaultConfigDir(): string;
2026-06-13 09:26:15 +00:00
viewWindow(): Window | null;
}
type ThreadsViewStatus =
| { kind: "idle" }
| { kind: "loading"; message: string }
| { kind: "empty"; message: string }
| { kind: "log"; message: string }
| { kind: "error"; message: string };
2026-06-26 11:23:37 +00:00
export class ThreadsViewSession {
private readonly lifetime = new OwnerLifetime();
private readonly operations: ThreadOperations;
private readonly titleService: ThreadTitleService;
private readonly renderTask: DeferredTask;
private activeRefresh: object | null = null;
2026-06-13 09:26:15 +00:00
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;
private observedAppServerContext: AppServerQueryContext;
private operationGeneration = 0;
2026-06-13 09:26:15 +00:00
2026-06-26 11:23:37 +00:00
constructor(private readonly environment: ThreadsViewSessionEnvironment) {
this.observedAppServerContext = this.currentAppServerContext();
this.renderTask = new DeferredTask(() => this.viewWindow(), 0);
this.operations = createThreadOperations({
transport: this.host.threadOperationsTransport,
nameMutations: this.host.threadNameMutations,
archiveExport: {
settings: () => this.host.settings.archiveExportSettings(),
enabled: () => this.host.settings.archiveExportEnabled(),
vaultPath: this.host.vaultPath,
2026-06-21 08:38:55 +00:00
vaultConfigDir: this.environment.vaultConfigDir(),
},
archiveDestination: () => this.environment.archiveDestination(),
2026-06-15 06:00:14 +00:00
catalog: this.host.threadCatalog,
notice: (message) => {
new Notice(message);
},
});
this.titleService = createThreadTitleService({
transport: this.host.threadTitleTransport,
});
2026-06-15 00:30:30 +00:00
}
2026-06-13 09:26:15 +00:00
open(): void {
this.lifetime.activate();
2026-06-13 09:26:15 +00:00
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.lifetime.dispose();
this.titleService.invalidate();
this.activeRefresh = null;
this.renderTask.clear();
2026-06-15 08:44:34 +00:00
this.unsubscribeThreads?.();
this.unsubscribeThreads = null;
2026-06-26 11:23:37 +00:00
unmountThreadsViewShell(this.environment.root);
2026-06-13 09:26:15 +00:00
}
async refresh(): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime)) return;
2026-06-13 09:26:15 +00:00
const refresh = this.startRefresh();
2026-06-26 11:23:37 +00:00
if (!this.currentThreadsSnapshot()) {
2026-06-21 06:55:02 +00:00
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();
if (!this.lifetime.isCurrent(lifetime) || this.isStaleRefresh(refresh)) return;
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" };
} catch (error) {
if (!this.lifetime.isCurrent(lifetime)) return;
if (isStaleAppServerResourceContextError(error)) return;
2026-06-26 11:23:37 +00:00
if (!this.currentThreadsSnapshot()) {
2026-06-21 06:55:02 +00:00
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
}
2026-06-13 09:26:15 +00:00
} finally {
this.finishRefresh(refresh);
}
}
async loadMore(): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActive() || this.activeRefresh) return;
const refresh = this.startRefresh();
this.render();
try {
const threads = await this.host.threadCatalog.loadMoreActive();
if (!this.lifetime.isCurrent(lifetime) || this.isStaleRefresh(refresh)) return;
this.threads = threads;
this.threadsLoaded = true;
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
} finally {
this.finishRefresh(refresh);
}
}
2026-06-13 09:26:15 +00:00
refreshLiveState(): void {
this.scheduleRender();
}
prepareAppServerContextChange(): void {
this.operationGeneration += 1;
this.titleService.invalidate();
this.activeRefresh = null;
this.renderTask.clear();
this.threads = [];
this.threadsLoaded = false;
this.renameStates.clear();
this.archiveConfirmThreadId = null;
this.status = { kind: "idle" };
this.render();
}
refreshSettings(): void {
const nextContext = this.currentAppServerContext();
if (appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) {
this.render();
return;
}
this.observedAppServerContext = nextContext;
const snapshot = this.host.threadCatalog.activeSnapshot();
this.threads = snapshot ?? [];
this.threadsLoaded = snapshot !== null;
this.status = snapshot?.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
this.render();
void this.refresh();
}
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();
}
2026-06-26 11:23:37 +00:00
private receiveObservedThreadsResult(result: ObservedResult<readonly Thread[]>): void {
const observedThreads = result.value;
2026-06-26 11:23:37 +00:00
if (observedThreads) {
this.receiveObservedThreads(observedThreads);
return;
}
2026-06-26 11:23:37 +00:00
const currentValue = this.currentThreadsSnapshot();
if (observedInitialLoading(result, currentValue)) {
this.status = { kind: "loading", message: "Loading threads..." };
this.render();
return;
}
2026-06-26 11:23:37 +00:00
const initialError = observedInitialError(result, currentValue);
2026-06-21 06:55:02 +00:00
if (initialError) {
this.status = { kind: "error", message: initialError.message };
this.render();
}
}
2026-06-26 11:23:37 +00:00
private currentThreadsSnapshot(): readonly Thread[] | null {
2026-06-21 06:55:02 +00:00
return this.threadsLoaded ? this.threads : null;
}
2026-06-26 11:23:37 +00:00
private get host(): ThreadsViewHost {
2026-06-13 09:26:15 +00:00
return this.environment.host;
}
private startRefresh(): object {
const refresh = {};
this.activeRefresh = refresh;
2026-06-13 09:26:15 +00:00
return refresh;
}
private finishRefresh(refresh: object): void {
2026-06-13 09:26:15 +00:00
if (this.isStaleRefresh(refresh)) return;
this.activeRefresh = null;
2026-06-13 09:26:15 +00:00
this.render();
}
private isStaleRefresh(refresh: object): boolean {
return this.activeRefresh !== refresh;
2026-06-13 09:26:15 +00:00
}
private render(): void {
if (!this.lifetime.isActive()) return;
2026-06-26 11:23:37 +00:00
renderThreadsViewShell(
2026-06-13 09:26:15 +00:00
this.environment.root,
{
2026-06-27 04:39:14 +00:00
status: this.status.kind === "idle" ? null : this.status.message,
loading: this.activeRefresh !== null,
hasMore: this.host.threadCatalog.hasMoreActive(),
2026-06-13 09:26:15 +00:00
rows: threadRows(
this.threads,
this.host.openPanelActivities(),
2026-06-13 09:26:15 +00:00
this.renameStates,
this.archiveConfirmThreadId,
this.host.settings.archiveExportEnabled(),
2026-06-13 09:26:15 +00:00
),
},
{
refresh: () => void this.refresh(),
loadMore: () => void this.loadMore(),
2026-06-13 09:26:15 +00:00
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.renderTask.schedule(() => {
2026-06-13 09:26:15 +00:00
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 lifetime = this.lifetime.signal();
const operationGeneration = this.operationGeneration;
2026-06-13 09:26:15 +00:00
const editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
try {
if (this.renameStates.get(threadId) !== editingState) return;
const operationIsCurrent = () => operationGeneration === this.operationGeneration;
const result = await this.operations.renameThread(threadId, value, {
shouldStart: operationIsCurrent,
shouldPublish: operationIsCurrent,
});
if (!operationIsCurrent()) return;
if (!this.lifetime.isCurrent(lifetime) || 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) {
if (!this.lifetime.isCurrent(lifetime)) return;
2026-06-13 09:26:15 +00:00
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
private async autoNameThread(threadId: string): Promise<void> {
const lifetime = this.lifetime.signal();
const operationGeneration = this.operationGeneration;
const previousState = this.renameStates.get(threadId);
const generationToken = this.nextRenameGenerationToken;
const generatingState = this.transitionRenameState(threadId, {
type: "generation-started",
generationToken,
});
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);
if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return;
this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title });
2026-06-13 09:26:15 +00:00
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return;
2026-06-13 09:26:15 +00:00
if (this.renameStates.get(threadId) === generatingState) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
}
} finally {
if (this.lifetime.isCurrent(lifetime) && operationGeneration === this.operationGeneration) {
this.finishAutoNameThread(threadId, generationToken);
}
2026-06-13 09:26:15 +00:00
}
}
private startArchive(threadId: string): void {
this.archiveConfirmThreadId = threadId;
this.render();
}
private cancelArchiveConfirmOnOutsidePointer(event: PointerEvent): void {
if (!this.archiveConfirmThreadId) return;
2026-06-27 02:36:00 +00:00
if (isThreadsArchiveConfirmPointer(event, this.environment.root, this.viewWindow())) return;
2026-06-13 09:26:15 +00:00
this.archiveConfirmThreadId = null;
this.render();
}
private async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
const lifetime = this.lifetime.signal();
const operationGeneration = this.operationGeneration;
2026-06-13 09:26:15 +00:00
try {
await this.operations.archiveThread(threadId, {
saveMarkdown,
shouldPublish: () => operationGeneration === this.operationGeneration,
});
if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return;
this.host.closeOpenPanelsForThread(threadId);
2026-06-13 09:26:15 +00:00
if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null;
this.renameStates.delete(threadId);
} catch (error) {
if (!this.lifetime.isCurrent(lifetime)) return;
2026-06-13 09:26:15 +00:00
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
}
}
private finishAutoNameThread(threadId: string, generationToken: number): void {
const previousState = this.renameStates.get(threadId);
const nextState = this.transitionRenameState(threadId, { type: "generation-finished", generationToken });
if (nextState !== previousState) this.render();
}
private transitionRenameState(threadId: string, event: ThreadRenameLifecycleEvent): 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;
}
private currentAppServerContext(): AppServerQueryContext {
return { codexPath: this.host.settings.codexPath(), vaultPath: this.host.vaultPath };
}
2026-06-13 09:26:15 +00:00
}