mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract restored and runtime settings controllers
This commit is contained in:
parent
428d435d01
commit
a9761d2b13
5 changed files with 500 additions and 173 deletions
99
src/features/chat/restored-thread-controller.ts
Normal file
99
src/features/chat/restored-thread-controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
141
src/features/chat/runtime-settings-controller.ts
Normal file
141
src/features/chat/runtime-settings-controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
80
tests/features/chat/restored-thread-controller.test.ts
Normal file
80
tests/features/chat/restored-thread-controller.test.ts
Normal 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 };
|
||||
}
|
||||
124
tests/features/chat/runtime-settings-controller.test.ts
Normal file
124
tests/features/chat/runtime-settings-controller.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue