Collapse redundant design-review indirection

Inline low-value wrappers around app-server transports, runtime resolution, session lifecycle wiring, message-stream projection, and settings lifecycle state.
This commit is contained in:
murashit 2026-07-04 16:49:05 +09:00
parent b4e4f6251e
commit ceb85bd251
32 changed files with 653 additions and 875 deletions

View file

@ -1,7 +1,6 @@
import type { ServerInitialization } from "../../domain/server/initialization";
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import { appServerInitializationFromResponse } from "../protocol/initialization";
import { AppServerClient, type AppServerClientHandlers } from "./client";
export interface ConnectionManagerHandlers {
@ -67,7 +66,7 @@ export class ConnectionManager {
async connect(handlers: ConnectionManagerHandlers): Promise<ServerInitialization> {
const currentClient = this.currentClient();
if (currentClient) {
return appServerInitializationFromResponse(currentClient.initializeResponse);
return serverInitializationFromResponse(currentClient.initializeResponse);
}
if (this.state.kind === "connecting" && this.stateMatchesCurrentContext(this.state)) return this.state.promise;
if (this.state.kind === "connecting" || this.state.kind === "connected") this.disconnect();
@ -102,7 +101,7 @@ export class ConnectionManager {
throw new StaleConnectionError();
}
this.state = { kind: "connected", generation, codexPath, cwd, client };
return appServerInitializationFromResponse(response);
return serverInitializationFromResponse(response);
})
.catch((error: unknown) => {
if (this.isStale(generation)) {
@ -139,3 +138,12 @@ export class ConnectionManager {
return state.codexPath === this.codexPath() && state.cwd === this.cwd;
}
}
function serverInitializationFromResponse(response: ServerInitialization): ServerInitialization {
return {
userAgent: response.userAgent,
codexHome: response.codexHome,
platformFamily: response.platformFamily,
platformOs: response.platformOs,
};
}

View file

@ -1,17 +0,0 @@
import type { ServerInitialization } from "../../domain/server/initialization";
interface AppServerInitializeResponse {
userAgent: string;
codexHome: string;
platformFamily: string;
platformOs: string;
}
export function appServerInitializationFromResponse(response: AppServerInitializeResponse): ServerInitialization {
return {
userAgent: response.userAgent,
codexHome: response.codexHome,
platformFamily: response.platformFamily,
platformOs: response.platformOs,
};
}

View file

@ -6,10 +6,6 @@ export interface ObservedResult<T> {
export type ObservedResultListener<T> = (result: ObservedResult<T>) => void;
export function observedValue<T>(result: ObservedResult<T>): T | null {
return result.value;
}
export function observedInitialLoading<T>(result: ObservedResult<T>, currentValue: T | null | undefined): boolean {
return currentValue == null && result.isFetching;
}

View file

@ -8,11 +8,15 @@ import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../applicatio
import type { ThreadHistoryTransport, ThreadResumeTransport } from "../application/threads/thread-loading-transport";
import type { ThreadMutationTransport } from "../application/threads/thread-mutation-transport";
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
import { createChatThreadGoalReadTransport, createChatThreadGoalTransport } from "./transports/goal-transport";
import { createChatThreadHistoryTransport, createChatThreadResumeTransport } from "./transports/thread-loading-transport";
import { createChatThreadMutationTransport } from "./transports/thread-mutation-transport";
import { createChatRuntimeSettingsTransport } from "./transports/thread-settings-transport";
import { createChatTurnTransport } from "./transports/turn-transport";
import {
createChatRuntimeSettingsTransport,
createChatThreadGoalReadTransport,
createChatThreadGoalTransport,
createChatThreadHistoryTransport,
createChatThreadMutationTransport,
createChatThreadResumeTransport,
createChatTurnTransport,
} from "./transports/session-transports";
export interface ChatAppServerGatewayHost {
vaultPath: string;

View file

@ -1,33 +0,0 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
export interface CurrentChatAppServerClientHost {
currentClient(): AppServerClient | null;
}
export interface ConnectedChatAppServerClientHost extends CurrentChatAppServerClientHost {
connectedClient(): Promise<AppServerClient | null>;
}
export function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client: AppServerClient): boolean {
return host.currentClient() !== client;
}
export async function withConnectedChatAppServerClient<T>(
host: ConnectedChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T | null> {
const client = await host.connectedClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}
export async function withCurrentChatAppServerClient<T>(
host: CurrentChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T | null> {
const client = host.currentClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}

View file

@ -1,47 +0,0 @@
import { clearThreadGoal, readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/services/threads";
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport";
import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "./client-scope";
import { chatAppServerClientIsStale, withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "./client-scope";
export function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport {
return {
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
};
}
export function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost): ThreadGoalTransport {
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
setThreadGoal: async (threadId, params) => {
const client = await host.connectedClient();
if (!client) return undefined;
const goal = await setThreadGoal(client, threadId, params);
return chatAppServerClientIsStale(host, client) ? undefined : goal;
},
clearThreadGoal: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await clearThreadGoal(client, threadId);
return true;
});
return result ?? false;
},
recordThreadGoalUserMessage: async (threadId, objective) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await recordThreadGoalUserMessage(client, threadId, objective);
return true;
});
return result ?? false;
},
};
}
async function readThreadGoalFromCurrentClient(
host: CurrentChatAppServerClientHost,
threadId: string,
): ReturnType<ThreadGoalReadTransport["readThreadGoal"]> {
const client = host.currentClient();
if (!client) return undefined;
const goal = await readThreadGoal(client, threadId);
return chatAppServerClientIsStale(host, client) ? undefined : goal;
}

View file

