mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Refactor panel graph and app-server boundaries
This commit is contained in:
parent
fa5f9214f1
commit
20e82a09ff
31 changed files with 681 additions and 552 deletions
5
src/app-server/connection/client-access.ts
Normal file
5
src/app-server/connection/client-access.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { AppServerClient } from "./client";
|
||||
|
||||
export interface AppServerClientAccess {
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: { unhandledServerRequestMessage?: string }): Promise<T>;
|
||||
}
|
||||
|
|
@ -173,6 +173,7 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
|
|||
export class AppServerClient {
|
||||
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
|
||||
private readonly rpc: JsonRpcClient;
|
||||
private readonly intentionallyStoppedTransports = new WeakSet<AppServerTransport>();
|
||||
|
||||
constructor(
|
||||
private readonly codexPath: string,
|
||||
|
|
@ -197,21 +198,30 @@ export class AppServerClient {
|
|||
throw new Error("Codex app-server is already running.");
|
||||
}
|
||||
|
||||
let transport: AppServerTransport;
|
||||
const transportHandlers: AppServerTransportHandlers = {
|
||||
onLine: (line) => {
|
||||
if (!this.isActiveTransport(transport)) return;
|
||||
this.rpc.handleLine(line);
|
||||
},
|
||||
onLog: this.handlers.onLog,
|
||||
onLog: (message) => {
|
||||
if (!this.isActiveTransport(transport)) return;
|
||||
this.handlers.onLog(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
this.rpc.rejectAll(error);
|
||||
if (!this.isActiveTransport(transport)) return;
|
||||
this.failActiveTransport(transport, error);
|
||||
},
|
||||
onExit: (code, signal) => {
|
||||
if (!this.isActiveTransport(transport)) return;
|
||||
const intentional = this.intentionallyStoppedTransports.has(transport);
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rpc.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
|
||||
if (intentional) return;
|
||||
this.handlers.onExit(code, signal);
|
||||
},
|
||||
};
|
||||
const transport = this.transportFactory
|
||||
transport = this.transportFactory
|
||||
? this.transportFactory(transportHandlers)
|
||||
: new StdioAppServerTransport(this.codexPath, this.cwd, transportHandlers);
|
||||
this.lifecycle = { kind: "starting", transport };
|
||||
|
|
@ -234,9 +244,12 @@ export class AppServerClient {
|
|||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.activeTransport()?.stop();
|
||||
const transport = this.activeTransport();
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rpc.rejectAll(new Error("Codex app-server disconnected."));
|
||||
if (!transport) return;
|
||||
this.intentionallyStoppedTransports.add(transport);
|
||||
transport.stop();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
|
|
@ -516,4 +529,16 @@ export class AppServerClient {
|
|||
private activeTransport(): AppServerTransport | null {
|
||||
return this.lifecycle.kind === "disconnected" ? null : this.lifecycle.transport;
|
||||
}
|
||||
|
||||
private isActiveTransport(transport: AppServerTransport): boolean {
|
||||
return this.activeTransport() === transport;
|
||||
}
|
||||
|
||||
private failActiveTransport(transport: AppServerTransport, error: Error): void {
|
||||
if (!this.isActiveTransport(transport)) return;
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rpc.rejectAll(error);
|
||||
this.intentionallyStoppedTransports.add(transport);
|
||||
transport.stop();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export interface ChatConnectionControllerHost {
|
|||
metadata: ChatConnectionMetadataActions;
|
||||
diagnostics: ChatConnectionDiagnosticsActions;
|
||||
invalidateResumeWork: () => void;
|
||||
loadSharedThreadList: () => Promise<void>;
|
||||
refreshSharedThreadList: () => Promise<void>;
|
||||
scheduleDeferredDiagnostics: () => void;
|
||||
clearDeferredDiagnostics: () => void;
|
||||
refreshTabHeader: () => void;
|
||||
|
|
@ -107,7 +107,7 @@ async function ensureConnected(host: ChatConnectionControllerHost): Promise<void
|
|||
async function refreshActiveThreads(host: ChatConnectionControllerHost): Promise<void> {
|
||||
if (!host.connection.currentClient()) return;
|
||||
try {
|
||||
await host.loadSharedThreadList();
|
||||
await host.refreshSharedThreadList();
|
||||
host.refreshTabHeader();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
|
|
@ -154,7 +154,7 @@ async function initializeConnection(host: ChatConnectionControllerHost, connecti
|
|||
if (!client) throw new Error("Codex app-server connection did not initialize.");
|
||||
await host.metadata.refreshAppServerMetadata();
|
||||
if (host.connectionWork.isStale(connection)) return;
|
||||
await host.loadSharedThreadList();
|
||||
await host.refreshSharedThreadList();
|
||||
if (host.connectionWork.isStale(connection)) return;
|
||||
host.scheduleDeferredDiagnostics();
|
||||
host.refreshTabHeader();
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ interface ChatPanelConnectionBundleHost {
|
|||
connectionWork: ConnectionWorkTracker;
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
invalidateResumeWork: () => void;
|
||||
loadSharedThreadList: () => Promise<void>;
|
||||
deferLiveStateRefresh: () => void;
|
||||
refreshTabHeader: () => void;
|
||||
refreshLiveState: () => void;
|
||||
|
|
@ -62,6 +61,7 @@ export interface ChatPanelConnectionBundle {
|
|||
metadata: ChatServerMetadataActions;
|
||||
diagnostics: ChatServerDiagnosticsActions;
|
||||
};
|
||||
refreshSharedThreadList: () => Promise<void>;
|
||||
}
|
||||
|
||||
function respondToCurrentServerRequest(currentClient: CurrentAppServerClient, requestId: RespondRequestId, result: unknown): boolean {
|
||||
|
|
@ -122,9 +122,13 @@ export function createConnectionBundle(
|
|||
void goalSync.syncThreadGoal(threadId);
|
||||
},
|
||||
});
|
||||
const refreshSharedThreadList = async (): Promise<void> => {
|
||||
const threads = await environment.plugin.threadCatalog.refresh();
|
||||
serverThreads.applyThreadList(threads);
|
||||
};
|
||||
const inboundController = new ChatInboundController(stateStore, {
|
||||
refreshActiveThreads: () => {
|
||||
void host.loadSharedThreadList();
|
||||
void refreshSharedThreadList();
|
||||
},
|
||||
refreshRateLimits: () => {
|
||||
void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true });
|
||||
|
|
@ -198,7 +202,7 @@ export function createConnectionBundle(
|
|||
diagnostics: {
|
||||
refreshDiagnosticProbes: (options) => serverDiagnostics.refreshDiagnosticProbes(options),
|
||||
},
|
||||
loadSharedThreadList: () => host.loadSharedThreadList(),
|
||||
refreshSharedThreadList,
|
||||
scheduleDeferredDiagnostics: () => {
|
||||
host.deferredTasks.scheduleDiagnostics(() => {
|
||||
if (connection.isConnected()) {
|
||||
|
|
@ -234,5 +238,6 @@ export function createConnectionBundle(
|
|||
metadata: serverMetadata,
|
||||
diagnostics: serverDiagnostics,
|
||||
},
|
||||
refreshSharedThreadList,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
|
||||
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
|
||||
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
|
||||
import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service";
|
||||
|
|
@ -13,7 +17,7 @@ import type { ComposerSubmitActions } from "../application/conversation/composer
|
|||
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
|
||||
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
|
||||
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
|
||||
import { activeTurnId, type ChatConnectionPhase } from "../application/state/root-reducer";
|
||||
import { activeTurnId, chatTurnBusy, type ChatAction, type ChatConnectionPhase } from "../application/state/root-reducer";
|
||||
import { messageStreamItems } from "../application/state/message-stream";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle";
|
||||
|
|
@ -46,13 +50,23 @@ import type { ToolbarActions } from "../ui/toolbar";
|
|||
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
|
||||
import { currentModel, runtimeConfigOrDefault } from "../domain/runtime/effective";
|
||||
import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context";
|
||||
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
|
||||
import { normalizeExplicitThreadName, type Thread } from "../../../domain/threads/model";
|
||||
import type { SharedServerMetadata } from "../../../domain/server/metadata";
|
||||
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
|
||||
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
|
||||
import {
|
||||
effortStatusLines as buildEffortStatusLines,
|
||||
modelStatusLines as buildModelStatusLines,
|
||||
statusSummaryLines as buildStatusSummaryLines,
|
||||
} from "../presentation/runtime/status";
|
||||
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
|
||||
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
|
||||
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
|
||||
import type { RuntimeSnapshot } from "../application/runtime/snapshot";
|
||||
import type { ChatPanelEnvironment } from "./runtime";
|
||||
import { createConnectionBundle, type ChatPanelConnectionBundle, type CurrentAppServerClient } from "./connection-bundle";
|
||||
|
||||
export interface ChatPanelSessionParts {
|
||||
export interface ChatPanelSessionGraph {
|
||||
connection: {
|
||||
manager: ConnectionManager;
|
||||
controller: ChatPanelConnectionBundle["connection"]["controller"];
|
||||
|
|
@ -85,15 +99,31 @@ export interface ChatPanelSessionParts {
|
|||
goal: ChatPanelGoalSurface;
|
||||
composer: ChatPanelComposerSurface;
|
||||
};
|
||||
actions: {
|
||||
invalidateResumeWork(): void;
|
||||
refreshSharedThreadList(): Promise<void>;
|
||||
startNewThread(): Promise<void>;
|
||||
statusSummaryLines(): string[];
|
||||
modelStatusLines(): string[];
|
||||
effortStatusLines(): string[];
|
||||
connectionDiagnosticDetails(): MessageStreamNoticeSection[];
|
||||
};
|
||||
runtime: {
|
||||
applyCachedAppServerState(): void;
|
||||
subscribeAppServerState(): void;
|
||||
unsubscribeAppServerState(): void;
|
||||
refreshLiveState(): void;
|
||||
deferLiveStateRefresh(): void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPanelSessionStatus {
|
||||
interface ChatPanelSessionStatus {
|
||||
set: (statusText: string, phase?: ChatConnectionPhase) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
|
||||
}
|
||||
|
||||
interface ChatPanelSessionPartsHost {
|
||||
interface ChatPanelSessionGraphHost {
|
||||
environment: ChatPanelEnvironment;
|
||||
stateStore: ChatStateStore;
|
||||
deferredTasks: ChatViewDeferredTasks;
|
||||
|
|
@ -102,17 +132,7 @@ interface ChatPanelSessionPartsHost {
|
|||
messageScrollIntent: ChatMessageScrollIntentState;
|
||||
getOpened: () => boolean;
|
||||
getClosing: () => boolean;
|
||||
invalidateResumeWork: () => void;
|
||||
loadSharedThreadList: () => Promise<void>;
|
||||
notifyActiveThreadIdentityChanged: () => void;
|
||||
refreshTabHeader: () => void;
|
||||
refreshLiveState: () => void;
|
||||
deferLiveStateRefresh: () => void;
|
||||
startNewThread: () => Promise<void>;
|
||||
statusSummaryLines: () => string[];
|
||||
modelStatusLines: () => string[];
|
||||
effortStatusLines: () => string[];
|
||||
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
|
||||
viewWindow: () => Window;
|
||||
}
|
||||
|
||||
type ChatPanelGoalSyncActions = ReturnType<typeof createThreadGoalSyncActions>;
|
||||
|
|
@ -141,20 +161,70 @@ interface ChatPanelSurfacePresenterParts {
|
|||
messageStreamPresenter: MessageStreamPresenter;
|
||||
}
|
||||
|
||||
export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, status: ChatPanelSessionStatus): ChatPanelSessionParts {
|
||||
interface ChatPanelSessionGraphActionParts {
|
||||
connection: ConnectionManager;
|
||||
serverParts: ChatPanelConnectionBundle;
|
||||
invalidateResumeWork: () => void;
|
||||
startNewThread: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ChatPanelSessionGraphRuntimeParts {
|
||||
serverActions: ChatPanelConnectionBundle["serverActions"];
|
||||
}
|
||||
|
||||
export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph {
|
||||
const { actionParts, runtimeParts, ...graphObjects } = buildChatPanelSessionGraphObjects(host);
|
||||
return {
|
||||
...graphObjects,
|
||||
actions: createChatPanelSessionGraphActions(host, actionParts),
|
||||
runtime: createChatPanelSessionGraphRuntime(host, runtimeParts),
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatPanelSessionGraphObjects extends Omit<ChatPanelSessionGraph, "actions" | "runtime"> {
|
||||
actionParts: ChatPanelSessionGraphActionParts;
|
||||
runtimeParts: ChatPanelSessionGraphRuntimeParts;
|
||||
}
|
||||
|
||||
function buildChatPanelSessionGraphObjects(host: ChatPanelSessionGraphHost): ChatPanelSessionGraphObjects {
|
||||
const { environment, stateStore } = host;
|
||||
const localItemIds = createLocalChatItemIdFactory();
|
||||
const connection = createConnectionManager(environment);
|
||||
const currentClient = () => connection.currentClient();
|
||||
const status = createSessionStatus(stateStore, localItemIds);
|
||||
let threadHistory: HistoryController | null = null;
|
||||
const invalidateGraphResumeWork = () => {
|
||||
host.resumeWork.invalidate();
|
||||
threadHistory?.invalidate();
|
||||
};
|
||||
const titleService = createSessionThreadTitleService(host, currentClient);
|
||||
const autoTitle = createSessionAutoTitleController(host, currentClient, titleService);
|
||||
const goalSync = createSessionGoalSyncActions(host, currentClient, status);
|
||||
const serverParts = createConnectionBundle(host, {
|
||||
connection,
|
||||
currentClient,
|
||||
goalSync,
|
||||
autoTitle,
|
||||
status,
|
||||
});
|
||||
const serverParts = createConnectionBundle(
|
||||
{
|
||||
environment,
|
||||
stateStore,
|
||||
connectionWork: host.connectionWork,
|
||||
deferredTasks: host.deferredTasks,
|
||||
invalidateResumeWork: invalidateGraphResumeWork,
|
||||
deferLiveStateRefresh: () => {
|
||||
deferLiveStateRefresh(host);
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
refreshTabHeader(host);
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
refreshLiveState(host);
|
||||
},
|
||||
},
|
||||
{
|
||||
connection,
|
||||
currentClient,
|
||||
goalSync,
|
||||
autoTitle,
|
||||
status,
|
||||
},
|
||||
);
|
||||
const {
|
||||
connection: { controller },
|
||||
inboundController,
|
||||
|
|
@ -163,16 +233,18 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
|
|||
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
|
||||
const ensureConnected = () => connectionController.ensureConnected();
|
||||
const refreshActiveThreads = () => connectionController.refreshActiveThreads();
|
||||
const threadOperations = createSessionThreadOperations(environment, currentClient, ensureConnected);
|
||||
const threadOperations = createSessionThreadOperations(environment, currentClient);
|
||||
const runtimeSettings = createSessionRuntimeSettingsActions(host, currentClient, status);
|
||||
const goals = createSessionGoalActions(host, currentClient, ensureConnected, status);
|
||||
const rename = createSessionThreadRenameEditor(stateStore, threadOperations, titleService, ensureConnected, status);
|
||||
const threadLifecycle = createSessionThreadLifecycle(host, currentClient, ensureConnected, status, goals, autoTitle);
|
||||
const { history, identity, restoration, resume } = threadLifecycle;
|
||||
threadHistory = history;
|
||||
|
||||
const composerSurface = createSessionComposerSurface(threadLifecycle, runtimeSettings);
|
||||
const messageStreamScrollBridge = new MessageStreamScrollBridge();
|
||||
const composerController = createSessionComposerController(host, composerSurface, runtimeSettings, messageStreamScrollBridge);
|
||||
const startNewThread = createStartNewThreadAction(host, { identity, composerController });
|
||||
const threadActionParts = createThreadActionParts(host, {
|
||||
operations: threadOperations,
|
||||
ensureConnected,
|
||||
|
|
@ -197,6 +269,14 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
|
|||
serverDiagnostics,
|
||||
goals,
|
||||
autoTitle,
|
||||
invalidateResumeWork: invalidateGraphResumeWork,
|
||||
startNewThread,
|
||||
runtimeProjection: {
|
||||
connectionDiagnosticDetails: () => connectionDiagnosticDetails(host, connection),
|
||||
modelStatusLines: () => modelStatusLines(host),
|
||||
effortStatusLines: () => effortStatusLines(host),
|
||||
statusSummaryLines: () => statusSummaryLines(host),
|
||||
},
|
||||
});
|
||||
const surfaceAndPresenter = createSurfacesAndPresenter(host, {
|
||||
connection,
|
||||
|
|
@ -213,6 +293,7 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
|
|||
pendingRequests: composerAndTurn.pendingRequests,
|
||||
turnActions: composerAndTurn.turnActions,
|
||||
messageStreamScrollBridge,
|
||||
startNewThread,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -244,6 +325,15 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
|
|||
goal: surfaceAndPresenter.goalSurface,
|
||||
composer: composerSurface,
|
||||
},
|
||||
actionParts: {
|
||||
connection,
|
||||
serverParts,
|
||||
invalidateResumeWork: invalidateGraphResumeWork,
|
||||
startNewThread,
|
||||
},
|
||||
runtimeParts: {
|
||||
serverActions: serverParts.serverActions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -251,14 +341,115 @@ function createConnectionManager(environment: ChatPanelEnvironment): ConnectionM
|
|||
return new ConnectionManager(() => environment.plugin.settingsRef.settings.codexPath, environment.plugin.settingsRef.vaultPath);
|
||||
}
|
||||
|
||||
function createSessionThreadTitleService(host: ChatPanelSessionPartsHost, currentClient: CurrentAppServerClient): ThreadTitleService {
|
||||
function createStartNewThreadAction(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: {
|
||||
identity: IdentitySync;
|
||||
composerController: ChatComposerController;
|
||||
},
|
||||
): () => Promise<void> {
|
||||
return async () => {
|
||||
if (chatTurnBusy(host.stateStore.getState())) return;
|
||||
|
||||
input.identity.clearActiveThreadContext();
|
||||
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
host.stateStore.dispatch({ type: "connection/status-set", statusText: "New chat." });
|
||||
input.composerController.focus();
|
||||
};
|
||||
}
|
||||
|
||||
function createChatPanelSessionGraphActions(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: ChatPanelSessionGraphActionParts,
|
||||
): ChatPanelSessionGraph["actions"] {
|
||||
return {
|
||||
invalidateResumeWork: () => {
|
||||
input.invalidateResumeWork();
|
||||
},
|
||||
refreshSharedThreadList: async () => {
|
||||
try {
|
||||
await input.serverParts.refreshSharedThreadList();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
startNewThread: input.startNewThread,
|
||||
statusSummaryLines: () => statusSummaryLines(host),
|
||||
modelStatusLines: () => modelStatusLines(host),
|
||||
effortStatusLines: () => effortStatusLines(host),
|
||||
connectionDiagnosticDetails: () => connectionDiagnosticDetails(host, input.connection),
|
||||
};
|
||||
}
|
||||
|
||||
function createChatPanelSessionGraphRuntime(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: ChatPanelSessionGraphRuntimeParts,
|
||||
): ChatPanelSessionGraph["runtime"] {
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
const receiveThreads = (threads: readonly Thread[]): void => {
|
||||
input.serverActions.threads.applyThreadList(threads);
|
||||
refreshTabHeader(host);
|
||||
};
|
||||
const receiveThreadResult = (result: AppServerObservedQueryResult<readonly Thread[]>): void => {
|
||||
if (result.data) receiveThreads(result.data);
|
||||
};
|
||||
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
|
||||
input.serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
};
|
||||
const receiveAppServerMetadataResult = (result: AppServerObservedQueryResult<SharedServerMetadata>): void => {
|
||||
if (result.data) receiveAppServerMetadata(result.data);
|
||||
};
|
||||
const receiveModels = (models: readonly ModelMetadata[]): void => {
|
||||
dispatch(host.stateStore, { type: "connection/metadata-applied", availableModels: models });
|
||||
};
|
||||
const receiveModelsResult = (result: AppServerObservedQueryResult<readonly ModelMetadata[]>): void => {
|
||||
if (result.data) receiveModels(result.data);
|
||||
};
|
||||
const unsubscribeAppServerState = (): void => {
|
||||
while (unsubscribers.length > 0) {
|
||||
unsubscribers.pop()?.();
|
||||
}
|
||||
};
|
||||
const applyCachedAppServerState = (): void => {
|
||||
const threads = host.environment.plugin.threadCatalog.snapshot();
|
||||
if (threads) input.serverActions.threads.applyThreadList(threads);
|
||||
const metadata = host.environment.plugin.appServerData.appServerMetadataSnapshot();
|
||||
if (metadata) input.serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
const models = host.environment.plugin.appServerData.modelsSnapshot();
|
||||
if (models) receiveModels(models);
|
||||
};
|
||||
|
||||
return {
|
||||
applyCachedAppServerState,
|
||||
subscribeAppServerState: () => {
|
||||
unsubscribeAppServerState();
|
||||
applyCachedAppServerState();
|
||||
unsubscribers.push(
|
||||
host.environment.plugin.threadCatalog.observe(receiveThreadResult, { emitCurrent: false }),
|
||||
host.environment.plugin.appServerData.observeAppServerMetadataResult(receiveAppServerMetadataResult, { emitCurrent: false }),
|
||||
host.environment.plugin.appServerData.observeModelsResult(receiveModelsResult, { emitCurrent: false }),
|
||||
);
|
||||
},
|
||||
unsubscribeAppServerState,
|
||||
refreshLiveState: () => {
|
||||
refreshLiveState(host);
|
||||
},
|
||||
deferLiveStateRefresh: () => {
|
||||
deferLiveStateRefresh(host);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionThreadTitleService(host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient): ThreadTitleService {
|
||||
const { environment, stateStore } = host;
|
||||
return createThreadTitleService({
|
||||
settings: {
|
||||
current: () => environment.plugin.settingsRef.settings,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
},
|
||||
currentClient,
|
||||
codexPath: () => environment.plugin.settingsRef.settings.codexPath,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
threadNamingModel: () => environment.plugin.settingsRef.settings.threadNamingModel,
|
||||
threadNamingEffort: () => environment.plugin.settingsRef.settings.threadNamingEffort,
|
||||
clientAccess: createCurrentClientAccess(currentClient),
|
||||
visibleContext: (threadId) => activeThreadRenameTitleContext(stateStore.getState(), threadId),
|
||||
visibleCompletedTurnContext: (turnId) =>
|
||||
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)),
|
||||
|
|
@ -266,7 +457,7 @@ function createSessionThreadTitleService(host: ChatPanelSessionPartsHost, curren
|
|||
}
|
||||
|
||||
function createSessionAutoTitleController(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
titleService: ThreadTitleService,
|
||||
): AutoTitleController {
|
||||
|
|
@ -291,7 +482,7 @@ function createSessionAutoTitleController(
|
|||
}
|
||||
|
||||
function createSessionGoalSyncActions(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
status: ChatPanelSessionStatus,
|
||||
): ChatPanelGoalSyncActions {
|
||||
|
|
@ -305,23 +496,17 @@ function createSessionGoalSyncActions(
|
|||
host.stateStore.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
host.refreshLiveState();
|
||||
refreshLiveState(host);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createSessionThreadOperations(
|
||||
environment: ChatPanelEnvironment,
|
||||
currentClient: CurrentAppServerClient,
|
||||
ensureConnected: () => Promise<void>,
|
||||
): ThreadOperations {
|
||||
function createSessionThreadOperations(environment: ChatPanelEnvironment, currentClient: CurrentAppServerClient): ThreadOperations {
|
||||
return createThreadOperations({
|
||||
connection: {
|
||||
ensureConnected,
|
||||
currentClient,
|
||||
},
|
||||
settings: {
|
||||
current: () => environment.plugin.settingsRef.settings,
|
||||
clientAccess: createCurrentClientAccess(currentClient),
|
||||
archiveExport: {
|
||||
settings: () => environment.plugin.settingsRef.settings,
|
||||
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
},
|
||||
archiveAdapter: environment.obsidian.archiveAdapter,
|
||||
|
|
@ -332,8 +517,22 @@ function createSessionThreadOperations(
|
|||
});
|
||||
}
|
||||
|
||||
function createCurrentClientAccess(currentClient: CurrentAppServerClient): AppServerClientAccess {
|
||||
return {
|
||||
withClient: async (operation) => {
|
||||
const client = currentClient();
|
||||
if (!client) throw new Error("Codex app-server is not connected.");
|
||||
const result = await operation(client);
|
||||
if (currentClient() !== client) {
|
||||
throw new Error("Codex app-server connection changed while running the operation.");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionRuntimeSettingsActions(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
status: ChatPanelSessionStatus,
|
||||
): ChatPanelRuntimeSettingsActions {
|
||||
|
|
@ -349,7 +548,7 @@ function createSessionRuntimeSettingsActions(
|
|||
}
|
||||
|
||||
function createSessionGoalActions(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
ensureConnected: () => Promise<void>,
|
||||
status: ChatPanelSessionStatus,
|
||||
|
|
@ -365,7 +564,7 @@ function createSessionGoalActions(
|
|||
host.stateStore.dispatch({ type: "message-stream/item-upserted", item });
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
host.refreshLiveState();
|
||||
refreshLiveState(host);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -387,7 +586,7 @@ function createSessionThreadRenameEditor(
|
|||
}
|
||||
|
||||
function createSessionThreadLifecycle(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
currentClient: CurrentAppServerClient,
|
||||
ensureConnected: () => Promise<void>,
|
||||
status: ChatPanelSessionStatus,
|
||||
|
|
@ -409,16 +608,16 @@ function createSessionThreadLifecycle(
|
|||
},
|
||||
thread: {
|
||||
notifyIdentityChanged: () => {
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
notifyActiveThreadIdentityChanged(host);
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
host.refreshTabHeader();
|
||||
refreshTabHeader(host);
|
||||
},
|
||||
},
|
||||
status,
|
||||
liveState: {
|
||||
refresh: () => {
|
||||
host.refreshLiveState();
|
||||
refreshLiveState(host);
|
||||
},
|
||||
},
|
||||
scroll: {
|
||||
|
|
@ -452,7 +651,7 @@ function createSessionComposerSurface(
|
|||
}
|
||||
|
||||
function createSessionComposerController(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
composerSurface: ChatPanelComposerSurface,
|
||||
runtimeSettings: ChatPanelRuntimeSettingsActions,
|
||||
messageStreamScrollBridge: MessageStreamScrollBridge,
|
||||
|
|
@ -479,7 +678,7 @@ function createSessionComposerController(
|
|||
toggleAutoReview: () => void runtimeSettings.toggleAutoReview(),
|
||||
toggleFast: () => void runtimeSettings.toggleFastMode(),
|
||||
onDraftChange: () => {
|
||||
host.refreshLiveState();
|
||||
refreshLiveState(host);
|
||||
},
|
||||
onHeightChange: () => {
|
||||
messageStreamScrollBridge.repinMessageStreamToBottomIfPinned();
|
||||
|
|
@ -488,7 +687,7 @@ function createSessionComposerController(
|
|||
}
|
||||
|
||||
function createThreadActionParts(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: {
|
||||
operations: ThreadOperations;
|
||||
ensureConnected: () => Promise<void>;
|
||||
|
|
@ -515,7 +714,7 @@ function createThreadActionParts(
|
|||
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
|
||||
openThreadInCurrentPanel: (threadId) => resume.resumeThread(threadId),
|
||||
notifyActiveThreadIdentityChanged: () => {
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
notifyActiveThreadIdentityChanged(host);
|
||||
},
|
||||
refreshAfterThreadMutation: async () => {
|
||||
await refreshActiveThreads();
|
||||
|
|
@ -542,7 +741,7 @@ function createThreadActionParts(
|
|||
}
|
||||
|
||||
function createComposerAndTurnActions(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: {
|
||||
connection: ConnectionManager;
|
||||
ensureConnected: () => Promise<void>;
|
||||
|
|
@ -558,6 +757,14 @@ function createComposerAndTurnActions(
|
|||
serverDiagnostics: ChatServerDiagnosticsActions;
|
||||
goals: ChatPanelGoalActions;
|
||||
autoTitle: AutoTitleController;
|
||||
invalidateResumeWork: () => void;
|
||||
startNewThread: () => Promise<void>;
|
||||
runtimeProjection: {
|
||||
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
|
||||
modelStatusLines: () => string[];
|
||||
effortStatusLines: () => string[];
|
||||
statusSummaryLines: () => string[];
|
||||
};
|
||||
},
|
||||
): ChatPanelComposerAndTurnParts {
|
||||
const {
|
||||
|
|
@ -575,13 +782,16 @@ function createComposerAndTurnActions(
|
|||
serverDiagnostics,
|
||||
goals,
|
||||
autoTitle,
|
||||
invalidateResumeWork,
|
||||
startNewThread,
|
||||
runtimeProjection,
|
||||
} = input;
|
||||
const pendingRequests = new PendingRequestController({
|
||||
stateStore: host.stateStore,
|
||||
responder: inboundController,
|
||||
composerHasFocus: () => composerController.hasFocus(),
|
||||
refreshLiveState: () => {
|
||||
host.refreshLiveState();
|
||||
refreshLiveState(host);
|
||||
},
|
||||
});
|
||||
const reconnectHost: ChatReconnectActionsHost = {
|
||||
|
|
@ -590,7 +800,7 @@ function createComposerAndTurnActions(
|
|||
host.connectionWork.invalidate();
|
||||
},
|
||||
invalidateResumeWork: () => {
|
||||
host.invalidateResumeWork();
|
||||
invalidateResumeWork();
|
||||
},
|
||||
clearDeferredDiagnostics: () => {
|
||||
host.deferredTasks.clearDiagnostics();
|
||||
|
|
@ -618,19 +828,19 @@ function createComposerAndTurnActions(
|
|||
},
|
||||
status,
|
||||
runtime: {
|
||||
connectionDiagnosticDetails: () => host.connectionDiagnosticDetails(),
|
||||
modelStatusLines: () => host.modelStatusLines(),
|
||||
effortStatusLines: () => host.effortStatusLines(),
|
||||
statusSummaryLines: () => host.statusSummaryLines(),
|
||||
connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails,
|
||||
modelStatusLines: runtimeProjection.modelStatusLines,
|
||||
effortStatusLines: runtimeProjection.effortStatusLines,
|
||||
statusSummaryLines: runtimeProjection.statusSummaryLines,
|
||||
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
|
||||
},
|
||||
thread: {
|
||||
ensureRestoredThreadLoaded: () =>
|
||||
threadLifecycle.restoration.ensureLoaded((threadId) => threadLifecycle.resume.resumeThread(threadId)),
|
||||
startNewThread: () => host.startNewThread(),
|
||||
startNewThread,
|
||||
selectThread: (threadId) => selection.selectThread(threadId),
|
||||
notifyIdentityChanged: () => {
|
||||
host.notifyActiveThreadIdentityChanged();
|
||||
notifyActiveThreadIdentityChanged(host);
|
||||
},
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
autoTitle.resetThreadTurnPresence(hadTurns);
|
||||
|
|
@ -666,7 +876,7 @@ function createComposerAndTurnActions(
|
|||
}
|
||||
|
||||
function createSurfacesAndPresenter(
|
||||
host: ChatPanelSessionPartsHost,
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: {
|
||||
connection: ConnectionManager;
|
||||
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
|
||||
|
|
@ -682,6 +892,7 @@ function createSurfacesAndPresenter(
|
|||
pendingRequests: PendingRequestController;
|
||||
turnActions: ChatPanelConversationTurnActions;
|
||||
messageStreamScrollBridge: MessageStreamScrollBridge;
|
||||
startNewThread: () => Promise<void>;
|
||||
},
|
||||
): ChatPanelSurfacePresenterParts {
|
||||
const {
|
||||
|
|
@ -699,12 +910,13 @@ function createSurfacesAndPresenter(
|
|||
pendingRequests,
|
||||
turnActions,
|
||||
messageStreamScrollBridge,
|
||||
startNewThread,
|
||||
} = input;
|
||||
const { environment, stateStore } = host;
|
||||
const toolbarActions = createChatPanelToolbarActions(
|
||||
{
|
||||
stateStore,
|
||||
startNewThread: () => host.startNewThread(),
|
||||
startNewThread,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
|
|
@ -729,7 +941,7 @@ function createSurfacesAndPresenter(
|
|||
};
|
||||
const goalSurface = createChatPanelGoalSurface(
|
||||
{
|
||||
settings: environment.plugin.settingsRef.settings,
|
||||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
|
||||
stateStore,
|
||||
},
|
||||
{
|
||||
|
|
@ -788,3 +1000,86 @@ function createSurfacesAndPresenter(
|
|||
function collaborationModeLabel(stateStore: ChatStateStore): string {
|
||||
return formatCollaborationModeLabel(stateStore.getState().runtime.selectedCollaborationMode);
|
||||
}
|
||||
|
||||
function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalChatItemIdFactory): ChatPanelSessionStatus {
|
||||
return {
|
||||
set: (statusText, phase) => {
|
||||
dispatch(stateStore, { type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
|
||||
},
|
||||
addSystemMessage: (text) => {
|
||||
dispatch(stateStore, { type: "message-stream/system-item-added", item: createSystemItem(localItemIds.next("system"), text) });
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
dispatch(stateStore, {
|
||||
type: "message-stream/system-item-added",
|
||||
item: createStructuredSystemItem(localItemIds.next("system"), text, details),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(stateStore: ChatStateStore, action: ChatAction): void {
|
||||
stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
function notifyActiveThreadIdentityChanged(host: ChatPanelSessionGraphHost): void {
|
||||
refreshTabHeader(host);
|
||||
host.environment.obsidian.requestWorkspaceLayoutSave();
|
||||
}
|
||||
|
||||
function refreshTabHeader(host: ChatPanelSessionGraphHost): void {
|
||||
host.environment.view.refreshTabHeader();
|
||||
}
|
||||
|
||||
function refreshLiveState(host: ChatPanelSessionGraphHost): void {
|
||||
host.environment.plugin.workspace.refreshThreadsViewLiveState();
|
||||
}
|
||||
|
||||
function deferLiveStateRefresh(host: ChatPanelSessionGraphHost): void {
|
||||
host.viewWindow().setTimeout(() => {
|
||||
refreshLiveState(host);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function statusSummaryLines(host: ChatPanelSessionGraphHost): string[] {
|
||||
const state = host.stateStore.getState();
|
||||
return buildStatusSummaryLines({
|
||||
activeThreadId: state.activeThread.id,
|
||||
snapshot: runtimeSnapshot(host),
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function modelStatusLines(host: ChatPanelSessionGraphHost): string[] {
|
||||
const state = host.stateStore.getState();
|
||||
return buildModelStatusLines({
|
||||
runtimeConfig: state.connection.runtimeConfig,
|
||||
requestedModel: state.runtime.requestedModel,
|
||||
snapshot: runtimeSnapshot(host),
|
||||
collaborationModeLabel: collaborationModeLabel(host.stateStore),
|
||||
});
|
||||
}
|
||||
|
||||
function effortStatusLines(host: ChatPanelSessionGraphHost): string[] {
|
||||
const state = host.stateStore.getState();
|
||||
return buildEffortStatusLines({
|
||||
runtimeConfig: state.connection.runtimeConfig,
|
||||
requestedReasoningEffort: state.runtime.requestedReasoningEffort,
|
||||
snapshot: runtimeSnapshot(host),
|
||||
});
|
||||
}
|
||||
|
||||
function connectionDiagnosticDetails(host: ChatPanelSessionGraphHost, connection: ConnectionManager): MessageStreamNoticeSection[] {
|
||||
return connectionDiagnosticsModel({
|
||||
state: host.stateStore.getState(),
|
||||
connected: connection.isConnected(),
|
||||
configuredCommand: host.environment.plugin.settingsRef.settings.codexPath,
|
||||
}).map((section) => ({
|
||||
title: section.title,
|
||||
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
|
||||
}));
|
||||
}
|
||||
|
||||
function runtimeSnapshot(host: ChatPanelSessionGraphHost): RuntimeSnapshot {
|
||||
return runtimeSnapshotForChatState(host.stateStore.getState());
|
||||
}
|
||||
|
|
@ -1,35 +1,20 @@
|
|||
import type { AppServerClient } from "../../../app-server/connection/client";
|
||||
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys";
|
||||
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 { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
|
||||
import { shortThreadId } from "../../../utils";
|
||||
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";
|
||||
import {
|
||||
effortStatusLines as buildEffortStatusLines,
|
||||
modelStatusLines as buildModelStatusLines,
|
||||
statusSummaryLines as buildStatusSummaryLines,
|
||||
} from "../presentation/runtime/status";
|
||||
import { createChatViewDeferredTasks } from "./lifecycle";
|
||||
import { ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle";
|
||||
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
|
||||
import { openPanelTurnLifecycle, parseRestoredThreadState, type ChatPanelSnapshot } from "../panel/snapshot";
|
||||
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
|
||||
import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "../application/runtime/snapshot";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState } from "../application/state/root-reducer";
|
||||
import type { ChatState } from "../application/state/root-reducer";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell";
|
||||
import { createChatStateStore, type ChatStateStore } from "../application/state/store";
|
||||
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll";
|
||||
import type { ChatSurfaceHandle } from "./surface-handle";
|
||||
import type { ChatPanelEnvironment } from "./runtime";
|
||||
import { createChatPanelSessionParts, type ChatPanelSessionParts, type ChatPanelSessionStatus } from "./session-parts";
|
||||
import { createChatPanelSessionGraph, type ChatPanelSessionGraph } from "./session-graph";
|
||||
|
||||
function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string {
|
||||
if (!activeThreadId) return "Codex";
|
||||
|
|
@ -41,14 +26,12 @@ function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly
|
|||
|
||||
export class ChatPanelSession implements ChatSurfaceHandle {
|
||||
private readonly stateStore: ChatStateStore = createChatStateStore();
|
||||
private readonly parts: ChatPanelSessionParts;
|
||||
private readonly graph: ChatPanelSessionGraph;
|
||||
|
||||
private readonly deferredTasks: ChatViewDeferredTasks;
|
||||
private readonly connectionWork = new ConnectionWorkTracker();
|
||||
private readonly resumeWork = new ChatResumeWorkTracker();
|
||||
private readonly messageScrollIntent: ChatMessageScrollIntentState = createChatMessageScrollIntentState();
|
||||
private readonly localItemIds: LocalChatItemIdFactory = createLocalChatItemIdFactory();
|
||||
private readonly appServerStateUnsubscribers: (() => void)[] = [];
|
||||
private observedAppServerContext: AppServerQueryContext;
|
||||
private opened = false;
|
||||
private closing = false;
|
||||
|
|
@ -56,7 +39,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
constructor(private readonly environment: ChatPanelEnvironment) {
|
||||
this.observedAppServerContext = this.currentAppServerContext();
|
||||
this.deferredTasks = createChatViewDeferredTasks(() => this.viewWindow());
|
||||
this.parts = this.createSessionParts();
|
||||
this.graph = this.createSessionGraph();
|
||||
}
|
||||
|
||||
displayTitle(): string {
|
||||
|
|
@ -78,14 +61,14 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
applyViewState(state: unknown): void {
|
||||
const restoredThread = parseRestoredThreadState(state);
|
||||
if (restoredThread) {
|
||||
this.parts.thread.restoration.restore(restoredThread);
|
||||
this.graph.thread.restoration.restore(restoredThread);
|
||||
this.scheduleRestoredThreadHydration();
|
||||
return;
|
||||
}
|
||||
|
||||
this.invalidateResumeWork();
|
||||
this.parts.thread.restoration.clear();
|
||||
this.parts.thread.restoration.clearHydration();
|
||||
this.graph.actions.invalidateResumeWork();
|
||||
this.graph.thread.restoration.clear();
|
||||
this.graph.thread.restoration.clearHydration();
|
||||
this.scheduleWarmup();
|
||||
}
|
||||
|
||||
|
|
@ -94,22 +77,22 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) {
|
||||
this.observedAppServerContext = nextContext;
|
||||
this.connectionWork.invalidate();
|
||||
this.invalidateResumeWork();
|
||||
this.parts.connection.manager.resetConnection();
|
||||
this.applyCachedAppServerState();
|
||||
this.graph.actions.invalidateResumeWork();
|
||||
this.graph.connection.manager.resetConnection();
|
||||
this.graph.runtime.applyCachedAppServerState();
|
||||
}
|
||||
this.mountOrRepairShell();
|
||||
}
|
||||
|
||||
refreshSharedThreadList(): Promise<void> {
|
||||
return this.loadSharedThreadList();
|
||||
return this.graph.actions.refreshSharedThreadList();
|
||||
}
|
||||
|
||||
async runWithAppServerClient<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
|
||||
const client = this.parts.connection.manager.currentClient();
|
||||
const client = this.graph.connection.manager.currentClient();
|
||||
if (!client) throw new Error("Codex app-server is not connected.");
|
||||
const result = await operation(client);
|
||||
if (this.parts.connection.manager.currentClient() !== client) {
|
||||
if (this.graph.connection.manager.currentClient() !== client) {
|
||||
throw new Error("Codex app-server connection changed while loading shared data.");
|
||||
}
|
||||
return result;
|
||||
|
|
@ -123,44 +106,44 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
pendingApprovals: this.state.requests.approvals.length,
|
||||
pendingUserInputs: this.state.requests.pendingUserInputs.length,
|
||||
hasComposerDraft: this.state.composer.draft.trim().length > 0,
|
||||
connected: this.parts.connection.manager.isConnected(),
|
||||
connected: this.graph.connection.manager.isConnected(),
|
||||
};
|
||||
}
|
||||
|
||||
async openThread(threadId: string): Promise<void> {
|
||||
await this.parts.thread.resume.resumeThread(threadId);
|
||||
await this.graph.thread.resume.resumeThread(threadId);
|
||||
this.focusComposer();
|
||||
}
|
||||
|
||||
async focusThread(threadId: string | null = null): Promise<void> {
|
||||
if (threadId && this.parts.thread.restoration.isPending(threadId)) {
|
||||
if (threadId && this.graph.thread.restoration.isPending(threadId)) {
|
||||
await this.ensureRestoredThreadLoaded();
|
||||
}
|
||||
this.focusComposer();
|
||||
}
|
||||
|
||||
focusComposer(): void {
|
||||
this.parts.composer.controller.focus();
|
||||
this.graph.composer.controller.focus();
|
||||
}
|
||||
|
||||
applyThreadArchived(threadId: string): void {
|
||||
this.parts.thread.identity.applyThreadArchived(threadId);
|
||||
this.graph.thread.identity.applyThreadArchived(threadId);
|
||||
}
|
||||
|
||||
applyThreadRenamed(threadId: string, name: string | null): void {
|
||||
this.parts.thread.identity.applyThreadRenamed(threadId, name);
|
||||
this.graph.thread.identity.applyThreadRenamed(threadId, name);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.opened = true;
|
||||
this.closing = false;
|
||||
this.parts.composer.controller.registerNoteIndexInvalidation((eventRef) => {
|
||||
this.graph.composer.controller.registerNoteIndexInvalidation((eventRef) => {
|
||||
this.environment.obsidian.registerEvent(eventRef);
|
||||
});
|
||||
this.environment.obsidian.registerPointerDown((event) => {
|
||||
this.closeToolbarPanelOnOutsidePointer(event);
|
||||
});
|
||||
this.subscribeAppServerState();
|
||||
this.graph.runtime.subscribeAppServerState();
|
||||
this.mountOrRepairShell();
|
||||
this.scheduleWarmup();
|
||||
this.scheduleRestoredThreadHydration();
|
||||
|
|
@ -170,108 +153,34 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
this.opened = false;
|
||||
this.closing = true;
|
||||
this.connectionWork.invalidate();
|
||||
this.invalidateResumeWork();
|
||||
this.graph.actions.invalidateResumeWork();
|
||||
this.deferredTasks.clearAll();
|
||||
this.unsubscribeAppServerState();
|
||||
this.graph.runtime.unsubscribeAppServerState();
|
||||
const panelRoot = this.environment.view.panelRoot();
|
||||
this.parts.render.messageStreamPresenter.dispose();
|
||||
this.parts.composer.controller.dispose();
|
||||
this.graph.render.messageStreamPresenter.dispose();
|
||||
this.graph.composer.controller.dispose();
|
||||
unmountChatPanelShell(panelRoot);
|
||||
this.parts.connection.manager.disconnect();
|
||||
this.refreshLiveState();
|
||||
this.deferLiveStateRefresh();
|
||||
this.graph.connection.manager.disconnect();
|
||||
this.graph.runtime.refreshLiveState();
|
||||
this.graph.runtime.deferLiveStateRefresh();
|
||||
}
|
||||
|
||||
setComposerText(text: string): void {
|
||||
this.parts.composer.controller.setDraft(text, { focus: true });
|
||||
this.graph.composer.controller.setDraft(text, { focus: true });
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.parts.connection.controller.ensureConnected();
|
||||
await this.graph.connection.controller.ensureConnected();
|
||||
}
|
||||
|
||||
async startNewThread(): Promise<void> {
|
||||
if (chatTurnBusy(this.state)) return;
|
||||
|
||||
this.parts.thread.identity.clearActiveThreadContext();
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
this.dispatch({ type: "connection/status-set", statusText: "New chat." });
|
||||
this.focusComposer();
|
||||
await this.graph.actions.startNewThread();
|
||||
}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
private receiveObservedThreads(threads: readonly Thread[]): void {
|
||||
this.parts.serverActions.threads.applyThreadList(threads);
|
||||
this.refreshTabHeader();
|
||||
}
|
||||
|
||||
private receiveObservedThreadResult(result: AppServerObservedQueryResult<readonly Thread[]>): void {
|
||||
if (result.data) this.receiveObservedThreads(result.data);
|
||||
}
|
||||
|
||||
private receiveObservedAppServerMetadata(metadata: SharedServerMetadata): void {
|
||||
this.parts.serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
}
|
||||
|
||||
private receiveObservedAppServerMetadataResult(result: AppServerObservedQueryResult<SharedServerMetadata>): void {
|
||||
if (result.data) this.receiveObservedAppServerMetadata(result.data);
|
||||
}
|
||||
|
||||
private receiveObservedModels(models: readonly ModelMetadata[]): void {
|
||||
this.dispatch({ type: "connection/metadata-applied", availableModels: models });
|
||||
}
|
||||
|
||||
private receiveObservedModelsResult(result: AppServerObservedQueryResult<readonly ModelMetadata[]>): void {
|
||||
if (result.data) this.receiveObservedModels(result.data);
|
||||
}
|
||||
|
||||
private applyCachedAppServerState(): void {
|
||||
const threads = this.environment.plugin.threadCatalog.snapshot();
|
||||
if (threads) this.parts.serverActions.threads.applyThreadList(threads);
|
||||
const metadata = this.environment.plugin.appServerData.appServerMetadataSnapshot();
|
||||
if (metadata) this.parts.serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
const models = this.environment.plugin.appServerData.modelsSnapshot();
|
||||
if (models) this.receiveObservedModels(models);
|
||||
}
|
||||
|
||||
private subscribeAppServerState(): void {
|
||||
this.unsubscribeAppServerState();
|
||||
this.applyCachedAppServerState();
|
||||
this.appServerStateUnsubscribers.push(
|
||||
this.environment.plugin.threadCatalog.observe(
|
||||
(result) => {
|
||||
this.receiveObservedThreadResult(result);
|
||||
},
|
||||
{ emitCurrent: false },
|
||||
),
|
||||
this.environment.plugin.appServerData.observeAppServerMetadataResult(
|
||||
(result) => {
|
||||
this.receiveObservedAppServerMetadataResult(result);
|
||||
},
|
||||
{ emitCurrent: false },
|
||||
),
|
||||
this.environment.plugin.appServerData.observeModelsResult(
|
||||
(result) => {
|
||||
this.receiveObservedModelsResult(result);
|
||||
},
|
||||
{ emitCurrent: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private unsubscribeAppServerState(): void {
|
||||
while (this.appServerStateUnsubscribers.length > 0) {
|
||||
this.appServerStateUnsubscribers.pop()?.();
|
||||
}
|
||||
}
|
||||
|
||||
private mountOrRepairShell(): void {
|
||||
const root = this.environment.view.panelRoot();
|
||||
if (!root) return;
|
||||
|
|
@ -280,15 +189,15 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar,
|
||||
parts: {
|
||||
toolbar: {
|
||||
surface: this.parts.surface.toolbar,
|
||||
actions: this.parts.toolbar.actions,
|
||||
surface: this.graph.surface.toolbar,
|
||||
actions: this.graph.toolbar.actions,
|
||||
},
|
||||
goal: this.parts.surface.goal,
|
||||
messageStream: this.parts.render.messageStreamPresenter,
|
||||
goal: this.graph.surface.goal,
|
||||
messageStream: this.graph.render.messageStreamPresenter,
|
||||
composer: {
|
||||
controller: this.parts.composer.controller,
|
||||
controller: this.graph.composer.controller,
|
||||
actions: {
|
||||
submit: () => void this.parts.composer.submission.submit(),
|
||||
submit: () => void this.graph.composer.submission.submit(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -296,49 +205,15 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
}
|
||||
|
||||
private scheduleWarmup(): void {
|
||||
const shouldWarmup = (): boolean => this.opened && !this.parts.connection.manager.isConnected();
|
||||
const shouldWarmup = (): boolean => this.opened && !this.graph.connection.manager.isConnected();
|
||||
if (!shouldWarmup()) return;
|
||||
|
||||
this.deferredTasks.scheduleAppServerWarmup(() => {
|
||||
if (!shouldWarmup() || this.closing) return;
|
||||
void this.parts.connection.controller.ensureConnected();
|
||||
void this.graph.connection.controller.ensureConnected();
|
||||
});
|
||||
}
|
||||
|
||||
private invalidateResumeWork(): void {
|
||||
this.resumeWork.invalidate();
|
||||
this.parts.thread.history.invalidate();
|
||||
}
|
||||
|
||||
private async loadSharedThreadList(): Promise<void> {
|
||||
try {
|
||||
const threads = await this.environment.plugin.threadCatalog.refresh();
|
||||
this.parts.serverActions.threads.applyThreadList(threads);
|
||||
} catch (error) {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private notifyActiveThreadIdentityChanged(): void {
|
||||
this.refreshTabHeader();
|
||||
this.environment.obsidian.requestWorkspaceLayoutSave();
|
||||
}
|
||||
|
||||
private refreshTabHeader(): void {
|
||||
this.environment.view.refreshTabHeader();
|
||||
}
|
||||
|
||||
private refreshLiveState(): void {
|
||||
this.environment.plugin.workspace.refreshThreadsViewLiveState();
|
||||
}
|
||||
|
||||
private deferLiveStateRefresh(): void {
|
||||
this.viewWindow().setTimeout(() => {
|
||||
this.refreshLiveState();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private viewWindow(): Window {
|
||||
return this.environment.view.viewWindow() ?? window;
|
||||
}
|
||||
|
|
@ -351,11 +226,11 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
}
|
||||
|
||||
private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void {
|
||||
this.parts.toolbar.panels.closeOnOutsidePointer({
|
||||
this.graph.toolbar.panels.closeOnOutsidePointer({
|
||||
target: event.target,
|
||||
viewWindow: this.environment.view.viewWindow() as (Window & { Element: typeof Element }) | null,
|
||||
contains: (element) => this.environment.view.containsElement(element),
|
||||
renameEditing: this.parts.thread.rename.isEditing(),
|
||||
renameEditing: this.graph.thread.rename.isEditing(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -367,23 +242,19 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
}
|
||||
|
||||
private restoredThreadTitle(): string | null {
|
||||
return this.parts.thread.restoration.title();
|
||||
return this.graph.thread.restoration.title();
|
||||
}
|
||||
|
||||
private ensureRestoredThreadLoaded(): Promise<boolean> {
|
||||
return this.parts.thread.restoration.ensureLoaded((threadId) => this.parts.thread.resume.resumeThread(threadId));
|
||||
return this.graph.thread.restoration.ensureLoaded((threadId) => this.graph.thread.resume.resumeThread(threadId));
|
||||
}
|
||||
|
||||
private scheduleRestoredThreadHydration(): void {
|
||||
this.parts.thread.restoration.scheduleHydration((threadId) => this.parts.thread.resume.resumeThread(threadId));
|
||||
this.graph.thread.restoration.scheduleHydration((threadId) => this.graph.thread.resume.resumeThread(threadId));
|
||||
}
|
||||
|
||||
private createSessionParts(): ChatPanelSessionParts {
|
||||
return createChatPanelSessionParts(this.sessionPartsHost(), this.createSessionStatus());
|
||||
}
|
||||
|
||||
private sessionPartsHost(): Parameters<typeof createChatPanelSessionParts>[0] {
|
||||
return {
|
||||
private createSessionGraph(): ChatPanelSessionGraph {
|
||||
return createChatPanelSessionGraph({
|
||||
environment: this.environment,
|
||||
stateStore: this.stateStore,
|
||||
deferredTasks: this.deferredTasks,
|
||||
|
|
@ -392,93 +263,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
|
|||
messageScrollIntent: this.messageScrollIntent,
|
||||
getOpened: () => this.opened,
|
||||
getClosing: () => this.closing,
|
||||
invalidateResumeWork: () => {
|
||||
this.invalidateResumeWork();
|
||||
},
|
||||
loadSharedThreadList: () => this.loadSharedThreadList(),
|
||||
notifyActiveThreadIdentityChanged: () => {
|
||||
this.notifyActiveThreadIdentityChanged();
|
||||
},
|
||||
refreshTabHeader: () => {
|
||||
this.refreshTabHeader();
|
||||
},
|
||||
refreshLiveState: () => {
|
||||
this.refreshLiveState();
|
||||
},
|
||||
deferLiveStateRefresh: () => {
|
||||
this.deferLiveStateRefresh();
|
||||
},
|
||||
startNewThread: () => this.startNewThread(),
|
||||
statusSummaryLines: () => this.statusSummaryLines(),
|
||||
modelStatusLines: () => this.modelStatusLines(),
|
||||
effortStatusLines: () => this.effortStatusLines(),
|
||||
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
private createSessionStatus(): ChatPanelSessionStatus {
|
||||
return {
|
||||
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 statusSummaryLines(): string[] {
|
||||
return buildStatusSummaryLines({
|
||||
activeThreadId: this.state.activeThread.id,
|
||||
snapshot: this.runtimeSnapshot(),
|
||||
nowMs: Date.now(),
|
||||
viewWindow: () => this.viewWindow(),
|
||||
});
|
||||
}
|
||||
|
||||
private modelStatusLines(): string[] {
|
||||
return buildModelStatusLines({
|
||||
runtimeConfig: this.state.connection.runtimeConfig,
|
||||
requestedModel: this.state.runtime.requestedModel,
|
||||
snapshot: this.runtimeSnapshot(),
|
||||
collaborationModeLabel: this.collaborationModeLabel(),
|
||||
});
|
||||
}
|
||||
|
||||
private effortStatusLines(): string[] {
|
||||
return buildEffortStatusLines({
|
||||
runtimeConfig: this.state.connection.runtimeConfig,
|
||||
requestedReasoningEffort: this.state.runtime.requestedReasoningEffort,
|
||||
snapshot: this.runtimeSnapshot(),
|
||||
});
|
||||
}
|
||||
|
||||
private connectionDiagnosticDetails(): MessageStreamNoticeSection[] {
|
||||
return connectionDiagnosticsModel({
|
||||
state: this.state,
|
||||
connected: this.parts.connection.manager.isConnected(),
|
||||
configuredCommand: this.environment.plugin.settingsRef.settings.codexPath,
|
||||
}).map((section) => ({
|
||||
title: section.title,
|
||||
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
|
||||
}));
|
||||
}
|
||||
|
||||
private collaborationModeLabel(): string {
|
||||
return formatCollaborationModeLabel(this.state.runtime.selectedCollaborationMode);
|
||||
}
|
||||
|
||||
private runtimeSnapshot(): RuntimeSnapshot {
|
||||
return runtimeSnapshotForChatState(this.state);
|
||||
}
|
||||
|
||||
private systemItem(text: string): MessageStreamItem {
|
||||
return createSystemItem(this.localItemIds.next("system"), text);
|
||||
}
|
||||
|
||||
private structuredSystemItem(text: string, details: MessageStreamNoticeSection[]): MessageStreamItem {
|
||||
return createStructuredSystemItem(this.localItemIds.next("system"), text, details);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import type { ChatConnectionController } from "../../application/connection/connection-controller";
|
||||
import type { ChatInboundController } from "../../app-server/inbound/controller";
|
||||
import type { GoalActions } from "../../application/threads/goal-actions";
|
||||
|
|
@ -31,7 +30,7 @@ export interface ChatPanelGoalSurface {
|
|||
}
|
||||
|
||||
export interface ChatPanelGoalSurfaceHost {
|
||||
settings: CodexPanelSettings;
|
||||
sendShortcut: () => SendShortcut;
|
||||
stateStore: ChatStateStore;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +59,7 @@ export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps:
|
|||
|
||||
return {
|
||||
settings: {
|
||||
sendShortcut: () => host.settings.sendShortcut,
|
||||
sendShortcut: host.sendShortcut,
|
||||
},
|
||||
actions: {
|
||||
goal: {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH
|
|||
new Notice("Select text to rewrite first.");
|
||||
return;
|
||||
}
|
||||
const viewDocument = view.containerEl.doc;
|
||||
const viewWindow = viewDocument.defaultView;
|
||||
if (!viewWindow) {
|
||||
new Notice("Could not open rewrite popover for this note.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rewriteState: SelectionRewriteState = {
|
||||
filePath: view.file.path,
|
||||
|
|
@ -61,6 +67,8 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH
|
|||
runtimeSettings: plugin.settings,
|
||||
sendShortcut: plugin.settings.sendShortcut,
|
||||
state: rewriteState,
|
||||
viewDocument,
|
||||
viewWindow,
|
||||
});
|
||||
popover.open();
|
||||
activePopovers.add(popover);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export interface SelectionRewritePopoverOptions {
|
|||
runtimeSettings: SelectionRewriteRuntimeSettings;
|
||||
sendShortcut: SendShortcut;
|
||||
state: SelectionRewriteState;
|
||||
viewDocument: Document;
|
||||
viewWindow: Window;
|
||||
}
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
|
@ -62,24 +64,24 @@ export class SelectionRewritePopover {
|
|||
const elements = this.createElements();
|
||||
this.elements = elements;
|
||||
|
||||
this.addDomListener(activeWindow, "resize", () => {
|
||||
this.addDomListener(this.options.viewWindow, "resize", () => {
|
||||
this.position();
|
||||
});
|
||||
this.addDomListener(
|
||||
activeWindow,
|
||||
this.options.viewWindow,
|
||||
"scroll",
|
||||
() => {
|
||||
this.position();
|
||||
},
|
||||
true,
|
||||
);
|
||||
this.addDomListener(activeDocument, "keydown", (event) => {
|
||||
this.addDomListener(this.options.viewDocument, "keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.cancel();
|
||||
}
|
||||
});
|
||||
this.addDomListener(activeDocument, "pointerdown", (event) => {
|
||||
this.addDomListener(this.options.viewDocument, "pointerdown", (event) => {
|
||||
if (!this.elements?.root.contains(event.target as Node | null)) this.cancel();
|
||||
});
|
||||
|
||||
|
|
@ -127,7 +129,7 @@ export class SelectionRewritePopover {
|
|||
}
|
||||
|
||||
private createElements(): SelectionRewriteElements {
|
||||
const root = activeDocument.body.createDiv({ cls: "codex-panel-selection-rewrite" });
|
||||
const root = this.options.viewDocument.body.createDiv({ cls: "codex-panel-selection-rewrite" });
|
||||
root.setAttr("role", "dialog");
|
||||
const elements: SelectionRewriteElements = { root, instruction: null, applyButton: null };
|
||||
this.renderView(elements);
|
||||
|
|
@ -172,7 +174,7 @@ export class SelectionRewritePopover {
|
|||
|
||||
private position(): void {
|
||||
if (!this.elements) return;
|
||||
if (!positionSelectionRewritePopover(this.elements.root, this.options.editor, POPOVER_MARGIN)) this.close();
|
||||
if (!positionSelectionRewritePopover(this.elements.root, this.options.editor, this.options.viewWindow, POPOVER_MARGIN)) this.close();
|
||||
}
|
||||
|
||||
private renderView(elements: SelectionRewriteElements | null = this.elements): void {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import type { Editor } from "obsidian";
|
||||
|
||||
export function positionSelectionRewritePopover(root: HTMLElement, editor: Editor, margin: number): boolean {
|
||||
export function positionSelectionRewritePopover(root: HTMLElement, editor: Editor, viewWindow: Window, margin: number): boolean {
|
||||
if (!root.isConnected) return false;
|
||||
|
||||
const view = editorViewFromEditor(editor);
|
||||
if (view?.dom instanceof HTMLElement && !view.dom.isConnected) return false;
|
||||
|
||||
const anchor = selectionRect() ?? editorCursorRect(editor) ?? root.ownerDocument.body.getBoundingClientRect();
|
||||
const anchor = selectionRect(viewWindow) ?? editorCursorRect(editor) ?? root.ownerDocument.body.getBoundingClientRect();
|
||||
const size = root.getBoundingClientRect();
|
||||
const viewportWidth = activeWindow.innerWidth;
|
||||
const viewportHeight = activeWindow.innerHeight;
|
||||
const viewportWidth = viewWindow.innerWidth;
|
||||
const viewportHeight = viewWindow.innerHeight;
|
||||
const left = clamp(anchor.left, margin, viewportWidth - size.width - margin);
|
||||
const belowTop = anchor.bottom + margin;
|
||||
const aboveTop = anchor.top - size.height - margin;
|
||||
|
|
@ -20,8 +20,8 @@ export function positionSelectionRewritePopover(root: HTMLElement, editor: Edito
|
|||
return true;
|
||||
}
|
||||
|
||||
function selectionRect(): DOMRect | null {
|
||||
const selection = activeWindow.getSelection();
|
||||
function selectionRect(viewWindow: Window): DOMRect | null {
|
||||
const selection = viewWindow.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
||||
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
return rect.width > 0 || rect.height > 0 ? rect : null;
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ import { Notice, Platform, SuggestModal, type App } from "obsidian";
|
|||
|
||||
import { getThreadTitle } from "../../domain/threads/model";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import { shortThreadId } from "../../utils";
|
||||
import type { ActiveThreadCatalogReader } from "../../workspace/active-thread-catalog";
|
||||
|
||||
export interface ThreadPickerHost {
|
||||
readonly app: App;
|
||||
readonly settings: CodexPanelSettings;
|
||||
readonly vaultPath: string;
|
||||
readonly threadCatalog: ActiveThreadCatalogReader;
|
||||
openThreadInCurrentView(threadId: string): Promise<void>;
|
||||
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import type { AppServerClient } from "../../app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
|
||||
import type { AppServerObservedQueryResult } from "../../app-server/query/cache";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
|
||||
import { ConnectionManager, type ConnectionManagerHandlers, StaleConnectionError } from "../../app-server/connection/connection-manager";
|
||||
import type { ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
|
||||
import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator";
|
||||
import type { ActiveThreadCatalogReader, ActiveThreadCatalogThreadEvents } from "../../workspace/active-thread-catalog";
|
||||
import { ConnectionWorkTracker } from "../../shared/lifecycle/connection-work";
|
||||
import type { ArchiveExportAdapter } from "../../domain/threads/archive-markdown";
|
||||
import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations";
|
||||
import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service";
|
||||
import { renderThreadsView, unmountThreadsView } from "./renderer";
|
||||
|
|
@ -32,8 +30,9 @@ import {
|
|||
} from "./view-lifecycle";
|
||||
|
||||
export interface CodexThreadsHost {
|
||||
readonly settings: CodexPanelSettings;
|
||||
readonly settings: CodexThreadsSettings;
|
||||
readonly vaultPath: string;
|
||||
readonly clientAccess: AppServerClientAccess;
|
||||
readonly threadCatalog: ThreadsThreadCatalog;
|
||||
openNewPanel(): Promise<unknown>;
|
||||
openThreadInAvailableView(threadId: string): Promise<void>;
|
||||
|
|
@ -42,6 +41,13 @@ export interface CodexThreadsHost {
|
|||
|
||||
type ThreadsThreadCatalog = ActiveThreadCatalogReader & ActiveThreadCatalogThreadEvents;
|
||||
|
||||
interface CodexThreadsSettings extends ArchiveExportSettings {
|
||||
codexPath: string;
|
||||
threadNamingModel: string | null;
|
||||
threadNamingEffort: ReasoningEffort | null;
|
||||
archiveExportEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CodexThreadsSessionEnvironment {
|
||||
root: HTMLElement;
|
||||
host: CodexThreadsHost;
|
||||
|
|
@ -58,12 +64,9 @@ type ThreadsViewStatus =
|
|||
| { kind: "error"; message: string };
|
||||
|
||||
export class CodexThreadsSession {
|
||||
private readonly connection: ConnectionManager;
|
||||
private readonly operations: ThreadOperations;
|
||||
private readonly titleService: ThreadTitleService;
|
||||
private readonly deferredTasks: ThreadsViewDeferredTasks;
|
||||
private readonly connectionWork = new ConnectionWorkTracker();
|
||||
private client: AppServerClient | null = null;
|
||||
private refreshLifecycle: ThreadsViewRefreshLifecycleState = { kind: "idle" };
|
||||
private status: ThreadsViewStatus = { kind: "idle" };
|
||||
private threads: readonly Thread[] = [];
|
||||
|
|
@ -73,14 +76,11 @@ 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 = createThreadOperations({
|
||||
connection: {
|
||||
ensureConnected: () => this.ensureConnected(),
|
||||
currentClient: () => this.client,
|
||||
},
|
||||
settings: {
|
||||
current: () => this.host.settings,
|
||||
clientAccess: this.host.clientAccess,
|
||||
archiveExport: {
|
||||
settings: () => this.host.settings,
|
||||
enabled: () => this.host.settings.archiveExportEnabled,
|
||||
vaultPath: this.host.vaultPath,
|
||||
},
|
||||
archiveAdapter: () => this.environment.archiveAdapter(),
|
||||
|
|
@ -90,36 +90,14 @@ export class CodexThreadsSession {
|
|||
},
|
||||
});
|
||||
this.titleService = createThreadTitleService({
|
||||
settings: {
|
||||
current: () => this.host.settings,
|
||||
vaultPath: this.host.vaultPath,
|
||||
},
|
||||
currentClient: () => this.client,
|
||||
codexPath: () => this.host.settings.codexPath,
|
||||
vaultPath: this.host.vaultPath,
|
||||
threadNamingModel: () => this.host.settings.threadNamingModel,
|
||||
threadNamingEffort: () => this.host.settings.threadNamingEffort,
|
||||
clientAccess: this.host.clientAccess,
|
||||
});
|
||||
}
|
||||
|
||||
private connectionHandlers(): ConnectionManagerHandlers {
|
||||
return {
|
||||
onNotification: () => {
|
||||
this.scheduleRefresh();
|
||||
},
|
||||
onServerRequest: (request) => {
|
||||
this.connection.currentClient()?.rejectServerRequest(request.id, -32601, "Codex Threads view does not handle server requests.");
|
||||
},
|
||||
onLog: (message) => {
|
||||
this.status = { kind: "log", message };
|
||||
this.render();
|
||||
},
|
||||
onExit: () => {
|
||||
this.client = null;
|
||||
this.connectionWork.invalidate();
|
||||
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" });
|
||||
this.status = { kind: "error", message: "Codex app-server stopped." };
|
||||
this.render();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.environment.registerPointerDown((event) => {
|
||||
this.cancelArchiveConfirmOnOutsidePointer(event);
|
||||
|
|
@ -136,13 +114,10 @@ export class CodexThreadsSession {
|
|||
}
|
||||
|
||||
close(): void {
|
||||
this.connectionWork.invalidate();
|
||||
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" });
|
||||
this.deferredTasks.clearAll();
|
||||
this.unsubscribeThreads?.();
|
||||
this.unsubscribeThreads = null;
|
||||
this.connection.disconnect();
|
||||
this.client = null;
|
||||
unmountThreadsView(this.environment.root);
|
||||
}
|
||||
|
||||
|
|
@ -151,14 +126,11 @@ export class CodexThreadsSession {
|
|||
this.status = this.threads.length === 0 ? { kind: "loading", message: "Loading threads..." } : { kind: "idle" };
|
||||
this.render();
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.isStaleRefresh(refresh) || !this.client) return;
|
||||
const threads = await this.host.threadCatalog.refresh();
|
||||
if (this.isStaleRefresh(refresh)) return;
|
||||
this.threads = threads;
|
||||
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
|
||||
} catch (error) {
|
||||
if (error instanceof StaleConnectionError) return;
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
|
|
@ -212,29 +184,6 @@ export class CodexThreadsSession {
|
|||
return this.refreshLifecycle !== refresh;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<void> {
|
||||
const connecting = this.connectionWork.active();
|
||||
if (connecting?.promise) return connecting.promise;
|
||||
|
||||
if (this.connection.isConnected()) {
|
||||
this.client = this.connection.currentClient();
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = this.connectionWork.begin();
|
||||
const promise = this.connection
|
||||
.connect(this.connectionHandlers())
|
||||
.then(() => {
|
||||
if (this.connectionWork.isStale(connection)) throw new StaleConnectionError();
|
||||
this.client = this.connection.currentClient();
|
||||
})
|
||||
.finally(() => {
|
||||
this.connectionWork.finish(connection, promise);
|
||||
});
|
||||
connection.promise = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
renderThreadsView(
|
||||
this.environment.root,
|
||||
|
|
@ -278,12 +227,6 @@ export class CodexThreadsSession {
|
|||
});
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
this.deferredTasks.scheduleRefresh(() => {
|
||||
void this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
private async openThread(threadId: string): Promise<void> {
|
||||
this.archiveConfirmThreadId = null;
|
||||
await this.host.openThreadInAvailableView(threadId);
|
||||
|
|
@ -313,7 +256,6 @@ export class CodexThreadsSession {
|
|||
const editingState = this.renameStates.get(threadId);
|
||||
if (!editingState || editingState.kind === "generating") return;
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== editingState) return;
|
||||
const result = await this.operations.renameThread(threadId, value);
|
||||
if (!result) {
|
||||
|
|
@ -334,7 +276,6 @@ export class CodexThreadsSession {
|
|||
this.render();
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
const title = await this.titleService.generateTitle(threadId);
|
||||
const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title);
|
||||
|
|
@ -367,7 +308,6 @@ export class CodexThreadsSession {
|
|||
|
||||
private async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
const result = await this.operations.archiveThread(threadId, {
|
||||
saveMarkdown,
|
||||
closeOpenPanels: true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
|
||||
import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import { explicitThreadName, getThreadTitle } from "../../domain/threads/model";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import type { AppServerClient } from "../../app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
|
||||
import { archiveThreadOnAppServer, type ArchiveThreadResult } from "../../app-server/services/thread-archive";
|
||||
import type { ArchiveExportAdapter } from "../../domain/threads/archive-markdown";
|
||||
import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import { normalizeExplicitThreadName } from "../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
|
||||
export interface ThreadOperationsHost {
|
||||
connection: {
|
||||
ensureConnected(): Promise<void>;
|
||||
currentClient(): AppServerClient | null;
|
||||
};
|
||||
settings: {
|
||||
current(): CodexPanelSettings;
|
||||
clientAccess: AppServerClientAccess;
|
||||
archiveExport: {
|
||||
settings(): ArchiveExportSettings;
|
||||
enabled(): boolean;
|
||||
vaultPath: string;
|
||||
};
|
||||
archiveAdapter(): ArchiveExportAdapter;
|
||||
|
|
@ -51,12 +48,7 @@ async function renameThread(
|
|||
const name = normalizeExplicitThreadName(value);
|
||||
if (!name) return false;
|
||||
|
||||
await host.connection.ensureConnected();
|
||||
const client = host.connection.currentClient();
|
||||
if (!client) return false;
|
||||
|
||||
await client.setThreadName(threadId, name);
|
||||
if (host.connection.currentClient() !== client) return false;
|
||||
await host.clientAccess.withClient((client) => client.setThreadName(threadId, name));
|
||||
if (options.shouldPublish?.() ?? true) {
|
||||
host.catalog.recordThreadRenamed(threadId, name);
|
||||
}
|
||||
|
|
@ -68,18 +60,15 @@ async function archiveThread(
|
|||
threadId: string,
|
||||
options: ArchiveThreadOptions = {},
|
||||
): Promise<ArchiveThreadResult | null> {
|
||||
await host.connection.ensureConnected();
|
||||
const client = host.connection.currentClient();
|
||||
if (!client) return null;
|
||||
|
||||
const settings = host.settings.current();
|
||||
const result = await archiveThreadOnAppServer(client, threadId, {
|
||||
settings,
|
||||
vaultPath: host.settings.vaultPath,
|
||||
archiveAdapter: () => host.archiveAdapter(),
|
||||
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled,
|
||||
});
|
||||
if (host.connection.currentClient() !== client) return null;
|
||||
const archiveSettings = host.archiveExport.settings();
|
||||
const result = await host.clientAccess.withClient((client) =>
|
||||
archiveThreadOnAppServer(client, threadId, {
|
||||
settings: archiveSettings,
|
||||
vaultPath: host.archiveExport.vaultPath,
|
||||
archiveAdapter: () => host.archiveAdapter(),
|
||||
saveMarkdown: options.saveMarkdown ?? host.archiveExport.enabled(),
|
||||
}),
|
||||
);
|
||||
if (result.exportedPath) {
|
||||
host.notice(`Saved archived thread to ${result.exportedPath}.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AppServerClient } from "../../app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
|
||||
import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation";
|
||||
import { readCompletedConversationSummariesPage } from "../../app-server/threads";
|
||||
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
|
||||
|
|
@ -8,14 +8,14 @@ import {
|
|||
threadTitleContextFromConversationSummary,
|
||||
type ThreadTitleContext,
|
||||
} from "../../domain/threads/title-generation-model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import type { ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
|
||||
export interface ThreadTitleServiceHost {
|
||||
settings: {
|
||||
current(): CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
};
|
||||
currentClient(): AppServerClient | null;
|
||||
codexPath: () => string;
|
||||
vaultPath: string;
|
||||
threadNamingModel: () => string | null;
|
||||
threadNamingEffort: () => ReasoningEffort | null;
|
||||
clientAccess: AppServerClientAccess;
|
||||
visibleContext?(threadId: string): ThreadTitleContext | null;
|
||||
visibleCompletedTurnContext?(turnId: string): ThreadTitleContext | null;
|
||||
generateThreadTitle?(context: ThreadTitleContext): Promise<string | null>;
|
||||
|
|
@ -47,16 +47,19 @@ async function generateTitle(host: ThreadTitleServiceHost, threadId: string): Pr
|
|||
}
|
||||
|
||||
async function resolveThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise<ThreadTitleContext | null> {
|
||||
const client = host.currentClient();
|
||||
const persistedContext = client
|
||||
? await findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
|
||||
})
|
||||
: null;
|
||||
const persistedContext = await persistedThreadTitleContext(host, threadId);
|
||||
return persistedContext ?? host.visibleContext?.(threadId) ?? null;
|
||||
}
|
||||
|
||||
async function persistedThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise<ThreadTitleContext | null> {
|
||||
return host.clientAccess.withClient((client) =>
|
||||
findThreadTitleContext({
|
||||
threadId,
|
||||
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function completedTurnContext(
|
||||
host: ThreadTitleServiceHost,
|
||||
turnId: string,
|
||||
|
|
@ -69,9 +72,8 @@ function completedTurnContext(
|
|||
|
||||
async function generateTitleFromContext(host: ThreadTitleServiceHost, context: ThreadTitleContext): Promise<string | null> {
|
||||
if (host.generateThreadTitle) return host.generateThreadTitle(context);
|
||||
const settings = host.settings.current();
|
||||
return generateThreadTitleWithCodex(settings.codexPath, host.settings.vaultPath, context, {
|
||||
threadNamingModel: settings.threadNamingModel,
|
||||
threadNamingEffort: settings.threadNamingEffort,
|
||||
return generateThreadTitleWithCodex(host.codexPath(), host.vaultPath, context, {
|
||||
threadNamingModel: host.threadNamingModel(),
|
||||
threadNamingEffort: host.threadNamingEffort(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"
|
|||
import { AppServerQueryCache } from "./app-server/query/cache";
|
||||
import { AppServerSharedQueries } from "./app-server/query/shared-queries";
|
||||
import type { AppServerClient } from "./app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "./app-server/connection/client-access";
|
||||
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
|
||||
import { appServerQueryContextIsComplete, type AppServerQueryContext } from "./app-server/query/keys";
|
||||
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
|
|
@ -28,7 +29,7 @@ export interface CodexPanelRuntimeOptions {
|
|||
saveSettings(): Promise<void>;
|
||||
}
|
||||
|
||||
export class CodexPanelRuntime {
|
||||
export class CodexPanelRuntime implements AppServerClientAccess {
|
||||
private readonly appServerQueries = new AppServerQueryCache({
|
||||
clientRunner: {
|
||||
runWithClient: (context, operation, options) => this.runWithAppServerClient(context, operation, options),
|
||||
|
|
@ -126,6 +127,7 @@ export class CodexPanelRuntime {
|
|||
return {
|
||||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
clientAccess: this,
|
||||
threadCatalog: this.threadCatalog,
|
||||
openNewPanel: () => this.panels.openNewPanel(),
|
||||
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
|
||||
|
|
@ -137,6 +139,7 @@ export class CodexPanelRuntime {
|
|||
return {
|
||||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
clientAccess: this,
|
||||
saveSettings: () => this.options.saveSettings(),
|
||||
refreshOpenViews: () => {
|
||||
this.refreshOpenViews();
|
||||
|
|
@ -149,14 +152,16 @@ export class CodexPanelRuntime {
|
|||
private threadPickerHost(): ThreadPickerHost {
|
||||
return {
|
||||
app: this.options.app,
|
||||
settings: this.options.settingsRef.settings,
|
||||
vaultPath: this.options.settingsRef.vaultPath,
|
||||
threadCatalog: this.threadCatalog,
|
||||
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
|
||||
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
|
||||
};
|
||||
}
|
||||
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: { unhandledServerRequestMessage?: string } = {}): Promise<T> {
|
||||
return this.runWithAppServerClient(this.appServerQueryContext(), operation, options);
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { AppServerClient } from "../app-server/connection/client";
|
||||
import type { AppServerObservedQueryResult } from "../app-server/query/cache";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries";
|
||||
import { withShortLivedAppServerClient } from "../app-server/connection/short-lived-client";
|
||||
import { setHookItemEnabled, trustHookItem } from "../app-server/catalog";
|
||||
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/threads";
|
||||
import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
|
||||
|
|
@ -440,7 +439,7 @@ export class SettingsDynamicDataController {
|
|||
}
|
||||
|
||||
private async withSettingsConnection<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
|
||||
return withShortLivedAppServerClient(this.host.settings.codexPath, this.host.vaultPath, operation, {
|
||||
return this.host.clientAccess.withClient(operation, {
|
||||
unhandledServerRequestMessage: "Codex Panel settings does not handle server requests.",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type { AppServerSharedQueries } from "../app-server/query/shared-queries";
|
||||
import type { AppServerClientAccess } from "../app-server/connection/client-access";
|
||||
import type { CodexPanelSettings } from "./model";
|
||||
import type { ActiveThreadCatalogThreadRestores } from "../workspace/active-thread-catalog";
|
||||
|
||||
export interface SettingsDynamicDataHost {
|
||||
settings: CodexPanelSettings;
|
||||
vaultPath: string;
|
||||
clientAccess: AppServerClientAccess;
|
||||
appServerData: Pick<
|
||||
AppServerSharedQueries,
|
||||
"modelsSnapshot" | "observeModelsResult" | "fetchModels" | "refreshModels" | "notifyContextChanged"
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
|
||||
|
||||
export type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
|
||||
|
||||
export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot {
|
||||
lastFocused: boolean;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import type { App, WorkspaceLeaf } from "obsidian";
|
|||
import { VIEW_TYPE_CODEX_PANEL } from "../constants";
|
||||
import { CodexChatView } from "../features/chat/host/view";
|
||||
import type { ChatWorkspacePanelSurface } from "../features/chat/host/surface-handle";
|
||||
import type { ChatPanelSnapshot, OpenCodexPanelSnapshot } from "./open-panel-snapshot";
|
||||
import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
|
||||
|
||||
const BOOT_RESTORED_PANEL_LOAD_DELAY_MS = 1_000;
|
||||
const BOOT_RESTORED_PANEL_LOAD_STAGGER_MS = 250;
|
||||
|
|
@ -38,6 +38,10 @@ type ThreadPanelTarget =
|
|||
|
||||
type BootRestoredPanelLoadLifecycleState = { kind: "idle" } | { kind: "scheduled"; timers: Set<number> } | { kind: "cancelled" };
|
||||
|
||||
export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot {
|
||||
lastFocused: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspacePanelCoordinatorOptions {
|
||||
app: App;
|
||||
refreshThreadsViewLiveState: () => void;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ class FakeTransport implements AppServerTransport {
|
|||
this.running = false;
|
||||
this.handlers.onExit(code, signal);
|
||||
}
|
||||
|
||||
emitError(error: Error): void {
|
||||
this.handlers.onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> {
|
||||
|
|
@ -212,6 +216,65 @@ describe("AppServerClient", () => {
|
|||
await expect(listing).rejects.toThrow("Codex app-server disconnected.");
|
||||
});
|
||||
|
||||
it("does not notify external exit handlers for intentional disconnect exits", async () => {
|
||||
let transport!: FakeTransport;
|
||||
const onExit = vi.fn();
|
||||
const client = new AppServerClient(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
{
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
},
|
||||
500,
|
||||
(handlers) => {
|
||||
transport = new FakeTransport(handlers);
|
||||
return transport;
|
||||
},
|
||||
);
|
||||
const connecting = client.connect();
|
||||
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
|
||||
await connecting;
|
||||
|
||||
client.disconnect();
|
||||
transport.emitExit(0);
|
||||
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails active transports on transport error without waiting for exit", async () => {
|
||||
let transport!: FakeTransport;
|
||||
const onExit = vi.fn();
|
||||
const client = new AppServerClient(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
{
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
},
|
||||
500,
|
||||
(handlers) => {
|
||||
transport = new FakeTransport(handlers);
|
||||
return transport;
|
||||
},
|
||||
);
|
||||
const connecting = client.connect();
|
||||
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
|
||||
await connecting;
|
||||
const listing = client.listModels();
|
||||
|
||||
transport.emitError(new Error("transport failed"));
|
||||
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(transport.isRunning()).toBe(false);
|
||||
await expect(listing).rejects.toThrow("transport failed");
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends typed turn steering requests", async () => {
|
||||
let transport: FakeTransport;
|
||||
const getTransport = () => transport;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function createController({ connected = false, client = {} as AppServerClient }
|
|||
metadata,
|
||||
diagnostics,
|
||||
invalidateResumeWork: vi.fn(),
|
||||
loadSharedThreadList: vi.fn().mockResolvedValue(undefined),
|
||||
refreshSharedThreadList: vi.fn().mockResolvedValue(undefined),
|
||||
scheduleDeferredDiagnostics: vi.fn(),
|
||||
clearDeferredDiagnostics: vi.fn(),
|
||||
refreshTabHeader: vi.fn(),
|
||||
|
|
@ -77,7 +77,7 @@ describe("ChatConnectionController", () => {
|
|||
userAgent: "test",
|
||||
});
|
||||
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
|
||||
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
|
||||
expect(host.refreshSharedThreadList).toHaveBeenCalledOnce();
|
||||
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
|
||||
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
|
||||
});
|
||||
|
|
@ -97,7 +97,7 @@ describe("ChatConnectionController", () => {
|
|||
|
||||
await controller.refreshActiveThreads();
|
||||
|
||||
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
|
||||
expect(host.refreshSharedThreadList).toHaveBeenCalledOnce();
|
||||
expect(refreshAppServerMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -135,11 +135,13 @@ function controllerFixture(
|
|||
const currentClient = overrides.currentClient ?? (() => fakeClient());
|
||||
const notifyThreadRenamed = vi.fn();
|
||||
const titleService = createThreadTitleService({
|
||||
settings: {
|
||||
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
|
||||
vaultPath: "/vault",
|
||||
codexPath: () => "codex",
|
||||
vaultPath: "/vault",
|
||||
threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
|
||||
threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
|
||||
clientAccess: {
|
||||
withClient: async (operation) => operation(currentClient()),
|
||||
},
|
||||
currentClient,
|
||||
visibleCompletedTurnContext: (turnId) =>
|
||||
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)),
|
||||
generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ describe("selection rewrite command", () => {
|
|||
getCursor: vi.fn((which: "from" | "to") => (which === "from" ? from : to)),
|
||||
};
|
||||
const view = new MarkdownView({} as never);
|
||||
Object.assign(view, { containerEl: { doc: document } });
|
||||
view.file = Object.assign(new TFile(), { path: "Draft.md", basename: "Draft" });
|
||||
|
||||
registerSelectionRewriteCommand(plugin as never);
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ installObsidianDomShims();
|
|||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "activeDocument", { configurable: true, value: document });
|
||||
Object.defineProperty(globalThis, "activeWindow", { configurable: true, value: window });
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
|
|
@ -624,6 +622,8 @@ function popoverOptions(
|
|||
runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
|
||||
sendShortcut: "enter",
|
||||
state: rewriteState(),
|
||||
viewDocument: document,
|
||||
viewWindow: window,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,8 +105,6 @@ function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost {
|
|||
const openedAvailable: string[] = [];
|
||||
return {
|
||||
app: {} as never,
|
||||
settings: { codexPath: "codex" } as never,
|
||||
vaultPath: "/vault",
|
||||
openedCurrent,
|
||||
openedAvailable,
|
||||
threadCatalog: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { renderThreadsView } from "../../../src/features/threads-view/renderer";
|
||||
import { threadRows, type ThreadsRowModel } from "../../../src/features/threads-view/state";
|
||||
import type { Thread } from "../../../src/domain/threads/model";
|
||||
import type { OpenCodexPanelSnapshot } from "../../../src/workspace/open-panel-snapshot";
|
||||
import type { OpenCodexPanelSnapshot } from "../../../src/workspace/panel-coordinator";
|
||||
import { changeInputValue, installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ describe("CodexThreadsView", () => {
|
|||
|
||||
await view.refresh();
|
||||
|
||||
expect(connectionMock.state.connectCalls).toBe(1);
|
||||
expect(view.containerEl.textContent).toContain("Thread preview");
|
||||
});
|
||||
|
||||
|
|
@ -124,24 +123,16 @@ describe("CodexThreadsView", () => {
|
|||
expect(view.containerEl.textContent).not.toContain("Late thread");
|
||||
});
|
||||
|
||||
it("ignores stale refresh results after the app-server exits", async () => {
|
||||
const threads = deferred<unknown>();
|
||||
const listThreads = vi.fn(() => threads.promise);
|
||||
it("renders shared thread refresh failures", async () => {
|
||||
const listThreads = vi.fn().mockRejectedValue(new Error("Codex app-server stopped."));
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads,
|
||||
});
|
||||
const view = await threadsView();
|
||||
|
||||
const refresh = view.refresh();
|
||||
await waitForAsyncWork(() => {
|
||||
expect(listThreads).toHaveBeenCalled();
|
||||
});
|
||||
connectionMock.state.onExit?.();
|
||||
threads.resolve({ data: [threadFixture({ id: "thread", preview: "Late thread" })] });
|
||||
await refresh;
|
||||
await view.refresh();
|
||||
|
||||
expect(view.containerEl.textContent).toContain("Codex app-server stopped.");
|
||||
expect(view.containerEl.textContent).not.toContain("Late thread");
|
||||
});
|
||||
|
||||
it("ignores stale refresh results when a newer refresh completes first", async () => {
|
||||
|
|
@ -421,6 +412,13 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
|
|||
codexPath: "codex",
|
||||
},
|
||||
vaultPath: "/vault",
|
||||
clientAccess: {
|
||||
withClient: async <T>(operation: (client: never) => Promise<T>): Promise<T> => {
|
||||
const client = connectionMock.state.client;
|
||||
if (!client) throw new Error("No current client.");
|
||||
return operation(client as never);
|
||||
},
|
||||
},
|
||||
openNewPanel: vi.fn().mockResolvedValue(undefined),
|
||||
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
|
||||
getOpenPanelSnapshots: vi.fn(() => []),
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ describe("ThreadOperations", () => {
|
|||
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.toBe(false);
|
||||
await expect(operations.archiveThread("thread")).resolves.toBeNull();
|
||||
await expect(operations.renameThread("thread", "Title")).rejects.toThrow("No current client.");
|
||||
await expect(operations.archiveThread("thread")).rejects.toThrow("No current client.");
|
||||
|
||||
expect(catalog.recordThreadRenamed).not.toHaveBeenCalled();
|
||||
expect(catalog.recordThreadArchived).not.toHaveBeenCalled();
|
||||
|
|
@ -63,7 +63,7 @@ describe("ThreadOperations", () => {
|
|||
currentClient = secondClient;
|
||||
});
|
||||
|
||||
await expect(operations.renameThread("thread", "Title")).resolves.toBe(false);
|
||||
await expect(operations.renameThread("thread", "Title")).rejects.toThrow("Client changed.");
|
||||
|
||||
expect(catalog.recordThreadRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -78,7 +78,7 @@ describe("ThreadOperations", () => {
|
|||
return { exportedPath: null } satisfies ArchiveThreadResult;
|
||||
});
|
||||
|
||||
await expect(operations.archiveThread("thread")).resolves.toBeNull();
|
||||
await expect(operations.archiveThread("thread")).rejects.toThrow("Client changed.");
|
||||
|
||||
expect(catalog.recordThreadArchived).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -95,12 +95,18 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
|
|||
};
|
||||
const notice = vi.fn();
|
||||
const host: ThreadOperationsHost = {
|
||||
connection: {
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
currentClient: () => currentClient() as AppServerClient | null,
|
||||
clientAccess: {
|
||||
withClient: async (operation) => {
|
||||
const client = currentClient() as AppServerClient | null;
|
||||
if (!client) throw new Error("No current client.");
|
||||
const result = await operation(client);
|
||||
if ((currentClient() as AppServerClient | null) !== client) throw new Error("Client changed.");
|
||||
return result;
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
current: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }),
|
||||
archiveExport: {
|
||||
settings: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }),
|
||||
enabled: () => false,
|
||||
vaultPath: "/vault",
|
||||
},
|
||||
archiveAdapter: () => archiveAdapterMock(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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 {
|
||||
|
|
@ -51,11 +50,13 @@ describe("ThreadTitleService", () => {
|
|||
|
||||
function titleService(options: Partial<ThreadTitleServiceHost> = {}): ThreadTitleService {
|
||||
return createThreadTitleService({
|
||||
settings: {
|
||||
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
|
||||
vaultPath: "/vault",
|
||||
codexPath: () => "codex",
|
||||
vaultPath: "/vault",
|
||||
threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
|
||||
threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
|
||||
clientAccess: {
|
||||
withClient: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
currentClient: () => null as AppServerClient | null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -841,22 +841,27 @@ function settingsTabHost(
|
|||
}>;
|
||||
} = {},
|
||||
): CodexPanelSettingTabHost {
|
||||
const settings = {
|
||||
codexPath: "codex",
|
||||
threadNamingModel: options.settings?.threadNamingModel ?? null,
|
||||
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
|
||||
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
|
||||
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
|
||||
showToolbar: true,
|
||||
sendShortcut: options.sendShortcut ?? "enter",
|
||||
scrollThreadFromComposerEdges: false,
|
||||
archiveExportEnabled: false,
|
||||
archiveExportFolderTemplate: "Codex Archives",
|
||||
archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md",
|
||||
archiveExportTags: "",
|
||||
};
|
||||
return {
|
||||
settings: {
|
||||
codexPath: "codex",
|
||||
threadNamingModel: options.settings?.threadNamingModel ?? null,
|
||||
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
|
||||
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
|
||||
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
|
||||
showToolbar: true,
|
||||
sendShortcut: options.sendShortcut ?? "enter",
|
||||
scrollThreadFromComposerEdges: false,
|
||||
archiveExportEnabled: false,
|
||||
archiveExportFolderTemplate: "Codex Archives",
|
||||
archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md",
|
||||
archiveExportTags: "",
|
||||
},
|
||||
settings,
|
||||
vaultPath: "/vault",
|
||||
clientAccess: {
|
||||
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: { unhandledServerRequestMessage?: string }) =>
|
||||
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
|
||||
},
|
||||
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
|
||||
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
|
||||
appServerData: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue