murashit_codex-panel/src/workspace/panel-coordinator.ts

459 lines
16 KiB
TypeScript

import type { App, WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../constants";
import { CodexChatView } from "../features/chat/host/view";
import type { ChatWorkspacePanelSurface } from "../features/chat/host/surface-handle";
import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
import { hasPendingRequests, pendingRequestCounts } from "../domain/pending-requests/aggregate";
type ThreadPanelTarget =
| {
kind: "open";
leaf: WorkspaceLeaf;
view: CodexChatView;
}
| {
kind: "restored";
leaf: WorkspaceLeaf;
}
| {
kind: "restored-reuse";
leaf: WorkspaceLeaf;
}
| {
kind: "empty";
leaf: WorkspaceLeaf;
view: CodexChatView;
}
| {
kind: "reuse";
leaf: WorkspaceLeaf;
view: CodexChatView;
}
| {
kind: "new";
};
type WorkspacePanelReconcileScheduleState = { kind: "idle" } | { kind: "scheduled"; timers: Set<number> } | { kind: "cancelled" };
interface WorkspacePanelReconcileOptions {
loadRestoredLeaves?: boolean;
}
export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot {
lastFocused: boolean;
}
export interface WorkspacePanelCoordinatorOptions {
app: App;
refreshThreadsViewLiveState: () => void;
}
export class WorkspacePanelCoordinator {
private workspacePanelReconcileSchedule: WorkspacePanelReconcileScheduleState = { kind: "idle" };
private lastFocusedPanelViewId: string | null = null;
constructor(private readonly options: WorkspacePanelCoordinatorOptions) {}
reset(): void {
this.clearWorkspacePanelReconcileTimers();
this.workspacePanelReconcileSchedule = { kind: "idle" };
this.lastFocusedPanelViewId = null;
}
async activateView(): Promise<CodexChatView> {
const target = this.findCurrentThreadPanelTarget();
if (target) return this.activateThreadPanelTarget(target);
const leaf = await this.options.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_PANEL, "right", {
active: true,
reveal: true,
});
const view = leaf.view as CodexChatView;
const surface = workspacePanelSurface(view);
await surface.connect();
surface.focusComposer();
return view;
}
async activateNewView(options: { connect?: boolean } = {}): Promise<CodexChatView> {
const leaf = this.createRightSidebarTab();
if (!leaf) throw new Error("Could not create a right sidebar leaf.");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true });
await this.options.app.workspace.revealLeaf(leaf);
const view = leaf.view as CodexChatView;
const surface = workspacePanelSurface(view);
if (options.connect !== false) await surface.connect();
surface.focusComposer();
return view;
}
async openThreadInNewView(threadId: string): Promise<CodexChatView> {
const view = await this.activateThreadResumeView();
await workspacePanelSurface(view).openThread(threadId);
return view;
}
async openNewPanel(): Promise<void> {
await this.activateNewView();
}
async openThreadInAvailableView(threadId: string): Promise<void> {
const target = this.findThreadPanelTarget(threadId);
await this.openThreadInTarget(target, threadId);
}
async openThreadInCurrentView(threadId: string): Promise<void> {
const target =
this.findOpenThreadPanelTarget(threadId) ?? this.findRestoredThreadPanelTarget(threadId) ?? this.findCurrentThreadPanelTarget();
await this.openThreadInTarget(target ?? { kind: "new" }, threadId);
}
async focusThreadInOpenView(threadId: string): Promise<boolean> {
const target = this.findOpenThreadPanelTarget(threadId) ?? this.findRestoredThreadPanelTarget(threadId);
if (!target) return false;
await this.openThreadInTarget(target, threadId);
return true;
}
async openThreadInIdleEmptyView(threadId: string): Promise<boolean> {
const target = this.findIdleEmptyThreadPanelTarget();
if (!target) return false;
await this.openThreadInTarget(target, threadId);
return true;
}
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[] {
const leaves = this.panelLeaves();
this.ensureInitialFocusedPanel(leaves);
return leaves.flatMap((leaf, index) => {
if (leaf.view instanceof CodexChatView)
return [this.openPanelSnapshotWithFocus(workspacePanelSurface(leaf.view).openPanelSnapshot())];
const restoredSnapshot = restoredPanelSnapshot(leaf, index);
return restoredSnapshot ? [restoredSnapshot] : [];
});
}
async focusOpenPanel(viewId: string, threadId: string | null = null): Promise<boolean> {
for (const leaf of this.panelLeaves()) {
if (leaf.view instanceof CodexChatView && workspacePanelSurface(leaf.view).openPanelSnapshot().viewId === viewId) {
await this.options.app.workspace.revealLeaf(leaf);
await workspacePanelSurface(leaf.view).focusThread(threadId);
return true;
}
}
return false;
}
panelLeavesForThread(threadId: string): WorkspaceLeaf[] {
return this.panelLeaves().filter((leaf) => {
if (leaf.view instanceof CodexChatView) return workspacePanelSurface(leaf.view).openPanelSnapshot().threadId === threadId;
return restoredThreadId(leaf) === threadId;
});
}
panelViews(): CodexChatView[] {
return this.panelLeaves().flatMap((leaf) => (leaf.view instanceof CodexChatView ? [leaf.view] : []));
}
reconcileWorkspacePanels(hintLeaf: WorkspaceLeaf | null = null, options: WorkspacePanelReconcileOptions = {}): void {
const leaves = this.panelLeaves();
const foregroundLeaf = this.foregroundPanelLeaf(leaves, hintLeaf);
if (foregroundLeaf) {
void this.hydratePanelLeaf(foregroundLeaf).catch((error: unknown) => {
console.warn("Codex Panel could not hydrate a foreground panel leaf.", error);
});
}
if (options.loadRestoredLeaves) {
for (const leaf of leaves) {
if (leaf === foregroundLeaf) continue;
void this.loadRestoredPanelLeaf(leaf);
}
this.options.refreshThreadsViewLiveState();
}
}
scheduleWorkspacePanelReconcile(): void {
this.scheduleWorkspacePanelReconcileTimer(() => {
this.reconcileWorkspacePanels(null, { loadRestoredLeaves: true });
}, 0);
}
cancelWorkspacePanelReconcile(): void {
this.clearWorkspacePanelReconcileTimers();
this.workspacePanelReconcileSchedule = { kind: "cancelled" };
}
private recordLastFocusedPanel(leaf: WorkspaceLeaf | null): void {
const viewId = focusedPanelViewId(leaf);
if (!viewId) return;
if (this.lastFocusedPanelViewId === viewId) return;
this.lastFocusedPanelViewId = viewId;
this.options.refreshThreadsViewLiveState();
}
private clearWorkspacePanelReconcileTimers(): void {
if (this.workspacePanelReconcileSchedule.kind === "scheduled") {
for (const timer of this.workspacePanelReconcileSchedule.timers) {
window.clearTimeout(timer);
}
this.workspacePanelReconcileSchedule.timers.clear();
}
}
private panelLeaves(): WorkspaceLeaf[] {
return this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL);
}
private createRightSidebarTab(): WorkspaceLeaf | null {
const { workspace } = this.options.app;
const existing = this.panelLeaves().find((leaf) => leaf.getRoot() === workspace.rightSplit);
if (!existing) return workspace.getRightLeaf(false);
return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER);
}
private activateThreadResumeView(): Promise<CodexChatView> {
return this.activateNewView({ connect: false });
}
private findThreadPanelTarget(threadId: string): ThreadPanelTarget {
return (
this.findOpenThreadPanelTarget(threadId) ??
this.findRestoredThreadPanelTarget(threadId) ??
this.findIdleEmptyThreadPanelTarget() ?? { kind: "new" }
);
}
private findOpenThreadPanelTarget(threadId: string): ThreadPanelTarget | null {
for (const leaf of this.panelLeaves()) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (workspacePanelSurface(leaf.view).openPanelSnapshot().threadId !== threadId) continue;
return { kind: "open", leaf, view: leaf.view };
}
return null;
}
private findRestoredThreadPanelTarget(threadId: string): ThreadPanelTarget | null {
for (const leaf of this.panelLeaves()) {
if (leaf.view instanceof CodexChatView) continue;
if (restoredThreadId(leaf) !== threadId) continue;
return { kind: "restored", leaf };
}
return null;
}
private findIdleEmptyThreadPanelTarget(): ThreadPanelTarget | null {
for (const leaf of this.panelLeaves()) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (!isIdleEmptyPanelSnapshot(workspacePanelSurface(leaf.view).openPanelSnapshot())) continue;
return { kind: "empty", leaf, view: leaf.view };
}
return null;
}
private findCurrentThreadPanelTarget(): ThreadPanelTarget | null {
const { workspace } = this.options.app;
const active = this.findActiveThreadPanelTarget();
if (active) return active;
const mostRecent = workspace.getMostRecentLeaf(workspace.rightSplit);
const target = mostRecent ? this.threadPanelTargetFromLeaf(mostRecent) : null;
if (target) return target;
for (const leaf of this.panelLeaves()) {
const fallback = this.threadPanelTargetFromLeaf(leaf);
if (fallback) return fallback;
}
return null;
}
private findActiveThreadPanelTarget(): ThreadPanelTarget | null {
const activeView = this.options.app.workspace.getActiveViewOfType(CodexChatView);
if (!activeView) return null;
for (const leaf of this.panelLeaves()) {
if (leaf.view === activeView) return this.threadPanelTargetFromLeaf(leaf);
}
return null;
}
private foregroundPanelLeaf(leaves: readonly WorkspaceLeaf[], hintLeaf: WorkspaceLeaf | null): WorkspaceLeaf | null {
return (
this.panelLeafFromLeaf(leaves, hintLeaf) ??
this.activePanelLeaf(leaves) ??
this.panelLeafFromLeaf(leaves, this.options.app.workspace.getMostRecentLeaf(this.options.app.workspace.rightSplit))
);
}
private activePanelLeaf(leaves: readonly WorkspaceLeaf[]): WorkspaceLeaf | null {
const activeView = this.options.app.workspace.getActiveViewOfType(CodexChatView);
if (!activeView) return null;
return leaves.find((leaf) => leaf.view === activeView) ?? null;
}
private panelLeafFromLeaf(leaves: readonly WorkspaceLeaf[], leaf: WorkspaceLeaf | null): WorkspaceLeaf | null {
if (!leaf || !leaves.includes(leaf)) return null;
if (leaf.view instanceof CodexChatView) return leaf;
return leaf.getViewState().type === VIEW_TYPE_CODEX_PANEL ? leaf : null;
}
private threadPanelTargetFromLeaf(leaf: WorkspaceLeaf): ThreadPanelTarget | null {
if (leaf.view instanceof CodexChatView) return { kind: "reuse", leaf, view: leaf.view };
if (leaf.getViewState().type === VIEW_TYPE_CODEX_PANEL) return { kind: "restored-reuse", leaf };
return null;
}
private async activateThreadPanelTarget(target: ThreadPanelTarget): Promise<CodexChatView> {
if (target.kind === "new") return this.activateNewView();
await this.options.app.workspace.revealLeaf(target.leaf);
if ("view" in target) {
const surface = workspacePanelSurface(target.view);
await surface.connect();
await surface.focusThread();
return target.view;
}
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
const surface = workspacePanelSurface(target.leaf.view);
await surface.connect();
await surface.focusThread();
return target.leaf.view;
}
return this.activateNewView();
}
private async openThreadInTarget(target: ThreadPanelTarget, threadId: string): Promise<void> {
switch (target.kind) {
case "open":
await this.options.app.workspace.revealLeaf(target.leaf);
await workspacePanelSurface(target.view).focusThread(threadId);
return;
case "restored":
await this.options.app.workspace.revealLeaf(target.leaf);
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
await workspacePanelSurface(target.leaf.view).focusThread(threadId);
}
return;
case "restored-reuse":
await this.options.app.workspace.revealLeaf(target.leaf);
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
await workspacePanelSurface(target.leaf.view).openThread(threadId);
}
return;
case "empty":
case "reuse":
await this.options.app.workspace.revealLeaf(target.leaf);
await workspacePanelSurface(target.view).openThread(threadId);
return;
case "new":
await this.openThreadInNewView(threadId);
return;
}
}
private ensureInitialFocusedPanel(leaves: readonly WorkspaceLeaf[]): void {
if (this.lastFocusedPanelViewId) return;
const activeView = this.options.app.workspace.getActiveViewOfType(CodexChatView);
const activeLeaf = activeView ? (leaves.find((leaf) => leaf.view === activeView) ?? null) : null;
const viewId =
focusedPanelViewId(activeLeaf) ??
focusedPanelViewId(this.options.app.workspace.getMostRecentLeaf(this.options.app.workspace.rightSplit));
if (viewId) this.lastFocusedPanelViewId = viewId;
}
private openPanelSnapshotWithFocus(snapshot: ChatPanelSnapshot): OpenCodexPanelSnapshot {
return { ...snapshot, lastFocused: snapshot.viewId === this.lastFocusedPanelViewId };
}
private scheduleWorkspacePanelReconcileTimer(callback: () => void, delay: number): void {
const lifecycle = this.ensureWorkspacePanelReconcileScheduled();
if (!lifecycle) return;
const timer = window.setTimeout(() => {
if (this.workspacePanelReconcileSchedule !== lifecycle) return;
lifecycle.timers.delete(timer);
callback();
if (this.workspacePanelReconcileSchedule === lifecycle && lifecycle.timers.size === 0) {
this.workspacePanelReconcileSchedule = { kind: "idle" };
}
}, delay);
lifecycle.timers.add(timer);
}
private async loadRestoredPanelLeaf(leaf: WorkspaceLeaf): Promise<void> {
if (this.workspacePanelReconcileSchedule.kind === "cancelled") return;
try {
await leaf.loadIfDeferred();
} catch (error) {
console.warn("Codex Panel could not load a restored panel leaf.", error);
}
}
private async hydratePanelLeaf(leaf: WorkspaceLeaf): Promise<void> {
if (!(leaf.view instanceof CodexChatView)) {
if (leaf.getViewState().type !== VIEW_TYPE_CODEX_PANEL) return;
await leaf.loadIfDeferred();
}
if (leaf.view instanceof CodexChatView) {
this.recordLastFocusedPanel(leaf);
await workspacePanelSurface(leaf.view).hydrateRestoredThread();
}
}
private ensureWorkspacePanelReconcileScheduled(): Extract<WorkspacePanelReconcileScheduleState, { kind: "scheduled" }> | null {
if (this.workspacePanelReconcileSchedule.kind === "cancelled") return null;
if (this.workspacePanelReconcileSchedule.kind === "scheduled") return this.workspacePanelReconcileSchedule;
const lifecycle: Extract<WorkspacePanelReconcileScheduleState, { kind: "scheduled" }> = { kind: "scheduled", timers: new Set() };
this.workspacePanelReconcileSchedule = lifecycle;
return lifecycle;
}
}
function isIdleEmptyPanelSnapshot(snapshot: ChatPanelSnapshot): boolean {
return (
snapshot.threadId === null &&
snapshot.turnLifecycle.kind === "idle" &&
!hasPendingRequests(pendingRequestCounts(snapshot)) &&
!snapshot.hasComposerDraft
);
}
function focusedPanelViewId(leaf: WorkspaceLeaf | null): string | null {
return leaf?.view instanceof CodexChatView ? workspacePanelSurface(leaf.view).openPanelSnapshot().viewId : null;
}
function workspacePanelSurface(view: CodexChatView): ChatWorkspacePanelSurface {
return view.surface;
}
function restoredThreadId(leaf: WorkspaceLeaf): string | null {
const state = leaf.getViewState().state;
if (!state || typeof state !== "object") return null;
const threadId = (state as { threadId?: unknown }).threadId;
return typeof threadId === "string" && threadId.length > 0 ? threadId : null;
}
function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): OpenCodexPanelSnapshot | null {
const threadId = restoredThreadId(leaf);
if (!threadId) return null;
return {
viewId: `restored:${String(index)}:${threadId}`,
threadId,
turnLifecycle: { kind: "idle" },
pendingApprovals: 0,
pendingUserInputs: 0,
pendingMcpElicitations: 0,
hasComposerDraft: false,
connected: false,
lastFocused: false,
};
}