@ -0,0 +1,231 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { AppServerRequestClient } from "../../../../app-server/services/request-client";
import {
clearThreadGoal,
compactThread,
forkThread,
listThreadTurns,
readThreadGoal,
recordThreadGoalUserMessage,
resumeThread,
rollbackThread,
setThreadGoal,
threadActivationSnapshotFromAppServerResponse,
updateThreadSettings,
} from "../../../../app-server/services/threads";
import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
import type { ChatTurnTransport } from "../../application/conversation/turn-transport";
import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport";
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport";
import type {
ThreadHistoryPage,
ThreadHistoryTransport,
ThreadResumeSnapshot,
ThreadResumeTransport,
} from "../../application/threads/thread-loading-transport";
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
interface CurrentChatAppServerClientHost {
currentClient(): AppServerClient | null;
}
interface ConnectedChatAppServerClientHost extends CurrentChatAppServerClientHost {
connectedClient(): Promise<AppServerClient | null>;
}
interface ChatAppServerTransportHost extends ConnectedChatAppServerClientHost {
vaultPath: string;
}
type ChatThreadHistoryClient = AppServerRequestClient;
type ChatThreadResumeClient = AppServerRequestClient;
interface AppServerThreadTurnsPage {
readonly data: ThreadTurnsPage["turns"];
readonly nextCursor: string | null;
}
export function createChatTurnTransport(host: ChatAppServerTransportHost): ChatTurnTransport {
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
startTurn: (request) =>
withCurrentChatAppServerClient(host, async (client) => {
const response = await startTurn(client, {
threadId: request.threadId,
cwd: host.vaultPath,
input: request.input,
clientUserMessageId: request.clientUserMessageId,
});
return { turnId: response.turn.id };
}),
steerTurn: async (request) => {
const steered = await withCurrentChatAppServerClient(host, async (client) => {
await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId);
return true;
});
return steered ?? false;
},
interruptTurn: async (threadId, turnId) => {
const interrupted = await withCurrentChatAppServerClient(host, async (client) => {
await interruptTurn(client, threadId, turnId);
return true;
});
return interrupted ?? false;
},
};
}
export function createChatRuntimeSettingsTransport(host: CurrentChatAppServerClientHost): RuntimeSettingsTransport {
return {
updateThreadSettings: async (threadId: string, update: RuntimeSettingsPatch) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await updateThreadSettings(client, threadId, update);
return true;
});
return result ?? false;
},
};
}
export function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost): ThreadHistoryTransport {
return {
readHistoryPage: (threadId, cursor, limit): Promise<ThreadHistoryPage | null> =>
withCurrentChatAppServerClient(host, (client) => readChatThreadHistoryPage(client, threadId, cursor, limit)),
};
}
export function createChatThreadResumeTransport(host: ChatAppServerTransportHost): ThreadResumeTransport {
return {
resumeThread: (threadId): Promise<ThreadResumeSnapshot | null> =>
withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
};
}
export function createChatThreadMutationTransport(host: ChatAppServerTransportHost): ThreadMutationTransport {
return {
compactThread: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await compactThread(client, threadId);
return true;
});
return result ?? false;
},
forkThread: (threadId) => withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath)),
rollbackForkedThread: (threadId, turnsToDrop) =>
withConnectedChatAppServerClient(host, async (client) => (await rollbackThread(client, threadId, turnsToDrop)).thread),
rollbackThread: (threadId) =>
withConnectedChatAppServerClient(host, async (client): Promise<ThreadRollbackSnapshot> => {
const snapshot = await rollbackThread(client, threadId);
return {
thread: snapshot.thread,
cwd: snapshot.cwd,
items: messageStreamItemsFromTurns(snapshot.turns),
};
}),
};
}
export function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport {
return {
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
};
}
export function createChatThreadGoalTransport(host: ConnectedChatAppServerClientHost): ThreadGoalTransport {
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),
setThreadGoal: async (threadId, params) => {
const client = await host.connectedClient();
if (!client) return undefined;
const goal = await setThreadGoal(client, threadId, params);
return chatAppServerClientIsStale(host, client) ? undefined : goal;
},
clearThreadGoal: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await clearThreadGoal(client, threadId);
return true;
});
return result ?? false;
},
recordThreadGoalUserMessage: async (threadId, objective) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await recordThreadGoalUserMessage(client, threadId, objective);
return true;
});
return result ?? false;
},
};
}
function chatAppServerClientIsStale(host: CurrentChatAppServerClientHost, client: AppServerClient): boolean {
return host.currentClient() !== client;
}
async function withConnectedChatAppServerClient<T>(
host: ConnectedChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T | null> {
const client = await host.connectedClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}
async function withCurrentChatAppServerClient<T>(
host: CurrentChatAppServerClientHost,
operation: (client: AppServerClient) => Promise<T>,
): Promise<T | null> {
const client = host.currentClient();
if (!client) return null;
const result = await operation(client);
return chatAppServerClientIsStale(host, client) ? null : result;
}
async function readChatThreadHistoryPage(
client: ChatThreadHistoryClient,
threadId: string,
cursor: string | null,
limit = 20,
): Promise<ThreadHistoryPage> {
return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await listThreadTurns(client, threadId, cursor, limit)));
}
function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage {
return {
items: messageStreamItemsFromTurns(page.turns),
nextCursor: page.nextCursor,
hadTurns: page.turns.length > 0,
};
}
async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise<ThreadResumeSnapshot> {
const response = await resumeThread(client, threadId, cwd);
return {
activation: threadActivationSnapshotFromAppServerResponse(response),
rolloutPath: response.thread.path,
initialHistoryPage: response.initialTurnsPage
? chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(response.initialTurnsPage))
: null,
};
}
async function readThreadGoalFromCurrentClient(
host: CurrentChatAppServerClientHost,
threadId: string,
): ReturnType<ThreadGoalReadTransport["readThreadGoal"]> {
const client = host.currentClient();
if (!client) return undefined;
const goal = await readThreadGoal(client, threadId);
return chatAppServerClientIsStale(host, client) ? undefined : goal;
}
function threadTurnsPageFromAppServerPage(page: AppServerThreadTurnsPage): ThreadTurnsPage {
return {
turns: page.data,
nextCursor: page.nextCursor,
};
}

View file

@ -1,73 +0,0 @@
import type { AppServerRequestClient } from "../../../../app-server/services/request-client";
import { listThreadTurns, resumeThread, threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads";
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
import type {
ThreadHistoryPage,
ThreadHistoryTransport,
ThreadResumeSnapshot,
ThreadResumeTransport,
} from "../../application/threads/thread-loading-transport";
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "./client-scope";
import { withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "./client-scope";
type ChatThreadHistoryClient = AppServerRequestClient;
type ChatThreadResumeClient = AppServerRequestClient;
interface AppServerThreadTurnsPage {
readonly data: ThreadTurnsPage["turns"];
readonly nextCursor: string | null;
}
interface ChatThreadResumeTransportHost extends ConnectedChatAppServerClientHost {
vaultPath: string;
}
export function createChatThreadHistoryTransport(host: CurrentChatAppServerClientHost): ThreadHistoryTransport {
return {
readHistoryPage: (threadId, cursor, limit): Promise<ThreadHistoryPage | null> =>
withCurrentChatAppServerClient(host, (client) => readChatThreadHistoryPage(client, threadId, cursor, limit)),
};
}
export function createChatThreadResumeTransport(host: ChatThreadResumeTransportHost): ThreadResumeTransport {
return {
resumeThread: (threadId): Promise<ThreadResumeSnapshot | null> =>
withConnectedChatAppServerClient(host, (client) => resumeChatThread(client, threadId, host.vaultPath)),
};
}
async function readChatThreadHistoryPage(
client: ChatThreadHistoryClient,
threadId: string,
cursor: string | null,
limit = 20,
): Promise<ThreadHistoryPage> {
return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await listThreadTurns(client, threadId, cursor, limit)));
}
function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage {
return {
items: messageStreamItemsFromTurns(page.turns),
nextCursor: page.nextCursor,
hadTurns: page.turns.length > 0,
};
}
async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise<ThreadResumeSnapshot> {
const response = await resumeThread(client, threadId, cwd);
return {
activation: threadActivationSnapshotFromAppServerResponse(response),
rolloutPath: response.thread.path,
initialHistoryPage: response.initialTurnsPage
? chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(response.initialTurnsPage))
: null,
};
}
function threadTurnsPageFromAppServerPage(page: AppServerThreadTurnsPage): ThreadTurnsPage {
return {
turns: page.data,
nextCursor: page.nextCursor,
};
}

View file

@ -1,33 +0,0 @@
import { compactThread, forkThread, rollbackThread } from "../../../../app-server/services/threads";
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
import type { ConnectedChatAppServerClientHost } from "./client-scope";
import { withConnectedChatAppServerClient } from "./client-scope";
interface ChatThreadMutationTransportHost extends ConnectedChatAppServerClientHost {
vaultPath: string;
}
export function createChatThreadMutationTransport(host: ChatThreadMutationTransportHost): ThreadMutationTransport {
return {
compactThread: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await compactThread(client, threadId);
return true;
});
return result ?? false;
},
forkThread: (threadId) => withConnectedChatAppServerClient(host, (client) => forkThread(client, threadId, host.vaultPath)),
rollbackForkedThread: (threadId, turnsToDrop) =>
withConnectedChatAppServerClient(host, async (client) => (await rollbackThread(client, threadId, turnsToDrop)).thread),
rollbackThread: (threadId) =>
withConnectedChatAppServerClient(host, async (client): Promise<ThreadRollbackSnapshot> => {
const snapshot = await rollbackThread(client, threadId);
return {
thread: snapshot.thread,
cwd: snapshot.cwd,
items: messageStreamItemsFromTurns(snapshot.turns),
};
}),
};
}

View file

@ -1,17 +0,0 @@
import { updateThreadSettings } from "../../../../app-server/services/threads";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport";
import type { CurrentChatAppServerClientHost } from "./client-scope";
import { withCurrentChatAppServerClient } from "./client-scope";
export function createChatRuntimeSettingsTransport(host: CurrentChatAppServerClientHost): RuntimeSettingsTransport {
return {
updateThreadSettings: async (threadId: string, update: RuntimeSettingsPatch) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await updateThreadSettings(client, threadId, update);
return true;
});
return result ?? false;
},
};
}

View file

@ -1,38 +0,0 @@
import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns";
import type { ChatTurnTransport } from "../../application/conversation/turn-transport";
import type { ConnectedChatAppServerClientHost } from "./client-scope";
import { withCurrentChatAppServerClient } from "./client-scope";
interface ChatTurnTransportHost extends ConnectedChatAppServerClientHost {
vaultPath: string;
}
export function createChatTurnTransport(host: ChatTurnTransportHost): ChatTurnTransport {
return {
ensureConnected: async () => (await host.connectedClient()) !== null,
startTurn: (request) =>
withCurrentChatAppServerClient(host, async (client) => {
const response = await startTurn(client, {
threadId: request.threadId,
cwd: host.vaultPath,
input: request.input,
clientUserMessageId: request.clientUserMessageId,
});
return { turnId: response.turn.id };
}),
steerTurn: async (request) => {
const steered = await withCurrentChatAppServerClient(host, async (client) => {
await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId);
return true;
});
return steered ?? false;
},
interruptTurn: async (threadId, turnId) => {
const interrupted = await withCurrentChatAppServerClient(host, async (client) => {
await interruptTurn(client, threadId, turnId);
return true;
});
return interrupted ?? false;
},
};
}

