import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, type ModelSlug, ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, ProviderModelOptions, RuntimeMode, ThreadId,} from "@t3tools/contracts";import * as Schema from "effect/Schema";import * as Equal from "effect/Equal";import { DeepMutable } from "effect/Types";import { getDefaultModel, normalizeModelSlug, resolveModelSlugForProvider,} from "@t3tools/shared/model";import { useMemo } from "react";import { getLocalStorageItem } from "./hooks/useLocalStorage";import { resolveAppModelSelection } from "./appSettings";import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText,} from "./lib/terminalContext";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware";import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";const COMPOSER_DRAFT_STORAGE_VERSION = 3;const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; const COMPOSER_PERSIST_DEBOUNCE_MS = 300; const composerDebouncedStorage = createDebouncedStorage( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), COMPOSER_PERSIST_DEBOUNCE_MS,); // Flush pending composer draft writes before page unload to prevent data loss.if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); });} export const PersistedComposerImageAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, dataUrl: Schema.String,});export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File;} const PersistedTerminalContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, createdAt: Schema.String, terminalId: Schema.String, terminalLabel: Schema.String, lineStart: Schema.Number, lineEnd: Schema.Number,});type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), modelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode),});type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; const LegacyCodexFields = Schema.Struct({ effort: Schema.optionalKey(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), codexFastMode: Schema.optionalKey(Schema.Boolean), serviceTier: Schema.optionalKey(Schema.String),});type LegacyCodexFields = typeof LegacyCodexFields.Type; const LegacyThreadModelFields = Schema.Struct({ provider: Schema.optionalKey(ProviderKind), model: Schema.optionalKey(Schema.String), modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)),});type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; type LegacyV2ThreadDraftFields = { modelSelection?: ModelSelection | null; modelOptions?: ProviderModelOptions | null;}; type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields & LegacyThreadModelFields & LegacyV2ThreadDraftFields; const LegacyStickyModelFields = Schema.Struct({ stickyProvider: Schema.optionalKey(ProviderKind), stickyModel: Schema.optionalKey(Schema.String), stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)),});type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; stickyModelOptions?: ProviderModelOptions | null;}; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & LegacyStickyModelFields & LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema,});type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), stickyModelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),});type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; const PersistedComposerDraftStoreStorage = Schema.Struct({ version: Schema.Number, state: PersistedComposerDraftStoreState,}); export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; activeProvider: ProviderKind | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null;} export interface DraftThreadState { projectId: ProjectId; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode;} interface ProjectDraftThread extends DraftThreadState { threadId: ThreadId;} interface ComposerDraftStoreState { draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; stickyActiveProvider: ProviderKind | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, options?: { branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; setDraftThreadContext: ( threadId: ThreadId, options: { branch?: string | null; worktreePath?: string | null; projectId?: ProjectId; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setModelSelection: ( threadId: ThreadId, modelSelection: ModelSelection | null | undefined, ) => void; setModelOptions: ( threadId: ThreadId, modelOptions: ProviderModelOptions | null | undefined, ) => void; applyStickyState: (threadId: ThreadId) => void; setProviderModelOptions: ( threadId: ThreadId, provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, options?: { persistSticky?: boolean; }, ) => void; setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; setInteractionMode: ( threadId: ThreadId, interactionMode: ProviderInteractionMode | null | undefined, ) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; insertTerminalContext: ( threadId: ThreadId, prompt: string, context: TerminalContextDraft, index: number, ) => boolean; addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadId: ThreadId, contextId: string) => void; clearTerminalContexts: (threadId: ThreadId) => void; clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadId: ThreadId) => void;} export interface EffectiveComposerModelState { selectedModel: ModelSlug; modelOptions: ProviderModelOptions | null;} function providerModelOptionsFromSelection( modelSelection: ModelSelection | null | undefined,): ProviderModelOptions | null { if (!modelSelection?.options) { return null; } return { [modelSelection.provider]: modelSelection.options, };} function modelSelectionByProviderToOptions( map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined,): ProviderModelOptions | null { if (!map) return null; const result: Record<string, unknown> = {}; for (const [provider, selection] of Object.entries(map)) { if (selection?.options) { result[provider] = selection.options; } } return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null;} const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null,}); const EMPTY_IMAGES: ComposerImageAttachment[] = [];const EMPTY_IDS: string[] = [];const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];Object.freeze(EMPTY_IMAGES);Object.freeze(EMPTY_IDS);Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> = Object.freeze({}); const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, interactionMode: null,}); function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, interactionMode: null, };} function composerImageDedupKey(image: ComposerImageAttachment): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // images reconstructed from localStorage (which get a fresh lastModified value). return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;} function terminalContextDedupKey(context: TerminalContextDraft): string { return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`;} function normalizeTerminalContextForThread( threadId: ThreadId, context: TerminalContextDraft,): TerminalContextDraft | null { const terminalId = context.terminalId.trim(); const terminalLabel = context.terminalLabel.trim(); if (terminalId.length === 0 || terminalLabel.length === 0) { return null; } const lineStart = Math.max(1, Math.floor(context.lineStart)); const lineEnd = Math.max(lineStart, Math.floor(context.lineEnd)); return { ...context, threadId, terminalId, terminalLabel, lineStart, lineEnd, text: normalizeTerminalContextText(context.text), };} function normalizeTerminalContextsForThread( threadId: ThreadId, contexts: ReadonlyArray<TerminalContextDraft>,): TerminalContextDraft[] { const existingIds = new Set<string>(); const existingDedupKeys = new Set<string>(); const normalizedContexts: TerminalContextDraft[] = []; for (const context of contexts) { const normalizedContext = normalizeTerminalContextForThread(threadId, context); if (!normalizedContext) { continue; } const dedupKey = terminalContextDedupKey(normalizedContext); if (existingIds.has(normalizedContext.id) || existingDedupKeys.has(dedupKey)) { continue; } normalizedContexts.push(normalizedContext); existingIds.add(normalizedContext.id); existingDedupKeys.add(dedupKey); } return normalizedContexts;} function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && draft.interactionMode === null );} function normalizeProviderKind(value: unknown): ProviderKind | null { return value === "codex" || value === "claudeAgent" ? value : null;} function normalizeProviderModelOptions( value: unknown, provider?: ProviderKind | null, legacy?: LegacyCodexFields,): ProviderModelOptions | null { const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; const codexCandidate = candidate?.codex && typeof candidate.codex === "object" ? (candidate.codex as Record<string, unknown>) : null; const claudeCandidate = candidate?.claudeAgent && typeof candidate.claudeAgent === "object" ? (candidate.claudeAgent as Record<string, unknown>) : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || codexCandidate?.reasoningEffort === "medium" || codexCandidate?.reasoningEffort === "high" || codexCandidate?.reasoningEffort === "xhigh" ? codexCandidate.reasoningEffort : provider === "codex" && (legacy?.effort === "low" || legacy?.effort === "medium" || legacy?.effort === "high" || legacy?.effort === "xhigh") ? legacy.effort : undefined; const codexFastMode = codexCandidate?.fastMode === true ? true : codexCandidate?.fastMode === false ? false : (provider === "codex" && legacy?.codexFastMode === true) || (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") ? true : undefined; const codex = codexReasoningEffort !== undefined || codexFastMode !== undefined ? { ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), } : undefined; const claudeThinking = claudeCandidate?.thinking === true ? true : claudeCandidate?.thinking === false ? false : undefined; const claudeEffort: ClaudeCodeEffort | undefined = claudeCandidate?.effort === "low" || claudeCandidate?.effort === "medium" || claudeCandidate?.effort === "high" || claudeCandidate?.effort === "max" || claudeCandidate?.effort === "ultrathink" ? claudeCandidate.effort : undefined; const claudeFastMode = claudeCandidate?.fastMode === true ? true : claudeCandidate?.fastMode === false ? false : undefined; const claude = claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined ? { ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), } : undefined; if (!codex && !claude) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), };} function normalizeModelSelection( value: unknown, legacy?: { provider?: unknown;Lines 1–500 of 2201. Load more
import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, type ModelSlug, ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, ProviderModelOptions, RuntimeMode, ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; import { getDefaultModel, normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./appSettings"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 3; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; const COMPOSER_PERSIST_DEBOUNCE_MS = 300; const composerDebouncedStorage = createDebouncedStorage( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), COMPOSER_PERSIST_DEBOUNCE_MS, ); // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); }); } export const PersistedComposerImageAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, dataUrl: Schema.String, }); export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File; } const PersistedTerminalContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, createdAt: Schema.String, terminalId: Schema.String, terminalLabel: Schema.String, lineStart: Schema.Number, lineEnd: Schema.Number, }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), modelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; const LegacyCodexFields = Schema.Struct({ effort: Schema.optionalKey(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), codexFastMode: Schema.optionalKey(Schema.Boolean), serviceTier: Schema.optionalKey(Schema.String), }); type LegacyCodexFields = typeof LegacyCodexFields.Type; const LegacyThreadModelFields = Schema.Struct({ provider: Schema.optionalKey(ProviderKind), model: Schema.optionalKey(Schema.String), modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), }); type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type; type LegacyV2ThreadDraftFields = { modelSelection?: ModelSelection | null; modelOptions?: ProviderModelOptions | null; }; type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields & LegacyThreadModelFields & LegacyV2ThreadDraftFields; const LegacyStickyModelFields = Schema.Struct({ stickyProvider: Schema.optionalKey(ProviderKind), stickyModel: Schema.optionalKey(Schema.String), stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)), }); type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; stickyModelOptions?: ProviderModelOptions | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & LegacyStickyModelFields & LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), stickyModelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; const PersistedComposerDraftStoreStorage = Schema.Struct({ version: Schema.Number, state: PersistedComposerDraftStoreState, }); export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; activeProvider: ProviderKind | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } export interface DraftThreadState { projectId: ProjectId; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; } interface ProjectDraftThread extends DraftThreadState { threadId: ThreadId; } interface ComposerDraftStoreState { draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; stickyActiveProvider: ProviderKind | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, options?: { branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; setDraftThreadContext: ( threadId: ThreadId, options: { branch?: string | null; worktreePath?: string | null; projectId?: ProjectId; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setModelSelection: ( threadId: ThreadId, modelSelection: ModelSelection | null | undefined, ) => void; setModelOptions: ( threadId: ThreadId, modelOptions: ProviderModelOptions | null | undefined, ) => void; applyStickyState: (threadId: ThreadId) => void; setProviderModelOptions: ( threadId: ThreadId, provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, options?: { persistSticky?: boolean; }, ) => void; setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; setInteractionMode: ( threadId: ThreadId, interactionMode: ProviderInteractionMode | null | undefined, ) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; insertTerminalContext: ( threadId: ThreadId, prompt: string, context: TerminalContextDraft, index: number, ) => boolean; addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadId: ThreadId, contextId: string) => void; clearTerminalContexts: (threadId: ThreadId) => void; clearPersistedAttachments: (threadId: ThreadId) => void; syncPersistedAttachments: ( threadId: ThreadId, attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadId: ThreadId) => void; } export interface EffectiveComposerModelState { selectedModel: ModelSlug; modelOptions: ProviderModelOptions | null; } function providerModelOptionsFromSelection( modelSelection: ModelSelection | null | undefined, ): ProviderModelOptions | null { if (!modelSelection?.options) { return null; } return { [modelSelection.provider]: modelSelection.options, }; } function modelSelectionByProviderToOptions( map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined, ): ProviderModelOptions | null { if (!map) return null; const result: Record<string, unknown> = {}; for (const [provider, selection] of Object.entries(map)) { if (selection?.options) { result[provider] = selection.options; } } return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null; } const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> = Object.freeze({}); const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ prompt: "", images: EMPTY_IMAGES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, interactionMode: null, }); function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, interactionMode: null, }; } function composerImageDedupKey(image: ComposerImageAttachment): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // images reconstructed from localStorage (which get a fresh lastModified value). return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; } function terminalContextDedupKey(context: TerminalContextDraft): string { return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`; } function normalizeTerminalContextForThread( threadId: ThreadId, context: TerminalContextDraft, ): TerminalContextDraft | null { const terminalId = context.terminalId.trim(); const terminalLabel = context.terminalLabel.trim(); if (terminalId.length === 0 || terminalLabel.length === 0) { return null; } const lineStart = Math.max(1, Math.floor(context.lineStart)); const lineEnd = Math.max(lineStart, Math.floor(context.lineEnd)); return { ...context, threadId, terminalId, terminalLabel, lineStart, lineEnd, text: normalizeTerminalContextText(context.text), }; } function normalizeTerminalContextsForThread( threadId: ThreadId, contexts: ReadonlyArray<TerminalContextDraft>, ): TerminalContextDraft[] { const existingIds = new Set<string>(); const existingDedupKeys = new Set<string>(); const normalizedContexts: TerminalContextDraft[] = []; for (const context of contexts) { const normalizedContext = normalizeTerminalContextForThread(threadId, context); if (!normalizedContext) { continue; } const dedupKey = terminalContextDedupKey(normalizedContext); if (existingIds.has(normalizedContext.id) || existingDedupKeys.has(dedupKey)) { continue; } normalizedContexts.push(normalizedContext); existingIds.add(normalizedContext.id); existingDedupKeys.add(dedupKey); } return normalizedContexts; } function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && draft.interactionMode === null ); } function normalizeProviderKind(value: unknown): ProviderKind | null { return value === "codex" || value === "claudeAgent" ? value : null; } function normalizeProviderModelOptions( value: unknown, provider?: ProviderKind | null, legacy?: LegacyCodexFields, ): ProviderModelOptions | null { const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null; const codexCandidate = candidate?.codex && typeof candidate.codex === "object" ? (candidate.codex as Record<string, unknown>) : null; const claudeCandidate = candidate?.claudeAgent && typeof candidate.claudeAgent === "object" ? (candidate.claudeAgent as Record<string, unknown>) : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || codexCandidate?.reasoningEffort === "medium" || codexCandidate?.reasoningEffort === "high" || codexCandidate?.reasoningEffort === "xhigh" ? codexCandidate.reasoningEffort : provider === "codex" && (legacy?.effort === "low" || legacy?.effort === "medium" || legacy?.effort === "high" || legacy?.effort === "xhigh") ? legacy.effort : undefined; const codexFastMode = codexCandidate?.fastMode === true ? true : codexCandidate?.fastMode === false ? false : (provider === "codex" && legacy?.codexFastMode === true) || (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") ? true : undefined; const codex = codexReasoningEffort !== undefined || codexFastMode !== undefined ? { ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), } : undefined; const claudeThinking = claudeCandidate?.thinking === true ? true : claudeCandidate?.thinking === false ? false : undefined; const claudeEffort: ClaudeCodeEffort | undefined = claudeCandidate?.effort === "low" || claudeCandidate?.effort === "medium" || claudeCandidate?.effort === "high" || claudeCandidate?.effort === "max" || claudeCandidate?.effort === "ultrathink" ? claudeCandidate.effort : undefined; const claudeFastMode = claudeCandidate?.fastMode === true ? true : claudeCandidate?.fastMode === false ? false : undefined; const claude = claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined ? { ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), } : undefined; if (!codex && !claude) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), }; } function normalizeModelSelection( value: unknown, legacy?: { provider?: unknown;