Extract chat view models

This commit is contained in:
murashit 2026-05-28 22:20:30 +09:00
parent 42f1a0698f
commit 770c280147
3 changed files with 326 additions and 103 deletions

View file

@ -0,0 +1,182 @@
import type { Thread } from "../../generated/app-server/v2/Thread";
import type { RuntimeSnapshot } from "../../runtime/state";
import {
autoReviewActive,
currentModel,
currentReasoningEffort,
currentServiceTier,
runtimeOverrideLabel,
runtimeSummaryLabel,
serviceTierLabel,
supportedReasoningEfforts,
} from "../../runtime/state";
import { readRuntimeConfig } from "../../runtime/config";
import { compactContextLabel } from "../../runtime/settings";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view";
import { getThreadTitle } from "../../domain/threads/model";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "./diagnostics";
import type { ChatState } from "./chat-state";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import type { ToolbarChoice, ToolbarThreadRow, ToolbarViewModel } from "./ui/toolbar";
export interface RuntimeSnapshotInput {
state: ChatState;
}
export interface ToolbarViewModelInput {
state: ChatState;
snapshot: RuntimeSnapshot;
connected: boolean;
turnBusy: boolean;
vaultPath: string;
configuredCommand: string;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
modelChoices: ToolbarChoice[];
effortChoices: ToolbarChoice[];
renameState: (threadId: string) => ToolbarThreadRow["rename"];
}
export interface ConnectionDiagnosticsModelInput {
state: ChatState;
connected: boolean;
configuredCommand: string;
}
export function runtimeSnapshotForChatState({ state }: RuntimeSnapshotInput): RuntimeSnapshot {
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: state.displayItems.some((item) => item.turnId),
availableModels: state.availableModels,
};
}
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
const config = readRuntimeConfig(state.effectiveConfig);
const context = contextSummary(snapshot);
const limit = rateLimitSummary(snapshot);
const historyOpen = state.openDetails.has("history");
const statusPanelOpen = state.openDetails.has("status-panel");
const runtimeOpen = state.runtimePicker !== null;
const statusState = input.turnBusy ? "running" : input.connected ? "connected" : "offline";
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
return {
connected: input.connected,
status: state.status,
statusState,
historyOpen,
statusPanelOpen,
runtimeOpen,
planActive: state.requestedCollaborationMode === "plan",
autoReviewActive: autoReviewActive(snapshot, config),
fastActive: currentServiceTier(snapshot, config) === "fast",
runtimeSummary: runtimeSummaryLabel(model, effort),
runtimeTitle: `Model: ${model ?? "(Codex default)"}; Effort: ${effort ?? "(Codex default)"}`,
runtimeEmphasized: false,
context: context ? { ...context, label: compactContextLabel(context.percent, context.label) } : null,
rateLimit: limit,
configSections: effectiveConfigSections(snapshot, input.vaultPath),
openPanel: historyOpen ? "history" : runtimeOpen ? "runtime" : statusPanelOpen ? "status" : null,
threads: toolbarThreadRows({
threads: state.listedThreads,
activeThreadId: state.activeThreadId,
turnBusy: input.turnBusy,
archiveConfirmThreadId: input.archiveConfirmThreadId,
archiveExportEnabled: input.archiveExportEnabled,
renameState: input.renameState,
}),
modelChoices: input.modelChoices,
effortChoices: input.effortChoices,
connectLabel: input.connected ? "Reconnect" : "Connect",
diagnostics: connectionDiagnosticsModel({
state,
connected: input.connected,
configuredCommand: input.configuredCommand,
}),
diagnosticAlertLevel: diagnosticAlertLevel(state.appServerDiagnostics),
};
}
export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInput): ReturnType<typeof connectionDiagnosticSections> {
return connectionDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,
initializeResponse: input.state.initializeResponse,
activeThreadCreationCliVersion: input.state.activeThreadCreationCliVersion,
diagnostics: input.state.appServerDiagnostics,
});
}
export function statusSummaryLines(state: ChatState, snapshot: RuntimeSnapshot): string[] {
const context = contextSummary(snapshot);
const limit = rateLimitSummary(snapshot);
return [
"Thread status",
`Thread: ${state.activeThreadId ?? "(none)"}`,
context ? context.title : "Context: not available",
...(limit ? usageLimitStatusLines(limit) : ["Usage limits: not available"]),
];
}
export function modelStatusLines(state: ChatState, snapshot: RuntimeSnapshot, collaborationModeLabel: string): string[] {
const config = readRuntimeConfig(state.effectiveConfig);
return [
`Model: ${currentModel(snapshot, config) ?? "(Codex default)"}`,
`Override: ${runtimeOverrideLabel(state.requestedModel)}`,
`Provider: ${statusValue(config.modelProvider, "(Codex default)")}`,
`Effort: ${currentReasoningEffort(snapshot, config) ?? "(Codex default)"}`,
`Mode: ${collaborationModeLabel}`,
`Service tier: ${serviceTierLabel(snapshot, config)}`,
];
}
export function effortStatusLines(state: ChatState, snapshot: RuntimeSnapshot): string[] {
const config = readRuntimeConfig(state.effectiveConfig);
return [
`Effort: ${currentReasoningEffort(snapshot, config) ?? "(Codex default)"}`,
`Override: ${runtimeOverrideLabel(state.requestedReasoningEffort)}`,
`Supported: ${supportedReasoningEfforts(snapshot).join(", ")}`,
];
}
function toolbarThreadRows(input: {
threads: readonly Thread[];
activeThreadId: string | null;
turnBusy: boolean;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
renameState: (threadId: string) => ToolbarThreadRow["rename"];
}): ToolbarThreadRow[] {
return input.threads.map((thread) => {
const threadId = thread.id;
return {
title: getThreadTitle(thread),
threadId,
selected: threadId === input.activeThreadId,
disabled: input.turnBusy && threadId !== input.activeThreadId,
canArchive: true,
archiveConfirm: {
active: input.archiveConfirmThreadId === threadId,
defaultSaveMarkdown: input.archiveExportEnabled,
},
rename: input.renameState(threadId),
};
});
}

