Extract restored and runtime settings controllers

This commit is contained in:
murashit 2026-05-28 22:31:11 +09:00
parent 428d435d01
commit a9761d2b13
5 changed files with 500 additions and 173 deletions

View file

@ -0,0 +1,99 @@
import type { ChatAction } from "./chat-state";
import type { DisplayItem } from "./display/types";
import {
transitionRestoredThreadLifecycle,
type RestoredThreadLifecycleState,
type RestoredThreadPlaceholderState,
type RestoredThreadState,
type ChatViewDeferredTasks,
} from "./view-lifecycle";
export interface RestoredThreadControllerHost {
deferredTasks: ChatViewDeferredTasks;
opened: () => boolean;
resumeThread: (threadId: string) => Promise<void>;
invalidateResumeWork: () => void;
dispatch: (action: ChatAction) => void;
systemItem: (text: string) => DisplayItem;
setStatus: (status: string) => void;
refreshTabHeader: () => void;
}
export class RestoredThreadController {
private lifecycle: RestoredThreadLifecycleState = { kind: "idle" };
constructor(private readonly host: RestoredThreadControllerHost) {}
placeholder(): RestoredThreadPlaceholderState | null {
return this.lifecycle.kind === "placeholder" ? this.lifecycle : null;
}
title(): string | null {
return this.placeholder()?.title ?? null;
}
clear(): void {
this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "cleared" });
}
rename(threadId: string, name: string | null): boolean {
const previous = this.placeholder();
this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "renamed", threadId, name });
return this.placeholder() !== previous;
}
restore(restoredThread: RestoredThreadState): void {
this.host.invalidateResumeWork();
this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, {
type: "placeholder-restored",
restoredThread,
});
this.host.dispatch({
type: "thread/restored-placeholder",
threadId: restoredThread.threadId,
item: this.host.systemItem("Thread restored. Send a message to resume it."),
});
this.host.setStatus("Thread ready to resume.");
this.host.refreshTabHeader();
this.scheduleHydration();
}
async ensureLoaded(): Promise<boolean> {
const restoredThread = this.placeholder();
if (!restoredThread) return true;
this.clearHydration();
if (restoredThread.loading) {
const threadId = restoredThread.threadId;
await restoredThread.loading;
return !this.isPending(threadId);
}
const threadId = restoredThread.threadId;
const loading = this.host.resumeThread(threadId);
this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "loading-started", loading });
try {
await loading;
} finally {
this.lifecycle = transitionRestoredThreadLifecycle(this.lifecycle, { type: "loading-finished", loading });
}
return !this.isPending(threadId);
}
isPending(threadId: string): boolean {
return this.placeholder()?.threadId === threadId;
}
scheduleHydration(): void {
const restoredThread = this.placeholder();
if (!this.host.opened() || !restoredThread) return;
const threadId = restoredThread.threadId;
this.host.deferredTasks.scheduleRestoredThreadHydration(() => {
if (!this.isPending(threadId)) return;
void this.ensureLoaded();
});
}
clearHydration(): void {
this.host.deferredTasks.clearRestoredThreadHydration();
}
}

View file