View file

@ -1,80 +0,0 @@
import type { ThreadTokenUsage } from "../../../../domain/runtime/metrics";
import type { ChatStateStore } from "../state/store";
import { createActiveThreadIdentitySync } from "./active-thread-identity-sync";
import type { GoalActions } from "./goal-actions";
import type { HistoryController } from "./history-controller";
import { RestorationController } from "./restoration-controller";
import { createResumeActions, type ResumeActions } from "./resume-actions";
import type { ChatResumeWorkTracker } from "./resume-work";
import type { ThreadResumeTransport } from "./thread-loading-transport";
export interface ThreadLifecyclePartsContext {
stateStore: ChatStateStore;
resumeTransport: ThreadResumeTransport;
lifecycle: {
resumeWork: ChatResumeWorkTracker;
history: HistoryController;
invalidateThreadWork: () => void;
getClosing: () => boolean;
recoverTokenUsageFromRollout?: (path: string) => Promise<ThreadTokenUsage | null>;
};
thread: {
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
goals: GoalActions;
resetThreadTurnPresence: (hadTurns: boolean) => void;
}
export interface ThreadLifecycleParts {
history: HistoryController;
restoration: RestorationController;
resume: ResumeActions;
identity: ReturnType<typeof createActiveThreadIdentitySync>;
}
export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts {
const { stateStore, resumeTransport, lifecycle, thread, status, liveState, goals, resetThreadTurnPresence } = context;
const { resumeWork, history, invalidateThreadWork } = lifecycle;
const restoration = new RestorationController({
invalidateThreadWork,
setStatus: status.set,
refreshTabHeader: thread.refreshTabHeader,
});
const resume = createResumeActions({
stateStore,
resumeWork,
history,
restoration,
resumeTransport,
closing: lifecycle.getClosing,
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
addSystemMessage: status.addSystemMessage,
refreshLiveState: liveState.refresh,
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),
...(lifecycle.recoverTokenUsageFromRollout ? { recoverTokenUsageFromRollout: lifecycle.recoverTokenUsageFromRollout } : {}),
});
const identity = createActiveThreadIdentitySync({
stateStore,
restoration,
invalidateThreadWork,
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,
});
return {
history,
restoration,
resume,
identity,
};
}

View file

@ -1,27 +0,0 @@
import { effectiveCollaborationMode } from "./intent";
import type { RuntimeSnapshot } from "./snapshot";
export interface CollaborationModeResolution {
readonly active: RuntimeSnapshot["active"]["collaborationMode"];
readonly pending: RuntimeSnapshot["pending"]["collaborationMode"];
readonly confirmed: NonNullable<RuntimeSnapshot["active"]["collaborationMode"]>;
readonly effective: NonNullable<RuntimeSnapshot["active"]["collaborationMode"]>;
readonly dirty: boolean;
readonly blockedReason: "missing-model" | null;
}
export function resolveCollaborationMode(snapshot: RuntimeSnapshot, model: string | null): CollaborationModeResolution {
const active = snapshot.active.collaborationMode;
const pending = snapshot.pending.collaborationMode;
const confirmed = effectiveCollaborationMode(active);
const effective = pending.kind === "set" ? pending.value : confirmed;
const dirty = pending.kind === "set";
return {
active,
pending,
confirmed,
effective,
dirty,
blockedReason: dirty && !model ? "missing-model" : null,
};
}

View file

@ -1,60 +0,0 @@
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
import { cloneRuntimePermissionState, type RuntimeApprovalPolicy, type RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
import { type PendingRuntimeIntent, resetRuntimeIntentToConfig, setRuntimeIntentValue, unchangedRuntimeIntent } from "./intent";
import { type RuntimeLayeredValue, resolveRuntimeNullablePendingValue, resolveRuntimeValue } from "./layered-value";
import type { RuntimeSnapshot } from "./snapshot";
export interface RuntimePermissionsResolution {
readonly permissionProfile: RuntimeLayeredValue<string>;
readonly sandboxPolicy: RuntimeLayeredValue<RuntimeSandboxPolicy, RuntimeSandboxPolicy | null>;
readonly approvalPolicy: RuntimeLayeredValue<RuntimeApprovalPolicy>;
}
export function resolveRuntimePermissions(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimePermissionsResolution {
return {
permissionProfile: resolveRuntimeValue({
configured: config.startupPermissions.activePermissionProfile?.id ?? null,
active: snapshot.active.activePermissionProfile?.id ?? null,
pending: snapshot.pending.permissionProfile,
activeKnown: snapshot.activeThreadId !== null && snapshot.active.permissionProfileKnown,
}),
sandboxPolicy: resolveRuntimeSandboxPolicy(snapshot, config),
approvalPolicy: resolveRuntimeValue({
configured: config.startupPermissions.approvalPolicy,
active: snapshot.active.approvalPolicy,
pending: snapshot.pending.approvalPolicy,
activeKnown: snapshot.activeThreadId !== null && snapshot.active.approvalPolicyKnown,
}),
};
}
function resolveRuntimeSandboxPolicy(
snapshot: RuntimeSnapshot,
config: RuntimeConfigSnapshot,
): RuntimeLayeredValue<RuntimeSandboxPolicy, RuntimeSandboxPolicy | null> {
return resolveRuntimeNullablePendingValue({
configured: cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy),
active: cloneRuntimeSandboxPolicy(snapshot.active.sandboxPolicy),
pending: sandboxPolicyIntentFromPermissionProfile(snapshot.pending.permissionProfile, config),
activeKnown: snapshot.activeThreadId !== null && snapshot.active.sandboxPolicyKnown,
});
}
function sandboxPolicyIntentFromPermissionProfile(
intent: PendingRuntimeIntent<string>,
config: RuntimeConfigSnapshot,
): PendingRuntimeIntent<RuntimeSandboxPolicy | null> {
if (intent.kind === "set") return setRuntimeIntentValue(sandboxPolicyForPermissionProfile(intent.value, config));
if (intent.kind === "resetToConfig") return resetRuntimeIntentToConfig();
return unchangedRuntimeIntent();
}
function sandboxPolicyForPermissionProfile(profile: string, config: RuntimeConfigSnapshot): RuntimeSandboxPolicy | null {
return profile === config.startupPermissions.activePermissionProfile?.id
? cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy)
: null;
}
function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy | null): RuntimeSandboxPolicy | null {
return cloneRuntimePermissionState({ approvalPolicy: null, activePermissionProfile: null, sandboxPolicy: value }).sandboxPolicy;
}

View file