View file

@ -23,8 +23,6 @@ import {
nextCollaborationMode,
} from "../../runtime/collaboration-mode";
import { ChatController } from "./chat-controller";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "./diagnostics";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view";
import {
autoReviewActive,
currentModel,
@ -32,20 +30,16 @@ import {
currentServiceTier,
requestedTurnRuntimeSettings,
runtimeOverridePayload,
runtimeOverrideLabel,
runtimeSummaryLabel,
serviceTierLabel,
supportedReasoningEfforts,
type RuntimeSnapshot,
} from "../../runtime/state";
import { readRuntimeConfig } from "../../runtime/config";
import { sortedAvailableModels } from "../../runtime/model";
import { compactContextLabel, modelOverrideMessage, reasoningEffortOverrideMessage } from "../../runtime/settings";
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";
import { ChatAppServerController } from "./chat-app-server-controller";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import { ThreadHistoryLoader } from "./thread-history";
import { ThreadRenameController } from "./thread-rename";
import { pendingRequestsSignature as requestStateSignature, userInputDraftKey, userInputOtherDraftKey } from "./request-state";
@ -78,6 +72,14 @@ import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
import type { SharedAppServerMetadata } from "../../runtime/shared-app-server-state";
import { ChatThreadActionController } from "./thread-actions";
import { unmountReactRoot } from "../../shared/ui/react-root";
import {
connectionDiagnosticsModel,
effortStatusLines as buildEffortStatusLines,
modelStatusLines as buildModelStatusLines,
runtimeSnapshotForChatState,
statusSummaryLines as buildStatusSummaryLines,
toolbarViewModel as buildToolbarViewModel,
} from "./view-model";
import {
ChatViewDeferredTasks,
transitionChatConnectionLifecycle,
@ -1338,55 +1340,19 @@ export class CodexChatView extends ItemView {
}
private toolbarViewModel(): ToolbarViewModel {
const snapshot = this.runtimeSnapshot();
const config = readRuntimeConfig(this.state.effectiveConfig);
const context = contextSummary(snapshot);
const limit = rateLimitSummary(snapshot);
const historyOpen = this.state.openDetails.has("history");
const statusPanelOpen = this.state.openDetails.has("status-panel");
const runtimeOpen = this.state.runtimePicker !== null;
const statusState = this.turnBusy ? "running" : this.connection.isConnected() ? "connected" : "offline";
const model = currentModel(snapshot, config);
const effort = currentReasoningEffort(snapshot, config);
const threads = this.state.listedThreads;
return {
return buildToolbarViewModel({
state: this.state,
snapshot: this.runtimeSnapshot(),
connected: this.connection.isConnected(),
status: this.state.status,
statusState,
historyOpen,
statusPanelOpen,
runtimeOpen,
planActive: this.state.requestedCollaborationMode === "plan",
autoReviewActive: autoReviewActive(snapshot, config),
fastActive: currentServiceTier(snapshot, config) === "fast",
runtimeSummary: runtimeSummaryLabel(model, effort),
runtimeTitle: `Model: ${model ?? "(Codex default)"}; Effort: ${effort ?? "(Codex default)"}`,
runtimeEmphasized: false,
context: context ? { ...context, label: compactContextLabel(context.percent, context.label) } : null,
rateLimit: limit,
configSections: effectiveConfigSections(snapshot, this.plugin.vaultPath),
openPanel: historyOpen ? "history" : runtimeOpen ? "runtime" : statusPanelOpen ? "status" : null,
threads: threads.map((thread) => {
const threadId = thread.id;
return {
title: getThreadTitle(thread),
threadId,
selected: threadId === this.state.activeThreadId,
disabled: this.turnBusy && threadId !== this.state.activeThreadId,
canArchive: true,
archiveConfirm: {
active: this.archiveConfirmThreadId === threadId,
defaultSaveMarkdown: this.plugin.settings.archiveExportEnabled,
},
rename: this.threadRename.editState(threadId),
};
}),
turnBusy: this.turnBusy,
vaultPath: this.plugin.vaultPath,
configuredCommand: this.plugin.settings.codexPath,
archiveConfirmThreadId: this.archiveConfirmThreadId,
archiveExportEnabled: this.plugin.settings.archiveExportEnabled,
modelChoices: this.modelToolbarChoices(),
effortChoices: this.effortToolbarChoices(),
connectLabel: this.connection.isConnected() ? "Reconnect" : "Connect",
diagnostics: this.connectionDiagnosticSections(),
diagnosticAlertLevel: diagnosticAlertLevel(this.state.appServerDiagnostics),
};
renameState: (threadId) => this.threadRename.editState(threadId),
});
}
private async reconnectFromToolbar(): Promise<void> {
@ -1540,47 +1506,22 @@ export class CodexChatView extends ItemView {
}
private statusSummaryLines(): string[] {
const snapshot = this.runtimeSnapshot();
const context = contextSummary(snapshot);
const limit = rateLimitSummary(snapshot);
return [
"Thread status",
`Thread: ${this.state.activeThreadId ?? "(none)"}`,
context ? context.title : "Context: not available",
...(limit ? usageLimitStatusLines(limit) : ["Usage limits: not available"]),
];
return buildStatusSummaryLines(this.state, this.runtimeSnapshot());
}
private modelStatusLines(): string[] {
const snapshot = this.runtimeSnapshot();
const config = readRuntimeConfig(this.state.effectiveConfig);
return [
`Model: ${currentModel(snapshot, config) ?? "(Codex default)"}`,
`Override: ${runtimeOverrideLabel(this.state.requestedModel)}`,
`Provider: ${statusValue(config.modelProvider, "(Codex default)")}`,
`Effort: ${currentReasoningEffort(snapshot, config) ?? "(Codex default)"}`,
`Mode: ${this.collaborationModeLabel()}`,
`Service tier: ${serviceTierLabel(snapshot, config)}`,
];
return buildModelStatusLines(this.state, this.runtimeSnapshot(), this.collaborationModeLabel());
}
private effortStatusLines(): string[] {
const snapshot = this.runtimeSnapshot();
const config = readRuntimeConfig(this.state.effectiveConfig);
return [
`Effort: ${currentReasoningEffort(snapshot, config) ?? "(Codex default)"}`,
`Override: ${runtimeOverrideLabel(this.state.requestedReasoningEffort)}`,
`Supported: ${supportedReasoningEfforts(snapshot).join(", ")}`,
];
return buildEffortStatusLines(this.state, this.runtimeSnapshot());
}
private connectionDiagnosticSections() {
return connectionDiagnosticSections({
return connectionDiagnosticsModel({
state: this.state,
connected: this.connection.isConnected(),
configuredCommand: this.plugin.settings.codexPath,
initializeResponse: this.state.initializeResponse,
activeThreadCreationCliVersion: this.state.activeThreadCreationCliVersion,
diagnostics: this.state.appServerDiagnostics,
});
}
@ -1612,26 +1553,7 @@ export class CodexChatView extends ItemView {
}
private runtimeSnapshotForState(state: ChatState): RuntimeSnapshot {
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: state.displayItems.some((item) => item.turnId),
availableModels: state.availableModels,
};
return runtimeSnapshotForChatState({ state });
}
private pendingRequestMessageNode() {

View file

@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { createAppServerDiagnostics } from "../../../src/app-server/compatibility";
import { createChatState } from "../../../src/features/chat/chat-state";
import {
effortStatusLines,
modelStatusLines,
runtimeSnapshotForChatState,
statusSummaryLines,
toolbarViewModel,
} from "../../../src/features/chat/view-model";
import type { Model } from "../../../src/generated/app-server/v2/Model";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import type { ConfigReadResponse } from "../../../src/generated/app-server/v2/ConfigReadResponse";
describe("chat view model", () => {
it("builds toolbar rows from immutable chat state snapshots", () => {
const state = createChatState();
state.activeThreadId = "thread-1";
state.listedThreads = [threadFixture("thread-1", "Active"), threadFixture("thread-2", "Other")];
state.turnLifecycle = { kind: "running", turnId: "turn" };
state.openDetails = new Set(["history"]);
state.effectiveConfig = effectiveConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" });
state.appServerDiagnostics = createAppServerDiagnostics();
const model = toolbarViewModel({
state,
snapshot: runtimeSnapshotForChatState({ state }),
connected: true,
turnBusy: true,
vaultPath: "/vault",
configuredCommand: "codex",
archiveConfirmThreadId: "thread-2",
archiveExportEnabled: true,
modelChoices: [{ label: "gpt-5.5", selected: true, onClick: () => undefined }],
effortChoices: [{ label: "high", selected: true, onClick: () => undefined }],
renameState: (threadId) => (threadId === "thread-1" ? { draft: "Active", generating: false } : null),
});
expect(model.statusState).toBe("running");
expect(model.openPanel).toBe("history");
expect(model.threads).toMatchObject([
{ threadId: "thread-1", title: "Active", selected: true, disabled: false, rename: { draft: "Active" } },
{ threadId: "thread-2", title: "Other", selected: false, disabled: true, archiveConfirm: { active: true } },
]);
expect(model.modelChoices).toHaveLength(1);
});
it("builds slash-command status lines from chat state", () => {
const state = createChatState();
state.activeThreadId = "thread-1";
state.effectiveConfig = effectiveConfigFixture({
model: "gpt-5.5",
model_provider: "openai",
model_reasoning_effort: "high",
service_tier: "fast",
});
state.availableModels = [modelFixture("gpt-5.5")];
const snapshot = runtimeSnapshotForChatState({ state });
expect(statusSummaryLines(state, snapshot)[1]).toBe("Thread: thread-1");
expect(modelStatusLines(state, snapshot, "Default")).toContain("Model: gpt-5.5");
expect(modelStatusLines(state, snapshot, "Default")).toContain("Mode: Default");
expect(effortStatusLines(state, snapshot)).toContain("Supported: high");
});
});
function effectiveConfigFixture(config: Record<string, unknown>): ConfigReadResponse {
return {
config: config as ConfigReadResponse["config"],
origins: {},
layers: null,
};
}
function threadFixture(id: string, name: string): Thread {
return {
id,
sessionId: "session",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/vault",
cliVersion: "0.0.0",
source: "appServer",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name,
turns: [],
};
}
function modelFixture(model: string): Model {
return {
id: model,
model,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: model,
description: "",
hidden: false,
supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }],
defaultReasoningEffort: "high",
inputModalities: [],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
defaultServiceTier: null,
isDefault: true,
};
}