@ -0,0 +1,141 @@
import type { AppServerClient } from "../../app-server/client";
import { requestedServiceTierRequestValue, type RequestedServiceTier } from "../../app-server/service-tier";
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
import type { ModeKind } from "../../generated/app-server/ModeKind";
import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer";
import type { ThreadSettingsUpdateParams } from "../../generated/app-server/v2/ThreadSettingsUpdateParams";
import { collaborationModeToggleMessage, nextCollaborationMode } from "../../runtime/collaboration-mode";
import { readRuntimeConfig } from "../../runtime/config";
import {
autoReviewActive,
currentServiceTier,
requestedTurnRuntimeSettings,
runtimeOverridePayload,
type RuntimeSnapshot,
} from "../../runtime/state";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../runtime/settings";
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
type ThreadSettingsUpdate = Omit<ThreadSettingsUpdateParams, "threadId">;
export interface RuntimeSettingsControllerHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
runtimeSnapshot: () => RuntimeSnapshot;
collaborationModeLabel: () => string;
addSystemMessage: (text: string) => void;
}
export class ChatRuntimeSettingsController {
constructor(private readonly host: RuntimeSettingsControllerHost) {}
private get state(): ChatState {
return this.host.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
async applyPendingThreadSettings(): Promise<boolean> {
const client = this.host.currentClient();
const threadId = this.state.activeThreadId;
if (!client || !threadId) return true;
const update = this.pendingThreadSettingsUpdate();
if (Object.keys(update).length === 0) return true;
try {
await client.updateThreadSettings(threadId, update);
this.dispatch({ type: "runtime/pending-thread-settings-committed", update });
return true;
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
async setRequestedModel(model: string | null): Promise<boolean> {
this.dispatch({ type: "runtime/requested-model-set", model });
return this.applyPendingThreadSettings();
}
async setRequestedModelFromUi(model: string | null): Promise<void> {
if (!(await this.setRequestedModel(model))) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.host.addSystemMessage(modelOverrideMessage(model));
}
async setRequestedReasoningEffort(effort: ReasoningEffort | null): Promise<boolean> {
this.dispatch({ type: "runtime/requested-effort-set", effort });
return this.applyPendingThreadSettings();
}
async setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): Promise<void> {
if (!(await this.setRequestedReasoningEffort(effort))) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.host.addSystemMessage(reasoningEffortOverrideMessage(effort));
}
async toggleFastMode(): Promise<void> {
const current = currentServiceTier(this.host.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig));
const next: RequestedServiceTier = current === "fast" ? "off" : "fast";
this.dispatch({ type: "runtime/requested-service-tier-set", serviceTier: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.host.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
}
async toggleCollaborationMode(): Promise<void> {
const next = nextCollaborationMode(this.state.requestedCollaborationMode);
await this.setCollaborationMode(next);
}
async setCollaborationMode(collaborationMode: ModeKind): Promise<boolean> {
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode });
this.dispatch({ type: "ui/panel-set", panel: null });
const applied = await this.applyPendingThreadSettings();
if (applied) this.host.addSystemMessage(collaborationModeToggleMessage(collaborationMode));
return applied;
}
async toggleAutoReview(): Promise<void> {
const next: ApprovalsReviewer = autoReviewActive(this.host.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig))
? "user"
: "auto_review";
this.dispatch({ type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.host.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
}
private pendingThreadSettingsUpdate(): ThreadSettingsUpdate {
const update: ThreadSettingsUpdate = {};
const state = this.state;
const turnSettings = requestedTurnRuntimeSettings(this.host.runtimeSnapshot());
if (state.requestedModel.kind !== "default") {
const model = runtimeOverridePayload(state.requestedModel);
if (model !== undefined) update.model = model;
}
if (state.requestedReasoningEffort.kind !== "default") {
const effort = runtimeOverridePayload(state.requestedReasoningEffort);
if (effort !== undefined) update.effort = effort;
}
if (state.requestedServiceTier !== null) {
const serviceTier = requestedServiceTierRequestValue(state.requestedServiceTier);
if (serviceTier !== undefined) update.serviceTier = serviceTier;
}
if (state.requestedApprovalsReviewer !== null) {
update.approvalsReviewer = state.requestedApprovalsReviewer;
}
if (state.requestedCollaborationMode !== state.activeCollaborationMode) {
if (turnSettings.warning) {
this.host.addSystemMessage(`${this.host.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
} else if (turnSettings.collaborationMode) {
update.collaborationMode = turnSettings.collaborationMode;
}
}
return update;
}
}

View file

@ -2,7 +2,7 @@ import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsi
import type { AppServerClient } from "../../app-server/client";
import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager";
import { parseServiceTier, requestedServiceTierRequestValue, type RequestedServiceTier } from "../../app-server/service-tier";
import { parseServiceTier } from "../../app-server/service-tier";
import type { ApprovalAction, PendingApproval } from "./approvals/model";
import type { SlashCommandName } from "./composer/slash-commands";
import { parseSlashCommand } from "./composer/suggestions";
@ -11,31 +11,15 @@ import { createSystemItem } from "./display/system";
import { fileMentionsFromInput } from "./display/thread-items";
import type { DisplayDetailSection, DisplayItem } from "./display/types";
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer";
import type { Model } from "../../generated/app-server/v2/Model";
import type { Thread } from "../../generated/app-server/v2/Thread";
import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse";
import type { ThreadSettingsUpdateParams } from "../../generated/app-server/v2/ThreadSettingsUpdateParams";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import {
collaborationModeLabel as formatCollaborationModeLabel,
collaborationModeToggleMessage,
nextCollaborationMode,
} from "../../runtime/collaboration-mode";
import { collaborationModeLabel as formatCollaborationModeLabel } from "../../runtime/collaboration-mode";
import { ChatController } from "./chat-controller";
import {
autoReviewActive,
currentModel,
currentReasoningEffort,
currentServiceTier,
requestedTurnRuntimeSettings,
runtimeOverridePayload,
supportedReasoningEfforts,
type RuntimeSnapshot,
} from "../../runtime/state";
import { currentModel, currentReasoningEffort, supportedReasoningEfforts, type RuntimeSnapshot } from "../../runtime/state";
import { readRuntimeConfig } from "../../runtime/config";
import { sortedAvailableModels } from "../../runtime/model";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../runtime/settings";
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult } from "./slash-commands";
import type { ThreadReferenceInput } from "./slash-commands";
import { mcpStatusLines } from "./mcp-status";
@ -71,6 +55,8 @@ import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-messag
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
import type { SharedAppServerMetadata } from "../../runtime/shared-app-server-state";
import { ChatThreadActionController } from "./thread-actions";
import { ChatRuntimeSettingsController } from "./runtime-settings-controller";
import { RestoredThreadController } from "./restored-thread-controller";
import { unmountReactRoot } from "../../shared/ui/react-root";
import {
connectionDiagnosticsModel,
@ -92,15 +78,11 @@ import {
ChatViewDeferredTasks,
transitionChatConnectionLifecycle,
transitionChatResumeLifecycle,
transitionRestoredThreadLifecycle,
type ActiveChatConnection,
type ActiveChatResume,
type ChatConnectionLifecycleState,
type ChatResumeLifecycleState,
type ChatViewRenderScheduleOptions,
type RestoredThreadLifecycleState,
type RestoredThreadPlaceholderState,
type RestoredThreadState,
} from "./view-lifecycle";
import { acknowledgeOptimisticTurnStart, cleanupFailedTurnStart, localUserMessageItem } from "./turn-submission";
@ -128,6 +110,8 @@ export class CodexChatView extends ItemView {
private readonly appServer: ChatAppServerController;
private readonly history: ThreadHistoryLoader;
private readonly threadActions: ChatThreadActionController;
private readonly runtimeSettings: ChatRuntimeSettingsController;
private readonly restoredThread: RestoredThreadController;
private readonly threadRename: ThreadRenameController;
private readonly chatState = createChatStateStore();
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
@ -138,7 +122,6 @@ export class CodexChatView extends ItemView {
private archiveConfirmThreadId: string | null = null;
private connectionLifecycle: ChatConnectionLifecycleState = { kind: "idle" };
private resumeLifecycle: ChatResumeLifecycleState = { kind: "idle" };
private restoredThreadLifecycle: RestoredThreadLifecycleState = { kind: "idle" };
private opened = false;
private closing = false;
private nextMessageScrollIntent: ChatMessageScrollIntent = "auto";
@ -297,6 +280,33 @@ export class CodexChatView extends ItemView {
this.plugin.refreshSharedThreadListFromOpenSurface();
},
});
this.runtimeSettings = new ChatRuntimeSettingsController({
stateStore: this.chatState,
currentClient: () => this.client,
runtimeSnapshot: () => this.runtimeSnapshot(),
collaborationModeLabel: () => this.collaborationModeLabel(),
addSystemMessage: (text) => {
this.addSystemMessage(text);
},
});
this.restoredThread = new RestoredThreadController({
deferredTasks: this.deferredTasks,
opened: () => this.opened,
resumeThread: (threadId) => this.resumeThread(threadId),
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
dispatch: (action) => {
this.dispatch(action);
},
systemItem: (text) => this.systemItem(text),
setStatus: (status) => {
this.setStatus(status);
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
});
this.threadRename = new ThreadRenameController({
stateStore: this.chatState,
vaultPath: this.plugin.vaultPath,
@ -443,7 +453,7 @@ export class CodexChatView extends ItemView {
this.dispatch({ type: "thread/list-applied", threads: listedThreads });
const restoredThread = this.restoredThreadPlaceholder();
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "renamed", threadId, name });
this.restoredThread.rename(threadId, name);
changed = true;
}
const activeThreadChanged = this.state.activeThreadId === threadId || this.isRestoredThreadPending(threadId);
@ -752,7 +762,7 @@ export class CodexChatView extends ItemView {
}
const activeThreadId = this.state.activeThreadId;
if (!activeThreadId) return;
if (!(await this.applyPendingThreadSettings())) return;
if (!(await this.runtimeSettings.applyPendingThreadSettings())) return;
const codexInput = codexInputOverride ?? this.composerController.codexInput(text);
const mentionedFiles = fileMentionsFromInput(codexInput);
@ -883,9 +893,9 @@ export class CodexChatView extends ItemView {
},
archiveThread: (threadId) => this.threadActions.archiveThread(threadId),
busy: this.turnBusy,
toggleFastMode: () => this.toggleFastMode(),
toggleCollaborationMode: () => this.toggleCollaborationMode(),
toggleAutoReview: () => void this.toggleAutoReview(),
toggleFastMode: () => this.runtimeSettings.toggleFastMode(),
toggleCollaborationMode: () => this.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void this.runtimeSettings.toggleAutoReview(),
addSystemMessage: (text) => {
this.addSystemMessage(text);
},
@ -895,8 +905,8 @@ export class CodexChatView extends ItemView {
setStatus: (status) => {
this.setStatus(status);
},
setRequestedModel: (model) => this.setRequestedModel(model),
setRequestedReasoningEffort: (effort) => this.setRequestedReasoningEffort(effort),
setRequestedModel: (model) => this.runtimeSettings.setRequestedModel(model),
setRequestedReasoningEffort: (effort) => this.runtimeSettings.setRequestedReasoningEffort(effort),
statusSummaryLines: () => this.statusSummaryLines(),
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
mcpStatusLines: () => this.mcpStatusLines(),
@ -927,84 +937,6 @@ export class CodexChatView extends ItemView {
}
}
private async applyPendingThreadSettings(): Promise<boolean> {
const client = this.client;
const threadId = this.state.activeThreadId;
if (!client || !threadId) return true;
const update = this.pendingThreadSettingsUpdate();
if (Object.keys(update).length === 0) return true;
try {
await client.updateThreadSettings(threadId, update);
this.commitPendingThreadSettings(update);
return true;
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
private pendingThreadSettingsUpdate(): Omit<ThreadSettingsUpdateParams, "threadId"> {
const update: Omit<ThreadSettingsUpdateParams, "threadId"> = {};
const turnSettings = requestedTurnRuntimeSettings(this.runtimeSnapshot());
if (this.state.requestedModel.kind !== "default") {
const model = runtimeOverridePayload(this.state.requestedModel);
if (model !== undefined) update.model = model;
}
if (this.state.requestedReasoningEffort.kind !== "default") {
const effort = runtimeOverridePayload(this.state.requestedReasoningEffort);
if (effort !== undefined) update.effort = effort;
}
if (this.state.requestedServiceTier !== null) {
const serviceTier = requestedServiceTierRequestValue(this.state.requestedServiceTier);
if (serviceTier !== undefined) update.serviceTier = serviceTier;
}
if (this.state.requestedApprovalsReviewer !== null) {
update.approvalsReviewer = this.state.requestedApprovalsReviewer;
}
if (this.state.requestedCollaborationMode !== this.state.activeCollaborationMode) {
if (turnSettings.warning) {
this.addSystemMessage(`${this.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
} else if (turnSettings.collaborationMode) {
update.collaborationMode = turnSettings.collaborationMode;
}
}
return update;
}
private commitPendingThreadSettings(update: Omit<ThreadSettingsUpdateParams, "threadId">): void {
this.dispatch({ type: "runtime/pending-thread-settings-committed", update });
}
private async toggleFastMode(): Promise<void> {
const current = currentServiceTier(this.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig));
const next: RequestedServiceTier = current === "fast" ? "off" : "fast";
this.dispatch({ type: "runtime/requested-service-tier-set", serviceTier: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
}
private async toggleCollaborationMode(): Promise<void> {
const next = nextCollaborationMode(this.state.requestedCollaborationMode);
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.addSystemMessage(collaborationModeToggleMessage(next));
}
private async toggleAutoReview(): Promise<void> {
const next: ApprovalsReviewer = autoReviewActive(this.runtimeSnapshot(), readRuntimeConfig(this.state.effectiveConfig))
? "user"
: "auto_review";
this.dispatch({ type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
}
private canImplementPlanItem(item: DisplayItem): boolean {
if (item.kind !== "message" || item.role !== "assistant" || item.proposedPlan !== true) return false;
if (!this.state.activeThreadId || this.turnBusy || this.state.composerDraft.trim().length > 0) return false;
@ -1018,25 +950,11 @@ export class CodexChatView extends ItemView {
}
private async setRequestedModelFromUi(model: string | null): Promise<void> {
if (!(await this.setRequestedModel(model))) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.addSystemMessage(modelOverrideMessage(model));
}
private async setRequestedModel(model: string | null): Promise<boolean> {
this.dispatch({ type: "runtime/requested-model-set", model });
return this.applyPendingThreadSettings();
await this.runtimeSettings.setRequestedModelFromUi(model);
}
private async setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): Promise<void> {
if (!(await this.setRequestedReasoningEffort(effort))) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.addSystemMessage(reasoningEffortOverrideMessage(effort));
}
private async setRequestedReasoningEffort(effort: ReasoningEffort | null): Promise<boolean> {
this.dispatch({ type: "runtime/requested-effort-set", effort });
return this.applyPendingThreadSettings();
await this.runtimeSettings.setRequestedReasoningEffortFromUi(effort);
}
private async resolveApproval(approval: PendingApproval, action: ApprovalAction): Promise<void> {
@ -1098,20 +1016,8 @@ export class CodexChatView extends ItemView {
this.dispatch({ type: "status/set", status });
}
private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void {
this.invalidateResumeWork();
this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, {
type: "placeholder-restored",
restoredThread,
});
this.dispatch({
type: "thread/restored-placeholder",
threadId: restoredThread.threadId,
item: this.systemItem("Thread restored. Send a message to resume it."),
});
this.setStatus("Thread ready to resume.");
this.refreshTabHeader();
this.scheduleDeferredRestoredThreadHydration();
private restoreThreadPlaceholder(restoredThread: Parameters<RestoredThreadController["restore"]>[0]): void {
this.restoredThread.restore(restoredThread);
}
private beginResumeWork(threadId: string): ActiveChatResume {
@ -1131,42 +1037,19 @@ export class CodexChatView extends ItemView {
}
private async ensureRestoredThreadLoaded(): Promise<boolean> {
const restoredThread = this.restoredThreadPlaceholder();
if (!restoredThread) return true;
this.clearDeferredRestoredThreadHydration();
if (restoredThread.loading) {
const threadId = restoredThread.threadId;
await restoredThread.loading;
return !this.isRestoredThreadPending(threadId);
}
const threadId = restoredThread.threadId;
const loading = this.resumeThread(threadId);
this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "loading-started", loading });
try {
await loading;
} finally {
this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "loading-finished", loading });
}
return !this.isRestoredThreadPending(threadId);
return this.restoredThread.ensureLoaded();
}
private isRestoredThreadPending(threadId: string): boolean {
return this.restoredThreadPlaceholder()?.threadId === threadId;
return this.restoredThread.isPending(threadId);
}
private scheduleDeferredRestoredThreadHydration(): void {
const restoredThread = this.restoredThreadPlaceholder();
if (!this.opened || !restoredThread) return;
const threadId = restoredThread.threadId;
this.deferredTasks.scheduleRestoredThreadHydration(() => {
if (!this.isRestoredThreadPending(threadId)) return;
void this.ensureRestoredThreadLoaded();
});
this.restoredThread.scheduleHydration();
}
private clearDeferredRestoredThreadHydration(): void {
this.deferredTasks.clearRestoredThreadHydration();
this.restoredThread.clearHydration();
}
private scheduleDeferredAppServerWarmup(): void {
@ -1188,16 +1071,16 @@ export class CodexChatView extends ItemView {
return thread ? getThreadTitle(thread) : null;
}
private restoredThreadPlaceholder(): RestoredThreadPlaceholderState | null {
return this.restoredThreadLifecycle.kind === "placeholder" ? this.restoredThreadLifecycle : null;
private restoredThreadPlaceholder() {
return this.restoredThread.placeholder();
}
private restoredThreadTitle(): string | null {
return this.restoredThreadPlaceholder()?.title ?? null;
return this.restoredThread.title();
}
private clearRestoredThreadLifecycle(): void {
this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "cleared" });
this.restoredThread.clear();
}
private composerPlaceholder(): string {
@ -1258,12 +1141,12 @@ export class CodexChatView extends ItemView {
toggleHistory: () => {
this.toggleHistoryPanel();
},
toggleAutoReview: () => void this.toggleAutoReview(),
toggleAutoReview: () => void this.runtimeSettings.toggleAutoReview(),
toggleStatusPanel: () => {
this.toggleStatusPanel();
},
togglePlan: () => void this.toggleCollaborationMode(),
toggleFast: () => void this.toggleFastMode(),
togglePlan: () => void this.runtimeSettings.toggleCollaborationMode(),
toggleFast: () => void this.runtimeSettings.toggleFastMode(),
toggleRuntime: () => {
this.toggleRuntimePicker("model");
},

View file

@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { RestoredThreadController } from "../../../src/features/chat/restored-thread-controller";
import { ChatViewDeferredTasks } from "../../../src/features/chat/view-lifecycle";
import type { ChatAction } from "../../../src/features/chat/chat-state";
describe("RestoredThreadController", () => {
it("restores a placeholder and schedules deferred hydration", () => {
vi.useFakeTimers();
const actions: ChatAction[] = [];
const resumeThread = vi.fn().mockResolvedValue(undefined);
const controller = new RestoredThreadController({
deferredTasks: new ChatViewDeferredTasks(() => window),
opened: () => true,
resumeThread,
invalidateResumeWork: vi.fn(),
dispatch: (action) => actions.push(action),
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
setStatus: vi.fn(),
refreshTabHeader: vi.fn(),
});
controller.restore({ threadId: "thread", title: "Title", explicitName: null });
expect(controller.placeholder()).toMatchObject({ threadId: "thread", title: "Title" });
expect(actions).toHaveLength(1);
vi.advanceTimersByTime(1_500);
expect(resumeThread).toHaveBeenCalledWith("thread");
vi.useRealTimers();
});
it("shares an in-flight restore load", async () => {
const resume = deferred<undefined>();
const controller = restoredThreadControllerFixture({ resumeThread: vi.fn(() => resume.promise) });
controller.restore({ threadId: "thread", title: null, explicitName: null });
const first = controller.ensureLoaded();
const second = controller.ensureLoaded();
await Promise.resolve();
expect(controller.placeholder()?.loading).toBe(resume.promise);
resume.resolve(undefined);
await Promise.all([first, second]);
expect(controller.placeholder()?.loading).toBeNull();
});
it("updates placeholder rename state without touching other threads", () => {
const controller = restoredThreadControllerFixture();
controller.restore({ threadId: "thread", title: "Old", explicitName: null });
expect(controller.rename("other", "Other")).toBe(false);
expect(controller.rename("thread", "New")).toBe(true);
expect(controller.placeholder()).toMatchObject({ title: "New", explicitName: "New" });
});
});
function restoredThreadControllerFixture(overrides: Partial<ConstructorParameters<typeof RestoredThreadController>[0]> = {}) {
return new RestoredThreadController({
deferredTasks: new ChatViewDeferredTasks(() => window),
opened: () => false,
resumeThread: vi.fn().mockResolvedValue(undefined),
invalidateResumeWork: vi.fn(),
dispatch: vi.fn(),
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
setStatus: vi.fn(),
refreshTabHeader: vi.fn(),
...overrides,
});
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}

View file

@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { ChatRuntimeSettingsController } from "../../../src/features/chat/runtime-settings-controller";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import type { AppServerClient } from "../../../src/app-server/client";
describe("ChatRuntimeSettingsController", () => {
it("applies pending runtime overrides through thread settings and commits them", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const store = createChatStateStore(state);
const client = clientFixture();
const messages: string[] = [];
const controller = new ChatRuntimeSettingsController({
stateStore: store,
currentClient: () => client as AppServerClient,
runtimeSnapshot: () => ({
effectiveConfig: store.getState().effectiveConfig,
activeThreadId: store.getState().activeThreadId,
activeModel: store.getState().activeModel,
activeReasoningEffort: store.getState().activeReasoningEffort,
activeCollaborationMode: store.getState().activeCollaborationMode,
activeServiceTier: store.getState().activeServiceTier,
activeApprovalPolicy: store.getState().activeApprovalPolicy,
activeApprovalsReviewer: store.getState().activeApprovalsReviewer,
activePermissionProfile: store.getState().activePermissionProfile,
requestedModel: store.getState().requestedModel,
requestedReasoningEffort: store.getState().requestedReasoningEffort,
requestedApprovalsReviewer: store.getState().requestedApprovalsReviewer,
requestedCollaborationMode: store.getState().requestedCollaborationMode,
requestedServiceTier: store.getState().requestedServiceTier,
tokenUsage: store.getState().tokenUsage,
rateLimit: store.getState().rateLimit,
hasThreadTurns: false,
availableModels: store.getState().availableModels,
}),
collaborationModeLabel: () => "Plan",
addSystemMessage: (text) => messages.push(text),
});
await expect(controller.setRequestedModel("gpt-5.5")).resolves.toBe(true);
expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" });
expect(store.getState().requestedModel).toEqual({ kind: "default" });
expect(store.getState().activeModel).toBe("gpt-5.5");
expect(messages).toEqual([]);
});
it("toggles fast mode and reports the user-visible result", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const store = createChatStateStore(state);
const client = clientFixture();
const messages: string[] = [];
const controller = runtimeControllerFixture(store, client, messages);
await controller.toggleFastMode();
expect(client.updateThreadSettings).toHaveBeenCalledWith("thread", { serviceTier: "fast" });
expect(store.getState().requestedServiceTier).toBeNull();
expect(store.getState().activeServiceTier).toBe("fast");
expect(messages).toEqual(["Fast mode on for subsequent turns."]);
});
it("leaves pending override in place when the app-server update fails", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const store = createChatStateStore(state);
const client = clientFixture({ updateThreadSettings: vi.fn().mockRejectedValue(new Error("nope")) });
const messages: string[] = [];
const controller = runtimeControllerFixture(store, client, messages);
await expect(controller.setRequestedModel("gpt-5.5")).resolves.toBe(false);
expect(store.getState().requestedModel).toEqual({ kind: "set", value: "gpt-5.5" });
expect(store.getState().activeModel).toBeNull();
expect(messages).toEqual(["nope"]);
});
});
function runtimeControllerFixture(
store: ReturnType<typeof createChatStateStore>,
client: Pick<AppServerClient, "updateThreadSettings">,
messages: string[],
): ChatRuntimeSettingsController {
return new ChatRuntimeSettingsController({
stateStore: store,
currentClient: () => client as AppServerClient,
runtimeSnapshot: () => {
const state = store.getState();
return {
effectiveConfig: state.effectiveConfig,
activeThreadId: state.activeThreadId,
activeModel: state.activeModel,
activeReasoningEffort: state.activeReasoningEffort,
activeCollaborationMode: state.activeCollaborationMode,
activeServiceTier: state.activeServiceTier,
activeApprovalPolicy: state.activeApprovalPolicy,
activeApprovalsReviewer: state.activeApprovalsReviewer,
activePermissionProfile: state.activePermissionProfile,
requestedModel: state.requestedModel,
requestedReasoningEffort: state.requestedReasoningEffort,
requestedApprovalsReviewer: state.requestedApprovalsReviewer,
requestedCollaborationMode: state.requestedCollaborationMode,
requestedServiceTier: state.requestedServiceTier,
tokenUsage: state.tokenUsage,
rateLimit: state.rateLimit,
hasThreadTurns: false,
availableModels: state.availableModels,
};
},
collaborationModeLabel: () => "Plan",
addSystemMessage: (text) => messages.push(text),
});
}
function clientFixture(
overrides: Partial<Pick<AppServerClient, "updateThreadSettings">> = {},
): Pick<AppServerClient, "updateThreadSettings"> {
return {
updateThreadSettings: vi.fn().mockResolvedValue({}),
...overrides,
};
}