@ -1,14 +1,58 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import type { ModelMetadata, ReasoningEffort } from "../../../../domain/catalog/metadata";
import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata";
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
import type { RuntimeApprovalPolicy, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
import { cloneRuntimePermissionState, type RuntimeApprovalPolicy, type RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy";
import { type CollaborationModeResolution, resolveCollaborationMode } from "./collaboration-resolution";
import { type RuntimeLayeredValue, resolveRuntimeValue, runtimeLayeredValue } from "./layered-value";
import { resolveRuntimePermissions } from "./permission-resolution";
import { type AutoReviewResolution, type FastModeResolution, resolveAutoReview, resolveFastMode } from "./runtime-toggles";
import {
effectiveCollaborationMode,
type PendingRuntimeIntent,
type RequestedFastMode,
resetRuntimeIntentToConfig,
setRuntimeIntentValue,
unchangedRuntimeIntent,
} from "./intent";
import {
type RuntimeLayeredValue,
type RuntimeValueSource,
resolveRuntimeNullablePendingValue,
resolveRuntimeValue,
runtimeLayeredValue,
} from "./layered-value";
import type { RuntimeSnapshot } from "./snapshot";
interface AutoReviewResolution {
readonly active: boolean;
readonly confirmedActive: boolean;
readonly source: RuntimeValueSource;
readonly confirmedSource: RuntimeValueSource;
}
interface CollaborationModeResolution {
readonly active: RuntimeSnapshot["active"]["collaborationMode"];
readonly pending: RuntimeSnapshot["pending"]["collaborationMode"];
readonly confirmed: NonNullable<RuntimeSnapshot["active"]["collaborationMode"]>;
readonly effective: NonNullable<RuntimeSnapshot["active"]["collaborationMode"]>;
readonly dirty: boolean;
readonly blockedReason: "missing-model" | null;
}
interface FastModeResolution {
readonly requested: PendingRuntimeIntent<RequestedFastMode>;
readonly active: boolean;
readonly confirmedActive: boolean;
readonly source: RuntimeValueSource;
readonly confirmedSource: RuntimeValueSource;
readonly effectiveServiceTier: ServiceTier | null;
readonly confirmedServiceTier: ServiceTier | null;
readonly serviceTierRequestValue: string;
}
interface RuntimePermissionsResolution {
readonly permissionProfile: RuntimeLayeredValue<string>;
readonly sandboxPolicy: RuntimeLayeredValue<RuntimeSandboxPolicy, RuntimeSandboxPolicy | null>;
readonly approvalPolicy: RuntimeLayeredValue<RuntimeApprovalPolicy>;
}
export interface RuntimeControlsResolution {
readonly model: RuntimeLayeredValue<string>;
readonly reasoningEffort: RuntimeLayeredValue<ReasoningEffort>;
@ -95,3 +139,105 @@ function serviceTierValue(
source,
});
}
function resolveAutoReview(reviewer: RuntimeLayeredValue<ApprovalsReviewer>): AutoReviewResolution {
return {
active: autoReviewActive(reviewer.effective),
confirmedActive: autoReviewActive(reviewer.confirmed),
source: reviewer.source,
confirmedSource: reviewer.confirmedSource,
};
}
function resolveFastMode(
requested: PendingRuntimeIntent<RequestedFastMode>,
serviceTier: RuntimeLayeredValue<ServiceTier>,
serviceTiers: ModelMetadata["serviceTiers"],
): FastModeResolution {
return {
requested,
active: isFastServiceTier(serviceTier.effective, serviceTiers),
confirmedActive: isFastServiceTier(serviceTier.confirmed, serviceTiers),
source: serviceTier.source,
confirmedSource: serviceTier.confirmedSource,
effectiveServiceTier: serviceTier.effective,
confirmedServiceTier: serviceTier.confirmed,
serviceTierRequestValue: serviceTiers.find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast",
};
}
function autoReviewActive(value: ApprovalsReviewer | null): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function isFastServiceTier(value: string | null | undefined, serviceTiers: ModelMetadata["serviceTiers"]): boolean {
if (!value) return false;
if (value === "fast") return true;
if (serviceTiers.length === 0) return value === "priority";
return serviceTiers.some((tier) => tier.id === value && tier.name.trim().toLowerCase() === "fast");
}
function resolveCollaborationMode(snapshot: RuntimeSnapshot, model: string | null): CollaborationModeResolution {
const active = snapshot.active.collaborationMode;
const pending = snapshot.pending.collaborationMode;
const confirmed = effectiveCollaborationMode(active);
const effective = pending.kind === "set" ? pending.value : confirmed;
const dirty = pending.kind === "set";
return {
active,
pending,
confirmed,
effective,
dirty,
blockedReason: dirty && !model ? "missing-model" : null,
};
}
function resolveRuntimePermissions(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimePermissionsResolution {
return {
permissionProfile: resolveRuntimeValue({
configured: config.startupPermissions.activePermissionProfile?.id ?? null,
active: snapshot.active.activePermissionProfile?.id ?? null,
pending: snapshot.pending.permissionProfile,
activeKnown: snapshot.activeThreadId !== null && snapshot.active.permissionProfileKnown,
}),
sandboxPolicy: resolveRuntimeSandboxPolicy(snapshot, config),
approvalPolicy: resolveRuntimeValue({
configured: config.startupPermissions.approvalPolicy,
active: snapshot.active.approvalPolicy,
pending: snapshot.pending.approvalPolicy,
activeKnown: snapshot.activeThreadId !== null && snapshot.active.approvalPolicyKnown,
}),
};
}
function resolveRuntimeSandboxPolicy(
snapshot: RuntimeSnapshot,
config: RuntimeConfigSnapshot,
): RuntimeLayeredValue<RuntimeSandboxPolicy, RuntimeSandboxPolicy | null> {
return resolveRuntimeNullablePendingValue({
configured: cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy),
active: cloneRuntimeSandboxPolicy(snapshot.active.sandboxPolicy),
pending: sandboxPolicyIntentFromPermissionProfile(snapshot.pending.permissionProfile, config),
activeKnown: snapshot.activeThreadId !== null && snapshot.active.sandboxPolicyKnown,
});
}
function sandboxPolicyIntentFromPermissionProfile(
intent: PendingRuntimeIntent<string>,
config: RuntimeConfigSnapshot,
): PendingRuntimeIntent<RuntimeSandboxPolicy | null> {
if (intent.kind === "set") return setRuntimeIntentValue(sandboxPolicyForPermissionProfile(intent.value, config));
if (intent.kind === "resetToConfig") return resetRuntimeIntentToConfig();
return unchangedRuntimeIntent();
}
function sandboxPolicyForPermissionProfile(profile: string, config: RuntimeConfigSnapshot): RuntimeSandboxPolicy | null {
return profile === config.startupPermissions.activePermissionProfile?.id
? cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy)
: null;
}
function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy | null): RuntimeSandboxPolicy | null {
return cloneRuntimePermissionState({ approvalPolicy: null, activePermissionProfile: null, sandboxPolicy: value }).sandboxPolicy;
}

View file

@ -1,59 +0,0 @@
import type { ModelMetadata } from "../../../../domain/catalog/metadata";
import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy";
import type { PendingRuntimeIntent, RequestedFastMode } from "./intent";
import type { RuntimeLayeredValue, RuntimeValueSource } from "./layered-value";
export interface FastModeResolution {
readonly requested: PendingRuntimeIntent<RequestedFastMode>;
readonly active: boolean;
readonly confirmedActive: boolean;
readonly source: RuntimeValueSource;
readonly confirmedSource: RuntimeValueSource;
readonly effectiveServiceTier: ServiceTier | null;
readonly confirmedServiceTier: ServiceTier | null;
readonly serviceTierRequestValue: string;
}
export interface AutoReviewResolution {
readonly active: boolean;
readonly confirmedActive: boolean;
readonly source: RuntimeValueSource;
readonly confirmedSource: RuntimeValueSource;
}
export function resolveAutoReview(reviewer: RuntimeLayeredValue<ApprovalsReviewer>): AutoReviewResolution {
return {
active: autoReviewActive(reviewer.effective),
confirmedActive: autoReviewActive(reviewer.confirmed),
source: reviewer.source,
confirmedSource: reviewer.confirmedSource,
};
}
export function resolveFastMode(
requested: PendingRuntimeIntent<RequestedFastMode>,
serviceTier: RuntimeLayeredValue<ServiceTier>,
serviceTiers: ModelMetadata["serviceTiers"],
): FastModeResolution {
return {
requested,
active: isFastServiceTier(serviceTier.effective, serviceTiers),
confirmedActive: isFastServiceTier(serviceTier.confirmed, serviceTiers),
source: serviceTier.source,
confirmedSource: serviceTier.confirmedSource,
effectiveServiceTier: serviceTier.effective,
confirmedServiceTier: serviceTier.confirmed,
serviceTierRequestValue: serviceTiers.find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast",
};
}
function autoReviewActive(value: ApprovalsReviewer | null): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function isFastServiceTier(value: string | null | undefined, serviceTiers: ModelMetadata["serviceTiers"]): boolean {
if (!value) return false;
if (value === "fast") return true;
if (serviceTiers.length === 0) return value === "priority";
return serviceTiers.some((tier) => tier.id === value && tier.name.trim().toLowerCase() === "fast");
}

View file

@ -1,53 +0,0 @@
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
import type { ConnectionWorkTracker } from "../../application/connection/connection-work";
import { type ChatReconnectActionsHost, reconnectPanel } from "../../application/connection/reconnect-actions";
import type { ChatConnectionPhase } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { ChatViewDeferredTasks } from "../session/deferred-work";
interface ChatPanelReconnectStatus {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
}
interface ChatPanelReconnectHost {
stateStore: ChatStateStore;
connectionWork: ConnectionWorkTracker;
deferredTasks: ChatViewDeferredTasks;
}
interface ChatPanelReconnectInput {
connection: ConnectionManager;
ensureConnected: () => Promise<void>;
invalidateThreadWork: () => void;
resumeThread: (threadId: string) => Promise<void>;
status: ChatPanelReconnectStatus;
}
export function createReconnectAction(host: ChatPanelReconnectHost, input: ChatPanelReconnectInput): () => Promise<void> {
const reconnectHost: ChatReconnectActionsHost = {
stateStore: host.stateStore,
invalidateConnectionWork: () => {
host.connectionWork.invalidate();
},
invalidateThreadWork: () => {
input.invalidateThreadWork();
},
clearDeferredDiagnostics: () => {
host.deferredTasks.clearDiagnostics();
},
resetConnection: () => {
input.connection.resetConnection();
},
setStatus: (statusText, phase) => {
input.status.set(statusText, phase);
},
ensureConnected: input.ensureConnected,
resumeThread: input.resumeThread,
addSystemMessage: (text) => {
input.status.addSystemMessage(text);
},
};
return () => reconnectPanel(reconnectHost);
}

View file

