import { DEFAULT_MODEL_BY_PROVIDER, type EnvironmentId, ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, ProviderOptionSelection, RuntimeMode, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, ThreadId,} from "@t3tools/contracts";import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, scopeProjectRef, scopedThreadKey, scopeThreadRef,} from "@t3tools/client-runtime";import * as Schema from "effect/Schema";import * as Equal from "effect/Equal";import { DeepMutable } from "effect/Types";import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";import { useMemo } from "react";import { getLocalStorageItem } from "./hooks/useLocalStorage";import { resolveAppModelSelection } from "./modelSelection";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 { useShallow } from "zustand/react/shallow";import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";import { getDefaultServerModel } from "./providerModels";import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";const COMPOSER_DRAFT_STORAGE_VERSION = 6;const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);const isRuntimeMode = Schema.is(RuntimeMode);export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; export const DraftId = Schema.String.pipe(Schema.brand("DraftId"));export type DraftId = typeof DraftId.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" && typeof window.addEventListener === "function") { 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; /** * Per-provider record of generic option selections. Used as a transient * representation when migrating legacy v2 storage payloads and when * deriving per-provider option bundles for downstream consumers. */type ProviderOptionSelectionsByProvider = Partial< Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>>; type LegacyCodexFields = { effort?: unknown; codexFastMode?: unknown; serviceTier?: unknown;}; type LegacyThreadModelFields = { provider?: unknown; model?: unknown; modelOptions?: unknown;}; type LegacyV2ThreadDraftFields = { modelSelection?: ModelSelection | null; modelOptions?: unknown;}; type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields & LegacyThreadModelFields & LegacyV2ThreadDraftFields; type LegacyStickyModelFields = { stickyProvider?: unknown; stickyModel?: unknown; stickyModelOptions?: unknown;}; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; stickyModelOptions?: unknown; projectDraftThreadIdByProjectId?: Record<string, string> | null; draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null; draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null; projectDraftThreadIdByProjectKey?: Record<string, string> | null; draftsByThreadKey?: Record<string, PersistedComposerThreadDraftState> | null; draftThreadsByThreadKey?: Record<string, PersistedDraftThreadState> | null; projectDraftThreadKeyByProjectKey?: Record<string, string> | null; logicalProjectDraftThreadKeyByLogicalProjectKey?: Record<string, string> | null;}; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & LegacyStickyModelFields & LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ threadId: ThreadId, environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ environmentId: Schema.String, threadId: Schema.String, }), ), ),});type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), 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,}); /** * Composer content keyed by either a draft session (`DraftId`) or a real server * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. */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;} /** * Mutable routing and execution context for a pre-thread draft session. * * Unlike a real server thread, a draft session can still change target * environment/worktree configuration before the first send. */export interface DraftSessionState { threadId: ThreadId; environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; promotedTo?: ScopedThreadRef | null;} export type DraftThreadState = DraftSessionState; /** * Draft session metadata paired with its stable draft-session identity. */interface ProjectDraftSession extends DraftSessionState { draftId: DraftId;} /** * App-facing composer identity: * - `DraftId` for pre-thread draft sessions * - `ScopedThreadRef` for server-backed threads * * Raw `ThreadId` is intentionally excluded so callers cannot drop environment * identity for real threads. */type ComposerThreadTarget = ScopedThreadRef | DraftId; /** * Persisted store for composer content plus draft-session metadata. * * The store intentionally models two domains: * - draft sessions keyed by `DraftId` * - server thread composer state keyed by `ScopedThreadRef` */interface ComposerDraftStoreState { draftsByThreadKey: Record<string, ComposerThreadDraftState>; draftThreadsByThreadKey: Record<string, DraftThreadState>; logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; stickyActiveProvider: ProviderKind | null; /** Returns the editable composer content for a draft session or server thread. */ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; /** Looks up the active draft session for a logical project identity. */ getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; /** Reads mutable draft-session metadata by `DraftId`. */ getDraftSession: (draftId: DraftId) => DraftSessionState | null; /** Resolves a server-thread ref back to a matching draft session when one exists. */ getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; /** Creates or updates the draft session tracked for a logical project. */ setLogicalProjectDraftThreadId: ( logicalProjectKey: string, projectRef: ScopedProjectRef, draftId: DraftId, options?: { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ setProjectDraftThreadId: ( projectRef: ScopedProjectRef, draftId: DraftId, options?: { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ setDraftThreadContext: ( threadRef: ComposerThreadTarget, options: { branch?: string | null; worktreePath?: string | null; projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; clearProjectDraftThreadById: ( projectRef: ScopedProjectRef, threadRef: ComposerThreadTarget, ) => void; /** Marks a draft session as being promoted to a real server thread. */ markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void; /** Removes draft-session metadata after promotion is complete. */ finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, ) => void; /** Replace the model options for one or more providers in the draft. */ setModelOptions: ( threadRef: ComposerThreadTarget, modelOptions: | Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> | null | undefined, ) => void; applyStickyState: (threadRef: ComposerThreadTarget) => void; setProviderModelOptions: ( threadRef: ComposerThreadTarget, provider: ProviderKind, nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, options?: { model?: string | null | undefined; persistSticky?: boolean; }, ) => void; setRuntimeMode: ( threadRef: ComposerThreadTarget, runtimeMode: RuntimeMode | null | undefined, ) => void; setInteractionMode: ( threadRef: ComposerThreadTarget, interactionMode: ProviderInteractionMode | null | undefined, ) => void; addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; insertTerminalContext: ( threadRef: ComposerThreadTarget, prompt: string, context: TerminalContextDraft, index: number, ) => boolean; addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void; addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void;} export interface EffectiveComposerModelState { selectedModel: string; modelOptions: ProviderOptionSelectionsByProvider | null;} interface ComposerDraftModelState { activeProvider: ProviderKind | null; modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;} function providerSelectionsFromModelSelection( modelSelection: ModelSelection | null | undefined,): ProviderOptionSelectionsByProvider | null { if (!modelSelection) { return null; } const options = modelSelection.options; if (!options || options.length === 0) { return null; } return { [modelSelection.provider]: options } as ProviderOptionSelectionsByProvider;} function modelSelectionByProviderToOptions( map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined,): ProviderOptionSelectionsByProvider | null { if (!map) return null; const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {}; for (const [provider, selection] of Object.entries(map)) { if (selection?.options && selection.options.length > 0) { result[provider as ProviderKind] = selection.options; } } return Object.keys(result).length > 0 ? result : null;} function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelection> { return { ...selection, ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}), } as DeepMutable<ModelSelection>;} function cloneModelSelectionByProvider( selections: Partial<Record<ProviderKind, ModelSelection>>,): DeepMutable<Partial<Record<ProviderKind, ModelSelection>>> { return Object.fromEntries( Object.entries(selections).map(([provider, selection]) => [ provider, selection ? cloneModelSelection(selection) : selection, ]), ) as DeepMutable<Partial<Record<ProviderKind, ModelSelection>>>;} const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, 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_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({ activeProvider: null, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,}); 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 {Lines 1–500 of 2912. Load more
import { DEFAULT_MODEL_BY_PROVIDER, type EnvironmentId, ModelSelection, ProjectId, ProviderInteractionMode, ProviderKind, ProviderOptionSelection, RuntimeMode, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, scopeProjectRef, scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; 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 { useShallow } from "zustand/react/shallow"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 6; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); const isRuntimeMode = Schema.is(RuntimeMode); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; export const DraftId = Schema.String.pipe(Schema.brand("DraftId")); export type DraftId = typeof DraftId.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" && typeof window.addEventListener === "function") { 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; /** * Per-provider record of generic option selections. Used as a transient * representation when migrating legacy v2 storage payloads and when * deriving per-provider option bundles for downstream consumers. */ type ProviderOptionSelectionsByProvider = Partial< Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>> >; type LegacyCodexFields = { effort?: unknown; codexFastMode?: unknown; serviceTier?: unknown; }; type LegacyThreadModelFields = { provider?: unknown; model?: unknown; modelOptions?: unknown; }; type LegacyV2ThreadDraftFields = { modelSelection?: ModelSelection | null; modelOptions?: unknown; }; type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields & LegacyThreadModelFields & LegacyV2ThreadDraftFields; type LegacyStickyModelFields = { stickyProvider?: unknown; stickyModel?: unknown; stickyModelOptions?: unknown; }; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; stickyModelOptions?: unknown; projectDraftThreadIdByProjectId?: Record<string, string> | null; draftsByThreadId?: Record<string, PersistedComposerThreadDraftState> | null; draftThreadsByThreadId?: Record<string, PersistedDraftThreadState> | null; projectDraftThreadIdByProjectKey?: Record<string, string> | null; draftsByThreadKey?: Record<string, PersistedComposerThreadDraftState> | null; draftThreadsByThreadKey?: Record<string, PersistedDraftThreadState> | null; projectDraftThreadKeyByProjectKey?: Record<string, string> | null; logicalProjectDraftThreadKeyByLogicalProjectKey?: Record<string, string> | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & LegacyStickyModelFields & LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ threadId: ThreadId, environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ environmentId: Schema.String, threadId: Schema.String, }), ), ), }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), 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, }); /** * Composer content keyed by either a draft session (`DraftId`) or a real server * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. */ 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; } /** * Mutable routing and execution context for a pre-thread draft session. * * Unlike a real server thread, a draft session can still change target * environment/worktree configuration before the first send. */ export interface DraftSessionState { threadId: ThreadId; environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; promotedTo?: ScopedThreadRef | null; } export type DraftThreadState = DraftSessionState; /** * Draft session metadata paired with its stable draft-session identity. */ interface ProjectDraftSession extends DraftSessionState { draftId: DraftId; } /** * App-facing composer identity: * - `DraftId` for pre-thread draft sessions * - `ScopedThreadRef` for server-backed threads * * Raw `ThreadId` is intentionally excluded so callers cannot drop environment * identity for real threads. */ type ComposerThreadTarget = ScopedThreadRef | DraftId; /** * Persisted store for composer content plus draft-session metadata. * * The store intentionally models two domains: * - draft sessions keyed by `DraftId` * - server thread composer state keyed by `ScopedThreadRef` */ interface ComposerDraftStoreState { draftsByThreadKey: Record<string, ComposerThreadDraftState>; draftThreadsByThreadKey: Record<string, DraftThreadState>; logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; stickyActiveProvider: ProviderKind | null; /** Returns the editable composer content for a draft session or server thread. */ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; /** Looks up the active draft session for a logical project identity. */ getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; /** Reads mutable draft-session metadata by `DraftId`. */ getDraftSession: (draftId: DraftId) => DraftSessionState | null; /** Resolves a server-thread ref back to a matching draft session when one exists. */ getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; /** Creates or updates the draft session tracked for a logical project. */ setLogicalProjectDraftThreadId: ( logicalProjectKey: string, projectRef: ScopedProjectRef, draftId: DraftId, options?: { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ setProjectDraftThreadId: ( projectRef: ScopedProjectRef, draftId: DraftId, options?: { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ setDraftThreadContext: ( threadRef: ComposerThreadTarget, options: { branch?: string | null; worktreePath?: string | null; projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; clearProjectDraftThreadById: ( projectRef: ScopedProjectRef, threadRef: ComposerThreadTarget, ) => void; /** Marks a draft session as being promoted to a real server thread. */ markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void; /** Removes draft-session metadata after promotion is complete. */ finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, ) => void; /** Replace the model options for one or more providers in the draft. */ setModelOptions: ( threadRef: ComposerThreadTarget, modelOptions: | Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> | null | undefined, ) => void; applyStickyState: (threadRef: ComposerThreadTarget) => void; setProviderModelOptions: ( threadRef: ComposerThreadTarget, provider: ProviderKind, nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, options?: { model?: string | null | undefined; persistSticky?: boolean; }, ) => void; setRuntimeMode: ( threadRef: ComposerThreadTarget, runtimeMode: RuntimeMode | null | undefined, ) => void; setInteractionMode: ( threadRef: ComposerThreadTarget, interactionMode: ProviderInteractionMode | null | undefined, ) => void; addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; insertTerminalContext: ( threadRef: ComposerThreadTarget, prompt: string, context: TerminalContextDraft, index: number, ) => boolean; addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void; addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { selectedModel: string; modelOptions: ProviderOptionSelectionsByProvider | null; } interface ComposerDraftModelState { activeProvider: ProviderKind | null; modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; } function providerSelectionsFromModelSelection( modelSelection: ModelSelection | null | undefined, ): ProviderOptionSelectionsByProvider | null { if (!modelSelection) { return null; } const options = modelSelection.options; if (!options || options.length === 0) { return null; } return { [modelSelection.provider]: options } as ProviderOptionSelectionsByProvider; } function modelSelectionByProviderToOptions( map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined, ): ProviderOptionSelectionsByProvider | null { if (!map) return null; const result: Partial<Record<ProviderKind, ReadonlyArray<ProviderOptionSelection>>> = {}; for (const [provider, selection] of Object.entries(map)) { if (selection?.options && selection.options.length > 0) { result[provider as ProviderKind] = selection.options; } } return Object.keys(result).length > 0 ? result : null; } function cloneModelSelection(selection: ModelSelection): DeepMutable<ModelSelection> { return { ...selection, ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}), } as DeepMutable<ModelSelection>; } function cloneModelSelectionByProvider( selections: Partial<Record<ProviderKind, ModelSelection>>, ): DeepMutable<Partial<Record<ProviderKind, ModelSelection>>> { return Object.fromEntries( Object.entries(selections).map(([provider, selection]) => [ provider, selection ? cloneModelSelection(selection) : selection, ]), ) as DeepMutable<Partial<Record<ProviderKind, ModelSelection>>>; } const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, 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_COMPOSER_DRAFT_MODEL_STATE = Object.freeze<ComposerDraftModelState>({ activeProvider: null, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, }); 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 {