mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Simplify shared thread catalog and chat runtime ownership
This commit is contained in:
parent
b40d6cdb5b
commit
40076477fc
32 changed files with 1791 additions and 1180 deletions
|
|
@ -6,8 +6,7 @@ import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
|
|||
export interface CodexChatHost {
|
||||
readonly settingsRef: PluginSettingsRef;
|
||||
readonly workspace: WorkspacePanels;
|
||||
readonly sharedCache: SharedAppServerCacheFacade;
|
||||
readonly threadSurfaces: ThreadSurfaceBroadcaster;
|
||||
readonly threadCatalog: ThreadCatalogFacade;
|
||||
}
|
||||
|
||||
export interface PluginSettingsRef {
|
||||
|
|
@ -21,17 +20,14 @@ export interface WorkspacePanels {
|
|||
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ThreadSurfaceBroadcaster {
|
||||
export interface ThreadCatalogFacade {
|
||||
notifyThreadArchived(threadId: string): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
refreshThreadsViewLiveState(): void;
|
||||
refreshSharedThreadListFromOpenSurface(): void;
|
||||
applyThreadListSnapshot(threads: readonly Thread[]): void;
|
||||
refreshFromOpenSurface(): void;
|
||||
applyThreads(threads: readonly Thread[]): void;
|
||||
publishAppServerMetadata(metadata: SharedServerMetadata): void;
|
||||
}
|
||||
|
||||
interface SharedAppServerCacheFacade {
|
||||
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
cachedThreadList(): readonly Thread[] | null;
|
||||
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
cachedThreads(): readonly Thread[] | null;
|
||||
cachedAppServerMetadata(): SharedServerMetadata | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { ThreadConversationSummary } from "../../../../domain/threads/transcript";
|
||||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import { renameThreadOnAppServer, threadRenameFromValue } from "../../../../app-server/services/thread-rename";
|
||||
import { generateThreadTitleWithCodex } from "../../../../app-server/services/thread-title-generation";
|
||||
import { threadTitleContextFromConversationSummary, type ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import type { ThreadTitleService } from "../../../threads/thread-title-service";
|
||||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { messageStreamItems } from "../state/message-stream";
|
||||
import { threadTitleContextFromMessageStreamItems } from "./title-context";
|
||||
|
||||
export interface AutoTitleControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
settings: () => CodexPanelSettings;
|
||||
currentClient: () => AppServerClient | null;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
|
||||
operations: Pick<ThreadOperations, "renameThread">;
|
||||
titleService: Pick<ThreadTitleService, "completedTurnContext" | "generate">;
|
||||
}
|
||||
|
||||
export class AutoTitleController {
|
||||
|
|
@ -45,7 +38,7 @@ export class AutoTitleController {
|
|||
if (hadTurnsBeforeThisCompletion) return;
|
||||
if (this.threadHasTitle(threadId)) return;
|
||||
if (this.attemptedThreadIds.has(threadId) || this.inFlightThreadIds.has(threadId)) return;
|
||||
const context = this.titleContextForCompletedTurn(turnId, completedSummary);
|
||||
const context = this.host.titleService.completedTurnContext(turnId, completedSummary);
|
||||
if (!context) return;
|
||||
|
||||
this.attemptedThreadIds.add(threadId);
|
||||
|
|
@ -53,28 +46,20 @@ export class AutoTitleController {
|
|||
void this.generateAndSetTitle(threadId, context);
|
||||
}
|
||||
|
||||
private titleContextForCompletedTurn(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null {
|
||||
const visibleContext = threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(this.state.messageStream));
|
||||
if (visibleContext) return visibleContext;
|
||||
return completedSummary ? threadTitleContextFromConversationSummary(completedSummary) : null;
|
||||
}
|
||||
|
||||
private async generateAndSetTitle(threadId: string, context: ThreadTitleContext): Promise<void> {
|
||||
try {
|
||||
const title = await this.generateTitle(context);
|
||||
if (!title || !this.threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
const rename = threadRenameFromValue(title);
|
||||
if (!rename) return;
|
||||
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
const result = await renameThreadOnAppServer(client, threadId, rename);
|
||||
const result = await this.host.operations.renameThread(threadId, title, {
|
||||
shouldPublish: () => this.threadCanReceiveGeneratedTitle(threadId),
|
||||
});
|
||||
if (!result) return;
|
||||
if (!this.threadCanReceiveGeneratedTitle(threadId)) return;
|
||||
this.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: this.state.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: result.name } : thread)),
|
||||
});
|
||||
this.host.notifyThreadRenamed(threadId, result.name);
|
||||
} catch {
|
||||
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
|
||||
} finally {
|
||||
|
|
@ -83,12 +68,7 @@ export class AutoTitleController {
|
|||
}
|
||||
|
||||
private async generateTitle(context: ThreadTitleContext): Promise<string | null> {
|
||||
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
|
||||
const settings = this.host.settings();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
|
||||
threadNamingModel: settings.threadNamingModel,
|
||||
threadNamingEffort: settings.threadNamingEffort,
|
||||
});
|
||||
return this.host.titleService.generate(context);
|
||||
}
|
||||
|
||||
private threadHasTitle(threadId: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,20 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import type { ArchiveExportAdapter } from "../../../../app-server/services/thread-archive-markdown";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import type { ThreadTitleService } from "../../../threads/thread-title-service";
|
||||
import type { GoalActions } from "./goal-actions";
|
||||
import { createSelectionActions } from "./selection-actions";
|
||||
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import type { PluginSettingsRef, ThreadSurfaceBroadcaster, WorkspacePanels } from "../ports/chat-host";
|
||||
import type { PluginSettingsRef, ThreadCatalogFacade, WorkspacePanels } from "../ports/chat-host";
|
||||
import type { AutoTitleController } from "./auto-title-controller";
|
||||
import { ThreadRenameEditorController } from "./rename-editor-controller";
|
||||
import { createThreadManagementActions, type ThreadManagementActions, type ThreadManagementActionsHost } from "./thread-management-actions";
|
||||
import { createThreadLifecycleParts } from "./lifecycle-parts";
|
||||
|
||||
interface ThreadPartsContext {
|
||||
obsidian: {
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
};
|
||||
settingsRef: PluginSettingsRef;
|
||||
workspace: Pick<WorkspacePanels, "focusThreadInOpenView" | "openThreadInNewView">;
|
||||
threadSurfaces: ThreadSurfaceBroadcaster;
|
||||
threadCatalog: Pick<ThreadCatalogFacade, "refreshFromOpenSurface">;
|
||||
state: {
|
||||
stateStore: ChatStateStore;
|
||||
};
|
||||
|
|
@ -39,9 +37,6 @@ interface ThreadPartsContext {
|
|||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
};
|
||||
notify: {
|
||||
showNotice: (text: string) => void;
|
||||
};
|
||||
liveState: {
|
||||
refresh: () => void;
|
||||
};
|
||||
|
|
@ -51,38 +46,36 @@ interface ThreadPartsContext {
|
|||
};
|
||||
goals: GoalActions;
|
||||
autoTitle: AutoTitleController;
|
||||
operations: ThreadOperations;
|
||||
titleService: ThreadTitleService;
|
||||
}
|
||||
|
||||
export function createThreadParts(context: ThreadPartsContext) {
|
||||
const {
|
||||
obsidian,
|
||||
settingsRef,
|
||||
workspace,
|
||||
threadSurfaces,
|
||||
threadCatalog,
|
||||
state,
|
||||
thread,
|
||||
status,
|
||||
notify,
|
||||
liveState,
|
||||
scroll,
|
||||
client,
|
||||
lifecycle,
|
||||
goals,
|
||||
autoTitle,
|
||||
operations,
|
||||
titleService,
|
||||
} = context;
|
||||
const stateStore = state.stateStore;
|
||||
const currentClient = client.getClient;
|
||||
|
||||
const rename = new ThreadRenameEditorController({
|
||||
stateStore,
|
||||
vaultPath: settingsRef.vaultPath,
|
||||
settings: () => settingsRef.settings,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
threadSurfaces.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
operations,
|
||||
titleService,
|
||||
});
|
||||
const threadLifecycle = createThreadLifecycleParts({
|
||||
settingsRef,
|
||||
|
|
@ -109,28 +102,20 @@ export function createThreadParts(context: ThreadPartsContext) {
|
|||
const threadManagementHost: ThreadManagementActionsHost = {
|
||||
stateStore,
|
||||
vaultPath: settingsRef.vaultPath,
|
||||
settings: () => settingsRef.settings,
|
||||
archiveAdapter: obsidian.archiveAdapter,
|
||||
operations,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
showNotice: notify.showNotice,
|
||||
setStatus: status.set,
|
||||
setComposerText: composer.setText,
|
||||
openThreadInNewView: (threadId) => workspace.openThreadInNewView(threadId),
|
||||
openThreadInCurrentPanel: (threadId) => resume.resumeThread(threadId),
|
||||
notifyThreadArchived: (threadId) => {
|
||||
threadSurfaces.notifyThreadArchived(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
threadSurfaces.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
notifyActiveThreadIdentityChanged: () => {
|
||||
thread.notifyIdentityChanged();
|
||||
},
|
||||
refreshThreads: () => thread.refreshThreads(),
|
||||
refreshSharedThreadListFromOpenSurface: () => {
|
||||
threadSurfaces.refreshSharedThreadListFromOpenSurface();
|
||||
refreshAfterThreadMutation: async () => {
|
||||
await thread.refreshThreads();
|
||||
threadCatalog.refreshFromOpenSurface();
|
||||
},
|
||||
};
|
||||
return createThreadManagementActions(threadManagementHost);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import { readCompletedConversationSummariesPage } from "../../../../app-server/services/threads";
|
||||
import { getThreadTitle } from "../../../../domain/threads/model";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import { threadRenameFromValue } from "../../../../app-server/services/thread-rename";
|
||||
import { generateThreadTitleWithCodex } from "../../../../app-server/services/thread-title-generation";
|
||||
import {
|
||||
findThreadTitleContext,
|
||||
THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE,
|
||||
type ThreadTitleContext,
|
||||
} from "../../../../domain/threads/title-generation-model";
|
||||
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import type { ThreadTitleService } from "../../../threads/thread-title-service";
|
||||
import {
|
||||
renameGenerationStillActive,
|
||||
type ChatAction,
|
||||
|
|
@ -19,7 +13,6 @@ import {
|
|||
} from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { messageStreamItems } from "../state/message-stream";
|
||||
import { renameConnectedThread } from "./thread-management-actions";
|
||||
import { firstThreadTitleContextFromMessageStreamItems } from "./title-context";
|
||||
|
||||
export interface RenameEditState {
|
||||
|
|
@ -29,13 +22,10 @@ export interface RenameEditState {
|
|||
|
||||
export interface ThreadRenameEditorControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
settings: () => CodexPanelSettings;
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: () => AppServerClient | null;
|
||||
addSystemMessage: (text: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
|
||||
operations: Pick<ThreadOperations, "renameThread">;
|
||||
titleService: Pick<ThreadTitleService, "generateTitle">;
|
||||
}
|
||||
|
||||
export class ThreadRenameEditorController {
|
||||
|
|
@ -92,10 +82,8 @@ export class ThreadRenameEditorController {
|
|||
|
||||
await this.host.ensureConnected();
|
||||
if (this.renameState !== editingState) return;
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
if (await renameConnectedThread(this.host, threadId, rename)) {
|
||||
if (await this.host.operations.renameThread(threadId, rename.name)) {
|
||||
if (this.renameState === editingState) this.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -119,10 +107,7 @@ export class ThreadRenameEditorController {
|
|||
this.nextRenameGenerationId += 1;
|
||||
|
||||
try {
|
||||
const context = await this.resolveNamingContext(threadId);
|
||||
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
const title = await this.generateTitle(context);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
const title = await this.host.titleService.generateTitle(threadId);
|
||||
this.dispatch({ type: "ui/rename-generation-succeeded", generatingState, draft: title });
|
||||
} catch (error) {
|
||||
if (renameGenerationStillActive(this.renameState, generatingState)) {
|
||||
|
|
@ -133,30 +118,6 @@ export class ThreadRenameEditorController {
|
|||
}
|
||||
}
|
||||
|
||||
private async resolveNamingContext(threadId: string): Promise<ThreadTitleContext | null> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return null;
|
||||
const context = await findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
|
||||
});
|
||||
return (
|
||||
context ??
|
||||
(this.state.activeThread.id === threadId
|
||||
? firstThreadTitleContextFromMessageStreamItems(messageStreamItems(this.state.messageStream))
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
private async generateTitle(context: ThreadTitleContext): Promise<string | null> {
|
||||
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
|
||||
const settings = this.host.settings();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
|
||||
threadNamingModel: settings.threadNamingModel,
|
||||
threadNamingEffort: settings.threadNamingEffort,
|
||||
});
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
this.dispatch({ type: "ui/rename-cleared" });
|
||||
}
|
||||
|
|
@ -169,3 +130,7 @@ export class ThreadRenameEditorController {
|
|||
return this.state.threadList.listedThreads.find((item) => item.id === threadId);
|
||||
}
|
||||
}
|
||||
|
||||
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
|
||||
return state.activeThread.id === threadId ? firstThreadTitleContextFromMessageStreamItems(messageStreamItems(state.messageStream)) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import { rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/services/threads";
|
||||
import { inheritedForkThreadName } from "../../../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import type { ArchiveExportAdapter } from "../../../../app-server/services/thread-archive-markdown";
|
||||
import { archiveThreadOnAppServer } from "../../../../app-server/services/thread-archive";
|
||||
import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../../../app-server/services/thread-rename";
|
||||
import { threadRenameFromValue } from "../../../../app-server/services/thread-rename";
|
||||
import type { ThreadOperations } from "../../../threads/thread-operations";
|
||||
import {
|
||||
archivedSourceOpenForkFailedMessage,
|
||||
finishBeforeArchivingThreadsMessage,
|
||||
|
|
@ -29,21 +27,16 @@ import { resumedThreadActionFromActiveRuntime } from "./resume";
|
|||
export interface ThreadManagementActionsHost {
|
||||
stateStore: ChatStateStore;
|
||||
vaultPath: string;
|
||||
settings: () => CodexPanelSettings;
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
operations: Pick<ThreadOperations, "archiveThread" | "renameThread">;
|
||||
ensureConnected: () => Promise<void>;
|
||||
currentClient: () => AppServerClient | null;
|
||||
addSystemMessage: (text: string) => void;
|
||||
showNotice: (text: string) => void;
|
||||
setStatus: (status: string) => void;
|
||||
setComposerText: (text: string) => void;
|
||||
openThreadInNewView: (threadId: string) => Promise<unknown>;
|
||||
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
|
||||
notifyThreadArchived: (threadId: string) => void;
|
||||
notifyThreadRenamed: (threadId: string, name: string) => void;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
refreshThreads: () => Promise<void>;
|
||||
refreshSharedThreadListFromOpenSurface: () => void;
|
||||
refreshAfterThreadMutation: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ThreadManagementActions {
|
||||
|
|
@ -55,16 +48,6 @@ export interface ThreadManagementActions {
|
|||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type RenameThreadHost = Pick<
|
||||
ThreadManagementActionsHost,
|
||||
"ensureConnected" | "currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed"
|
||||
>;
|
||||
|
||||
type ConnectedRenameThreadHost = Pick<
|
||||
ThreadManagementActionsHost,
|
||||
"currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed"
|
||||
>;
|
||||
|
||||
export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
|
||||
return {
|
||||
compactThread: (threadId) => compactThread(host, threadId),
|
||||
|
|
@ -91,39 +74,18 @@ async function compactThread(host: ThreadManagementActionsHost, threadId: string
|
|||
}
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
host: ThreadManagementActionsHost,
|
||||
threadId: string,
|
||||
saveMarkdown = host.settings().archiveExportEnabled,
|
||||
): Promise<void> {
|
||||
if (await archiveThreadFromPanel(host, threadId, saveMarkdown)) {
|
||||
host.notifyThreadArchived(threadId);
|
||||
}
|
||||
async function archiveThread(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<void> {
|
||||
await archiveThreadFromPanel(host, threadId, saveMarkdown);
|
||||
}
|
||||
|
||||
async function archiveThreadFromPanel(
|
||||
host: ThreadManagementActionsHost,
|
||||
threadId: string,
|
||||
saveMarkdown = host.settings().archiveExportEnabled,
|
||||
): Promise<boolean> {
|
||||
async function archiveThreadFromPanel(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<boolean> {
|
||||
if (chatTurnBusy(threadManagementState(host))) {
|
||||
host.addSystemMessage(finishBeforeArchivingThreadsMessage());
|
||||
return false;
|
||||
}
|
||||
await host.ensureConnected();
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
const result = await archiveThreadOnAppServer(client, threadId, {
|
||||
settings: host.settings(),
|
||||
vaultPath: host.vaultPath,
|
||||
archiveAdapter: host.archiveAdapter,
|
||||
saveMarkdown,
|
||||
});
|
||||
if (result.exportedPath) {
|
||||
host.showNotice(`Saved archived thread to ${result.exportedPath}.`);
|
||||
}
|
||||
return true;
|
||||
const options = saveMarkdown === undefined ? {} : { saveMarkdown };
|
||||
return Boolean(await host.operations.archiveThread(threadId, options));
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
|
|
@ -167,8 +129,7 @@ async function forkThreadFromTurn(
|
|||
try {
|
||||
const rename = threadRenameFromValue(sourceName);
|
||||
if (!rename) return;
|
||||
await renameThreadOnAppServer(client, forkedThreadId, rename);
|
||||
host.notifyThreadRenamed(forkedThreadId, sourceName);
|
||||
await host.operations.renameThread(forkedThreadId, rename.name);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(forkNameCopyFailedMessage(forkedThreadId, message));
|
||||
|
|
@ -182,7 +143,6 @@ async function forkThreadFromTurn(
|
|||
const message = error instanceof Error ? error.message : String(error);
|
||||
host.addSystemMessage(archivedSourceOpenForkFailedMessage(threadId, forkedThreadId, message));
|
||||
}
|
||||
host.notifyThreadArchived(threadId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -196,26 +156,18 @@ async function forkThreadFromTurn(
|
|||
}
|
||||
}
|
||||
|
||||
async function renameThread(host: RenameThreadHost, threadId: string, value: string): Promise<boolean> {
|
||||
async function renameThread(host: ThreadManagementActionsHost, threadId: string, value: string): Promise<boolean> {
|
||||
const rename = threadRenameFromValue(value);
|
||||
if (!rename) return false;
|
||||
|
||||
await host.ensureConnected();
|
||||
return renameConnectedThread(host, threadId, rename);
|
||||
}
|
||||
|
||||
export async function renameConnectedThread(host: ConnectedRenameThreadHost, threadId: string, rename: ThreadRename): Promise<boolean> {
|
||||
const client = host.currentClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
const result = await renameThreadOnAppServer(client, threadId, rename);
|
||||
const result = await host.operations.renameThread(threadId, rename.name);
|
||||
if (!result) return false;
|
||||
const { name } = result;
|
||||
host.stateStore.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: host.stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)),
|
||||
});
|
||||
host.notifyThreadRenamed(threadId, name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
|
|
@ -261,8 +213,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
host.addSystemMessage(rollbackCompletedMessage());
|
||||
host.setStatus(STATUS_ROLLBACK_COMPLETE);
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
await host.refreshThreads();
|
||||
host.refreshSharedThreadListFromOpenSurface();
|
||||
await host.refreshAfterThreadMutation();
|
||||
} catch (error) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
host.setStatus(STATUS_ROLLBACK_FAILED);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { ThreadSurfaceBroadcaster } from "../application/ports/chat-host";
|
||||
import type { ThreadCatalogFacade } from "../application/ports/chat-host";
|
||||
import type { ChatConnectionWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle";
|
||||
import { ChatConnectionController, handleChatConnectionExit } from "../application/connection/connection-controller";
|
||||
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
|
||||
|
|
@ -42,10 +41,10 @@ export interface ChatConnectionBundleContext {
|
|||
vaultPath: string;
|
||||
connectionWork: ChatConnectionWorkTracker;
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
threadSurfaces: ThreadSurfaceBroadcaster;
|
||||
sharedCache: {
|
||||
refreshThreadList: (fetchThreads: () => Promise<readonly Thread[]>) => Promise<readonly Thread[]>;
|
||||
};
|
||||
threadCatalog: Pick<
|
||||
ThreadCatalogFacade,
|
||||
"applyThreads" | "publishAppServerMetadata" | "refreshThreads" | "notifyThreadArchived" | "notifyThreadRenamed"
|
||||
>;
|
||||
goalSync: ThreadGoalSyncActions;
|
||||
autoTitle: AutoTitleController;
|
||||
status: ChatConnectionBundleStatus;
|
||||
|
|
@ -57,15 +56,14 @@ export interface ChatConnectionBundleContext {
|
|||
}
|
||||
|
||||
export function createChatConnectionBundle(context: ChatConnectionBundleContext): ChatConnectionBundle {
|
||||
const { connection, stateStore, vaultPath, connectionWork, deferredTasks, threadSurfaces, sharedCache, goalSync, autoTitle, status } =
|
||||
context;
|
||||
const { connection, stateStore, vaultPath, connectionWork, deferredTasks, threadCatalog, goalSync, autoTitle, status } = context;
|
||||
const currentClient = () => connection.currentClient();
|
||||
const serverMetadata = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath,
|
||||
currentClient,
|
||||
publishAppServerMetadata: (metadata) => {
|
||||
threadSurfaces.publishAppServerMetadata(metadata);
|
||||
threadCatalog.publishAppServerMetadata(metadata);
|
||||
},
|
||||
});
|
||||
const serverDiagnostics = createChatServerDiagnosticsActions({
|
||||
|
|
@ -73,7 +71,7 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
|
|||
vaultPath,
|
||||
currentClient,
|
||||
publishAppServerMetadata: (metadata) => {
|
||||
threadSurfaces.publishAppServerMetadata(metadata);
|
||||
threadCatalog.publishAppServerMetadata(metadata);
|
||||
},
|
||||
serverMetadataSnapshot: () => serverMetadata.serverMetadataSnapshot(),
|
||||
});
|
||||
|
|
@ -83,14 +81,14 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
|
|||
currentClient,
|
||||
runtimeSnapshotForState: runtimeSnapshotForChatState,
|
||||
publishThreadList: (threads) => {
|
||||
threadSurfaces.applyThreadListSnapshot(threads);
|
||||
threadCatalog.applyThreads(threads);
|
||||
},
|
||||
syncThreadGoal: (threadId) => {
|
||||
void goalSync.syncThreadGoal(threadId);
|
||||
},
|
||||
});
|
||||
const loadSharedThreadList = async (): Promise<void> => {
|
||||
const threads = await sharedCache.refreshThreadList(() => serverThreads.loadThreadList());
|
||||
const threads = await threadCatalog.refreshThreads(() => serverThreads.loadThreadList());
|
||||
serverThreads.applyThreadList(threads);
|
||||
};
|
||||
const serverRequestHost = {
|
||||
|
|
@ -111,10 +109,10 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
|
|||
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
|
||||
},
|
||||
notifyThreadArchived: (threadId) => {
|
||||
threadSurfaces.notifyThreadArchived(threadId);
|
||||
threadCatalog.notifyThreadArchived(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
threadSurfaces.notifyThreadRenamed(threadId, name);
|
||||
threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
recordMcpStartupStatus: (name, mcpStatus, message) => {
|
||||
serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message);
|
||||
|
|
|
|||
526
src/features/chat/host/runtime.ts
Normal file
526
src/features/chat/host/runtime.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import { Notice, type App, type Component, type EventRef } from "obsidian";
|
||||
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown";
|
||||
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
|
||||
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
|
||||
import type { ChatConnectionPhase, ChatAction, ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ChatViewDeferredTasks, ChatConnectionWorkTracker, ChatResumeWorkTracker } from "../application/lifecycle";
|
||||
import type { ChatConnectionController } from "../application/connection/connection-controller";
|
||||
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
|
||||
import { AutoTitleController } from "../application/threads/auto-title-controller";
|
||||
import { createGoalActions, createThreadGoalSyncActions } from "../application/threads/goal-actions";
|
||||
import type { HistoryController } from "../application/threads/history-controller";
|
||||
import type { IdentitySync } from "../application/threads/identity-sync";
|
||||
import { activeThreadRenameTitleContext, type ThreadRenameEditorController } from "../application/threads/rename-editor-controller";
|
||||
import type { RestorationController } from "../application/threads/restoration-controller";
|
||||
import type { ResumeController } from "../application/threads/resume-controller";
|
||||
import { createThreadParts, createThreadSelectionActions } from "../application/threads/composition";
|
||||
import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions";
|
||||
import { createConversationParts } from "./conversation";
|
||||
import { createConversationComposer } from "./composer";
|
||||
import { createChatConnectionBundle, type ChatConnectionBundle } from "./connection-bundle";
|
||||
import type { ChatComposerController } from "../panel/composer-controller";
|
||||
import type { ChatPanelComposerSurface, ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/surface/model";
|
||||
import { chatPanelComposerProjection } from "../panel/surface/composer-projection";
|
||||
import type { ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
|
||||
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
|
||||
import { createChatPanelGoalSurface } from "../panel/surface/goal-surface";
|
||||
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
|
||||
import type { ToolbarActions } from "../ui/toolbar";
|
||||
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
|
||||
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
|
||||
import { ThreadOperations } from "../../threads/thread-operations";
|
||||
import { ThreadTitleService } from "../../threads/thread-title-service";
|
||||
import type { CodexChatHost } from "../application/ports/chat-host";
|
||||
import { messageStreamItems } from "../application/state/message-stream";
|
||||
import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context";
|
||||
|
||||
export interface ChatPanelEnvironment {
|
||||
obsidian: {
|
||||
app: App;
|
||||
owner: Component;
|
||||
viewId: string;
|
||||
registerEvent: (eventRef: EventRef) => void;
|
||||
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
requestWorkspaceLayoutSave: () => void;
|
||||
};
|
||||
plugin: CodexChatHost;
|
||||
view: {
|
||||
panelRoot: () => HTMLElement | null;
|
||||
viewWindow: () => Window | null;
|
||||
containsElement: (element: Element) => boolean;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelRuntimeParts {
|
||||
connection: {
|
||||
manager: ConnectionManager;
|
||||
controller: ChatConnectionController;
|
||||
};
|
||||
serverActions: {
|
||||
threads: ChatConnectionBundle["serverActions"]["threads"];
|
||||
metadata: ChatConnectionBundle["serverActions"]["metadata"];
|
||||
diagnostics: ChatConnectionBundle["serverActions"]["diagnostics"];
|
||||
};
|
||||
thread: {
|
||||
history: HistoryController;
|
||||
resume: ResumeController;
|
||||
restoration: RestorationController;
|
||||
identity: IdentitySync;
|
||||
rename: ThreadRenameEditorController;
|
||||
};
|
||||
toolbar: {
|
||||
panels: ToolbarPanelActions;
|
||||
actions: ToolbarActions;
|
||||
};
|
||||
composer: {
|
||||
controller: ChatComposerController;
|
||||
submission: ComposerSubmitActions;
|
||||
};
|
||||
render: {
|
||||
messageStreamPresenter: MessageStreamPresenter;
|
||||
};
|
||||
surface: {
|
||||
toolbar: ChatPanelToolbarSurface;
|
||||
goal: ChatPanelGoalSurface;
|
||||
composer: ChatPanelComposerSurface;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatPanelRuntimeStatus {
|
||||
set: (statusText: string, phase?: ChatConnectionPhase) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
|
||||
}
|
||||
|
||||
export interface ChatPanelRuntimeContext {
|
||||
environment: ChatPanelEnvironment;
|
||||
stateStore: ChatStateStore;
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
connectionWork: ChatConnectionWorkTracker;
|
||||
resumeWork: ChatResumeWorkTracker;
|
||||
messageScrollIntent: ChatMessageScrollIntentState;
|
||||
state(): ChatState;
|
||||
dispatch(action: ChatAction): void;
|
||||
systemItem(text: string): MessageStreamItem;
|
||||
structuredSystemItem(text: string, details: MessageStreamNoticeSection[]): MessageStreamItem;
|
||||
opened(): boolean;
|
||||
closing(): boolean;
|
||||
startNewThread(): Promise<void>;
|
||||
invalidateResumeWork(): void;
|
||||
refreshTabHeader(): void;
|
||||
refreshLiveState(): void;
|
||||
deferLiveStateRefresh(): void;
|
||||
notifyActiveThreadIdentityChanged(): void;
|
||||
connectionDiagnosticDetails(): MessageStreamNoticeSection[];
|
||||
modelStatusLines(): string[];
|
||||
effortStatusLines(): string[];
|
||||
statusSummaryLines(): string[];
|
||||
collaborationModeLabel(): string;
|
||||
}
|
||||
|
||||
export function createChatPanelRuntime(context: ChatPanelRuntimeContext): ChatPanelRuntimeParts {
|
||||
const { environment, stateStore } = context;
|
||||
const status = createRuntimeStatus(context);
|
||||
const connection = new ConnectionManager(
|
||||
() => environment.plugin.settingsRef.settings.codexPath,
|
||||
environment.plugin.settingsRef.vaultPath,
|
||||
);
|
||||
const currentClient = () => connection.currentClient();
|
||||
let connectionController: ChatConnectionController;
|
||||
const threadOperations = new ThreadOperations({
|
||||
connection: {
|
||||
ensureConnected: () => connectionController.ensureConnected(),
|
||||
currentClient,
|
||||
},
|
||||
settings: {
|
||||
current: () => environment.plugin.settingsRef.settings,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
},
|
||||
archiveAdapter: environment.obsidian.archiveAdapter,
|
||||
catalog: {
|
||||
notifyThreadArchived: (threadId) => {
|
||||
environment.plugin.threadCatalog.notifyThreadArchived(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
environment.plugin.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
environment.plugin.threadCatalog.refreshFromOpenSurface();
|
||||
},
|
||||
},
|
||||
notice: (text) => {
|
||||
new Notice(text);
|
||||
},
|
||||
});
|
||||
const titleService = new ThreadTitleService({
|
||||
settings: {
|
||||
current: () => environment.plugin.settingsRef.settings,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
},
|
||||
currentClient,
|
||||
visibleContext: (threadId) => activeThreadRenameTitleContext(context.state(), threadId),
|
||||
visibleCompletedTurnContext: (turnId) =>
|
||||
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(context.state().messageStream)),
|
||||
});
|
||||
const autoTitle = new AutoTitleController({
|
||||
stateStore,
|
||||
operations: threadOperations,
|
||||
titleService,
|
||||
});
|
||||
const goalSync = createThreadGoalSyncActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
addGoalEvent: (item) => {
|
||||
context.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
context.refreshLiveState();
|
||||
},
|
||||
});
|
||||
const serverParts = createChatConnectionBundle({
|
||||
connection,
|
||||
stateStore,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
connectionWork: context.connectionWork,
|
||||
deferredTasks: context.deferredTasks,
|
||||
threadCatalog: environment.plugin.threadCatalog,
|
||||
goalSync,
|
||||
autoTitle,
|
||||
status,
|
||||
invalidateResumeWork: () => {
|
||||
context.invalidateResumeWork();
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
context.refreshTabHeader();
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
context.refreshLiveState();
|
||||
},
|
||||
deferLiveStateRefresh: () => {
|
||||
context.deferLiveStateRefresh();
|
||||
},
|
||||
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
|
||||
});
|
||||
const {
|
||||
connection: { controller },
|
||||
inboundController,
|
||||
} = serverParts;
|
||||
connectionController = controller;
|
||||
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
|
||||
const ensureConnected = () => connectionController.ensureConnected();
|
||||
const refreshThreads = () => connectionController.refreshThreads();
|
||||
|
||||
const runtimeSettings = createChatRuntimeSettingsActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
runtimeSnapshotForState: runtimeSnapshotForChatState,
|
||||
collaborationModeLabel: () => context.collaborationModeLabel(),
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
});
|
||||
const goals = createGoalActions({
|
||||
stateStore,
|
||||
currentClient,
|
||||
ensureConnected,
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
addGoalEvent: (item) => {
|
||||
context.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
context.refreshLiveState();
|
||||
},
|
||||
});
|
||||
const threadParts = createThreadParts({
|
||||
settingsRef: environment.plugin.settingsRef,
|
||||
workspace: environment.plugin.workspace,
|
||||
threadCatalog: environment.plugin.threadCatalog,
|
||||
state: {
|
||||
stateStore,
|
||||
},
|
||||
lifecycle: {
|
||||
deferredTasks: context.deferredTasks,
|
||||
resumeWork: context.resumeWork,
|
||||
getOpened: () => context.opened(),
|
||||
getClosing: () => context.closing(),
|
||||
},
|
||||
client: {
|
||||
getClient: currentClient,
|
||||
ensureConnected,
|
||||
},
|
||||
status,
|
||||
thread: {
|
||||
refreshThreads,
|
||||
notifyIdentityChanged: () => {
|
||||
context.notifyActiveThreadIdentityChanged();
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
context.refreshTabHeader();
|
||||
},
|
||||
},
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
context.refreshLiveState();
|
||||
},
|
||||
},
|
||||
scroll: {
|
||||
preservePosition: () => {
|
||||
context.messageScrollIntent.preservePosition();
|
||||
},
|
||||
forceBottom: () => {
|
||||
context.messageScrollIntent.forceBottom();
|
||||
},
|
||||
},
|
||||
goals,
|
||||
autoTitle,
|
||||
operations: threadOperations,
|
||||
titleService,
|
||||
});
|
||||
const { history, identity, restoration, resume, rename } = threadParts;
|
||||
const composerSurface: ChatPanelComposerSurface = {
|
||||
thread: {
|
||||
restoredPlaceholder: () => restoration.placeholder(),
|
||||
},
|
||||
runtime: {
|
||||
requestModel: (model) => runtimeSettings.requestModelFromUi(model),
|
||||
requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort),
|
||||
},
|
||||
};
|
||||
const composer = createConversationComposer(
|
||||
{
|
||||
app: environment.obsidian.app,
|
||||
settingsRef: environment.plugin.settingsRef,
|
||||
stateStore,
|
||||
viewId: environment.obsidian.viewId,
|
||||
surface: {
|
||||
composerProjection: (state) => chatPanelComposerProjection(composerSurface, state),
|
||||
},
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
context.refreshLiveState();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
runtimeSettings,
|
||||
},
|
||||
);
|
||||
const threadActions = threadParts.createManagementActions({
|
||||
setText: (text) => {
|
||||
composer.controller.setDraft(text, { focus: true });
|
||||
},
|
||||
});
|
||||
const toolbarPanels = createToolbarPanelActions({
|
||||
stateStore,
|
||||
threadActions,
|
||||
});
|
||||
const selection = createThreadSelectionActions(
|
||||
{
|
||||
workspace: environment.plugin.workspace,
|
||||
state: {
|
||||
stateStore,
|
||||
},
|
||||
thread: {
|
||||
resumeThread: (threadId) => resume.resumeThread(threadId),
|
||||
},
|
||||
status,
|
||||
},
|
||||
{
|
||||
closeForThreadSelection: () => {
|
||||
toolbarPanels.closeForThreadSelection();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const reconnectHost: ChatReconnectActionsHost = {
|
||||
stateStore,
|
||||
invalidateConnectionWork: () => {
|
||||
context.connectionWork.invalidate();
|
||||
},
|
||||
invalidateResumeWork: () => {
|
||||
context.invalidateResumeWork();
|
||||
},
|
||||
clearDeferredDiagnostics: () => {
|
||||
context.deferredTasks.clearDiagnostics();
|
||||
},
|
||||
reconnect: () => {
|
||||
connection.reconnect();
|
||||
},
|
||||
setStatus: (statusText, phase) => {
|
||||
status.set(statusText, phase);
|
||||
},
|
||||
ensureConnected,
|
||||
resumeThread: (threadId) => resume.resumeThread(threadId),
|
||||
addSystemMessage: (text) => {
|
||||
status.addSystemMessage(text);
|
||||
},
|
||||
};
|
||||
const reconnect = () => reconnectPanel(reconnectHost);
|
||||
|
||||
const toolbarActions = createChatPanelToolbarActions(
|
||||
{
|
||||
stateStore,
|
||||
startNewThread: () => context.startNewThread(),
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
reconnectPanel: reconnect,
|
||||
inboundController,
|
||||
threadActions,
|
||||
toolbarPanels,
|
||||
rename,
|
||||
selection,
|
||||
},
|
||||
);
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
state: {
|
||||
connected: () => connection.isConnected(),
|
||||
nowMs: () => Date.now(),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => environment.plugin.settingsRef.vaultPath,
|
||||
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
|
||||
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
|
||||
},
|
||||
};
|
||||
const goalSurface = createChatPanelGoalSurface(
|
||||
{
|
||||
settings: environment.plugin.settingsRef.settings,
|
||||
stateStore,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
inboundController,
|
||||
threadStarter: serverThreads,
|
||||
goals,
|
||||
},
|
||||
);
|
||||
const conversationParts = createConversationParts(
|
||||
{
|
||||
obsidian: {
|
||||
app: environment.obsidian.app,
|
||||
owner: environment.obsidian.owner,
|
||||
viewId: environment.obsidian.viewId,
|
||||
},
|
||||
settingsRef: environment.plugin.settingsRef,
|
||||
workspace: environment.plugin.workspace,
|
||||
state: {
|
||||
stateStore,
|
||||
},
|
||||
composer,
|
||||
lifecycle: {
|
||||
messageScrollIntent: context.messageScrollIntent,
|
||||
},
|
||||
surface: {
|
||||
pendingRequestsSignature: () =>
|
||||
pendingRequestsSignature(
|
||||
context.state().requests.approvals,
|
||||
context.state().requests.pendingUserInputs,
|
||||
context.state().requests.userInputDrafts,
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
connectionDiagnosticDetails: () => context.connectionDiagnosticDetails(),
|
||||
modelStatusLines: () => context.modelStatusLines(),
|
||||
effortStatusLines: () => context.effortStatusLines(),
|
||||
statusSummaryLines: () => context.statusSummaryLines(),
|
||||
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
|
||||
},
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
context.refreshLiveState();
|
||||
},
|
||||
},
|
||||
client: {
|
||||
getClient: currentClient,
|
||||
ensureConnected,
|
||||
},
|
||||
status,
|
||||
thread: {
|
||||
ensureRestoredThreadLoaded: () => restoration.ensureLoaded((threadId) => resume.resumeThread(threadId)),
|
||||
startNewThread: () => context.startNewThread(),
|
||||
selectThread: (threadId) => selection.selectThread(threadId),
|
||||
notifyIdentityChanged: () => {
|
||||
context.notifyActiveThreadIdentityChanged();
|
||||
},
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
},
|
||||
scroll: {
|
||||
forceBottom: () => {
|
||||
context.messageScrollIntent.forceBottom();
|
||||
},
|
||||
followBottom: () => {
|
||||
context.messageScrollIntent.followBottom();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
controller: inboundController,
|
||||
threadStarter: serverThreads,
|
||||
runtimeSettings,
|
||||
threadActions,
|
||||
reconnectPanel: reconnect,
|
||||
goals,
|
||||
history,
|
||||
},
|
||||
);
|
||||
const { composerSubmit, messageStreamPresenter } = conversationParts;
|
||||
const composerController = composer.controller;
|
||||
|
||||
return {
|
||||
connection: {
|
||||
manager: connection,
|
||||
controller: connectionController,
|
||||
},
|
||||
serverActions: serverParts.serverActions,
|
||||
thread: {
|
||||
history,
|
||||
resume,
|
||||
restoration,
|
||||
identity,
|
||||
rename,
|
||||
},
|
||||
toolbar: {
|
||||
panels: toolbarPanels,
|
||||
actions: toolbarActions,
|
||||
},
|
||||
composer: {
|
||||
controller: composerController,
|
||||
submission: composerSubmit,
|
||||
},
|
||||
render: {
|
||||
messageStreamPresenter,
|
||||
},
|
||||
surface: {
|
||||
toolbar: toolbarSurface,
|
||||
goal: goalSurface,
|
||||
composer: composerSurface,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeStatus(context: ChatPanelRuntimeContext): ChatPanelRuntimeStatus {
|
||||
return {
|
||||
set: (statusText, phase) => {
|
||||
context.dispatch({ type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
|
||||
},
|
||||
addSystemMessage: (text) => {
|
||||
context.dispatch({ type: "message-stream/system-item-added", item: context.systemItem(text) });
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
context.dispatch({ type: "message-stream/system-item-added", item: context.structuredSystemItem(text, details) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,20 +1,10 @@
|
|||
import { Notice, type App, type Component, type EventRef } from "obsidian";
|
||||
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import { getThreadTitle } from "../../../domain/threads/model";
|
||||
import type { SharedServerMetadata } from "../../../domain/server/metadata";
|
||||
import { shortThreadId } from "../../../utils";
|
||||
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
|
||||
import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown";
|
||||
import type { CodexChatHost } from "../application/ports/chat-host";
|
||||
import type { ChatConnectionController } from "../application/connection/connection-controller";
|
||||
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
|
||||
import type { ChatComposerController } from "../panel/composer-controller";
|
||||
import { createConversationParts } from "./conversation";
|
||||
import { createConversationComposer } from "./composer";
|
||||
import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions";
|
||||
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
|
||||
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
|
||||
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
|
||||
|
|
@ -25,93 +15,16 @@ import {
|
|||
} from "../presentation/runtime/status";
|
||||
import { createChatViewDeferredTasks } from "./lifecycle";
|
||||
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle";
|
||||
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
|
||||
import { connectionDiagnosticsModel } from "../panel/surface/toolbar-projection";
|
||||
import { openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot";
|
||||
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
|
||||
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
|
||||
import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "../application/runtime/snapshot";
|
||||
import { chatPanelComposerProjection } from "../panel/surface/composer-projection";
|
||||
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell";
|
||||
import { chatTurnBusy, type ChatAction, type ChatConnectionPhase, type ChatState } from "../application/state/root-reducer";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState } from "../application/state/root-reducer";
|
||||
import { createChatStateStore, type ChatStateStore } from "../application/state/store";
|
||||
import type { HistoryController } from "../application/threads/history-controller";
|
||||
import type { IdentitySync } from "../application/threads/identity-sync";
|
||||
import type { ThreadRenameEditorController } from "../application/threads/rename-editor-controller";
|
||||
import type { RestorationController } from "../application/threads/restoration-controller";
|
||||
import type { ResumeController } from "../application/threads/resume-controller";
|
||||
import { createThreadParts, createThreadSelectionActions } from "../application/threads/composition";
|
||||
import { createChatConnectionBundle, type ChatConnectionBundle } from "./connection-bundle";
|
||||
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
|
||||
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
|
||||
import { createChatPanelGoalSurface } from "../panel/surface/goal-surface";
|
||||
import type { ChatPanelComposerSurface, ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/surface/model";
|
||||
import type { ToolbarActions } from "../ui/toolbar";
|
||||
import { AutoTitleController } from "../application/threads/auto-title-controller";
|
||||
import { createGoalActions, createThreadGoalSyncActions } from "../application/threads/goal-actions";
|
||||
|
||||
export interface ChatPanelEnvironment {
|
||||
obsidian: {
|
||||
app: App;
|
||||
owner: Component;
|
||||
viewId: string;
|
||||
registerEvent: (eventRef: EventRef) => void;
|
||||
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
|
||||
archiveAdapter: () => ArchiveExportAdapter;
|
||||
requestWorkspaceLayoutSave: () => void;
|
||||
};
|
||||
plugin: CodexChatHost;
|
||||
view: {
|
||||
panelRoot: () => HTMLElement | null;
|
||||
viewWindow: () => Window | null;
|
||||
containsElement: (element: Element) => boolean;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatPanelSessionParts {
|
||||
connection: {
|
||||
manager: ConnectionManager;
|
||||
controller: ChatConnectionController;
|
||||
};
|
||||
serverActions: {
|
||||
threads: ChatConnectionBundle["serverActions"]["threads"];
|
||||
metadata: ChatConnectionBundle["serverActions"]["metadata"];
|
||||
diagnostics: ChatConnectionBundle["serverActions"]["diagnostics"];
|
||||
};
|
||||
thread: {
|
||||
history: HistoryController;
|
||||
resume: ResumeController;
|
||||
restoration: RestorationController;
|
||||
identity: IdentitySync;
|
||||
rename: ThreadRenameEditorController;
|
||||
};
|
||||
toolbar: {
|
||||
panels: ToolbarPanelActions;
|
||||
actions: ToolbarActions;
|
||||
};
|
||||
composer: {
|
||||
controller: ChatComposerController;
|
||||
submission: ComposerSubmitActions;
|
||||
};
|
||||
render: {
|
||||
messageStreamPresenter: MessageStreamPresenter;
|
||||
};
|
||||
surface: {
|
||||
toolbar: ChatPanelToolbarSurface;
|
||||
goal: ChatPanelGoalSurface;
|
||||
composer: ChatPanelComposerSurface;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatSessionSideEffects {
|
||||
status: {
|
||||
set: (statusText: string, phase?: ChatConnectionPhase) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
|
||||
};
|
||||
}
|
||||
import type { ChatSurfaceHandle } from "./surface-handle";
|
||||
import { createChatPanelRuntime, type ChatPanelEnvironment, type ChatPanelRuntimeParts } from "./runtime";
|
||||
|
||||
interface ChatPanelWarmupHost {
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
|
|
@ -140,9 +53,9 @@ function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly
|
|||
return title ? `Codex: ${title}` : "Codex";
|
||||
}
|
||||
|
||||
export class ChatPanelSession {
|
||||
export class ChatPanelSession implements ChatSurfaceHandle {
|
||||
private readonly stateStore: ChatStateStore = createChatStateStore();
|
||||
private readonly parts: ChatPanelSessionParts;
|
||||
private readonly parts: ChatPanelRuntimeParts;
|
||||
|
||||
private readonly deferredTasks: ChatViewDeferredTasks;
|
||||
private readonly connectionWork = new ChatConnectionWorkTracker();
|
||||
|
|
@ -154,7 +67,43 @@ export class ChatPanelSession {
|
|||
|
||||
constructor(private readonly environment: ChatPanelEnvironment) {
|
||||
this.deferredTasks = createChatViewDeferredTasks(() => this.viewWindow());
|
||||
this.parts = this.createSessionParts();
|
||||
this.parts = createChatPanelRuntime({
|
||||
environment,
|
||||
stateStore: this.stateStore,
|
||||
deferredTasks: this.deferredTasks,
|
||||
connectionWork: this.connectionWork,
|
||||
resumeWork: this.resumeWork,
|
||||
messageScrollIntent: this.messageScrollIntent,
|
||||
state: () => this.state,
|
||||
dispatch: (action) => {
|
||||
this.dispatch(action);
|
||||
},
|
||||
systemItem: (text) => this.systemItem(text),
|
||||
structuredSystemItem: (text, details) => this.structuredSystemItem(text, details),
|
||||
opened: () => this.opened,
|
||||
closing: () => this.closing,
|
||||
startNewThread: () => this.startNewThread(),
|
||||
invalidateResumeWork: () => {
|
||||
this.invalidateResumeWork();
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
this.refreshTabHeader();
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
deferLiveStateRefresh: () => {
|
||||
this.deferLiveStateRefresh();
|
||||
},
|
||||
notifyActiveThreadIdentityChanged: () => {
|
||||
this.notifyActiveThreadIdentityChanged();
|
||||
},
|
||||
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
|
||||
modelStatusLines: () => this.modelStatusLines(),
|
||||
effortStatusLines: () => this.effortStatusLines(),
|
||||
statusSummaryLines: () => this.statusSummaryLines(),
|
||||
collaborationModeLabel: () => this.collaborationModeLabel(),
|
||||
});
|
||||
}
|
||||
|
||||
private get state(): ChatState {
|
||||
|
|
@ -300,377 +249,10 @@ export class ChatPanelSession {
|
|||
this.focusComposer();
|
||||
}
|
||||
|
||||
private createSessionParts(): ChatPanelSessionParts {
|
||||
const sideEffects = this.createSideEffects();
|
||||
const connection = new ConnectionManager(
|
||||
() => this.environment.plugin.settingsRef.settings.codexPath,
|
||||
this.environment.plugin.settingsRef.vaultPath,
|
||||
);
|
||||
const currentClient = () => connection.currentClient();
|
||||
const autoTitle = new AutoTitleController({
|
||||
stateStore: this.stateStore,
|
||||
vaultPath: this.environment.plugin.settingsRef.vaultPath,
|
||||
settings: () => this.environment.plugin.settingsRef.settings,
|
||||
currentClient,
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.environment.plugin.threadSurfaces.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
});
|
||||
const goalSync = createThreadGoalSyncActions({
|
||||
stateStore: this.stateStore,
|
||||
currentClient,
|
||||
addSystemMessage: sideEffects.status.addSystemMessage,
|
||||
addGoalEvent: (item) => {
|
||||
this.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
});
|
||||
const serverParts = createChatConnectionBundle({
|
||||
connection,
|
||||
stateStore: this.stateStore,
|
||||
vaultPath: this.environment.plugin.settingsRef.vaultPath,
|
||||
connectionWork: this.connectionWork,
|
||||
deferredTasks: this.deferredTasks,
|
||||
threadSurfaces: this.environment.plugin.threadSurfaces,
|
||||
sharedCache: this.environment.plugin.sharedCache,
|
||||
goalSync,
|
||||
autoTitle,
|
||||
status: sideEffects.status,
|
||||
invalidateResumeWork: () => {
|
||||
this.invalidateResumeWork();
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
this.refreshTabHeader();
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
deferLiveStateRefresh: () => {
|
||||
this.deferLiveStateRefresh();
|
||||
},
|
||||
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
|
||||
});
|
||||
const {
|
||||
connection: { controller: connectionController },
|
||||
inboundController,
|
||||
} = serverParts;
|
||||
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
|
||||
const ensureConnected = () => connectionController.ensureConnected();
|
||||
const refreshThreads = () => connectionController.refreshThreads();
|
||||
|
||||
const runtimeSettings = createChatRuntimeSettingsActions({
|
||||
stateStore: this.stateStore,
|
||||
currentClient,
|
||||
runtimeSnapshotForState: runtimeSnapshotForChatState,
|
||||
collaborationModeLabel: () => this.collaborationModeLabel(),
|
||||
addSystemMessage: sideEffects.status.addSystemMessage,
|
||||
});
|
||||
const goals = createGoalActions({
|
||||
stateStore: this.stateStore,
|
||||
currentClient,
|
||||
ensureConnected,
|
||||
addSystemMessage: sideEffects.status.addSystemMessage,
|
||||
addGoalEvent: (item) => {
|
||||
this.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
});
|
||||
const threadParts = createThreadParts({
|
||||
obsidian: {
|
||||
archiveAdapter: this.environment.obsidian.archiveAdapter,
|
||||
},
|
||||
settingsRef: this.environment.plugin.settingsRef,
|
||||
workspace: this.environment.plugin.workspace,
|
||||
threadSurfaces: this.environment.plugin.threadSurfaces,
|
||||
state: {
|
||||
stateStore: this.stateStore,
|
||||
},
|
||||
lifecycle: {
|
||||
deferredTasks: this.deferredTasks,
|
||||
resumeWork: this.resumeWork,
|
||||
getOpened: () => this.opened,
|
||||
getClosing: () => this.closing,
|
||||
},
|
||||
client: {
|
||||
getClient: currentClient,
|
||||
ensureConnected,
|
||||
},
|
||||
status: sideEffects.status,
|
||||
notify: {
|
||||
showNotice: (text) => {
|
||||
new Notice(text);
|
||||
},
|
||||
},
|
||||
thread: {
|
||||
refreshThreads,
|
||||
notifyIdentityChanged: () => {
|
||||
this.notifyActiveThreadIdentityChanged();
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
this.refreshTabHeader();
|
||||
},
|
||||
},
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
},
|
||||
scroll: {
|
||||
preservePosition: () => {
|
||||
this.messageScrollIntent.preservePosition();
|
||||
},
|
||||
forceBottom: () => {
|
||||
this.messageScrollIntent.forceBottom();
|
||||
},
|
||||
},
|
||||
goals,
|
||||
autoTitle,
|
||||
});
|
||||
const { history, identity, restoration, resume, rename } = threadParts;
|
||||
const composerSurface: ChatPanelComposerSurface = {
|
||||
thread: {
|
||||
restoredPlaceholder: () => restoration.placeholder(),
|
||||
},
|
||||
runtime: {
|
||||
requestModel: (model) => runtimeSettings.requestModelFromUi(model),
|
||||
requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort),
|
||||
},
|
||||
};
|
||||
const composer = createConversationComposer(
|
||||
{
|
||||
app: this.environment.obsidian.app,
|
||||
settingsRef: this.environment.plugin.settingsRef,
|
||||
stateStore: this.stateStore,
|
||||
viewId: this.environment.obsidian.viewId,
|
||||
surface: {
|
||||
composerProjection: (state) => chatPanelComposerProjection(composerSurface, state),
|
||||
},
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
runtimeSettings,
|
||||
},
|
||||
);
|
||||
const threadActions = threadParts.createManagementActions({
|
||||
setText: (text) => {
|
||||
composer.controller.setDraft(text, { focus: true });
|
||||
},
|
||||
});
|
||||
const toolbarPanels = createToolbarPanelActions({
|
||||
stateStore: this.stateStore,
|
||||
threadActions,
|
||||
});
|
||||
const selection = createThreadSelectionActions(
|
||||
{
|
||||
workspace: this.environment.plugin.workspace,
|
||||
state: {
|
||||
stateStore: this.stateStore,
|
||||
},
|
||||
thread: {
|
||||
resumeThread: (threadId) => resume.resumeThread(threadId),
|
||||
},
|
||||
status: sideEffects.status,
|
||||
},
|
||||
{
|
||||
closeForThreadSelection: () => {
|
||||
toolbarPanels.closeForThreadSelection();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const reconnectHost: ChatReconnectActionsHost = {
|
||||
stateStore: this.stateStore,
|
||||
invalidateConnectionWork: () => {
|
||||
this.connectionWork.invalidate();
|
||||
},
|
||||
invalidateResumeWork: () => {
|
||||
this.invalidateResumeWork();
|
||||
},
|
||||
clearDeferredDiagnostics: () => {
|
||||
this.deferredTasks.clearDiagnostics();
|
||||
},
|
||||
reconnect: () => {
|
||||
connection.reconnect();
|
||||
},
|
||||
setStatus: sideEffects.status.set,
|
||||
ensureConnected,
|
||||
resumeThread: (threadId) => resume.resumeThread(threadId),
|
||||
addSystemMessage: sideEffects.status.addSystemMessage,
|
||||
};
|
||||
const reconnect = () => reconnectPanel(reconnectHost);
|
||||
|
||||
const toolbarActions = createChatPanelToolbarActions(
|
||||
{
|
||||
stateStore: this.stateStore,
|
||||
startNewThread: () => this.startNewThread(),
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
reconnectPanel: reconnect,
|
||||
inboundController,
|
||||
threadActions,
|
||||
toolbarPanels,
|
||||
rename,
|
||||
selection,
|
||||
},
|
||||
);
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
state: {
|
||||
connected: () => connection.isConnected(),
|
||||
nowMs: () => Date.now(),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => this.environment.plugin.settingsRef.vaultPath,
|
||||
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
|
||||
archiveExportEnabled: () => this.environment.plugin.settingsRef.settings.archiveExportEnabled,
|
||||
},
|
||||
};
|
||||
const goalSurface = createChatPanelGoalSurface(
|
||||
{
|
||||
settings: this.environment.plugin.settingsRef.settings,
|
||||
stateStore: this.stateStore,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
inboundController,
|
||||
threadStarter: serverThreads,
|
||||
goals,
|
||||
},
|
||||
);
|
||||
const conversationParts = createConversationParts(
|
||||
{
|
||||
obsidian: {
|
||||
app: this.environment.obsidian.app,
|
||||
owner: this.environment.obsidian.owner,
|
||||
viewId: this.environment.obsidian.viewId,
|
||||
},
|
||||
settingsRef: this.environment.plugin.settingsRef,
|
||||
workspace: this.environment.plugin.workspace,
|
||||
state: {
|
||||
stateStore: this.stateStore,
|
||||
},
|
||||
composer,
|
||||
lifecycle: {
|
||||
messageScrollIntent: this.messageScrollIntent,
|
||||
},
|
||||
surface: {
|
||||
pendingRequestsSignature: () =>
|
||||
pendingRequestsSignature(
|
||||
this.state.requests.approvals,
|
||||
this.state.requests.pendingUserInputs,
|
||||
this.state.requests.userInputDrafts,
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
|
||||
modelStatusLines: () => this.modelStatusLines(),
|
||||
effortStatusLines: () => this.effortStatusLines(),
|
||||
statusSummaryLines: () => this.statusSummaryLines(),
|
||||
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
|
||||
},
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
},
|
||||
client: {
|
||||
getClient: currentClient,
|
||||
ensureConnected,
|
||||
},
|
||||
status: sideEffects.status,
|
||||
thread: {
|
||||
ensureRestoredThreadLoaded: () => restoration.ensureLoaded((threadId) => resume.resumeThread(threadId)),
|
||||
startNewThread: () => this.startNewThread(),
|
||||
selectThread: (threadId) => selection.selectThread(threadId),
|
||||
notifyIdentityChanged: () => {
|
||||
this.notifyActiveThreadIdentityChanged();
|
||||
},
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
},
|
||||
scroll: {
|
||||
forceBottom: () => {
|
||||
this.messageScrollIntent.forceBottom();
|
||||
},
|
||||
followBottom: () => {
|
||||
this.messageScrollIntent.followBottom();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
controller: inboundController,
|
||||
threadStarter: serverThreads,
|
||||
runtimeSettings,
|
||||
threadActions,
|
||||
reconnectPanel: reconnect,
|
||||
goals,
|
||||
history,
|
||||
},
|
||||
);
|
||||
const { composerSubmit, messageStreamPresenter } = conversationParts;
|
||||
const composerController = composer.controller;
|
||||
|
||||
return {
|
||||
connection: {
|
||||
manager: connection,
|
||||
controller: connectionController,
|
||||
},
|
||||
serverActions: serverParts.serverActions,
|
||||
thread: {
|
||||
history,
|
||||
resume,
|
||||
restoration,
|
||||
identity,
|
||||
rename,
|
||||
},
|
||||
toolbar: {
|
||||
panels: toolbarPanels,
|
||||
actions: toolbarActions,
|
||||
},
|
||||
composer: {
|
||||
controller: composerController,
|
||||
submission: composerSubmit,
|
||||
},
|
||||
render: {
|
||||
messageStreamPresenter,
|
||||
},
|
||||
surface: {
|
||||
toolbar: toolbarSurface,
|
||||
goal: goalSurface,
|
||||
composer: composerSurface,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createSideEffects(): ChatSessionSideEffects {
|
||||
return {
|
||||
status: {
|
||||
set: (statusText, phase) => {
|
||||
this.dispatch({ type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
|
||||
},
|
||||
addSystemMessage: (text) => {
|
||||
this.dispatch({ type: "message-stream/system-item-added", item: this.systemItem(text) });
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
this.dispatch({ type: "message-stream/system-item-added", item: this.structuredSystemItem(text, details) });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private applyCachedAppServerState(): void {
|
||||
const threads = this.environment.plugin.sharedCache.cachedThreadList();
|
||||
const threads = this.environment.plugin.threadCatalog.cachedThreads();
|
||||
if (threads) this.parts.serverActions.threads.applyThreadList(threads);
|
||||
const metadata = this.environment.plugin.sharedCache.cachedAppServerMetadata();
|
||||
const metadata = this.environment.plugin.threadCatalog.cachedAppServerMetadata();
|
||||
if (metadata) this.parts.serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
}
|
||||
|
||||
|
|
@ -713,7 +295,7 @@ export class ChatPanelSession {
|
|||
}
|
||||
|
||||
private async loadSharedThreadList(): Promise<void> {
|
||||
const threads = await this.environment.plugin.sharedCache.refreshThreadList(() => this.parts.serverActions.threads.loadThreadList());
|
||||
const threads = await this.environment.plugin.threadCatalog.refreshThreads(() => this.parts.serverActions.threads.loadThreadList());
|
||||
this.parts.serverActions.threads.applyThreadList(threads);
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +309,7 @@ export class ChatPanelSession {
|
|||
}
|
||||
|
||||
private refreshLiveState(): void {
|
||||
this.environment.plugin.threadSurfaces.refreshThreadsViewLiveState();
|
||||
this.environment.plugin.threadCatalog.refreshThreadsViewLiveState();
|
||||
}
|
||||
|
||||
private deferLiveStateRefresh(): void {
|
||||
|
|
|
|||
26
src/features/chat/host/surface-handle.ts
Normal file
26
src/features/chat/host/surface-handle.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { SharedServerMetadata } from "../../../domain/server/metadata";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
|
||||
|
||||
export interface ChatSurfaceHandle {
|
||||
displayTitle(): string;
|
||||
persistedState(): Record<string, unknown>;
|
||||
applyViewState(state: unknown): void;
|
||||
open(): void;
|
||||
close(): void;
|
||||
refreshSettings(): void;
|
||||
refreshSharedThreadList(): Promise<void>;
|
||||
applyThreadListSnapshot(threads: readonly Thread[]): void;
|
||||
applyAppServerMetadataSnapshot(metadata: SharedServerMetadata): void;
|
||||
applyAvailableModelsSnapshot(models: readonly ModelMetadata[]): void;
|
||||
openPanelSnapshot(): OpenCodexPanelSnapshot;
|
||||
openThread(threadId: string): Promise<void>;
|
||||
focusThread(threadId?: string | null): Promise<void>;
|
||||
focusComposer(): void;
|
||||
notifyThreadArchived(threadId: string): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
setComposerText(text: string): void;
|
||||
connect(): Promise<void>;
|
||||
startNewThread(): Promise<void>;
|
||||
}
|
||||
|
|
@ -1,20 +1,17 @@
|
|||
import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
|
||||
import type { SharedServerMetadata } from "../../../domain/server/metadata";
|
||||
import type { CodexChatHost } from "../application/ports/chat-host";
|
||||
import { ChatPanelSession } from "./session";
|
||||
import type { ChatSurfaceHandle } from "./surface-handle";
|
||||
|
||||
export class CodexChatView extends ItemView {
|
||||
private readonly session: ChatPanelSession;
|
||||
readonly surface: ChatSurfaceHandle;
|
||||
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: CodexChatHost) {
|
||||
super(leaf);
|
||||
this.session = new ChatPanelSession({
|
||||
this.surface = new ChatPanelSession({
|
||||
obsidian: {
|
||||
app: this.app,
|
||||
owner: this,
|
||||
|
|
@ -47,7 +44,7 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
override getDisplayText(): string {
|
||||
return this.session.displayTitle();
|
||||
return this.surface.displayTitle();
|
||||
}
|
||||
|
||||
override getIcon(): string {
|
||||
|
|
@ -55,76 +52,20 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
override getState(): Record<string, unknown> {
|
||||
return this.session.persistedState();
|
||||
return this.surface.persistedState();
|
||||
}
|
||||
|
||||
override async setState(state: unknown, result: ViewStateResult): Promise<void> {
|
||||
await super.setState(state, result);
|
||||
this.session.applyViewState(state);
|
||||
this.surface.applyViewState(state);
|
||||
}
|
||||
|
||||
override async onOpen(): Promise<void> {
|
||||
this.session.open();
|
||||
this.surface.open();
|
||||
}
|
||||
|
||||
override async onClose(): Promise<void> {
|
||||
this.session.close();
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
this.session.refreshSettings();
|
||||
}
|
||||
|
||||
refreshSharedThreadList(): Promise<void> {
|
||||
return this.session.refreshSharedThreadList();
|
||||
}
|
||||
|
||||
applyThreadListSnapshot(threads: readonly Thread[]): void {
|
||||
this.session.applyThreadListSnapshot(threads);
|
||||
}
|
||||
|
||||
applyAppServerMetadataSnapshot(metadata: SharedServerMetadata): void {
|
||||
this.session.applyAppServerMetadataSnapshot(metadata);
|
||||
}
|
||||
|
||||
applyAvailableModelsSnapshot(models: readonly ModelMetadata[]): void {
|
||||
this.session.applyAvailableModelsSnapshot(models);
|
||||
}
|
||||
|
||||
openPanelSnapshot(): OpenCodexPanelSnapshot {
|
||||
return this.session.openPanelSnapshot();
|
||||
}
|
||||
|
||||
openThread(threadId: string): Promise<void> {
|
||||
return this.session.openThread(threadId);
|
||||
}
|
||||
|
||||
focusThread(threadId: string | null = null): Promise<void> {
|
||||
return this.session.focusThread(threadId);
|
||||
}
|
||||
|
||||
focusComposer(): void {
|
||||
this.session.focusComposer();
|
||||
}
|
||||
|
||||
notifyThreadArchived(threadId: string): void {
|
||||
this.session.notifyThreadArchived(threadId);
|
||||
}
|
||||
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void {
|
||||
this.session.notifyThreadRenamed(threadId, name);
|
||||
}
|
||||
|
||||
setComposerText(text: string): void {
|
||||
this.session.setComposerText(text);
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return this.session.connect();
|
||||
}
|
||||
|
||||
startNewThread(): Promise<void> {
|
||||
return this.session.startNewThread();
|
||||
this.surface.close();
|
||||
}
|
||||
|
||||
private refreshTabHeader(): void {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ export interface ThreadPickerHost {
|
|||
readonly app: App;
|
||||
readonly settings: CodexPanelSettings;
|
||||
readonly vaultPath: string;
|
||||
cachedThreadList(): readonly Thread[] | null;
|
||||
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
readonly threadCatalog: {
|
||||
cachedThreads(): readonly Thread[] | null;
|
||||
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
};
|
||||
openThreadInCurrentView(threadId: string): Promise<void>;
|
||||
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -76,14 +78,14 @@ function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMod
|
|||
}
|
||||
|
||||
async function loadThreadPickerThreads(host: ThreadPickerHost): Promise<readonly Thread[]> {
|
||||
const cached = host.cachedThreadList();
|
||||
const cached = host.threadCatalog.cachedThreads();
|
||||
if (cached) return cached;
|
||||
|
||||
return withShortLivedAppServerClient(
|
||||
host.settings.codexPath,
|
||||
host.vaultPath,
|
||||
async (client) =>
|
||||
host.refreshThreadList(async () => {
|
||||
host.threadCatalog.refreshThreads(async () => {
|
||||
return listThreads(client, host.vaultPath);
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,15 +2,14 @@ import { Notice } from "obsidian";
|
|||
|
||||
import type { AppServerClient } from "../../app-server/connection/client";
|
||||
import { ConnectionManager, type ConnectionManagerHandlers, StaleConnectionError } from "../../app-server/connection/connection-manager";
|
||||
import { listThreads, readCompletedConversationSummariesPage } from "../../app-server/services/threads";
|
||||
import { listThreads } from "../../app-server/services/threads";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
|
||||
import { archiveThreadOnAppServer } from "../../app-server/services/thread-archive";
|
||||
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
|
||||
import { renameThreadOnAppServer, threadRenameFromValue } from "../../app-server/services/thread-rename";
|
||||
import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation";
|
||||
import { findThreadTitleContext, THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE } from "../../domain/threads/title-generation-model";
|
||||
import { threadRenameFromValue } from "../../app-server/services/thread-rename";
|
||||
import { ThreadOperations } from "../threads/thread-operations";
|
||||
import { ThreadTitleService } from "../threads/thread-title-service";
|
||||
import { renderThreadsView, unmountThreadsView } from "./renderer";
|
||||
import {
|
||||
completedThreadAutoNameState,
|
||||
|
|
@ -36,13 +35,16 @@ import {
|
|||
export interface CodexThreadsHost {
|
||||
readonly settings: CodexPanelSettings;
|
||||
readonly vaultPath: string;
|
||||
readonly threadCatalog: {
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
refreshFromOpenSurface(): void;
|
||||
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
cachedThreads(): readonly Thread[] | null;
|
||||
};
|
||||
openNewPanel(): Promise<unknown>;
|
||||
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[];
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
|
||||
cachedThreadList(): readonly Thread[] | null;
|
||||
}
|
||||
|
||||
export interface CodexThreadsSessionEnvironment {
|
||||
|
|
@ -62,6 +64,8 @@ type ThreadsViewStatus =
|
|||
|
||||
export class CodexThreadsSession {
|
||||
private readonly connection: ConnectionManager;
|
||||
private readonly operations: ThreadOperations;
|
||||
private readonly titleService: ThreadTitleService;
|
||||
private readonly deferredTasks: ThreadsViewDeferredTasks;
|
||||
private client: AppServerClient | null = null;
|
||||
private connectionLifecycle: ThreadsViewConnectionLifecycleState = { kind: "idle" };
|
||||
|
|
@ -74,6 +78,38 @@ export class CodexThreadsSession {
|
|||
constructor(private readonly environment: CodexThreadsSessionEnvironment) {
|
||||
this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow());
|
||||
this.connection = new ConnectionManager(() => this.host.settings.codexPath, this.host.vaultPath);
|
||||
this.operations = new ThreadOperations({
|
||||
connection: {
|
||||
ensureConnected: () => this.ensureConnected(),
|
||||
currentClient: () => this.client,
|
||||
},
|
||||
settings: {
|
||||
current: () => this.host.settings,
|
||||
vaultPath: this.host.vaultPath,
|
||||
},
|
||||
archiveAdapter: () => this.environment.archiveAdapter(),
|
||||
catalog: {
|
||||
notifyThreadArchived: (threadId, options) => {
|
||||
this.host.threadCatalog.notifyThreadArchived(threadId, options);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.host.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
this.host.threadCatalog.refreshFromOpenSurface();
|
||||
},
|
||||
},
|
||||
notice: (message) => {
|
||||
new Notice(message);
|
||||
},
|
||||
});
|
||||
this.titleService = new ThreadTitleService({
|
||||
settings: {
|
||||
current: () => this.host.settings,
|
||||
vaultPath: this.host.vaultPath,
|
||||
},
|
||||
currentClient: () => this.client,
|
||||
});
|
||||
}
|
||||
|
||||
private connectionHandlers(): ConnectionManagerHandlers {
|
||||
|
|
@ -102,7 +138,7 @@ export class CodexThreadsSession {
|
|||
this.environment.registerPointerDown((event) => {
|
||||
this.cancelArchiveConfirmOnOutsidePointer(event);
|
||||
});
|
||||
const cachedThreads = this.host.cachedThreadList();
|
||||
const cachedThreads = this.host.threadCatalog.cachedThreads();
|
||||
if (cachedThreads) {
|
||||
this.threads = cachedThreads;
|
||||
}
|
||||
|
|
@ -126,7 +162,7 @@ export class CodexThreadsSession {
|
|||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.isStaleRefresh(refresh) || !this.client) return;
|
||||
const threads = await this.host.refreshThreadList(async () => {
|
||||
const threads = await this.host.threadCatalog.refreshThreads(async () => {
|
||||
if (!this.client) return [];
|
||||
return listThreads(this.client, this.host.vaultPath);
|
||||
});
|
||||
|
|
@ -301,10 +337,9 @@ export class CodexThreadsSession {
|
|||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== editingState) return;
|
||||
if (!this.client) return;
|
||||
const result = await renameThreadOnAppServer(this.client, threadId, rename);
|
||||
const result = await this.operations.renameThread(threadId, rename.name);
|
||||
if (!result) return;
|
||||
this.renameStates.delete(threadId);
|
||||
this.host.notifyThreadRenamed(threadId, result.name);
|
||||
} catch (error) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
|
|
@ -320,18 +355,7 @@ export class CodexThreadsSession {
|
|||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
if (!this.client) return;
|
||||
const client = this.client;
|
||||
const context = await findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
|
||||
});
|
||||
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
const title = await generateThreadTitleWithCodex(this.host.settings.codexPath, this.host.vaultPath, context, {
|
||||
threadNamingModel: this.host.settings.threadNamingModel,
|
||||
threadNamingEffort: this.host.settings.threadNamingEffort,
|
||||
});
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
const title = await this.titleService.generateTitle(threadId);
|
||||
const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title);
|
||||
if (renamedState) this.renameStates.set(threadId, renamedState);
|
||||
} catch (error) {
|
||||
|
|
@ -363,19 +387,13 @@ export class CodexThreadsSession {
|
|||
private async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (!this.client) return;
|
||||
const result = await archiveThreadOnAppServer(this.client, threadId, {
|
||||
settings: this.host.settings,
|
||||
vaultPath: this.host.vaultPath,
|
||||
archiveAdapter: () => this.environment.archiveAdapter(),
|
||||
const result = await this.operations.archiveThread(threadId, {
|
||||
saveMarkdown,
|
||||
closeOpenPanels: true,
|
||||
});
|
||||
if (result.exportedPath) {
|
||||
new Notice(`Saved archived thread to ${result.exportedPath}.`);
|
||||
}
|
||||
if (!result) return;
|
||||
if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null;
|
||||
this.renameStates.delete(threadId);
|
||||
this.host.notifyThreadArchived(threadId, { closeOpenPanels: true });
|
||||
} catch (error) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
|
|
|
|||
83
src/features/threads/thread-operations.ts
Normal file
83
src/features/threads/thread-operations.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { AppServerClient } from "../../app-server/connection/client";
|
||||
import { archiveThreadOnAppServer, type ArchiveThreadResult } from "../../app-server/services/thread-archive";
|
||||
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
|
||||
import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../app-server/services/thread-rename";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
|
||||
export interface ThreadOperationsHost {
|
||||
connection: {
|
||||
ensureConnected(): Promise<void>;
|
||||
currentClient(): AppServerClient | null;
|
||||
};
|
||||
settings: {
|
||||
current(): CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
};
|
||||
archiveAdapter(): ArchiveExportAdapter;
|
||||
catalog: {
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void;
|
||||
refreshFromOpenSurface(): void;
|
||||
};
|
||||
notice(message: string): void;
|
||||
}
|
||||
|
||||
export interface ArchiveThreadOptions {
|
||||
saveMarkdown?: boolean;
|
||||
closeOpenPanels?: boolean;
|
||||
}
|
||||
|
||||
export interface RenameThreadResult {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RenameThreadOptions {
|
||||
shouldPublish?: () => boolean;
|
||||
}
|
||||
|
||||
export class ThreadOperations {
|
||||
constructor(private readonly host: ThreadOperationsHost) {}
|
||||
|
||||
async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise<RenameThreadResult | null> {
|
||||
const rename = threadRenameFromValue(value);
|
||||
if (!rename) return null;
|
||||
|
||||
await this.host.connection.ensureConnected();
|
||||
return this.renameConnectedThread(threadId, rename, options);
|
||||
}
|
||||
|
||||
private async renameConnectedThread(
|
||||
threadId: string,
|
||||
rename: ThreadRename,
|
||||
options: RenameThreadOptions = {},
|
||||
): Promise<RenameThreadResult | null> {
|
||||
const client = this.host.connection.currentClient();
|
||||
if (!client) return null;
|
||||
|
||||
const result = await renameThreadOnAppServer(client, threadId, rename);
|
||||
if (options.shouldPublish?.() ?? true) {
|
||||
this.host.catalog.notifyThreadRenamed(threadId, result.name);
|
||||
}
|
||||
return { name: result.name };
|
||||
}
|
||||
|
||||
async archiveThread(threadId: string, options: ArchiveThreadOptions = {}): Promise<ArchiveThreadResult | null> {
|
||||
await this.host.connection.ensureConnected();
|
||||
const client = this.host.connection.currentClient();
|
||||
if (!client) return null;
|
||||
|
||||
const settings = this.host.settings.current();
|
||||
const result = await archiveThreadOnAppServer(client, threadId, {
|
||||
settings,
|
||||
vaultPath: this.host.settings.vaultPath,
|
||||
archiveAdapter: () => this.host.archiveAdapter(),
|
||||
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled,
|
||||
});
|
||||
if (result.exportedPath) {
|
||||
this.host.notice(`Saved archived thread to ${result.exportedPath}.`);
|
||||
}
|
||||
const notificationOptions = options.closeOpenPanels === undefined ? undefined : { closeOpenPanels: options.closeOpenPanels };
|
||||
this.host.catalog.notifyThreadArchived(threadId, notificationOptions);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
62
src/features/threads/thread-title-service.ts
Normal file
62
src/features/threads/thread-title-service.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { AppServerClient } from "../../app-server/connection/client";
|
||||
import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation";
|
||||
import { readCompletedConversationSummariesPage } from "../../app-server/services/threads";
|
||||
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
|
||||
import {
|
||||
findThreadTitleContext,
|
||||
THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE,
|
||||
threadTitleContextFromConversationSummary,
|
||||
type ThreadTitleContext,
|
||||
} from "../../domain/threads/title-generation-model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
|
||||
export interface ThreadTitleServiceHost {
|
||||
settings: {
|
||||
current(): CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
};
|
||||
currentClient(): AppServerClient | null;
|
||||
visibleContext?(threadId: string): ThreadTitleContext | null;
|
||||
visibleCompletedTurnContext?(turnId: string): ThreadTitleContext | null;
|
||||
generateThreadTitle?(context: ThreadTitleContext): Promise<string | null>;
|
||||
}
|
||||
|
||||
export class ThreadTitleService {
|
||||
constructor(private readonly host: ThreadTitleServiceHost) {}
|
||||
|
||||
async generateTitle(threadId: string): Promise<string> {
|
||||
const context = await this.resolveContext(threadId);
|
||||
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
|
||||
const title = await this.generate(context);
|
||||
if (!title) throw new Error("Codex did not return a usable thread title.");
|
||||
return title;
|
||||
}
|
||||
|
||||
async resolveContext(threadId: string): Promise<ThreadTitleContext | null> {
|
||||
const client = this.host.currentClient();
|
||||
const persistedContext = client
|
||||
? await findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
|
||||
})
|
||||
: null;
|
||||
return persistedContext ?? this.host.visibleContext?.(threadId) ?? null;
|
||||
}
|
||||
|
||||
completedTurnContext(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null {
|
||||
return (
|
||||
this.host.visibleCompletedTurnContext?.(turnId) ??
|
||||
(completedSummary ? threadTitleContextFromConversationSummary(completedSummary) : null)
|
||||
);
|
||||
}
|
||||
|
||||
async generate(context: ThreadTitleContext): Promise<string | null> {
|
||||
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
|
||||
const settings = this.host.settings.current();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, this.host.settings.vaultPath, context, {
|
||||
threadNamingModel: settings.threadNamingModel,
|
||||
threadNamingEffort: settings.threadNamingEffort,
|
||||
});
|
||||
}
|
||||
}
|
||||
194
src/main.ts
194
src/main.ts
|
|
@ -3,116 +3,80 @@ import { Plugin } from "obsidian";
|
|||
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
|
||||
import { registerSelectionRewriteCommand } from "./features/selection-rewrite/command";
|
||||
import { CodexChatView } from "./features/chat/host/view";
|
||||
import type { CodexChatHost } from "./features/chat/application/ports/chat-host";
|
||||
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view";
|
||||
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal";
|
||||
import { CodexThreadsView, type CodexThreadsHost } from "./features/threads-view/view";
|
||||
import { SharedAppServerCache } from "./app-server/services/shared-cache";
|
||||
import type { SharedAppServerCacheContext } from "./app-server/services/shared-cache-state";
|
||||
import { CodexThreadsView } from "./features/threads-view/view";
|
||||
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData, type CodexPanelSettings } from "./settings/model";
|
||||
import { CodexPanelSettingTab, type CodexPanelSettingTabHost } from "./settings/tab";
|
||||
import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
|
||||
import { createThreadSurfaceActions } from "./workspace/thread-surface-actions";
|
||||
import type { SharedServerMetadata } from "./domain/server/metadata";
|
||||
import type { Thread } from "./domain/threads/model";
|
||||
import { CodexPanelSettingTab } from "./settings/tab";
|
||||
import { CodexPanelRuntime } from "./plugin-runtime";
|
||||
|
||||
export default class CodexPanelPlugin extends Plugin {
|
||||
settings: CodexPanelSettings = DEFAULT_SETTINGS;
|
||||
vaultPath = "";
|
||||
private readonly sharedAppServerCache = new SharedAppServerCache();
|
||||
private readonly panels = new WorkspacePanelCoordinator({
|
||||
readonly runtime = new CodexPanelRuntime({
|
||||
app: this.app,
|
||||
refreshThreadsViewLiveState: () => {
|
||||
this.threadSurfaces.refreshThreadsViewLiveState();
|
||||
},
|
||||
});
|
||||
private readonly threadSurfaces = createThreadSurfaceActions({
|
||||
app: this.app,
|
||||
panels: this.panels,
|
||||
settingsRef: this,
|
||||
saveSettings: () => this.saveSettings(),
|
||||
});
|
||||
|
||||
override async onload(): Promise<void> {
|
||||
this.panels.reset();
|
||||
this.runtime.reset();
|
||||
this.vaultPath = getVaultPath(this.app);
|
||||
await this.loadSettings();
|
||||
|
||||
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this.chatHost()));
|
||||
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this.runtime.chatHost()));
|
||||
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexChatTurnDiffView(leaf));
|
||||
this.registerView(VIEW_TYPE_CODEX_THREADS, (leaf) => new CodexThreadsView(leaf, this.threadsHost()));
|
||||
this.registerView(VIEW_TYPE_CODEX_THREADS, (leaf) => new CodexThreadsView(leaf, this.runtime.threadsHost()));
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
this.panels.recordLastFocusedPanel(leaf);
|
||||
this.runtime.recordLastFocusedPanel(leaf);
|
||||
}),
|
||||
);
|
||||
|
||||
this.addRibbonIcon("bot-message-square", "Open panel", () => {
|
||||
void this.panels.activateView();
|
||||
void this.runtime.activatePanel();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-panel",
|
||||
name: "Open panel",
|
||||
callback: () => void this.panels.activateView(),
|
||||
callback: () => void this.runtime.activatePanel(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-new-panel",
|
||||
name: "Open new panel",
|
||||
callback: () => void this.panels.activateNewView(),
|
||||
callback: () => void this.runtime.activateNewPanel(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-threads-view",
|
||||
name: "Open threads view",
|
||||
callback: () => void this.activateThreadsView(),
|
||||
callback: () => void this.runtime.activateThreadsView(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-thread",
|
||||
name: "Open thread...",
|
||||
callback: () => void openThreadPicker(this.threadPickerHost()),
|
||||
callback: () => {
|
||||
this.runtime.openThreadPicker();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "new-chat",
|
||||
name: "Start new chat",
|
||||
callback: async () => {
|
||||
const view = await this.panels.activateView();
|
||||
await view.startNewThread();
|
||||
},
|
||||
callback: () => void this.runtime.startNewChat(),
|
||||
});
|
||||
|
||||
registerSelectionRewriteCommand(this);
|
||||
|
||||
this.addSettingTab(new CodexPanelSettingTab(this.app, this, this.settingTabHost()));
|
||||
this.addSettingTab(new CodexPanelSettingTab(this.app, this, this.runtime.settingTabHost()));
|
||||
|
||||
this.panels.scheduleBootRestoredPanelLoads();
|
||||
this.runtime.scheduleBootRestoredPanelLoads();
|
||||
}
|
||||
|
||||
override onunload(): void {
|
||||
this.panels.cancelBootRestoredPanelLoads();
|
||||
}
|
||||
|
||||
private async activateThreadsView(): Promise<CodexThreadsView> {
|
||||
const leaf = await this.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_THREADS, "left", {
|
||||
active: true,
|
||||
reveal: true,
|
||||
});
|
||||
const view = leaf.view as CodexThreadsView;
|
||||
await view.refresh();
|
||||
return view;
|
||||
}
|
||||
|
||||
private async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
|
||||
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
|
||||
const leaf = existing ?? this.app.workspace.getLeaf("tab");
|
||||
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedChatTurnDiffViewState(state) } });
|
||||
await leaf.loadIfDeferred();
|
||||
if (leaf.view instanceof CodexChatTurnDiffView) {
|
||||
leaf.view.setDiffPayload(state);
|
||||
}
|
||||
await this.app.workspace.revealLeaf(leaf);
|
||||
this.runtime.cancelBootRestoredPanelLoads();
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
|
|
@ -126,120 +90,4 @@ export default class CodexPanelPlugin extends Plugin {
|
|||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
private sharedAppServerCacheContext(): SharedAppServerCacheContext {
|
||||
return {
|
||||
codexPath: this.settings.codexPath,
|
||||
vaultPath: this.vaultPath,
|
||||
};
|
||||
}
|
||||
|
||||
private applyThreadListSnapshot(threads: readonly Thread[]): void {
|
||||
this.sharedAppServerCache.applyThreadListSnapshot(this.sharedAppServerCacheContext(), threads);
|
||||
this.threadSurfaces.applyThreadListSnapshot(threads);
|
||||
}
|
||||
|
||||
private refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]> {
|
||||
return this.sharedAppServerCache.refreshThreadList(this.sharedAppServerCacheContext(), fetchThreads, (threads) => {
|
||||
this.threadSurfaces.applyThreadListSnapshot(threads);
|
||||
});
|
||||
}
|
||||
|
||||
private cachedThreadList(): readonly Thread[] | null {
|
||||
return this.sharedAppServerCache.cachedThreadList(this.sharedAppServerCacheContext());
|
||||
}
|
||||
|
||||
private publishAppServerMetadata(metadata: SharedServerMetadata): void {
|
||||
this.sharedAppServerCache.applyAppServerMetadataSnapshot(this.sharedAppServerCacheContext(), metadata);
|
||||
this.threadSurfaces.publishAppServerMetadata(metadata);
|
||||
}
|
||||
|
||||
private publishModels(models: Parameters<CodexPanelSettingTabHost["publishModels"]>[0]): void {
|
||||
this.sharedAppServerCache.applyModelsSnapshot(this.sharedAppServerCacheContext(), models);
|
||||
this.threadSurfaces.publishModels(models);
|
||||
}
|
||||
|
||||
private chatHost(): CodexChatHost {
|
||||
return {
|
||||
settingsRef: this,
|
||||
workspace: {
|
||||
openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId),
|
||||
focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId),
|
||||
openTurnDiff: (state) => this.openTurnDiff(state),
|
||||
},
|
||||
threadSurfaces: {
|
||||
notifyThreadArchived: (threadId) => {
|
||||
this.threadSurfaces.notifyThreadArchived(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.threadSurfaces.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshThreadsViewLiveState: () => {
|
||||
this.threadSurfaces.refreshThreadsViewLiveState();
|
||||
},
|
||||
refreshSharedThreadListFromOpenSurface: () => {
|
||||
this.threadSurfaces.refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
applyThreadListSnapshot: (threads) => {
|
||||
this.applyThreadListSnapshot(threads);
|
||||
},
|
||||
publishAppServerMetadata: (metadata) => {
|
||||
this.publishAppServerMetadata(metadata);
|
||||
},
|
||||
},
|
||||
sharedCache: {
|
||||
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
|
||||
cachedThreadList: () => this.cachedThreadList(),
|
||||
cachedAppServerMetadata: () => this.sharedAppServerCache.cachedAppServerMetadata(this.sharedAppServerCacheContext()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private threadsHost(): CodexThreadsHost {
|
||||
return {
|
||||
settings: this.settings,
|
||||
vaultPath: this.vaultPath,
|
||||
openNewPanel: () => this.panels.openNewPanel(),
|
||||
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
|
||||
getOpenPanelSnapshots: () => this.panels.getOpenPanelSnapshots(),
|
||||
notifyThreadArchived: (threadId, options) => {
|
||||
this.threadSurfaces.notifyThreadArchived(threadId, options);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.threadSurfaces.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
|
||||
cachedThreadList: () => this.cachedThreadList(),
|
||||
};
|
||||
}
|
||||
|
||||
private threadPickerHost(): ThreadPickerHost {
|
||||
return {
|
||||
app: this.app,
|
||||
settings: this.settings,
|
||||
vaultPath: this.vaultPath,
|
||||
cachedThreadList: () => this.cachedThreadList(),
|
||||
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
|
||||
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
|
||||
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
|
||||
};
|
||||
}
|
||||
|
||||
private settingTabHost(): CodexPanelSettingTabHost {
|
||||
return {
|
||||
settings: this.settings,
|
||||
vaultPath: this.vaultPath,
|
||||
saveSettings: () => this.saveSettings(),
|
||||
refreshOpenViews: () => {
|
||||
this.threadSurfaces.refreshOpenViews();
|
||||
},
|
||||
refreshSharedThreadListFromOpenSurface: () => {
|
||||
this.threadSurfaces.refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
cachedModels: () => this.sharedAppServerCache.cachedModels(this.sharedAppServerCacheContext()),
|
||||
publishModels: (models) => {
|
||||
this.publishModels(models);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
src/plugin-runtime.ts
Normal file
201
src/plugin-runtime.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type { App } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
|
||||
import { SharedAppServerCache } from "./app-server/services/shared-cache";
|
||||
import type { SharedAppServerCacheContext } from "./app-server/services/shared-cache-state";
|
||||
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/application/ports/chat-host";
|
||||
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view";
|
||||
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal";
|
||||
import type { CodexThreadsHost, CodexThreadsView } from "./features/threads-view/view";
|
||||
import type { CodexPanelSettingTabHost } from "./settings/tab";
|
||||
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
|
||||
import { createThreadSurfaceActions, type ThreadSurfaceActions } from "./workspace/thread-surface-actions";
|
||||
import { SharedThreadCatalog } from "./workspace/shared-thread-catalog";
|
||||
|
||||
export interface CodexPanelRuntimeOptions {
|
||||
app: App;
|
||||
settingsRef: PluginSettingsRef;
|
||||
saveSettings(): Promise<void>;
|
||||
}
|
||||
|
||||
export class CodexPanelRuntime {
|
||||
readonly sharedAppServerCache = new SharedAppServerCache();
|
||||
readonly panels: WorkspacePanelCoordinator;
|
||||
readonly threadSurfaces: ThreadSurfaceActions;
|
||||
readonly threadCatalog: SharedThreadCatalog;
|
||||
|
||||
constructor(private readonly options: CodexPanelRuntimeOptions) {
|
||||
this.panels = new WorkspacePanelCoordinator({
|
||||
app: options.app,
|
||||
refreshThreadsViewLiveState: () => {
|
||||
this.threadCatalog.refreshThreadsViewLiveState();
|
||||
},
|
||||
});
|
||||
this.threadSurfaces = createThreadSurfaceActions({
|
||||
app: options.app,
|
||||
panels: this.panels,
|
||||
});
|
||||
this.threadCatalog = new SharedThreadCatalog({
|
||||
cache: this.sharedAppServerCache,
|
||||
surfaces: this.threadSurfaces,
|
||||
context: () => this.sharedAppServerCacheContext(),
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.panels.reset();
|
||||
}
|
||||
|
||||
recordLastFocusedPanel(leaf: Parameters<WorkspacePanelCoordinator["recordLastFocusedPanel"]>[0]): void {
|
||||
this.panels.recordLastFocusedPanel(leaf);
|
||||
}
|
||||
|
||||
activatePanel(): Promise<unknown> {
|
||||
return this.panels.activateView();
|
||||
}
|
||||
|
||||
activateNewPanel(): Promise<unknown> {
|
||||
return this.panels.activateNewView();
|
||||
}
|
||||
|
||||
async startNewChat(): Promise<void> {
|
||||
const view = await this.panels.activateView();
|
||||
await view.surface.startNewThread();
|
||||
}
|
||||
|
||||
openThreadPicker(): void {
|
||||
void openThreadPicker(this.threadPickerHost());
|
||||
}
|
||||
|
||||
async activateThreadsView(): Promise<CodexThreadsView> {
|
||||
const leaf = await this.options.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_THREADS, "left", {
|
||||
active: true,
|
||||
reveal: true,
|
||||
});
|
||||
const view = leaf.view as CodexThreadsView;
|
||||
await view.refresh();
|
||||
return view;
|
||||
}
|
||||
|
||||
scheduleBootRestoredPanelLoads(): void {
|
||||
this.panels.scheduleBootRestoredPanelLoads();
|
||||
}
|
||||
|
||||
cancelBootRestoredPanelLoads(): void {
|
||||
this.panels.cancelBootRestoredPanelLoads();
|
||||
}
|
||||
|
||||
chatHost(): CodexChatHost {
|
||||
return {
|
||||
settingsRef: this.options.settingsRef,
|
||||
workspace: {
|
||||
openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId),
|
||||
focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId),
|
||||
openTurnDiff: (state) => this.openTurnDiff(state),
|
||||
},
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: (threadId) => {
|
||||
this.threadCatalog.notifyThreadArchived(threadId);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshThreadsViewLiveState: () => {
|
||||
this.threadCatalog.refreshThreadsViewLiveState();
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
this.threadCatalog.refreshFromOpenSurface();
|
||||
},
|
||||
applyThreads: (threads) => {
|
||||
this.threadCatalog.applyThreads(threads);
|
||||
},
|
||||
publishAppServerMetadata: (metadata) => {
|
||||
this.threadCatalog.publishAppServerMetadata(metadata);
|
||||
},
|
||||
refreshThreads: (fetchThreads) => this.threadCatalog.refreshThreads(fetchThreads),
|
||||
cachedThreads: () => this.threadCatalog.cachedThreads(),
|
||||
cachedAppServerMetadata: () => this.threadCatalog.cachedAppServerMetadata(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
threadsHost(): CodexThreadsHost {
|
||||
return {
|
||||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: (threadId, options) => {
|
||||
this.threadCatalog.notifyThreadArchived(threadId, options);
|
||||
},
|
||||
notifyThreadRenamed: (threadId, name) => {
|
||||
this.threadCatalog.notifyThreadRenamed(threadId, name);
|
||||
},
|
||||
refreshFromOpenSurface: () => {
|
||||
this.threadCatalog.refreshFromOpenSurface();
|
||||
},
|
||||
refreshThreads: (fetchThreads) => this.threadCatalog.refreshThreads(fetchThreads),
|
||||
cachedThreads: () => this.threadCatalog.cachedThreads(),
|
||||
},
|
||||
openNewPanel: () => this.panels.openNewPanel(),
|
||||
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
|
||||
getOpenPanelSnapshots: () => this.panels.getOpenPanelSnapshots(),
|
||||
};
|
||||
}
|
||||
|
||||
settingTabHost(): CodexPanelSettingTabHost {
|
||||
return {
|
||||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
saveSettings: () => this.options.saveSettings(),
|
||||
refreshOpenViews: () => {
|
||||
this.threadSurfaces.refreshOpenViews();
|
||||
},
|
||||
threadCatalog: {
|
||||
refreshFromOpenSurface: () => {
|
||||
this.threadCatalog.refreshFromOpenSurface();
|
||||
},
|
||||
cachedModels: () => {
|
||||
const models = this.threadCatalog.cachedModels();
|
||||
return models ? [...models] : null;
|
||||
},
|
||||
publishModels: (models) => {
|
||||
this.threadCatalog.publishModels(models);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private threadPickerHost(): ThreadPickerHost {
|
||||
return {
|
||||
app: this.options.app,
|
||||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
threadCatalog: {
|
||||
cachedThreads: () => this.threadCatalog.cachedThreads(),
|
||||
refreshThreads: (fetchThreads) => this.threadCatalog.refreshThreads(fetchThreads),
|
||||
},
|
||||
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
|
||||
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
|
||||
};
|
||||
}
|
||||
|
||||
private async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
|
||||
const existing = this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
|
||||
const leaf = existing ?? this.options.app.workspace.getLeaf("tab");
|
||||
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedChatTurnDiffViewState(state) } });
|
||||
await leaf.loadIfDeferred();
|
||||
if (leaf.view instanceof CodexChatTurnDiffView) {
|
||||
leaf.view.setDiffPayload(state);
|
||||
}
|
||||
await this.options.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
private sharedAppServerCacheContext(): SharedAppServerCacheContext {
|
||||
return {
|
||||
codexPath: this.options.settingsRef.settings.codexPath,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -20,9 +20,11 @@ import type { CodexPanelSettings } from "./model";
|
|||
export interface SettingsDynamicDataHost {
|
||||
settings: CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
refreshSharedThreadListFromOpenSurface(): void;
|
||||
cachedModels(): ModelMetadata[] | null;
|
||||
publishModels(models: ModelMetadata[]): void;
|
||||
threadCatalog: {
|
||||
refreshFromOpenSurface(): void;
|
||||
cachedModels(): ModelMetadata[] | null;
|
||||
publishModels(models: ModelMetadata[]): void;
|
||||
};
|
||||
}
|
||||
|
||||
interface SettingsDynamicDataControllerCallbacks {
|
||||
|
|
@ -59,7 +61,7 @@ export class SettingsDynamicDataController {
|
|||
private readonly host: SettingsDynamicDataHost,
|
||||
private readonly callbacks: SettingsDynamicDataControllerCallbacks,
|
||||
) {
|
||||
this.models = host.cachedModels() ?? [];
|
||||
this.models = host.threadCatalog.cachedModels() ?? [];
|
||||
}
|
||||
|
||||
maybeAutoLoadSettingsData(): void {
|
||||
|
|
@ -72,7 +74,7 @@ export class SettingsDynamicDataController {
|
|||
this.settingsDataAutoLoadStarted = false;
|
||||
this.settingsDynamicOperationId += 1;
|
||||
this.settingsDataRefreshLifecycle = { kind: "idle" };
|
||||
this.models = this.host.cachedModels() ?? [];
|
||||
this.models = this.host.threadCatalog.cachedModels() ?? [];
|
||||
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
|
||||
this.hooks = [];
|
||||
this.hookWarnings = [];
|
||||
|
|
@ -113,7 +115,7 @@ export class SettingsDynamicDataController {
|
|||
|
||||
if (result.models.ok) {
|
||||
this.models = result.models.data;
|
||||
this.host.publishModels(result.models.data);
|
||||
this.host.threadCatalog.publishModels(result.models.data);
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "loaded",
|
||||
status: result.models.status,
|
||||
|
|
@ -288,7 +290,7 @@ export class SettingsDynamicDataController {
|
|||
status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`,
|
||||
operationId,
|
||||
});
|
||||
this.host.refreshSharedThreadListFromOpenSurface();
|
||||
this.host.threadCatalog.refreshFromOpenSurface();
|
||||
} catch (error) {
|
||||
if (this.isStaleSettingsDynamicOperation(operationId)) return;
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ export class WorkspacePanelCoordinator {
|
|||
reveal: true,
|
||||
});
|
||||
const view = leaf.view as CodexChatView;
|
||||
await view.connect();
|
||||
view.focusComposer();
|
||||
await view.surface.connect();
|
||||
view.surface.focusComposer();
|
||||
return view;
|
||||
}
|
||||
|
||||
|
|
@ -74,14 +74,14 @@ export class WorkspacePanelCoordinator {
|
|||
await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true });
|
||||
await this.options.app.workspace.revealLeaf(leaf);
|
||||
const view = leaf.view as CodexChatView;
|
||||
if (options.connect !== false) await view.connect();
|
||||
view.focusComposer();
|
||||
if (options.connect !== false) await view.surface.connect();
|
||||
view.surface.focusComposer();
|
||||
return view;
|
||||
}
|
||||
|
||||
async openThreadInNewView(threadId: string): Promise<CodexChatView> {
|
||||
const view = await this.activateThreadResumeView();
|
||||
await view.openThread(threadId);
|
||||
await view.surface.openThread(threadId);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
|
@ -120,15 +120,15 @@ export class WorkspacePanelCoordinator {
|
|||
const leaves = this.panelLeaves();
|
||||
this.ensureInitialFocusedPanel(leaves);
|
||||
return leaves.flatMap((leaf) =>
|
||||
leaf.view instanceof CodexChatView ? [this.openPanelSnapshotWithFocus(leaf.view.openPanelSnapshot())] : [],
|
||||
leaf.view instanceof CodexChatView ? [this.openPanelSnapshotWithFocus(leaf.view.surface.openPanelSnapshot())] : [],
|
||||
);
|
||||
}
|
||||
|
||||
async focusOpenPanel(viewId: string, threadId: string | null = null): Promise<boolean> {
|
||||
for (const leaf of this.panelLeaves()) {
|
||||
if (leaf.view instanceof CodexChatView && leaf.view.openPanelSnapshot().viewId === viewId) {
|
||||
if (leaf.view instanceof CodexChatView && leaf.view.surface.openPanelSnapshot().viewId === viewId) {
|
||||
await this.options.app.workspace.revealLeaf(leaf);
|
||||
await leaf.view.focusThread(threadId);
|
||||
await leaf.view.surface.focusThread(threadId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ export class WorkspacePanelCoordinator {
|
|||
|
||||
panelLeavesForThread(threadId: string): WorkspaceLeaf[] {
|
||||
return this.panelLeaves().filter((leaf) => {
|
||||
if (leaf.view instanceof CodexChatView) return leaf.view.openPanelSnapshot().threadId === threadId;
|
||||
if (leaf.view instanceof CodexChatView) return leaf.view.surface.openPanelSnapshot().threadId === threadId;
|
||||
return restoredThreadId(leaf) === threadId;
|
||||
});
|
||||
}
|
||||
|
|
@ -202,7 +202,7 @@ export class WorkspacePanelCoordinator {
|
|||
private findOpenThreadPanelTarget(threadId: string): ThreadPanelTarget | null {
|
||||
for (const leaf of this.panelLeaves()) {
|
||||
if (!(leaf.view instanceof CodexChatView)) continue;
|
||||
if (leaf.view.openPanelSnapshot().threadId !== threadId) continue;
|
||||
if (leaf.view.surface.openPanelSnapshot().threadId !== threadId) continue;
|
||||
return { kind: "open", leaf, view: leaf.view };
|
||||
}
|
||||
return null;
|
||||
|
|
@ -220,7 +220,7 @@ export class WorkspacePanelCoordinator {
|
|||
private findIdleEmptyThreadPanelTarget(): ThreadPanelTarget | null {
|
||||
for (const leaf of this.panelLeaves()) {
|
||||
if (!(leaf.view instanceof CodexChatView)) continue;
|
||||
if (!isIdleEmptyPanelSnapshot(leaf.view.openPanelSnapshot())) continue;
|
||||
if (!isIdleEmptyPanelSnapshot(leaf.view.surface.openPanelSnapshot())) continue;
|
||||
return { kind: "empty", leaf, view: leaf.view };
|
||||
}
|
||||
return null;
|
||||
|
|
@ -263,15 +263,15 @@ export class WorkspacePanelCoordinator {
|
|||
|
||||
await this.options.app.workspace.revealLeaf(target.leaf);
|
||||
if ("view" in target) {
|
||||
await target.view.connect();
|
||||
await target.view.focusThread();
|
||||
await target.view.surface.connect();
|
||||
await target.view.surface.focusThread();
|
||||
return target.view;
|
||||
}
|
||||
|
||||
await target.leaf.loadIfDeferred();
|
||||
if (target.leaf.view instanceof CodexChatView) {
|
||||
await target.leaf.view.connect();
|
||||
await target.leaf.view.focusThread();
|
||||
await target.leaf.view.surface.connect();
|
||||
await target.leaf.view.surface.focusThread();
|
||||
return target.leaf.view;
|
||||
}
|
||||
|
||||
|
|
@ -282,26 +282,26 @@ export class WorkspacePanelCoordinator {
|
|||
switch (target.kind) {
|
||||
case "open":
|
||||
await this.options.app.workspace.revealLeaf(target.leaf);
|
||||
await target.view.focusThread(threadId);
|
||||
await target.view.surface.focusThread(threadId);
|
||||
return;
|
||||
case "restored":
|
||||
await this.options.app.workspace.revealLeaf(target.leaf);
|
||||
await target.leaf.loadIfDeferred();
|
||||
if (target.leaf.view instanceof CodexChatView) {
|
||||
await target.leaf.view.focusThread(threadId);
|
||||
await target.leaf.view.surface.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 target.leaf.view.openThread(threadId);
|
||||
await target.leaf.view.surface.openThread(threadId);
|
||||
}
|
||||
return;
|
||||
case "empty":
|
||||
case "reuse":
|
||||
await this.options.app.workspace.revealLeaf(target.leaf);
|
||||
await target.view.openThread(threadId);
|
||||
await target.view.surface.openThread(threadId);
|
||||
return;
|
||||
case "new":
|
||||
await this.openThreadInNewView(threadId);
|
||||
|
|
@ -366,7 +366,7 @@ function isIdleEmptyPanelSnapshot(snapshot: OpenCodexPanelSnapshot): boolean {
|
|||
}
|
||||
|
||||
function focusedPanelViewId(leaf: WorkspaceLeaf | null): string | null {
|
||||
return leaf?.view instanceof CodexChatView ? leaf.view.openPanelSnapshot().viewId : null;
|
||||
return leaf?.view instanceof CodexChatView ? leaf.view.surface.openPanelSnapshot().viewId : null;
|
||||
}
|
||||
|
||||
function restoredThreadId(leaf: WorkspaceLeaf): string | null {
|
||||
|
|
|
|||
69
src/workspace/shared-thread-catalog.ts
Normal file
69
src/workspace/shared-thread-catalog.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { ModelMetadata } from "../domain/catalog/metadata";
|
||||
import type { SharedServerMetadata } from "../domain/server/metadata";
|
||||
import type { Thread } from "../domain/threads/model";
|
||||
import type { SharedAppServerCache } from "../app-server/services/shared-cache";
|
||||
import type { SharedAppServerCacheContext } from "../app-server/services/shared-cache-state";
|
||||
import type { ThreadSurfaceActions } from "./thread-surface-actions";
|
||||
|
||||
export interface SharedThreadCatalogOptions {
|
||||
cache: SharedAppServerCache;
|
||||
surfaces: ThreadSurfaceActions;
|
||||
context: () => SharedAppServerCacheContext;
|
||||
}
|
||||
|
||||
export class SharedThreadCatalog {
|
||||
constructor(private readonly options: SharedThreadCatalogOptions) {}
|
||||
|
||||
cachedThreads(): readonly Thread[] | null {
|
||||
return this.options.cache.cachedThreadList(this.context());
|
||||
}
|
||||
|
||||
async refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]> {
|
||||
return this.options.cache.refreshThreadList(this.context(), fetchThreads, (threads) => {
|
||||
this.options.surfaces.applyThreadListSnapshot(threads);
|
||||
});
|
||||
}
|
||||
|
||||
applyThreads(threads: readonly Thread[]): void {
|
||||
this.options.cache.applyThreadListSnapshot(this.context(), threads);
|
||||
this.options.surfaces.applyThreadListSnapshot(threads);
|
||||
}
|
||||
|
||||
cachedAppServerMetadata(): SharedServerMetadata | null {
|
||||
return this.options.cache.cachedAppServerMetadata(this.context());
|
||||
}
|
||||
|
||||
publishAppServerMetadata(metadata: SharedServerMetadata): void {
|
||||
this.options.cache.applyAppServerMetadataSnapshot(this.context(), metadata);
|
||||
this.options.surfaces.publishAppServerMetadata(metadata);
|
||||
}
|
||||
|
||||
cachedModels(): readonly ModelMetadata[] | null {
|
||||
return this.options.cache.cachedModels(this.context());
|
||||
}
|
||||
|
||||
publishModels(models: readonly ModelMetadata[]): void {
|
||||
this.options.cache.applyModelsSnapshot(this.context(), models);
|
||||
this.options.surfaces.publishModels(models);
|
||||
}
|
||||
|
||||
refreshFromOpenSurface(): void {
|
||||
this.options.surfaces.refreshSharedThreadListFromOpenSurface();
|
||||
}
|
||||
|
||||
refreshThreadsViewLiveState(): void {
|
||||
this.options.surfaces.refreshThreadsViewLiveState();
|
||||
}
|
||||
|
||||
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void {
|
||||
this.options.surfaces.notifyThreadArchived(threadId, options);
|
||||
}
|
||||
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void {
|
||||
this.options.surfaces.notifyThreadRenamed(threadId, name);
|
||||
}
|
||||
|
||||
private context(): SharedAppServerCacheContext {
|
||||
return this.options.context();
|
||||
}
|
||||
}
|
||||
|
|
@ -30,9 +30,9 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
|
||||
|
||||
const refreshSharedThreadListFromOpenSurface = (): void => {
|
||||
const chatView = options.panels.panelViews().find((view) => view.openPanelSnapshot().connected);
|
||||
const chatView = options.panels.panelViews().find((view) => view.surface.openPanelSnapshot().connected);
|
||||
if (chatView) {
|
||||
void chatView.refreshSharedThreadList();
|
||||
void chatView.surface.refreshSharedThreadList();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
return {
|
||||
refreshOpenViews(): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.refreshSettings();
|
||||
view.surface.refreshSettings();
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
|
||||
applyThreadListSnapshot(threads: readonly Thread[]): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.applyThreadListSnapshot(threads);
|
||||
view.surface.applyThreadListSnapshot(threads);
|
||||
}
|
||||
for (const view of threadsViews()) {
|
||||
view.applyThreadListSnapshot(threads);
|
||||
|
|
@ -60,13 +60,13 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
|
||||
publishAppServerMetadata(metadata: SharedServerMetadata): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.applyAppServerMetadataSnapshot(metadata);
|
||||
view.surface.applyAppServerMetadataSnapshot(metadata);
|
||||
}
|
||||
},
|
||||
|
||||
publishModels(models: readonly ModelMetadata[]): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.applyAvailableModelsSnapshot(models);
|
||||
view.surface.applyAvailableModelsSnapshot(models);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
notifyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
|
||||
const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : [];
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.notifyThreadArchived(threadId);
|
||||
view.surface.notifyThreadArchived(threadId);
|
||||
}
|
||||
for (const leaf of leavesToClose) {
|
||||
leaf.detach();
|
||||
|
|
@ -89,7 +89,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
|
|||
|
||||
notifyThreadRenamed(threadId: string, name: string | null): void {
|
||||
for (const view of options.panels.panelViews()) {
|
||||
view.notifyThreadRenamed(threadId, name);
|
||||
view.surface.notifyThreadRenamed(threadId, name);
|
||||
}
|
||||
refreshSharedThreadListFromOpenSurface();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { AppServerClient } from "../../../../src/app-server/connection/client";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream";
|
||||
import { AutoTitleController } from "../../../../src/features/chat/application/threads/auto-title-controller";
|
||||
import { threadTitleContextFromMessageStreamItems } from "../../../../src/features/chat/application/threads/title-context";
|
||||
import { ThreadTitleService } from "../../../../src/features/threads/thread-title-service";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import type { ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model";
|
||||
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
|
||||
import { deferred } from "../../../support/async";
|
||||
|
||||
|
|
@ -118,19 +122,40 @@ describe("AutoTitleController", () => {
|
|||
});
|
||||
|
||||
function controllerFixture(
|
||||
overrides: Partial<ConstructorParameters<typeof AutoTitleController>[0]> = {},
|
||||
): ConstructorParameters<typeof AutoTitleController>[0] & { controller: AutoTitleController } {
|
||||
overrides: {
|
||||
currentClient?: () => AppServerClient;
|
||||
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
|
||||
} = {},
|
||||
): ConstructorParameters<typeof AutoTitleController>[0] & {
|
||||
controller: AutoTitleController;
|
||||
notifyThreadRenamed: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const stateStore = createChatStateStore();
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] });
|
||||
const currentClient = overrides.currentClient ?? (() => fakeClient());
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const titleService = new ThreadTitleService({
|
||||
settings: {
|
||||
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
|
||||
vaultPath: "/vault",
|
||||
},
|
||||
currentClient,
|
||||
visibleCompletedTurnContext: (turnId) =>
|
||||
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)),
|
||||
generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),
|
||||
});
|
||||
const host = {
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
settings: () => DEFAULT_SETTINGS,
|
||||
currentClient: () => fakeClient(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
...overrides,
|
||||
operations: {
|
||||
renameThread: async (threadId: string, value: string, options?: { shouldPublish?: () => boolean }) => {
|
||||
await currentClient().setThreadName(threadId, value);
|
||||
if (options?.shouldPublish?.() ?? true) notifyThreadRenamed(threadId, value);
|
||||
return { name: value };
|
||||
},
|
||||
},
|
||||
titleService,
|
||||
} satisfies ConstructorParameters<typeof AutoTitleController>[0];
|
||||
return { ...host, controller: new AutoTitleController(host) };
|
||||
return { ...host, notifyThreadRenamed, controller: new AutoTitleController(host) };
|
||||
}
|
||||
|
||||
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
|
||||
|
|
@ -153,4 +178,6 @@ function threadFixture(id: string): Thread {
|
|||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { ThreadRenameEditorController } from "../../../../src/features/chat/appl
|
|||
import type { AppServerClient } from "../../../../src/app-server/connection/client";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
|
||||
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
|
||||
import { deferred } from "../../../support/async";
|
||||
|
||||
describe("ThreadRenameEditorController", () => {
|
||||
|
|
@ -100,7 +99,7 @@ describe("ThreadRenameEditorController", () => {
|
|||
});
|
||||
|
||||
it("keeps an edited draft when auto-name generation finishes later", async () => {
|
||||
const generatedTitle = deferred<string | null>();
|
||||
const generatedTitle = deferred<string>();
|
||||
const { controller } = controllerFixture({
|
||||
generateThreadTitle: vi.fn(() => generatedTitle.promise),
|
||||
});
|
||||
|
|
@ -119,8 +118,8 @@ describe("ThreadRenameEditorController", () => {
|
|||
});
|
||||
|
||||
it("does not let an older auto-name request finish a newer generation", async () => {
|
||||
const firstGeneratedTitle = deferred<string | null>();
|
||||
const secondGeneratedTitle = deferred<string | null>();
|
||||
const firstGeneratedTitle = deferred<string>();
|
||||
const secondGeneratedTitle = deferred<string>();
|
||||
const generateThreadTitle = vi.fn().mockReturnValueOnce(firstGeneratedTitle.promise).mockReturnValueOnce(secondGeneratedTitle.promise);
|
||||
const { controller } = controllerFixture({ generateThreadTitle });
|
||||
|
||||
|
|
@ -145,21 +144,40 @@ describe("ThreadRenameEditorController", () => {
|
|||
});
|
||||
|
||||
function controllerFixture(
|
||||
overrides: Partial<ConstructorParameters<typeof ThreadRenameEditorController>[0]> = {},
|
||||
): ConstructorParameters<typeof ThreadRenameEditorController>[0] & { controller: ThreadRenameEditorController } {
|
||||
overrides: Partial<Pick<ConstructorParameters<typeof ThreadRenameEditorController>[0], "ensureConnected" | "addSystemMessage">> & {
|
||||
currentClient?: () => AppServerClient;
|
||||
generateThreadTitle?: () => Promise<string>;
|
||||
} = {},
|
||||
): ConstructorParameters<typeof ThreadRenameEditorController>[0] & {
|
||||
controller: ThreadRenameEditorController;
|
||||
notifyThreadRenamed: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const stateStore = createChatStateStore();
|
||||
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] });
|
||||
const currentClient = overrides.currentClient ?? (() => fakeClient());
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const host = {
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
settings: () => DEFAULT_SETTINGS,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
currentClient: () => fakeClient(),
|
||||
addSystemMessage: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
...overrides,
|
||||
ensureConnected: overrides.ensureConnected ?? vi.fn().mockResolvedValue(undefined),
|
||||
addSystemMessage: overrides.addSystemMessage ?? vi.fn(),
|
||||
operations: {
|
||||
renameThread: async (threadId: string, value: string) => {
|
||||
await currentClient().setThreadName(threadId, value);
|
||||
stateStore.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: stateStore
|
||||
.getState()
|
||||
.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: value } : thread)),
|
||||
});
|
||||
notifyThreadRenamed(threadId, value);
|
||||
return { name: value };
|
||||
},
|
||||
},
|
||||
titleService: {
|
||||
generateTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),
|
||||
},
|
||||
} satisfies ConstructorParameters<typeof ThreadRenameEditorController>[0];
|
||||
return { ...host, controller: new ThreadRenameEditorController(host) };
|
||||
return { ...host, notifyThreadRenamed, controller: new ThreadRenameEditorController(host) };
|
||||
}
|
||||
|
||||
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { AppServerClient } from "../../../../src/app-server/connection/client";
|
||||
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
|
||||
import { archiveThreadOnAppServer } from "../../../../src/app-server/services/thread-archive";
|
||||
import type { ArchiveExportAdapter } from "../../../../src/app-server/services/thread-archive-markdown";
|
||||
import { createChatState } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
|
|
@ -170,8 +171,8 @@ describe("thread management actions", () => {
|
|||
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked");
|
||||
expect(host.notifyThreadArchived).toHaveBeenCalledWith("source");
|
||||
expect(callOrder(adapter.write)).toBeLessThan(callOrder(client.archiveThread));
|
||||
expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.openThreadInCurrentPanel));
|
||||
expect(callOrder(host.openThreadInCurrentPanel)).toBeLessThan(callOrder(host.notifyThreadArchived));
|
||||
expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.notifyThreadArchived));
|
||||
expect(callOrder(host.notifyThreadArchived)).toBeLessThan(callOrder(host.openThreadInCurrentPanel));
|
||||
});
|
||||
|
||||
it("keeps the source panel when fork and archive fails to archive", async () => {
|
||||
|
|
@ -306,8 +307,7 @@ describe("thread management actions", () => {
|
|||
{ kind: "message", role: "assistant", text: "kept answer", turnId: "kept-turn" },
|
||||
]);
|
||||
expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
|
||||
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(vi.mocked(host.refreshThreads)));
|
||||
expect(callOrder(vi.mocked(host.refreshThreads))).toBeLessThan(callOrder(host.refreshSharedThreadListFromOpenSurface));
|
||||
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.refreshAfterThreadMutation));
|
||||
});
|
||||
|
||||
it("ignores stale rollback responses after the panel switches threads", async () => {
|
||||
|
|
@ -355,7 +355,7 @@ describe("thread management actions", () => {
|
|||
expect(host.stateStore.getState().activeThread.id).toBe("other");
|
||||
expect(host.setComposerText).not.toHaveBeenCalled();
|
||||
expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
|
||||
expect(host.refreshSharedThreadListFromOpenSurface).not.toHaveBeenCalled();
|
||||
expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -423,25 +423,46 @@ function hostMock({
|
|||
const state = createChatState();
|
||||
setChatStateMessageStreamItems(state, items);
|
||||
const stateStore = createChatStateStore(state);
|
||||
const notifyThreadArchived = vi.fn();
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const showNotice = vi.fn();
|
||||
const ensureConnected = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
settings: () => ({ ...DEFAULT_SETTINGS, ...settings }),
|
||||
archiveAdapter: () => archiveAdapter,
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
ensureConnected,
|
||||
currentClient: () => client as unknown as AppServerClient,
|
||||
addSystemMessage: vi.fn(),
|
||||
showNotice: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
setComposerText: vi.fn(),
|
||||
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
|
||||
openThreadInCurrentPanel: vi.fn().mockResolvedValue(undefined),
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
operations: {
|
||||
archiveThread: vi.fn(async (threadId: string, options: { saveMarkdown?: boolean } = {}) => {
|
||||
await ensureConnected();
|
||||
const result = await archiveThreadOnAppServer(client as unknown as AppServerClient, threadId, {
|
||||
settings: { ...DEFAULT_SETTINGS, ...settings },
|
||||
vaultPath: "/vault",
|
||||
archiveAdapter: () => archiveAdapter,
|
||||
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled ?? DEFAULT_SETTINGS.archiveExportEnabled,
|
||||
});
|
||||
if (result.exportedPath) showNotice(`Saved archived thread to ${result.exportedPath}.`);
|
||||
notifyThreadArchived(threadId);
|
||||
return result;
|
||||
}),
|
||||
renameThread: vi.fn(async (threadId: string, value: string) => {
|
||||
await ensureConnected();
|
||||
await client.setThreadName(threadId, value);
|
||||
notifyThreadRenamed(threadId, value);
|
||||
return { name: value };
|
||||
}),
|
||||
},
|
||||
showNotice,
|
||||
notifyThreadArchived,
|
||||
notifyThreadRenamed,
|
||||
notifyActiveThreadIdentityChanged: vi.fn(),
|
||||
refreshThreads: vi.fn().mockResolvedValue(undefined),
|
||||
refreshSharedThreadListFromOpenSurface: vi.fn(),
|
||||
} satisfies ThreadManagementActionsHost;
|
||||
refreshAfterThreadMutation: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function archiveAdapterMock(overrides: Partial<MockArchiveExportAdapter> = {}): MockArchiveExportAdapter {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await Promise.all([view.connect(), view.connect()]);
|
||||
await Promise.all([view.surface.connect(), view.surface.connect()]);
|
||||
|
||||
expect(connectionMock.state.connectCalls).toBe(1);
|
||||
expect(client.readEffectiveConfig).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -140,13 +140,13 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
const firstConnect = view.connect();
|
||||
const firstConnect = view.surface.connect();
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
let secondResolved = false;
|
||||
const secondConnect = view.connect().then(() => {
|
||||
const secondConnect = view.surface.connect().then(() => {
|
||||
secondResolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
|
@ -162,20 +162,20 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
|
||||
it("refreshes shared thread lists after connecting", async () => {
|
||||
const refreshThreadList = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
|
||||
const refreshThreads = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
|
||||
const threads = [threadFixture("thread-1")];
|
||||
const client = connectedClient({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: threads }),
|
||||
});
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView({
|
||||
host: chatHost({ refreshThreadList }),
|
||||
host: chatHost({ refreshThreads }),
|
||||
});
|
||||
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
await view.surface.connect();
|
||||
|
||||
expect(refreshThreadList).toHaveBeenCalledOnce();
|
||||
expect(refreshThreads).toHaveBeenCalledOnce();
|
||||
expect(client.listThreads).toHaveBeenCalledWith("/vault", { archived: false, cursor: null, limit: 100 });
|
||||
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
|
||||
await waitForAsyncWork(() => {
|
||||
|
|
@ -190,7 +190,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
host: chatHost({ publishAppServerMetadata }),
|
||||
});
|
||||
|
||||
await view.connect();
|
||||
await view.surface.connect();
|
||||
|
||||
expect(publishAppServerMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -272,7 +272,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ threadId: "thread-new" });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-new" });
|
||||
expect(view.containerEl.textContent).toContain("Ship the feature");
|
||||
});
|
||||
|
||||
|
|
@ -289,7 +289,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
const connecting = view.connect();
|
||||
const connecting = view.surface.connect();
|
||||
await Promise.resolve();
|
||||
await view.onClose();
|
||||
resolveConfig({});
|
||||
|
|
@ -307,7 +307,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
const connecting = view.connect();
|
||||
const connecting = view.surface.connect();
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
|
@ -318,7 +318,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
await connecting;
|
||||
|
||||
expect(client.listThreads).not.toHaveBeenCalled();
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ connected: false });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false });
|
||||
});
|
||||
|
||||
it("restores the active thread from workspace state and hydrates it after a delay", async () => {
|
||||
|
|
@ -355,14 +355,14 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.setState({ threadId: "thread-named" }, {} as never);
|
||||
view.applyThreadListSnapshot([panelThread({ id: "thread-named", name: "作業メモ" })]);
|
||||
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-named", name: "作業メモ" })]);
|
||||
expect(view.getDisplayText()).toBe("Codex: 作業メモ");
|
||||
|
||||
view.applyThreadListSnapshot([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]);
|
||||
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]);
|
||||
expect(view.getDisplayText()).toBe("Codex: 初回依頼");
|
||||
|
||||
await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never);
|
||||
view.applyThreadListSnapshot([]);
|
||||
view.surface.applyThreadListSnapshot([]);
|
||||
expect(view.getDisplayText()).toBe("Codex: 019e061e");
|
||||
});
|
||||
|
||||
|
|
@ -415,7 +415,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const cachedThread = threadFixture("thread-cached");
|
||||
const view = await chatView({
|
||||
host: chatHost({
|
||||
cachedThreadList: vi.fn(() => [cachedThread] as never[]),
|
||||
cachedThreads: vi.fn(() => [cachedThread] as never[]),
|
||||
cachedAppServerMetadata: vi.fn(
|
||||
() =>
|
||||
({
|
||||
|
|
@ -451,7 +451,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
await view.onOpen();
|
||||
|
||||
expect(client.resumeThread).not.toHaveBeenCalled();
|
||||
await view.focusThread("thread-1");
|
||||
await view.surface.focusThread("thread-1");
|
||||
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
||||
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
||||
|
|
@ -464,7 +464,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
|
||||
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
|
||||
await view.onOpen();
|
||||
view.setComposerText("hello");
|
||||
view.surface.setComposerText("hello");
|
||||
await submitComposerByEnter(view);
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
|
|
@ -488,8 +488,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
view.setComposerText("hello");
|
||||
await view.surface.openThread("thread-1");
|
||||
view.surface.setComposerText("hello");
|
||||
await submitComposerByEnter(view);
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.startTurn).toHaveBeenCalled();
|
||||
|
|
@ -497,12 +497,12 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
|
||||
connectionMock.state.onNotification?.(turnStartedNotification("thread-1", "turn-1"));
|
||||
connectionMock.state.onNotification?.(turnCompletedNotification("thread-1", "turn-1"));
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
|
||||
|
||||
startTurn.resolve({ turn: { id: "turn-1" } });
|
||||
await flushAsyncTicks();
|
||||
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
|
||||
});
|
||||
|
||||
it("requests a workspace layout save after resuming a thread", async () => {
|
||||
|
|
@ -511,7 +511,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView({ requestSaveLayout });
|
||||
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
||||
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -523,12 +523,12 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView({ requestSaveLayout });
|
||||
|
||||
await view.openThread("thread-1");
|
||||
await view.startNewThread();
|
||||
await view.surface.openThread("thread-1");
|
||||
await view.surface.startNewThread();
|
||||
|
||||
expect(client.startThread).not.toHaveBeenCalled();
|
||||
expect(view.getState()).toEqual({ version: 1 });
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false });
|
||||
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
|
@ -540,9 +540,9 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
await view.onOpen();
|
||||
const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined);
|
||||
|
||||
await view.openThread("thread-1");
|
||||
await view.focusThread("thread-1");
|
||||
await view.startNewThread();
|
||||
await view.surface.openThread("thread-1");
|
||||
await view.surface.focusThread("thread-1");
|
||||
await view.surface.startNewThread();
|
||||
|
||||
expect(focus).toHaveBeenCalledTimes(3);
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
|
@ -557,8 +557,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
|
||||
await view.onOpen();
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
view.setComposerText("/clear hello");
|
||||
await view.surface.connect();
|
||||
view.surface.setComposerText("/clear hello");
|
||||
await submitComposerByEnter(view);
|
||||
|
||||
expect(client.startThread).not.toHaveBeenCalled();
|
||||
|
|
@ -576,8 +576,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
view.setComposerText("/resume thread-1");
|
||||
await view.surface.connect();
|
||||
view.surface.setComposerText("/resume thread-1");
|
||||
await submitComposerByEnter(view);
|
||||
|
||||
expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1");
|
||||
|
|
@ -598,8 +598,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
view.setComposerText("/resume thread-1");
|
||||
await view.surface.connect();
|
||||
view.surface.setComposerText("/resume thread-1");
|
||||
await submitComposerByEnter(view);
|
||||
|
||||
expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1");
|
||||
|
|
@ -615,14 +615,14 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
await view.surface.connect();
|
||||
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
||||
expect(messages).not.toBeNull();
|
||||
if (!messages) return;
|
||||
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 });
|
||||
messages.scrollTop = 0;
|
||||
|
||||
view.setComposerText("/resume thread-1");
|
||||
view.surface.setComposerText("/resume thread-1");
|
||||
await submitComposerByEnter(view);
|
||||
await waitForMessagesFrame(messages);
|
||||
await waitForMessagesFrame(messages);
|
||||
|
|
@ -644,8 +644,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
view.setComposerText("/archive thread-1");
|
||||
await view.surface.connect();
|
||||
view.surface.setComposerText("/archive thread-1");
|
||||
await submitComposerByEnter(view);
|
||||
|
||||
expect(client.archiveThread).toHaveBeenCalledWith("thread-1");
|
||||
|
|
@ -665,8 +665,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView({ host });
|
||||
|
||||
await view.onOpen();
|
||||
await view.connect();
|
||||
await view.openThread("source");
|
||||
await view.surface.connect();
|
||||
await view.surface.openThread("source");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(view.containerEl.textContent).toContain("done");
|
||||
});
|
||||
|
|
@ -681,7 +681,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
expect(client.archiveThread).toHaveBeenCalledWith("source");
|
||||
expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault");
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" });
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ threadId: "forked" });
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" });
|
||||
expect(notifyThreadArchived).toHaveBeenCalledWith("source");
|
||||
});
|
||||
});
|
||||
|
|
@ -692,8 +692,8 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView({ requestSaveLayout });
|
||||
|
||||
await view.openThread("thread-1");
|
||||
view.notifyThreadArchived("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
view.surface.notifyThreadArchived("thread-1");
|
||||
|
||||
expect(view.getState()).toEqual({ version: 1 });
|
||||
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -703,7 +703,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never);
|
||||
view.notifyThreadRenamed("thread-1", "After rename");
|
||||
view.surface.notifyThreadRenamed("thread-1", "After rename");
|
||||
|
||||
expect(view.getDisplayText()).toBe("Codex: After rename");
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" });
|
||||
|
|
@ -717,7 +717,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
|
||||
view.notifyThreadRenamed("thread-1", "Explicit name");
|
||||
view.surface.notifyThreadRenamed("thread-1", "Explicit name");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Explicit name”...");
|
||||
|
|
@ -730,7 +730,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Restored thread”...");
|
||||
});
|
||||
|
|
@ -746,7 +746,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
});
|
||||
|
|
@ -757,14 +757,14 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
view.notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
await view.surface.openThread("thread-1");
|
||||
view.surface.notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”...");
|
||||
});
|
||||
|
||||
view.notifyThreadRenamed("thread-1", null);
|
||||
view.surface.notifyThreadRenamed("thread-1", null);
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
|
||||
|
|
@ -777,15 +777,15 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
view.setComposerText("keep this draft");
|
||||
await view.surface.openThread("thread-1");
|
||||
view.surface.setComposerText("keep this draft");
|
||||
const composer = composerElement(view);
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composer.value).toBe("keep this draft");
|
||||
});
|
||||
composer.setSelectionRange(5, 9);
|
||||
|
||||
view.notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
view.surface.notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(composerElement(view)).toBe(composer);
|
||||
|
|
@ -810,7 +810,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 });
|
||||
messages.scrollTop = 0;
|
||||
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
await waitForMessagesFrame(messages);
|
||||
await waitForMessagesFrame(messages);
|
||||
|
||||
|
|
@ -828,13 +828,13 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 });
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
||||
expect(messages).not.toBeNull();
|
||||
if (!messages) return;
|
||||
messages.scrollTop = 0;
|
||||
|
||||
await view.focusThread("thread-1");
|
||||
await view.surface.focusThread("thread-1");
|
||||
|
||||
expect(messages.scrollTop).toBe(0);
|
||||
restoreMessagesLayout();
|
||||
|
|
@ -850,7 +850,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 });
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
||||
expect(messages).not.toBeNull();
|
||||
if (!messages) return;
|
||||
|
|
@ -872,7 +872,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
const opening = view.openThread("thread-1");
|
||||
const opening = view.surface.openThread("thread-1");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
||||
});
|
||||
|
|
@ -900,7 +900,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await view.openThread("thread-1");
|
||||
await view.surface.openThread("thread-1");
|
||||
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
||||
expect(client.threadTurnsList).not.toHaveBeenCalled();
|
||||
|
|
@ -919,11 +919,11 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
const firstOpen = view.openThread("thread-1");
|
||||
const firstOpen = view.surface.openThread("thread-1");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
||||
});
|
||||
const secondOpen = view.openThread("thread-2");
|
||||
const secondOpen = view.surface.openThread("thread-2");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-2", "/vault");
|
||||
});
|
||||
|
|
@ -949,11 +949,11 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
const firstOpen = view.openThread("thread-1");
|
||||
const firstOpen = view.surface.openThread("thread-1");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
||||
});
|
||||
const secondOpen = view.openThread("thread-2");
|
||||
const secondOpen = view.surface.openThread("thread-2");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20);
|
||||
});
|
||||
|
|
@ -1282,15 +1282,15 @@ interface ChatHostFixtureOverrides {
|
|||
openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"];
|
||||
focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"];
|
||||
openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"];
|
||||
notifyThreadArchived?: CodexChatHost["threadSurfaces"]["notifyThreadArchived"];
|
||||
notifyThreadRenamed?: CodexChatHost["threadSurfaces"]["notifyThreadRenamed"];
|
||||
refreshSharedThreadListFromOpenSurface?: CodexChatHost["threadSurfaces"]["refreshSharedThreadListFromOpenSurface"];
|
||||
refreshThreadsViewLiveState?: CodexChatHost["threadSurfaces"]["refreshThreadsViewLiveState"];
|
||||
applyThreadListSnapshot?: CodexChatHost["threadSurfaces"]["applyThreadListSnapshot"];
|
||||
publishAppServerMetadata?: CodexChatHost["threadSurfaces"]["publishAppServerMetadata"];
|
||||
refreshThreadList?: CodexChatHost["sharedCache"]["refreshThreadList"];
|
||||
cachedThreadList?: CodexChatHost["sharedCache"]["cachedThreadList"];
|
||||
cachedAppServerMetadata?: CodexChatHost["sharedCache"]["cachedAppServerMetadata"];
|
||||
notifyThreadArchived?: CodexChatHost["threadCatalog"]["notifyThreadArchived"];
|
||||
notifyThreadRenamed?: CodexChatHost["threadCatalog"]["notifyThreadRenamed"];
|
||||
refreshFromOpenSurface?: CodexChatHost["threadCatalog"]["refreshFromOpenSurface"];
|
||||
refreshThreadsViewLiveState?: CodexChatHost["threadCatalog"]["refreshThreadsViewLiveState"];
|
||||
applyThreads?: CodexChatHost["threadCatalog"]["applyThreads"];
|
||||
publishAppServerMetadata?: CodexChatHost["threadCatalog"]["publishAppServerMetadata"];
|
||||
refreshThreads?: CodexChatHost["threadCatalog"]["refreshThreads"];
|
||||
cachedThreads?: CodexChatHost["threadCatalog"]["cachedThreads"];
|
||||
cachedAppServerMetadata?: CodexChatHost["threadCatalog"]["cachedAppServerMetadata"];
|
||||
}
|
||||
|
||||
function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
|
||||
|
|
@ -1310,21 +1310,19 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
|
|||
focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false),
|
||||
openTurnDiff: overrides.openTurnDiff ?? vi.fn(),
|
||||
},
|
||||
threadSurfaces: {
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: overrides.notifyThreadArchived ?? vi.fn(),
|
||||
notifyThreadRenamed: overrides.notifyThreadRenamed ?? vi.fn(),
|
||||
refreshSharedThreadListFromOpenSurface: overrides.refreshSharedThreadListFromOpenSurface ?? vi.fn(),
|
||||
refreshFromOpenSurface: overrides.refreshFromOpenSurface ?? vi.fn(),
|
||||
refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(),
|
||||
applyThreadListSnapshot: overrides.applyThreadListSnapshot ?? vi.fn(),
|
||||
applyThreads: overrides.applyThreads ?? vi.fn(),
|
||||
publishAppServerMetadata: overrides.publishAppServerMetadata ?? vi.fn(),
|
||||
},
|
||||
sharedCache: {
|
||||
refreshThreadList:
|
||||
overrides.refreshThreadList ??
|
||||
refreshThreads:
|
||||
overrides.refreshThreads ??
|
||||
(vi.fn(
|
||||
(fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>,
|
||||
) as CodexChatHost["sharedCache"]["refreshThreadList"]),
|
||||
cachedThreadList: overrides.cachedThreadList ?? vi.fn(() => null),
|
||||
) as CodexChatHost["threadCatalog"]["refreshThreads"]),
|
||||
cachedThreads: overrides.cachedThreads ?? vi.fn(() => null),
|
||||
cachedAppServerMetadata: overrides.cachedAppServerMetadata ?? vi.fn(() => null),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function firstSuggestion(modal: CapturedThreadPickerModal): ThreadSuggestion {
|
|||
}
|
||||
|
||||
function isThreadPickerHost(input: readonly Thread[] | TestThreadPickerHost): input is TestThreadPickerHost {
|
||||
return "cachedThreadList" in input;
|
||||
return "threadCatalog" in input;
|
||||
}
|
||||
|
||||
interface TestThreadPickerHost extends ThreadPickerHost {
|
||||
|
|
@ -109,8 +109,10 @@ function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost {
|
|||
vaultPath: "/vault",
|
||||
openedCurrent,
|
||||
openedAvailable,
|
||||
cachedThreadList: () => threads,
|
||||
refreshThreadList: async () => threads,
|
||||
threadCatalog: {
|
||||
cachedThreads: () => threads,
|
||||
refreshThreads: async () => threads,
|
||||
},
|
||||
openThreadInCurrentView: async (threadId) => {
|
||||
openedCurrent.push(threadId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -237,18 +237,20 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
it("refreshes thread lists through the plugin coordinator", async () => {
|
||||
const threads = [threadFixture({ id: "thread", preview: "Thread preview" })];
|
||||
const refreshThreadList = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
|
||||
const refreshThreads = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: threads }),
|
||||
});
|
||||
const host = threadsHost({
|
||||
refreshThreadList,
|
||||
threadCatalog: {
|
||||
refreshThreads,
|
||||
},
|
||||
});
|
||||
const view = await threadsView(host);
|
||||
|
||||
await view.refresh();
|
||||
|
||||
expect(refreshThreadList).toHaveBeenCalledOnce();
|
||||
expect(refreshThreads).toHaveBeenCalledOnce();
|
||||
expect(view.containerEl.textContent).toContain("Thread preview");
|
||||
});
|
||||
|
||||
|
|
@ -263,7 +265,9 @@ describe("CodexThreadsView", () => {
|
|||
});
|
||||
const view = await threadsView(
|
||||
threadsHost({
|
||||
cachedThreadList: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]),
|
||||
threadCatalog: {
|
||||
cachedThreads: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -278,8 +282,9 @@ describe("CodexThreadsView", () => {
|
|||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
archiveThread,
|
||||
});
|
||||
const notifyThreadArchived = vi.fn();
|
||||
const host = threadsHost({
|
||||
notifyThreadArchived: vi.fn(),
|
||||
threadCatalog: { notifyThreadArchived },
|
||||
});
|
||||
const view = await threadsView(host);
|
||||
|
||||
|
|
@ -289,7 +294,7 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(archiveThread).toHaveBeenCalledWith("thread");
|
||||
expect(host.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
expect(notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -299,8 +304,9 @@ describe("CodexThreadsView", () => {
|
|||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
setThreadName,
|
||||
});
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const host = threadsHost({
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
threadCatalog: { notifyThreadRenamed },
|
||||
});
|
||||
const view = await threadsView(host);
|
||||
|
||||
|
|
@ -314,7 +320,7 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -405,6 +411,9 @@ function clientFixture(overrides: Record<string, unknown> = {}): Record<string,
|
|||
}
|
||||
|
||||
function threadsHost(overrides: Record<string, unknown> = {}) {
|
||||
const threadCatalogOverrides =
|
||||
"threadCatalog" in overrides && typeof overrides["threadCatalog"] === "object" ? overrides["threadCatalog"] : {};
|
||||
const hostOverrides = Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== "threadCatalog"));
|
||||
return {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
|
|
@ -414,11 +423,15 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
|
|||
openNewPanel: vi.fn().mockResolvedValue(undefined),
|
||||
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
|
||||
getOpenPanelSnapshots: vi.fn(() => []),
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
refreshThreadList: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
|
||||
cachedThreadList: vi.fn(() => null),
|
||||
...overrides,
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
refreshFromOpenSurface: vi.fn(),
|
||||
refreshThreads: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
|
||||
cachedThreads: vi.fn(() => null),
|
||||
...threadCatalogOverrides,
|
||||
},
|
||||
...hostOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
99
tests/features/threads/thread-operations.test.ts
Normal file
99
tests/features/threads/thread-operations.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../src/app-server/connection/client";
|
||||
import type { ArchiveThreadResult } from "../../../src/app-server/services/thread-archive";
|
||||
import type { ArchiveExportAdapter } from "../../../src/app-server/services/thread-archive-markdown";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import { ThreadOperations, type ThreadOperationsHost } from "../../../src/features/threads/thread-operations";
|
||||
|
||||
const archiveMock = vi.hoisted(() => ({
|
||||
archiveThreadOnAppServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/app-server/services/thread-archive", () => ({
|
||||
archiveThreadOnAppServer: archiveMock.archiveThreadOnAppServer,
|
||||
}));
|
||||
|
||||
describe("ThreadOperations", () => {
|
||||
it("renames a thread and notifies shared surfaces after success", async () => {
|
||||
const { operations, client, catalog } = operationsFixture();
|
||||
|
||||
await expect(operations.renameThread("thread", " Saved title ")).resolves.toEqual({ name: "Saved title" });
|
||||
|
||||
expect(client?.setThreadName).toHaveBeenCalledWith("thread", "Saved title");
|
||||
expect(catalog.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Saved title");
|
||||
});
|
||||
|
||||
it("can skip rename publication when the caller invalidates the save", async () => {
|
||||
const { operations, catalog } = operationsFixture();
|
||||
|
||||
await operations.renameThread("thread", "Generated title", { shouldPublish: () => false });
|
||||
|
||||
expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => {
|
||||
const { operations, catalog, notice } = operationsFixture();
|
||||
archiveMock.archiveThreadOnAppServer.mockResolvedValueOnce({ exportedPath: "Archive/thread.md" } satisfies ArchiveThreadResult);
|
||||
|
||||
await expect(operations.archiveThread("thread", { saveMarkdown: true, closeOpenPanels: true })).resolves.toEqual({
|
||||
exportedPath: "Archive/thread.md",
|
||||
});
|
||||
|
||||
expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/thread.md.");
|
||||
expect(catalog.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
});
|
||||
|
||||
it("does not notify surfaces when an operation has no current client", async () => {
|
||||
const { operations, catalog } = operationsFixture({ client: null });
|
||||
|
||||
await expect(operations.renameThread("thread", "Title")).resolves.toBeNull();
|
||||
await expect(operations.archiveThread("thread")).resolves.toBeNull();
|
||||
|
||||
expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled();
|
||||
expect(catalog.notifyThreadArchived).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function operationsFixture(options: { client?: MockClient | null } = {}) {
|
||||
archiveMock.archiveThreadOnAppServer.mockReset();
|
||||
archiveMock.archiveThreadOnAppServer.mockResolvedValue({ exportedPath: null } satisfies ArchiveThreadResult);
|
||||
const client = options.client === undefined ? clientMock() : options.client;
|
||||
const catalog = {
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
refreshFromOpenSurface: vi.fn(),
|
||||
};
|
||||
const notice = vi.fn();
|
||||
const host: ThreadOperationsHost = {
|
||||
connection: {
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
currentClient: () => client as AppServerClient | null,
|
||||
},
|
||||
settings: {
|
||||
current: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }),
|
||||
vaultPath: "/vault",
|
||||
},
|
||||
archiveAdapter: () => archiveAdapterMock(),
|
||||
catalog,
|
||||
notice,
|
||||
};
|
||||
return { operations: new ThreadOperations(host), client, catalog, notice };
|
||||
}
|
||||
|
||||
type MockClient = ReturnType<typeof clientMock>;
|
||||
|
||||
function clientMock() {
|
||||
return {
|
||||
setThreadName: vi.fn().mockResolvedValue({}),
|
||||
archiveThread: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function archiveAdapterMock(): ArchiveExportAdapter {
|
||||
return {
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
61
tests/features/threads/thread-title-service.test.ts
Normal file
61
tests/features/threads/thread-title-service.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../src/app-server/connection/client";
|
||||
import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../src/domain/threads/title-generation-model";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import { ThreadTitleService } from "../../../src/features/threads/thread-title-service";
|
||||
|
||||
describe("ThreadTitleService", () => {
|
||||
it("generates a title from visible context without saving it", async () => {
|
||||
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
|
||||
const service = titleService({
|
||||
visibleContext: () => titleContext("visible request", "visible response"),
|
||||
generateThreadTitle,
|
||||
});
|
||||
|
||||
await expect(service.generateTitle("thread")).resolves.toBe("Generated title");
|
||||
|
||||
expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"));
|
||||
});
|
||||
|
||||
it("throws the existing unavailable-context error when no context can be resolved", async () => {
|
||||
const service = titleService();
|
||||
|
||||
await expect(service.generateTitle("thread")).rejects.toThrow(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
|
||||
});
|
||||
|
||||
it("prefers visible completed-turn context over completed summaries", () => {
|
||||
const service = titleService({
|
||||
visibleCompletedTurnContext: () => titleContext("visible turn", "visible answer"),
|
||||
});
|
||||
|
||||
expect(service.completedTurnContext("turn", { userText: "summary turn", assistantText: "summary answer" })).toEqual(
|
||||
titleContext("visible turn", "visible answer"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from visible completed-turn context to completed summaries", () => {
|
||||
const service = titleService({
|
||||
visibleCompletedTurnContext: () => null,
|
||||
});
|
||||
|
||||
expect(service.completedTurnContext("turn", { userText: "summary turn", assistantText: "summary answer" })).toEqual(
|
||||
titleContext("summary turn", "summary answer"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function titleService(options: Partial<ConstructorParameters<typeof ThreadTitleService>[0]> = {}): ThreadTitleService {
|
||||
return new ThreadTitleService({
|
||||
settings: {
|
||||
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
|
||||
vaultPath: "/vault",
|
||||
},
|
||||
currentClient: () => null as AppServerClient | null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function titleContext(userRequest: string, assistantResponse: string): ThreadTitleContext {
|
||||
return { userRequest, assistantResponse };
|
||||
}
|
||||
|
|
@ -18,15 +18,15 @@ import { installObsidianDomShims } from "./support/dom";
|
|||
installObsidianDomShims();
|
||||
|
||||
function panels(plugin: CodexPanelPlugin): WorkspacePanelCoordinator {
|
||||
return (plugin as unknown as { panels: WorkspacePanelCoordinator }).panels;
|
||||
return plugin.runtime.panels;
|
||||
}
|
||||
|
||||
function threadSurfaces(plugin: CodexPanelPlugin): ThreadSurfaceActions {
|
||||
return (plugin as unknown as { threadSurfaces: ThreadSurfaceActions }).threadSurfaces;
|
||||
return plugin.runtime.threadSurfaces;
|
||||
}
|
||||
|
||||
function sharedAppServerCache(plugin: CodexPanelPlugin): SharedAppServerCache {
|
||||
return (plugin as unknown as { sharedAppServerCache: SharedAppServerCache }).sharedAppServerCache;
|
||||
return plugin.runtime.sharedAppServerCache;
|
||||
}
|
||||
|
||||
function sharedAppServerCacheContext(
|
||||
|
|
@ -96,7 +96,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const openLeaf = leaf();
|
||||
openLeaf.view = chatView(CodexChatView, openLeaf);
|
||||
const openView = openLeaf.view as CodexChatView;
|
||||
vi.spyOn(openView, "openPanelSnapshot").mockReturnValue({
|
||||
vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue({
|
||||
viewId: "open-view",
|
||||
threadId: "thread-1",
|
||||
lastFocused: false,
|
||||
|
|
@ -106,11 +106,11 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
hasComposerDraft: false,
|
||||
connected: true,
|
||||
});
|
||||
vi.spyOn(openView, "focusThread").mockResolvedValue(undefined);
|
||||
vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined);
|
||||
const emptyLeaf = leaf();
|
||||
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
|
||||
const emptyView = emptyLeaf.view as CodexChatView;
|
||||
const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined);
|
||||
const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]);
|
||||
|
||||
await panels(plugin).openThreadInAvailableView("thread-1");
|
||||
|
|
@ -124,7 +124,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const busyLeaf = leaf();
|
||||
busyLeaf.view = chatView(CodexChatView, busyLeaf);
|
||||
const busyView = busyLeaf.view as CodexChatView;
|
||||
vi.spyOn(busyView, "openPanelSnapshot").mockReturnValue({
|
||||
vi.spyOn(busyView.surface, "openPanelSnapshot").mockReturnValue({
|
||||
viewId: "busy-view",
|
||||
threadId: "other-thread",
|
||||
lastFocused: false,
|
||||
|
|
@ -137,7 +137,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const emptyLeaf = leaf();
|
||||
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
|
||||
const emptyView = emptyLeaf.view as CodexChatView;
|
||||
vi.spyOn(emptyView, "openPanelSnapshot").mockReturnValue({
|
||||
vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue({
|
||||
viewId: "empty-view",
|
||||
threadId: null,
|
||||
lastFocused: false,
|
||||
|
|
@ -147,7 +147,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
hasComposerDraft: false,
|
||||
connected: true,
|
||||
});
|
||||
const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined);
|
||||
const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([busyLeaf, emptyLeaf]);
|
||||
|
||||
await panels(plugin).openThreadInAvailableView("thread-1");
|
||||
|
|
@ -160,11 +160,11 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const olderLeaf = leaf();
|
||||
olderLeaf.view = chatView(CodexChatView, olderLeaf);
|
||||
const olderView = olderLeaf.view as CodexChatView;
|
||||
const openOlderThread = vi.spyOn(olderView, "openThread").mockResolvedValue(undefined);
|
||||
const openOlderThread = vi.spyOn(olderView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const currentLeaf = leaf();
|
||||
currentLeaf.view = chatView(CodexChatView, currentLeaf);
|
||||
const currentView = currentLeaf.view as CodexChatView;
|
||||
const openCurrentThread = vi.spyOn(currentView, "openThread").mockResolvedValue(undefined);
|
||||
const openCurrentThread = vi.spyOn(currentView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([olderLeaf, currentLeaf]);
|
||||
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(currentLeaf);
|
||||
|
||||
|
|
@ -179,11 +179,11 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const fallbackLeaf = leaf();
|
||||
fallbackLeaf.view = chatView(CodexChatView, fallbackLeaf);
|
||||
const fallbackView = fallbackLeaf.view as CodexChatView;
|
||||
const openFallbackThread = vi.spyOn(fallbackView, "openThread").mockResolvedValue(undefined);
|
||||
const openFallbackThread = vi.spyOn(fallbackView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const activeLeaf = leaf();
|
||||
activeLeaf.view = chatView(CodexChatView, activeLeaf);
|
||||
const activeView = activeLeaf.view as CodexChatView;
|
||||
const openActiveThread = vi.spyOn(activeView, "openThread").mockResolvedValue(undefined);
|
||||
const openActiveThread = vi.spyOn(activeView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([fallbackLeaf, activeLeaf]);
|
||||
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
|
||||
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(fallbackLeaf);
|
||||
|
|
@ -199,12 +199,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const openLeaf = leaf();
|
||||
openLeaf.view = chatView(CodexChatView, openLeaf);
|
||||
const openView = openLeaf.view as CodexChatView;
|
||||
vi.spyOn(openView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" }));
|
||||
const focusOpenThread = vi.spyOn(openView, "focusThread").mockResolvedValue(undefined);
|
||||
vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" }));
|
||||
const focusOpenThread = vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined);
|
||||
const currentLeaf = leaf();
|
||||
currentLeaf.view = chatView(CodexChatView, currentLeaf);
|
||||
const currentView = currentLeaf.view as CodexChatView;
|
||||
const openCurrentThread = vi.spyOn(currentView, "openThread").mockResolvedValue(undefined);
|
||||
const openCurrentThread = vi.spyOn(currentView.surface, "openThread").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([openLeaf, currentLeaf]);
|
||||
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(currentLeaf);
|
||||
|
||||
|
|
@ -220,8 +220,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(restoredLeaf);
|
||||
const { CodexChatView } = await import("../src/features/chat/host/view");
|
||||
const view = chatView(CodexChatView, restoredLeaf);
|
||||
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
|
||||
const focusThread = vi.spyOn(view, "focusThread").mockResolvedValue(undefined);
|
||||
const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
|
||||
const focusThread = vi.spyOn(view.surface, "focusThread").mockResolvedValue(undefined);
|
||||
restoredLeaf.loadIfDeferred.mockImplementation(async () => {
|
||||
restoredLeaf.view = view;
|
||||
});
|
||||
|
|
@ -242,9 +242,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
newLeaf.setViewState.mockImplementation(async () => {
|
||||
newLeaf.view = view;
|
||||
});
|
||||
const connect = vi.spyOn(view, "connect").mockResolvedValue(undefined);
|
||||
const focusComposer = vi.spyOn(view, "focusComposer").mockImplementation(() => undefined);
|
||||
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
|
||||
const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined);
|
||||
const focusComposer = vi.spyOn(view.surface, "focusComposer").mockImplementation(() => undefined);
|
||||
const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
|
||||
|
||||
await panels(plugin).openThreadInNewView("thread-1");
|
||||
|
||||
|
|
@ -258,13 +258,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const firstLeaf = leaf();
|
||||
firstLeaf.view = chatView(CodexChatView, firstLeaf);
|
||||
const firstView = firstLeaf.view as CodexChatView;
|
||||
const connectFirst = vi.spyOn(firstView, "connect").mockResolvedValue(undefined);
|
||||
const focusFirst = vi.spyOn(firstView, "focusThread").mockResolvedValue(undefined);
|
||||
const connectFirst = vi.spyOn(firstView.surface, "connect").mockResolvedValue(undefined);
|
||||
const focusFirst = vi.spyOn(firstView.surface, "focusThread").mockResolvedValue(undefined);
|
||||
const activeLeaf = leaf();
|
||||
activeLeaf.view = chatView(CodexChatView, activeLeaf);
|
||||
const activeView = activeLeaf.view as CodexChatView;
|
||||
const connectActive = vi.spyOn(activeView, "connect").mockResolvedValue(undefined);
|
||||
const focusActive = vi.spyOn(activeView, "focusThread").mockResolvedValue(undefined);
|
||||
const connectActive = vi.spyOn(activeView.surface, "connect").mockResolvedValue(undefined);
|
||||
const focusActive = vi.spyOn(activeView.surface, "focusThread").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]);
|
||||
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
|
||||
|
||||
|
|
@ -282,12 +282,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const { CodexChatView } = await import("../src/features/chat/host/view");
|
||||
const firstLeaf = leaf();
|
||||
firstLeaf.view = chatView(CodexChatView, firstLeaf);
|
||||
vi.spyOn(firstLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
|
||||
vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "first", threadId: "thread-1" }),
|
||||
);
|
||||
const secondLeaf = leaf();
|
||||
secondLeaf.view = chatView(CodexChatView, secondLeaf);
|
||||
vi.spyOn(secondLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
|
||||
vi.spyOn((secondLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "second", threadId: "thread-2" }),
|
||||
);
|
||||
const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]);
|
||||
|
|
@ -314,13 +314,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const { CodexChatView } = await import("../src/features/chat/host/view");
|
||||
const firstLeaf = leaf();
|
||||
firstLeaf.view = chatView(CodexChatView, firstLeaf);
|
||||
vi.spyOn(firstLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
|
||||
vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "first", threadId: "thread-1" }),
|
||||
);
|
||||
const activeLeaf = leaf();
|
||||
activeLeaf.view = chatView(CodexChatView, activeLeaf);
|
||||
const activeView = activeLeaf.view as CodexChatView;
|
||||
vi.spyOn(activeView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" }));
|
||||
vi.spyOn(activeView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" }));
|
||||
const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]);
|
||||
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
|
||||
|
||||
|
|
@ -334,12 +334,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const { CodexChatView } = await import("../src/features/chat/host/view");
|
||||
const recentLeaf = leaf();
|
||||
recentLeaf.view = chatView(CodexChatView, recentLeaf);
|
||||
vi.spyOn(recentLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
|
||||
vi.spyOn((recentLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "recent", threadId: "thread-1" }),
|
||||
);
|
||||
const otherLeaf = leaf();
|
||||
otherLeaf.view = chatView(CodexChatView, otherLeaf);
|
||||
vi.spyOn(otherLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
|
||||
vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "other", threadId: "thread-2" }),
|
||||
);
|
||||
const plugin = await pluginWithLeaves([recentLeaf, otherLeaf]);
|
||||
|
|
@ -360,9 +360,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
newLeaf.setViewState.mockImplementation(async () => {
|
||||
newLeaf.view = view;
|
||||
});
|
||||
const connect = vi.spyOn(view, "connect").mockResolvedValue(undefined);
|
||||
const focusComposer = vi.spyOn(view, "focusComposer").mockImplementation(() => undefined);
|
||||
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
|
||||
const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined);
|
||||
const focusComposer = vi.spyOn(view.surface, "focusComposer").mockImplementation(() => undefined);
|
||||
const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
|
||||
|
||||
await panels(plugin).openNewPanel();
|
||||
|
||||
|
|
@ -376,8 +376,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const connectedLeaf = leaf();
|
||||
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
|
||||
const connectedView = connectedLeaf.view as CodexChatView;
|
||||
vi.spyOn(connectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const refreshSharedThreadList = vi.spyOn(connectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
|
||||
threadSurfaces(plugin).notifyThreadArchived("thread-1");
|
||||
|
|
@ -390,12 +390,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const restoredMatchingLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored" } });
|
||||
const matchingLeaf = leaf();
|
||||
matchingLeaf.view = chatView(CodexChatView, matchingLeaf);
|
||||
vi.spyOn(matchingLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" }));
|
||||
vi.spyOn(matchingLeaf.view as CodexChatView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn((matchingLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" }));
|
||||
vi.spyOn((matchingLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const otherLeaf = leaf();
|
||||
otherLeaf.view = chatView(CodexChatView, otherLeaf);
|
||||
vi.spyOn(otherLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" }));
|
||||
vi.spyOn(otherLeaf.view as CodexChatView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" }));
|
||||
vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]);
|
||||
|
||||
threadSurfaces(plugin).notifyThreadArchived("thread-1");
|
||||
|
|
@ -416,8 +416,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const connectedLeaf = leaf();
|
||||
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
|
||||
const connectedView = connectedLeaf.view as CodexChatView;
|
||||
vi.spyOn(connectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const refreshSharedThreadList = vi.spyOn(connectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
|
||||
threadSurfaces(plugin).notifyThreadRenamed("thread-1", "Renamed thread");
|
||||
|
|
@ -491,14 +491,14 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const disconnectedLeaf = leaf();
|
||||
disconnectedLeaf.view = chatView(CodexChatView, disconnectedLeaf);
|
||||
const disconnectedView = disconnectedLeaf.view as CodexChatView;
|
||||
vi.spyOn(disconnectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "disconnected", connected: false }));
|
||||
const disconnectedRefresh = vi.spyOn(disconnectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn(disconnectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "disconnected", connected: false }));
|
||||
const disconnectedRefresh = vi.spyOn(disconnectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
|
||||
const connectedLeaf = leaf();
|
||||
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
|
||||
const connectedView = connectedLeaf.view as CodexChatView;
|
||||
vi.spyOn(connectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const connectedRefresh = vi.spyOn(connectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const connectedRefresh = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
|
||||
const plugin = await pluginWithLeaves([disconnectedLeaf, connectedLeaf]);
|
||||
|
||||
|
|
@ -513,16 +513,18 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
const sourceLeaf = leaf();
|
||||
sourceLeaf.view = chatView(CodexChatView, sourceLeaf);
|
||||
const sourceView = sourceLeaf.view as CodexChatView;
|
||||
vi.spyOn(sourceView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "source", threadId: "thread-1", connected: true }));
|
||||
const sourceRefresh = vi.spyOn(sourceView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
vi.spyOn(sourceView.surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "source", threadId: "thread-1", connected: true }),
|
||||
);
|
||||
const sourceRefresh = vi.spyOn(sourceView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
|
||||
const remainingLeaf = leaf();
|
||||
remainingLeaf.view = chatView(CodexChatView, remainingLeaf);
|
||||
const remainingView = remainingLeaf.view as CodexChatView;
|
||||
vi.spyOn(remainingView, "openPanelSnapshot").mockReturnValue(
|
||||
vi.spyOn(remainingView.surface, "openPanelSnapshot").mockReturnValue(
|
||||
panelSnapshot({ viewId: "remaining", threadId: "forked-thread", connected: true }),
|
||||
);
|
||||
const remainingRefresh = vi.spyOn(remainingView, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
const remainingRefresh = vi.spyOn(remainingView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
|
||||
|
||||
const leaves = [sourceLeaf, remainingLeaf];
|
||||
sourceLeaf.detach.mockImplementation(() => {
|
||||
|
|
@ -617,17 +619,15 @@ function chatHostFixture(): CodexChatHost {
|
|||
focusThreadInOpenView: vi.fn(),
|
||||
openTurnDiff: vi.fn(),
|
||||
},
|
||||
threadSurfaces: {
|
||||
threadCatalog: {
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
refreshSharedThreadListFromOpenSurface: vi.fn(),
|
||||
refreshFromOpenSurface: vi.fn(),
|
||||
refreshThreadsViewLiveState: vi.fn(),
|
||||
applyThreadListSnapshot: vi.fn(),
|
||||
applyThreads: vi.fn(),
|
||||
publishAppServerMetadata: vi.fn(),
|
||||
},
|
||||
sharedCache: {
|
||||
refreshThreadList: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
|
||||
cachedThreadList: vi.fn(() => null),
|
||||
refreshThreads: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
|
||||
cachedThreads: vi.fn(() => null),
|
||||
cachedAppServerMetadata: vi.fn(() => null),
|
||||
},
|
||||
};
|
||||
|
|
@ -645,8 +645,8 @@ function thread(id: string): Thread {
|
|||
}
|
||||
|
||||
function panelSnapshot(
|
||||
overrides: Partial<ReturnType<CodexChatView["openPanelSnapshot"]>> = {},
|
||||
): ReturnType<CodexChatView["openPanelSnapshot"]> {
|
||||
overrides: Partial<ReturnType<CodexChatView["surface"]["openPanelSnapshot"]>> = {},
|
||||
): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> {
|
||||
return {
|
||||
viewId: "view",
|
||||
threadId: "thread",
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ describe("settings tab", () => {
|
|||
|
||||
it("ignores stale archived restore results after a newer dynamic operation completes", async () => {
|
||||
const staleRestore = deferred<{ thread: ThreadRecord }>();
|
||||
const refreshSharedThreadListFromOpenSurface = vi.fn();
|
||||
const refreshFromOpenSurface = vi.fn();
|
||||
const initialClient = settingsClient({
|
||||
threads: [appServerThread({ id: "thread-old", preview: "Old archived" })],
|
||||
});
|
||||
|
|
@ -338,7 +338,7 @@ describe("settings tab", () => {
|
|||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(newerClient),
|
||||
);
|
||||
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshSharedThreadListFromOpenSurface }), {
|
||||
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshFromOpenSurface }), {
|
||||
display: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
|
@ -353,7 +353,7 @@ describe("settings tab", () => {
|
|||
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
|
||||
await restore;
|
||||
|
||||
expect(refreshSharedThreadListFromOpenSurface).not.toHaveBeenCalled();
|
||||
expect(refreshFromOpenSurface).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
|
||||
});
|
||||
|
||||
|
|
@ -551,7 +551,7 @@ function newSettingsTab(
|
|||
cachedModels?: ModelMetadata[];
|
||||
publishModels?: (models: ModelMetadata[]) => void;
|
||||
refreshOpenViews?: () => void;
|
||||
refreshSharedThreadListFromOpenSurface?: () => void;
|
||||
refreshFromOpenSurface?: () => void;
|
||||
settings?: Partial<{
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: string | null;
|
||||
|
|
@ -570,7 +570,7 @@ function settingsTabHost(
|
|||
cachedModels?: ModelMetadata[];
|
||||
publishModels?: (models: ModelMetadata[]) => void;
|
||||
refreshOpenViews?: () => void;
|
||||
refreshSharedThreadListFromOpenSurface?: () => void;
|
||||
refreshFromOpenSurface?: () => void;
|
||||
settings?: Partial<{
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: string | null;
|
||||
|
|
@ -597,9 +597,11 @@ function settingsTabHost(
|
|||
vaultPath: "/vault",
|
||||
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
|
||||
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
|
||||
refreshSharedThreadListFromOpenSurface: options.refreshSharedThreadListFromOpenSurface ?? vi.fn(),
|
||||
cachedModels: vi.fn(() => options.cachedModels ?? []),
|
||||
publishModels: options.publishModels ?? vi.fn(),
|
||||
threadCatalog: {
|
||||
refreshFromOpenSurface: options.refreshFromOpenSurface ?? vi.fn(),
|
||||
cachedModels: vi.fn(() => options.cachedModels ?? []),
|
||||
publishModels: options.publishModels ?? vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
129
tests/workspace/shared-thread-catalog.test.ts
Normal file
129
tests/workspace/shared-thread-catalog.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import { SharedAppServerCache } from "../../src/app-server/services/shared-cache";
|
||||
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
|
||||
import { createServerDiagnostics } from "../../src/domain/server/diagnostics";
|
||||
import type { SharedServerMetadata } from "../../src/domain/server/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { SharedThreadCatalog } from "../../src/workspace/shared-thread-catalog";
|
||||
import type { ThreadSurfaceActions } from "../../src/workspace/thread-surface-actions";
|
||||
|
||||
type MockSurfaceActions = ThreadSurfaceActions & {
|
||||
applyThreadListSnapshot: Mock<(threads: readonly Thread[]) => void>;
|
||||
publishAppServerMetadata: Mock<(metadata: SharedServerMetadata) => void>;
|
||||
publishModels: Mock<(models: readonly ModelMetadata[]) => void>;
|
||||
notifyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>;
|
||||
notifyThreadRenamed: Mock<(threadId: string, name: string | null) => void>;
|
||||
};
|
||||
|
||||
describe("SharedThreadCatalog", () => {
|
||||
it("applies thread snapshots to the shared cache and open surfaces", () => {
|
||||
const { catalog, surfaces } = catalogFixture();
|
||||
const threads = [thread("thread")];
|
||||
|
||||
catalog.applyThreads(threads);
|
||||
|
||||
expect(catalog.cachedThreads()).toEqual(threads);
|
||||
expect(surfaces.applyThreadListSnapshot).toHaveBeenCalledWith(threads);
|
||||
});
|
||||
|
||||
it("refreshes thread snapshots through the cache single-flight and publishes the snapshot once", async () => {
|
||||
const { catalog, surfaces } = catalogFixture();
|
||||
const fetchThreads = vi.fn().mockResolvedValue([thread("thread")]);
|
||||
|
||||
const first = catalog.refreshThreads(fetchThreads);
|
||||
const second = catalog.refreshThreads(fetchThreads);
|
||||
|
||||
await expect(first).resolves.toEqual([thread("thread")]);
|
||||
await expect(second).resolves.toEqual([thread("thread")]);
|
||||
expect(fetchThreads).toHaveBeenCalledOnce();
|
||||
expect(catalog.cachedThreads()).toEqual([thread("thread")]);
|
||||
expect(surfaces.applyThreadListSnapshot).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("publishes metadata and model snapshots to cache and surfaces", () => {
|
||||
const { catalog, surfaces } = catalogFixture();
|
||||
const metadata = serverMetadata({ availableModels: [model("gpt-test")] });
|
||||
const models = [model("gpt-other")];
|
||||
|
||||
catalog.publishAppServerMetadata(metadata);
|
||||
catalog.publishModels(models);
|
||||
|
||||
expect(catalog.cachedAppServerMetadata()).toEqual({ ...metadata, availableModels: models });
|
||||
expect(catalog.cachedModels()).toEqual(models);
|
||||
expect(surfaces.publishAppServerMetadata).toHaveBeenCalledWith(metadata);
|
||||
expect(surfaces.publishModels).toHaveBeenCalledWith(models);
|
||||
});
|
||||
|
||||
it("forwards archive and rename notifications through the surface owner", () => {
|
||||
const { catalog, surfaces } = catalogFixture();
|
||||
|
||||
catalog.notifyThreadArchived("thread", { closeOpenPanels: true });
|
||||
catalog.notifyThreadRenamed("thread", "Renamed");
|
||||
|
||||
expect(surfaces.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
|
||||
expect(surfaces.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed");
|
||||
});
|
||||
});
|
||||
|
||||
function catalogFixture() {
|
||||
const surfaces = surfaceActions();
|
||||
const catalog = new SharedThreadCatalog({
|
||||
cache: new SharedAppServerCache(),
|
||||
surfaces,
|
||||
context: () => ({ codexPath: "codex", vaultPath: "/vault" }),
|
||||
});
|
||||
return { catalog, surfaces };
|
||||
}
|
||||
|
||||
function surfaceActions(): MockSurfaceActions {
|
||||
return {
|
||||
refreshOpenViews: vi.fn(),
|
||||
refreshSharedThreadListFromOpenSurface: vi.fn(),
|
||||
applyThreadListSnapshot: vi.fn(),
|
||||
publishAppServerMetadata: vi.fn(),
|
||||
publishModels: vi.fn(),
|
||||
refreshThreadsViewLiveState: vi.fn(),
|
||||
notifyThreadArchived: vi.fn(),
|
||||
notifyThreadRenamed: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function thread(id: string): Thread {
|
||||
return {
|
||||
id,
|
||||
preview: id,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
name: null,
|
||||
archived: false,
|
||||
};
|
||||
}
|
||||
|
||||
function model(modelId: string): ModelMetadata {
|
||||
return {
|
||||
id: modelId,
|
||||
model: modelId,
|
||||
displayName: modelId,
|
||||
description: "",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: [],
|
||||
additionalSpeedTiers: [],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
|
||||
return {
|
||||
runtimeConfig: null,
|
||||
availableModels: [],
|
||||
availableSkills: [],
|
||||
rateLimit: null,
|
||||
serverDiagnostics: createServerDiagnostics(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -20,8 +20,10 @@ describe("createThreadSurfaceActions", () => {
|
|||
panels: {
|
||||
panelViews: () => [
|
||||
{
|
||||
openPanelSnapshot: () => panelSnapshot({ connected: false }),
|
||||
refreshSharedThreadList: disconnectedPanelRefresh,
|
||||
surface: {
|
||||
openPanelSnapshot: () => panelSnapshot({ connected: false }),
|
||||
refreshSharedThreadList: disconnectedPanelRefresh,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
|
|
@ -48,12 +50,16 @@ describe("createThreadSurfaceActions", () => {
|
|||
panels: {
|
||||
panelViews: () => [
|
||||
{
|
||||
openPanelSnapshot: () => panelSnapshot({ viewId: "disconnected", connected: false }),
|
||||
refreshSharedThreadList: disconnectedPanelRefresh,
|
||||
surface: {
|
||||
openPanelSnapshot: () => panelSnapshot({ viewId: "disconnected", connected: false }),
|
||||
refreshSharedThreadList: disconnectedPanelRefresh,
|
||||
},
|
||||
},
|
||||
{
|
||||
openPanelSnapshot: () => panelSnapshot({ viewId: "connected", connected: true }),
|
||||
refreshSharedThreadList: connectedPanelRefresh,
|
||||
surface: {
|
||||
openPanelSnapshot: () => panelSnapshot({ viewId: "connected", connected: true }),
|
||||
refreshSharedThreadList: connectedPanelRefresh,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
|
|
|
|||
Loading…
Reference in a new issue