@ -9,18 +9,17 @@ import type { ChatAppServerGateway } from "../../app-server/session-gateway";
import type { LocalIdSource } from "../../application/local-id-source";
import { messageStreamItems } from "../../application/state/message-stream";
import type { ChatStateStore } from "../../application/state/store";
import type { ActiveThreadIdentitySync } from "../../application/threads/active-thread-identity-sync";
import { type ActiveThreadIdentitySync, createActiveThreadIdentitySync } from "../../application/threads/active-thread-identity-sync";
import { type AutoTitleCoordinator, createAutoTitleCoordinator } from "../../application/threads/auto-title-coordinator";
import { createGoalActions, createThreadGoalSyncActions } from "../../application/threads/goal-actions";
import { HistoryController } from "../../application/threads/history-controller";
import { createThreadLifecycleParts } from "../../application/threads/lifecycle-parts";
import {
activeThreadRenameTitleContext,
createThreadRenameEditorActions,
type ThreadRenameEditorActions,
} from "../../application/threads/rename-editor-actions";
import type { RestorationController } from "../../application/threads/restoration-controller";
import type { ResumeActions } from "../../application/threads/resume-actions";
import { RestorationController } from "../../application/threads/restoration-controller";
import { createResumeActions, type ResumeActions } from "../../application/threads/resume-actions";
import type { ChatResumeWorkTracker } from "../../application/threads/resume-work";
import { createThreadManagementActions, type ThreadManagementActionsHost } from "../../application/threads/thread-management-actions";
import { createThreadNavigationActions } from "../../application/threads/thread-navigation-actions";
@ -31,10 +30,16 @@ import type { ChatPanelEnvironment } from "../contracts";
type ChatPanelGoalSyncActions = ReturnType<typeof createThreadGoalSyncActions>;
export type ChatPanelGoalActions = ReturnType<typeof createGoalActions>;
export type ChatPanelThreadLifecycle = ReturnType<typeof createThreadLifecycleParts>;
export type ChatPanelThreadActions = ReturnType<typeof createThreadManagementActions>;
export type ChatPanelThreadNavigationActions = ReturnType<typeof createThreadNavigationActions>;
export interface ChatPanelThreadLifecycle {
history: HistoryController;
restoration: RestorationController;
resume: ResumeActions;
identity: ActiveThreadIdentitySync;
}
interface ChatPanelThreadStatus {
set: (statusText: string) => void;
addSystemMessage: (text: string) => void;
@ -378,28 +383,42 @@ function createSessionThreadLifecycle(
refreshLiveState,
notifyActiveThreadIdentityChanged,
} = input;
return createThreadLifecycleParts({
const restoration = new RestorationController({
invalidateThreadWork,
setStatus: status.set,
refreshTabHeader,
});
const resetThreadTurnPresence = (hadTurns: boolean) => {
autoTitleCoordinator.resetThreadTurnPresence(hadTurns);
};
const resume = createResumeActions({
stateStore: host.stateStore,
resumeTransport: appServer.threadResume,
lifecycle: {
resumeWork: host.resumeWork,
history,
invalidateThreadWork,
getClosing: host.getClosing,
recoverTokenUsageFromRollout: (path) =>
recoverRolloutTokenUsage(path, (filePath, options) => appServer.readFileBase64(filePath, options)),
},
thread: {
notifyIdentityChanged: notifyActiveThreadIdentityChanged,
refreshTabHeader,
},
status,
liveState: {
refresh: refreshLiveState,
},
goals,
resetThreadTurnPresence: (hadTurns) => {
autoTitleCoordinator.resetThreadTurnPresence(hadTurns);
},
resumeWork: host.resumeWork,
history,
restoration,
closing: host.getClosing,
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged,
addSystemMessage: status.addSystemMessage,
refreshLiveState,
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),
recoverTokenUsageFromRollout: (path) =>
recoverRolloutTokenUsage(path, (filePath, options) => appServer.readFileBase64(filePath, options)),
});
const identity = createActiveThreadIdentitySync({
stateStore: host.stateStore,
restoration,
invalidateThreadWork,
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged,
refreshTabHeader,
});
return {
history,
restoration,
resume,
identity,
};
}

View file

@ -2,6 +2,7 @@ import { ConnectionManager } from "../../../app-server/connection/connection-man
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import { createChatAppServerGateway } from "../app-server/session-gateway";
import type { ConnectionWorkTracker } from "../application/connection/connection-work";
import { reconnectPanel } from "../application/connection/reconnect-actions";
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
import type { ChatAction, ChatConnectionPhase } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
@ -15,7 +16,6 @@ import type { ChatComposerController } from "../panel/composer-controller";
import type { ChatMessageStreamScrollBinding } from "../panel/message-stream-scroll-binding";
import { createChatComposerController } from "./bundles/composer-bundle";
import { type ChatPanelConnectionBundle, createConnectionBundle } from "./bundles/connection-bundle";
import { createReconnectAction } from "./bundles/reconnect-bundle";
import { createRuntimeBundle } from "./bundles/runtime-bundle";
import { type ChatPanelShellBundle, createShellBundle } from "./bundles/shell-bundle";
import { createThreadActionBundle, createThreadFoundation, createThreadLifecycleBundle } from "./bundles/thread-bundle";
@ -164,15 +164,30 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
refreshActiveThreads,
notifyActiveThreadIdentityChanged,
});
const reconnect = createReconnectAction(host, {
connection,
ensureConnected,
invalidateThreadWork: () => {
threadFoundation.invalidateThreadWork();
},
resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId),
status,
});
const reconnect = () =>
reconnectPanel({
stateStore,
invalidateConnectionWork: () => {
host.connectionWork.invalidate();
},
invalidateThreadWork: () => {
threadFoundation.invalidateThreadWork();
},
clearDeferredDiagnostics: () => {
host.deferredTasks.clearDiagnostics();
},
resetConnection: () => {
connection.resetConnection();
},
setStatus: (statusText, phase) => {
status.set(statusText, phase);
},
ensureConnected,
resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId),
addSystemMessage: (text) => {
status.addSystemMessage(text);
},
});
const turn = createTurnBundle(host, {
localItemIds,
appServer,

View file

@ -1,5 +1,4 @@
import type { ObservedResult } from "../../../../app-server/query/observed-result";
import { observedValue } from "../../../../app-server/query/observed-result";
import type { ModelMetadata } from "../../../../domain/catalog/metadata";
import type { SharedServerMetadata } from "../../../../domain/server/metadata";
import type { Thread } from "../../../../domain/threads/model";
@ -45,21 +44,21 @@ export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateB
refreshTabHeader();
};
const receiveThreadResult = (result: ObservedResult<readonly Thread[]>): void => {
const observedThreads = observedValue(result);
const observedThreads = result.value;
if (observedThreads) receiveThreads(observedThreads);
};
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
serverActions.metadata.applyAppServerMetadata(metadata);
};
const receiveAppServerMetadataResult = (result: ObservedResult<SharedServerMetadata>): void => {
const observedMetadata = observedValue(result);
const observedMetadata = result.value;
if (observedMetadata) receiveAppServerMetadata(observedMetadata);
};
const receiveModels = (models: readonly ModelMetadata[]): void => {
stateStore.dispatch({ type: "connection/metadata-applied", availableModels: models });
};
const receiveModelsResult = (result: ObservedResult<readonly ModelMetadata[]>): void => {
const observedModels = observedValue(result);
const observedModels = result.value;
if (observedModels) receiveModels(observedModels);
};
const unsubscribe = (): void => {

View file

@ -10,11 +10,7 @@ import type { MessageStreamScrollPortBinding } from "../../ui/message-stream/flo
import { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/message-stream/markdown-renderer.obsidian";
import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/stream-blocks";
import type { ChatPanelMessageStreamReadModel } from "../shell-read-model";
import {
type ChatMessageStreamSurfaceContext,
createMessageStreamSurfaceContext,
messageStreamSurfaceProjectionFromModel,
} from "./message-stream-projection";
import { type ChatMessageStreamSurfaceContext, messageStreamSurfaceProjectionFromModel } from "./message-stream-projection";
export interface ChatPanelMessageStreamPresenter {
renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState;
@ -79,6 +75,7 @@ export interface MessageStreamPresenterOptions {
export class MessageStreamPresenter {
private readonly obsidianMarkdownRenderer: MarkdownMessageRenderer;
private readonly surfaceContext: ChatMessageStreamSurfaceContext;
constructor(private readonly options: MessageStreamPresenterOptions) {
this.obsidianMarkdownRenderer = new MarkdownMessageRenderer({
@ -86,18 +83,34 @@ export class MessageStreamPresenter {
owner: options.obsidian.owner,
vaultPath: options.workspace.vaultPath,
});
}
private dispatch(action: ChatAction): void {
this.options.state.store.dispatch(action);
this.surfaceContext = {
vaultPath: options.workspace.vaultPath,
setDisclosureOpen: (bucket, id, open) => {
this.dispatch({ type: "ui/disclosure-set", bucket, id, open });
},
setForkMenuItem: (itemId) => {
this.dispatch({ type: "ui/message-fork-menu-set", itemId });
},
loadOlderTurns: () => {
options.history.loadOlderTurns();
},
renderObsidianMarkdown: (element, text) => {
this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text);
},
renderStreamMarkdown: (element, text) => {
renderStreamMarkdown(element, text, {
app: options.obsidian.app,
vaultPath: options.workspace.vaultPath,
});
},
copyMessageText: (text) => void this.copyMessageText(text),
actions: options.actions,
requests: options.requests,
};
}
renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState {
return this.renderStateFor(model);
}
private renderStateFor(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState {
const projection = messageStreamSurfaceProjectionFromModel(model, this.messageStreamSurfaceContext());
const projection = messageStreamSurfaceProjectionFromModel(model, this.surfaceContext);
return {
blocks: projection.blocks,
@ -106,28 +119,8 @@ export class MessageStreamPresenter {
};
}
private messageStreamSurfaceContext(): ChatMessageStreamSurfaceContext {
return createMessageStreamSurfaceContext({
vaultPath: this.options.workspace.vaultPath,
dispatch: (action) => {
this.dispatch(action);
},
loadOlderTurns: () => {
this.options.history.loadOlderTurns();
},
renderObsidianMarkdown: (element, text) => {
this.obsidianMarkdownRenderer.renderObsidianMarkdown(element, text);
},
renderStreamMarkdown: (element, text) => {
renderStreamMarkdown(element, text, {
app: this.options.obsidian.app,
vaultPath: this.options.workspace.vaultPath,
});
},
copyMessageText: (text) => void this.copyMessageText(text),
actions: this.options.actions,
requests: this.options.requests,
});
private dispatch(action: ChatAction): void {
this.options.state.store.dispatch(action);
}
dispose(): void {

View file

@ -1,14 +1,14 @@
import type { TurnDiffViewState } from "../../../turn-diff/model";
import type { PendingRequestBlockActions } from "../../application/pending-requests/block";
import { type PendingRequestBlockActions, pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block";
import type { ChatRequestState } from "../../application/pending-requests/state";
import type { MessageStreamRollbackCandidate } from "../../application/state/message-stream";
import type { ChatAction } from "../../application/state/root-reducer";
import { type ForkCandidate, messageStreamSegmentsEmpty, type PlanImplementationTarget } from "../../domain/message-stream/selectors";
import { pendingRequestsSignature } from "../../domain/pending-requests/signatures";
import type { MessageStreamTextActionTargets } from "../../presentation/message-stream/text-view";
import { type MessageStreamViewBlock, messageStreamViewBlocks } from "../../presentation/message-stream/view-model";
import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/view-model";
import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model";
import type { MessageStreamContext, MessageStreamDisclosureBucket, MessageStreamDisclosureState } from "../../ui/message-stream/context";
import type { ChatPanelMessageStreamReadModel } from "../shell-read-model";
import { pendingRequestSurfaceProjectionFromState } from "./pending-request-block-projection";
interface ChatMessageStreamActions {
rollbackThread: (threadId: string) => void;
@ -34,17 +34,6 @@ export interface ChatMessageStreamSurfaceContext {
requests: ChatMessageStreamRequests;
}
export interface MessageStreamSurfaceContextOptions {
vaultPath: string;
dispatch: (action: ChatAction) => void;
loadOlderTurns: () => void;
renderObsidianMarkdown: (element: HTMLElement, text: string) => void;
renderStreamMarkdown: (element: HTMLElement, text: string) => void;
copyMessageText: (text: string) => void;
actions: ChatMessageStreamActions;
requests: ChatMessageStreamRequests;
}
interface MessageStreamStateProjection {
activeThreadId: string | null;
workspaceRoot: string;
@ -54,29 +43,16 @@ interface MessageStreamStateProjection {
viewBlocks: readonly MessageStreamViewBlock[];
}
interface PendingRequestSurfaceProjection {
readonly signature: string;
readonly snapshot: PendingRequestBlockSnapshot;
}
export interface MessageStreamSurfaceProjection {
blocks: readonly MessageStreamViewBlock[];
context: MessageStreamContext;
}
export function createMessageStreamSurfaceContext(options: MessageStreamSurfaceContextOptions): ChatMessageStreamSurfaceContext {
return {
vaultPath: options.vaultPath,
setDisclosureOpen: (bucket, id, open) => {
options.dispatch({ type: "ui/disclosure-set", bucket, id, open });
},
setForkMenuItem: (itemId) => {
options.dispatch({ type: "ui/message-fork-menu-set", itemId });
},
loadOlderTurns: options.loadOlderTurns,
renderObsidianMarkdown: options.renderObsidianMarkdown,
renderStreamMarkdown: options.renderStreamMarkdown,
copyMessageText: options.copyMessageText,
actions: options.actions,
requests: options.requests,
};
}
export function messageStreamSurfaceProjectionFromModel(
model: ChatPanelMessageStreamReadModel,
context: ChatMessageStreamSurfaceContext,
@ -188,6 +164,24 @@ function textActionTargetsForMessageStreamItems(
return byItemId;
}
function pendingRequestSurfaceProjectionFromState(
requests: ChatRequestState,
approvalDetails: ReadonlySet<string>,
): PendingRequestSurfaceProjection | null {
const signature = pendingRequestsSignature(
requests.approvals,
requests.pendingUserInputs,
requests.pendingMcpElicitations,
requests.userInputDrafts,
requests.mcpElicitationDrafts,
);
if (!signature) return null;
return {
signature,
snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromRequestState(requests, approvalDetails)),
};
}
function patchTextActionTargets(
byItemId: Map<string, MessageStreamTextActionTargets>,
itemId: string,

View file

@ -1,27 +0,0 @@
import { pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block";
import type { ChatRequestState } from "../../application/pending-requests/state";
import { pendingRequestsSignature } from "../../domain/pending-requests/signatures";
import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model";
export interface PendingRequestSurfaceProjection {
readonly signature: string;
readonly snapshot: PendingRequestBlockSnapshot;
}
export function pendingRequestSurfaceProjectionFromState(
requests: ChatRequestState,
approvalDetails: ReadonlySet<string>,
): PendingRequestSurfaceProjection | null {
const signature = pendingRequestsSignature(
requests.approvals,
requests.pendingUserInputs,
requests.pendingMcpElicitations,
requests.userInputDrafts,
requests.mcpElicitationDrafts,
);
if (!signature) return null;
return {
signature,
snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromRequestState(requests, approvalDetails)),
};
}

View file

@ -2,7 +2,7 @@ import { Notice } from "obsidian";
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
import type { ObservedResult } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading, observedValue } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
@ -161,7 +161,7 @@ export class ThreadsViewSession {
}
private receiveObservedThreadsResult(result: ObservedResult<readonly Thread[]>): void {
const observedThreads = observedValue(result);
const observedThreads = result.value;
if (observedThreads) {
this.receiveObservedThreads(observedThreads);
return;

View file

@ -1,24 +1,22 @@
import type { ObservedResult } from "../app-server/query/observed-result";
import { observedValue } from "../app-server/query/observed-result";
import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
import type { Thread } from "../domain/threads/model";
import { threadArchiveDisplayTitle } from "../domain/threads/title";
import { isStaleSettingsDynamicDataContextError } from "./dynamic-data";
import type { SettingsDynamicSectionsHost } from "./host";
import {
createSettingsDynamicSectionLifecycle,
type SettingsDynamicSectionLifecycleState,
settingsDynamicSectionFailed,
settingsDynamicSectionLoaded,
settingsDynamicSectionLoading,
} from "./lifecycle";
interface SettingsDynamicSectionsControllerCallbacks {
display(): void;
notify(message: string): void;
}
type SettingsDynamicSectionLifecycleState =
| { kind: "idle"; status: "" }
| { kind: "loading"; status: string }
| { kind: "loaded"; status: string }
| { kind: "failed"; status: string };
export interface SettingsDynamicSectionsSnapshot {
archivedThreads: readonly Thread[];
archivedThreadsLifecycle: SettingsDynamicSectionLifecycleState;
@ -110,14 +108,14 @@ export class SettingsDynamicSectionsController {
}
private receiveObservedModelsResult(result: ObservedResult<readonly ModelMetadata[]>): void {
const observedModels = observedValue(result);
const observedModels = result.value;
if (!observedModels) return;
this.models = [...observedModels];
this.callbacks.display();
}
private receiveObservedArchivedThreadsResult(result: ObservedResult<readonly Thread[]>): void {
const observedThreads = observedValue(result);
const observedThreads = result.value;
if (!observedThreads) return;
this.archivedThreads = [...observedThreads];
this.archivedThreadsLoaded = true;
@ -391,6 +389,22 @@ function archivedThreadsStatus(count: number): string {
return `Loaded ${String(count)} archived thread${count === 1 ? "" : "s"}.`;
}
function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState {
return { kind: "idle", status: "" };
}
function settingsDynamicSectionLoading(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "loading", status };
}
function settingsDynamicSectionLoaded(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "loaded", status };
}
function settingsDynamicSectionFailed(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "failed", status };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,21 +0,0 @@
export type SettingsDynamicSectionLifecycleState =
| { kind: "idle"; status: "" }
| { kind: "loading"; status: string }
| { kind: "loaded"; status: string }
| { kind: "failed"; status: string };
export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState {
return { kind: "idle", status: "" };
}
export function settingsDynamicSectionLoading(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "loading", status };
}
export function settingsDynamicSectionLoaded(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "loaded", status };
}
export function settingsDynamicSectionFailed(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "failed", status };
}

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ObservedResult } from "../../../src/app-server/query/observed-result";
import { observedInitialError, observedInitialLoading, observedValue } from "../../../src/app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../../src/app-server/query/observed-result";
describe("observed query result helpers", () => {
it("treats successful empty arrays as current values", () => {
@ -19,11 +19,6 @@ describe("observed query result helpers", () => {
expect(observedInitialLoading(observedResult({ value: null, isFetching: true }), null)).toBe(true);
expect(observedInitialError(observedResult({ value: null, error }), null)).toBe(error);
});
it("projects nullable observed values without reinterpreting empty values", () => {
expect(observedValue(observedResult({ value: [] as readonly string[] }))).toEqual([]);
expect(observedValue(observedResult({ value: null }))).toBeNull();
});
});
function observedResult<T>(overrides: Partial<ObservedResult<T>> & Pick<ObservedResult<T>, "value">): ObservedResult<T> {

View file

@ -4,18 +4,8 @@ import type { AppServerClient, ClientResponseByMethod } from "../../../../src/ap
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import type { CodexInput } from "../../../../src/domain/chat/input";
import { createChatAppServerGateway } from "../../../../src/features/chat/app-server/session-gateway";
import { createThreadReferenceResolver } from "../../../../src/features/chat/app-server/thread-reference-resolver";
import {
createChatThreadGoalReadTransport,
createChatThreadGoalTransport,
} from "../../../../src/features/chat/app-server/transports/goal-transport";
import {
createChatThreadHistoryTransport,
createChatThreadResumeTransport,
} from "../../../../src/features/chat/app-server/transports/thread-loading-transport";
import { createChatThreadMutationTransport } from "../../../../src/features/chat/app-server/transports/thread-mutation-transport";
import { createChatRuntimeSettingsTransport } from "../../../../src/features/chat/app-server/transports/thread-settings-transport";
import { createChatTurnTransport } from "../../../../src/features/chat/app-server/transports/turn-transport";
import { deferred } from "../../../support/async";
const textInput = (text: string): CodexInput => [{ type: "text", text }];
@ -24,11 +14,10 @@ describe("chat app-server transports", () => {
it("starts turns with the session vault path and returns chat-owned turn ids", async () => {
const request = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });
const client = { request } as unknown as AppServerClient;
const transport = createChatTurnTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
}).turn;
await expect(
transport.startTurn({
@ -51,11 +40,10 @@ describe("chat app-server transports", () => {
const firstClient = { request: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatTurnTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => currentClient,
connectedClient: vi.fn().mockResolvedValue(firstClient),
});
}).turn;
const starting = transport.startTurn({ threadId: "thread", input: textInput("hello"), clientUserMessageId: "local-user" });
currentClient = secondClient;
@ -67,11 +55,10 @@ describe("chat app-server transports", () => {
it("compacts threads through a connected app-server client", async () => {
const request = vi.fn().mockResolvedValue({});
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
}).threadMutation;
await expect(transport.compactThread("thread")).resolves.toBe(true);
@ -81,11 +68,10 @@ describe("chat app-server transports", () => {
it("forks threads with the session vault path and returns panel threads", async () => {
const request = vi.fn().mockResolvedValue({ thread: threadRecord("forked") });
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
}).threadMutation;
const thread = await transport.forkThread("source");
@ -98,11 +84,10 @@ describe("chat app-server transports", () => {
const firstClient = { request: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => currentClient,
connectedClient: vi.fn().mockResolvedValue(firstClient),
});
}).threadMutation;
const forking = transport.forkThread("source");
currentClient = secondClient;
@ -114,11 +99,10 @@ describe("chat app-server transports", () => {
it("projects rollback turns into message stream items", async () => {
const request = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) });
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
}).threadMutation;
const snapshot = await transport.rollbackThread("thread");
@ -134,9 +118,10 @@ describe("chat app-server transports", () => {
nextCursor: "older",
});
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadHistoryTransport({
const transport = createTestGateway({
currentClient: () => client,
});
connectedClient: vi.fn().mockResolvedValue(client),
}).threadHistory;
const page = await transport.readHistoryPage("thread", "cursor", 20);
@ -160,9 +145,10 @@ describe("chat app-server transports", () => {
const firstClient = { request: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadHistoryTransport({
const transport = createTestGateway({
currentClient: () => currentClient,
});
connectedClient: vi.fn().mockResolvedValue(firstClient),
}).threadHistory;
const loading = transport.readHistoryPage("thread", "cursor", 20);
currentClient = secondClient;
@ -185,11 +171,10 @@ describe("chat app-server transports", () => {
},
});
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
}).threadResume;
const snapshot = await transport.resumeThread("thread");
@ -214,11 +199,10 @@ describe("chat app-server transports", () => {
const firstClient = { request: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => currentClient,
connectedClient: vi.fn().mockResolvedValue(firstClient),
});
}).threadResume;
const resuming = transport.resumeThread("thread");
currentClient = secondClient;
@ -228,28 +212,28 @@ describe("chat app-server transports", () => {
});
it("returns no resume snapshot when no connected client is available", async () => {
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
const transport = createTestGateway({
currentClient: () => null,
connectedClient: vi.fn().mockResolvedValue(null),
});
}).threadResume;
await expect(transport.resumeThread("thread")).resolves.toBeNull();
});
it("distinguishes absent goals from unavailable goal clients", async () => {
const client = { request: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
const transport = createChatThreadGoalTransport({
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
});
const unavailable = createChatThreadGoalTransport({
}).threadGoal;
const unavailable = createTestGateway({
currentClient: () => null,
connectedClient: vi.fn().mockResolvedValue(null),
});
const readOnlyUnavailable = createChatThreadGoalReadTransport({
}).threadGoal;
const readOnlyUnavailable = createTestGateway({
currentClient: () => null,
});
connectedClient: vi.fn().mockResolvedValue(null),
}).threadGoalRead;
await expect(transport.readThreadGoal("thread")).resolves.toBeNull();
await expect(unavailable.readThreadGoal("thread")).resolves.toBeUndefined();
@ -262,9 +246,10 @@ describe("chat app-server transports", () => {
const firstClient = { request } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatRuntimeSettingsTransport({
const transport = createTestGateway({
currentClient: () => currentClient,
});
connectedClient: vi.fn().mockResolvedValue(firstClient),
}).runtimeSettings;
const updating = transport.updateThreadSettings("thread", { model: "gpt-5.5" });
currentClient = secondClient;
@ -312,6 +297,18 @@ describe("chat app-server transports", () => {
type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"];
function createTestGateway(options: {
vaultPath?: string;
currentClient: () => AppServerClient | null;
connectedClient?: () => Promise<AppServerClient | null>;
}) {
return createChatAppServerGateway({
vaultPath: options.vaultPath ?? "/vault",
currentClient: options.currentClient,
connectedClient: options.connectedClient ?? (async () => options.currentClient()),
});
}
function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial<ThreadRecord> = {}): ThreadRecord {
return {
id,

View file

@ -1,63 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { ConnectionManager } from "../../../../src/app-server/connection/connection-manager";
import { ConnectionWorkTracker } from "../../../../src/features/chat/application/connection/connection-work";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createReconnectAction } from "../../../../src/features/chat/host/bundles/reconnect-bundle";
import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/session/deferred-work";
describe("createReconnectAction", () => {
it("wires host connection lifecycle cleanup into panel reconnect", async () => {
const stateStore = createChatStateStore();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread-1" } as never,
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
const connectionWork = new ConnectionWorkTracker();
const activeConnectionWork = connectionWork.begin();
const deferredTasks = createChatViewDeferredTasks(() => ({ setTimeout, clearTimeout }) as Pick<Window, "setTimeout" | "clearTimeout">);
const connection = new ConnectionManager(() => "codex", "/vault");
const resetConnection = vi.spyOn(connection, "resetConnection");
const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics");
const status = {
set: vi.fn(),
addSystemMessage: vi.fn(),
};
const ensureConnected = vi.fn().mockResolvedValue(undefined);
const resumeThread = vi.fn().mockResolvedValue(undefined);
const reconnect = createReconnectAction(
{
stateStore,
connectionWork,
deferredTasks,
},
{
connection,
ensureConnected,
invalidateThreadWork: vi.fn(),
resumeThread,
status,
},
);
await reconnect();
expect(connectionWork.isStale(activeConnectionWork)).toBe(true);
expect(clearDiagnostics).toHaveBeenCalledOnce();
expect(resetConnection).toHaveBeenCalledOnce();
expect(status.set).toHaveBeenCalledWith("Reconnecting...", { kind: "connecting" });
expect(ensureConnected).toHaveBeenCalledOnce();
expect(resumeThread).toHaveBeenCalledWith("thread-1");
});
});

View file

@ -16,6 +16,7 @@ import { ChatComposerController } from "../../../../src/features/chat/panel/comp
import { createChatMessageStreamScrollBinding } from "../../../../src/features/chat/panel/message-stream-scroll-binding";
import { MessageStreamPresenter } from "../../../../src/features/chat/panel/surface/message-stream-presenter";
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { waitForAsyncWork } from "../../../support/async";
import { installObsidianDomShims } from "../../../support/dom";
import { chatPanelSettingsAccess } from "../support/settings";
@ -199,6 +200,41 @@ describe("createChatPanelSessionGraph actions", () => {
expect(focusComposer).not.toHaveBeenCalled();
});
it("wires reconnect cleanup through the graph toolbar action", async () => {
const { graph, stateStore, connectionWork, deferredTasks } = sessionGraphFixture();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: threadFixture({ id: "thread-1", preview: "Active" }),
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
const activeConnectionWork = connectionWork.begin();
const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics");
const resetConnection = vi.spyOn(graph.connection.manager, "resetConnection");
const ensureConnected = vi.spyOn(graph.connection.actions, "ensureConnected").mockResolvedValue(undefined);
const resumeThread = vi.spyOn(graph.thread.resume, "resumeThread").mockResolvedValue(undefined);
graph.shell.parts.toolbar.actions.status.connect();
await waitForAsyncWork(() => {
expect(resumeThread).toHaveBeenCalledWith("thread-1");
});
expect(connectionWork.isStale(activeConnectionWork)).toBe(true);
expect(clearDiagnostics).toHaveBeenCalledOnce();
expect(resetConnection).toHaveBeenCalledOnce();
expect(stateStore.getState().connection.statusText).toBe("Reconnecting...");
expect(ensureConnected).toHaveBeenCalledOnce();
});
it("disposes presenter and composer resources from the graph action", () => {
const disposePresenter = vi.spyOn(MessageStreamPresenter.prototype, "dispose").mockImplementation(() => undefined);
const disposeComposer = vi.spyOn(ChatComposerController.prototype, "dispose").mockImplementation(() => undefined);
@ -214,21 +250,25 @@ describe("createChatPanelSessionGraph actions", () => {
graph: ReturnType<typeof createChatPanelSessionGraph>;
stateStore: ChatStateStore;
resumeWork: ChatResumeWorkTracker;
connectionWork: ConnectionWorkTracker;
deferredTasks: ReturnType<typeof createChatViewDeferredTasks>;
} {
const stateStore = createChatStateStore();
const resumeWork = new ChatResumeWorkTracker();
const connectionWork = new ConnectionWorkTracker();
const deferredTasks = createChatViewDeferredTasks(() => window);
const environment = chatPanelEnvironmentFixture(options.environment);
const graph = createChatPanelSessionGraph({
environment,
stateStore,
deferredTasks: createChatViewDeferredTasks(() => window),
deferredTasks,
resumeWork,
connectionWork: new ConnectionWorkTracker(),
connectionWork,
messageScrollBinding: createChatMessageStreamScrollBinding(),
getClosing: () => false,
viewWindow: () => window,
});
return { graph, stateStore, resumeWork };
return { graph, stateStore, resumeWork, connectionWork, deferredTasks };
}
interface PartialChatPanelEnvironment {

View file

@ -58,7 +58,7 @@ describe("MessageStreamPresenter scroll pinning", () => {
const projection = messageStreamSurfaceProjectionFromModel(
messageStreamReadModelFromChatState(store.getState()),
messageStreamSurfaceContext({
testMessageStreamSurfaceContext({
vaultPath: "/vault",
dispatch: (action) => {
store.dispatch(action);
@ -75,7 +75,7 @@ describe("MessageStreamPresenter scroll pinning", () => {
it("wires message stream disclosure actions through the surface context", () => {
const store = createChatStateStore(chatStateFixture());
const surfaceContext = messageStreamSurfaceContext({
const surfaceContext = testMessageStreamSurfaceContext({
vaultPath: "/vault",
dispatch: (action) => {
store.dispatch(action);
@ -95,7 +95,7 @@ describe("MessageStreamPresenter scroll pinning", () => {
state = chatStateWith(state, { requests: { approvals: [pendingApproval()] } });
const projection = messageStreamSurfaceProjectionFromModel(
messageStreamReadModelFromChatState(state),
messageStreamSurfaceContext({
testMessageStreamSurfaceContext({
vaultPath: "/vault",
dispatch: () => undefined,
}),
@ -522,7 +522,7 @@ describe("MessageStreamPresenter scroll pinning", () => {
});
});
function messageStreamSurfaceContext(options: {
function testMessageStreamSurfaceContext(options: {
vaultPath: string;
dispatch: (action: ChatAction) => void;
}): ChatMessageStreamSurfaceContext {

View file

@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createSettingsDynamicSectionLifecycle,
settingsDynamicSectionFailed,
settingsDynamicSectionLoaded,
settingsDynamicSectionLoading,
} from "../../src/settings/lifecycle";
describe("settings lifecycle", () => {
it("tracks dynamic section lifecycle", () => {
const idle = createSettingsDynamicSectionLifecycle();
expect(idle).toEqual({ kind: "idle", status: "" });
const loading = settingsDynamicSectionLoading("Loading hooks...");
expect(loading).toEqual({ kind: "loading", status: "Loading hooks..." });
const loaded = settingsDynamicSectionLoaded("Loaded 1 hook.");
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook." });
const failed = settingsDynamicSectionFailed("Could not load hooks.");
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks." });
expect(createSettingsDynamicSectionLifecycle()).toEqual(idle);
});
});