import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, EnvironmentId, ModelSelection, ProjectId, ProviderInstanceId, ProviderInteractionMode, ProviderDriverKind, ProviderOptionSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PreviewAnnotationPayloadSchema, type PreviewAnnotationPayload, RuntimeMode, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, ThreadId, SnapShotSource,} from "@t3tools/contracts";import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, scopeProjectRef, scopedThreadKey, scopeThreadRef,} from "@t3tools/client-runtime/environment";import * as Schema from "effect/Schema";import * as Equal from "effect/Equal";import * as Effect from "effect/Effect";import { DeepMutable } from "effect/Types";import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";import { useMemo } from "react";import { getLocalStorageItem } from "./hooks/useLocalStorage";import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatFileAttachment, type ChatImageAttachment, videoMimeType,} from "./types";import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText,} from "./lib/terminalContext";import { type ElementContextDraft, type ElementContextSelection, elementContextDedupKey, newElementContextId,} from "./lib/elementContext";import { create } from "zustand";import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";import { useShallow } from "zustand/react/shallow";import { createDeferredStorage, createMemoryStorage } from "./lib/storage";import { getDefaultServerModel } from "./providerModels";import { UnifiedSettings } from "@t3tools/contracts/settings";import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext";const isRuntimeMode = Schema.is(RuntimeMode);const isProviderDriverKind = Schema.is(ProviderDriverKind);const isReviewCommentContext = Schema.is(ReviewCommentContextSchema);const isSnapShotSource = Schema.is(SnapShotSource); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";const COMPOSER_DRAFT_STORAGE_VERSION = 9;const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);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; // Keep the immutable state until flush. Migration writebacks already have the persisted shape.type ComposerPersistState = | { capturedState: ComposerDraftStoreState } | PersistedComposerDraftStoreState; const composerDebouncedStorage = createDeferredStorage<StorageValue<ComposerPersistState>>( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), (value) => JSON.stringify({ state: "capturedState" in value.state ? partializeComposerDraftStoreState(value.state.capturedState) : value.state, version: value.version, }), COMPOSER_PERSIST_DEBOUNCE_MS,); const composerPersistStorage: PersistStorage<ComposerPersistState> = { getItem: (name) => { // The base storage is localStorage (or in-memory), which is synchronous. const raw = composerDebouncedStorage.getItem(name); if (typeof raw !== "string") { return null; } // Parsed persisted JSON. `migrate` and `merge` normalize it from unknown, // so the cast mirrors the one zustand's createJSONStorage performs. return JSON.parse(raw) as StorageValue<ComposerPersistState>; }, setItem: (name, value) => composerDebouncedStorage.setItem(name, value), removeItem: (name) => composerDebouncedStorage.removeItem(name),}; // 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, source: Schema.optional(SnapShotSource), dataUrl: Schema.String,});export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File;} export interface ComposerFileAttachment extends Omit<ChatFileAttachment, "previewUrl"> { file: File | null; uploadedAttachmentId?: string; uploadEnvironmentId?: EnvironmentId;} /** * A hydrated draft file whose upload never finished before the reload has * neither bytes (`file` is only ever null after hydration) nor a server-side * upload. The composer renders it as a needs-reattach row: the user must * attach the file again or remove it before sending. */export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean { return file.file === null && file.uploadedAttachmentId === undefined;} function clearStaleFileUploadMetadata( draft: ComposerThreadDraftState, environmentId: EnvironmentId,): ComposerThreadDraftState { let changed = false; const files = draft.files.map((file) => { if ( (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) || file.uploadEnvironmentId === environmentId ) { return file; } changed = true; const nextFile = { ...file }; delete nextFile.uploadedAttachmentId; delete nextFile.uploadEnvironmentId; return nextFile; }); return changed ? { ...draft, files } : draft;} export const PersistedComposerFileAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, attachmentId: Schema.String, environmentId: EnvironmentId,});export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; /** * Draft-persisted file. Unlike a stash entry (which requires a finished * upload), a draft may hold a file whose upload never completed. Its `File` * handle cannot serialize, so it persists as a metadata-only marker (no * `attachmentId`) and hydrates as a needs-reattach row instead of vanishing. */export const PersistedComposerDraftFileAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, attachmentId: Schema.optionalKey(Schema.String), environmentId: Schema.optionalKey(EnvironmentId),});export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type;const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); 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 PersistedElementContextStackFrame = Schema.Struct({ functionName: Schema.NullOr(Schema.String), fileName: Schema.NullOr(Schema.String), lineNumber: Schema.NullOr(Schema.Number), columnNumber: Schema.NullOr(Schema.Number),}); const PersistedElementContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, pickedAt: Schema.String, pageUrl: Schema.String, pageTitle: Schema.NullOr(Schema.String), tagName: Schema.String, selector: Schema.NullOr(Schema.String), htmlPreview: Schema.String, componentName: Schema.NullOr(Schema.String), source: Schema.NullOr(PersistedElementContextStackFrame), styles: Schema.String,});type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` // literal satisfies the `ProviderInstanceId` slug pattern, so existing // persisted drafts decode unchanged. // // The record's value schema is NOT wrapped in `Schema.optionalKey`: // that helper is only meaningful on property signatures with a known // key set, and `Schema.Record(<branded string>, …)` produces an index // signature at runtime (Schema rejects the combination). Absence of // an entry already encodes "no selection for this instance". modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), // True only when a human picked this selection in the composer. Seeded // selections (project default / sticky) leave it unset so later seeds can // replace them; legacy entries predate the flag and read as seeded too. modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), 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<string, 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), environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), 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(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)),});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[]; files: ComposerFileAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; /** * Element-pick attachments captured from the in-app preview browser. The * full payload (selector / html / styles / source frame) is persisted * inline because — unlike terminal contexts — there's no live session to * re-derive the snapshot from on reload. */ elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored * `codex_personal` instance each persist their own selected model. Every * historical `ProviderDriverKind` literal (`codex` / `claudeAgent` / `cursor` / * `opencode`) also satisfies the `ProviderInstanceId` slug pattern, so * legacy kind-keyed drafts round-trip unchanged. */ modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; /** Routing key of the last picked instance (see `modelSelectionByProvider`). */ activeProvider: ProviderInstanceId | null; /** * True only when a human picked the active selection in the composer. * Absent/false means seeded (project default / sticky), so later seeds * may replace it. Legacy entries predate the flag and read as seeded. */ modelSelectionExplicit?: boolean; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null;} /** * True when the user has invested real content in the draft: typed text or * any attachment/context. Model selection and mode choices alone do not * count — those are ambient defaults, not work in progress. Used by the * sidebar draft rows (which draft sessions deserve a row) and by new-thread * resurrection (a draft with content keeps its settings instead of being * reset to defaults). */export function composerDraftHasUserContent( draft: ComposerThreadDraftState | null | undefined,): boolean { if (!draft) { return false; } return ( draft.prompt.trim().length > 0 || draft.images.length > 0 || draft.files.length > 0 || draft.persistedAttachments.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || draft.previewAnnotations.length > 0 || draft.reviewComments.length > 0 );} /** * 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; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; 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. */export 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>; backgroundSubmissionThreadKeys: Record<string, true>; stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; stickyActiveProvider: ProviderInstanceId | 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. * Reassigning an existing draft removes its previous logical-project * mapping so one session cannot resolve from two projects. */ setLogicalProjectDraftThreadId: (Lines 1–500 of 4212. Load more
import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, EnvironmentId, ModelSelection, ProjectId, ProviderInstanceId, ProviderInteractionMode, ProviderDriverKind, ProviderOptionSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PreviewAnnotationPayloadSchema, type PreviewAnnotationPayload, RuntimeMode, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, ThreadId, SnapShotSource, } from "@t3tools/contracts"; import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, scopeProjectRef, scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatFileAttachment, type ChatImageAttachment, videoMimeType, } from "./types"; import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; import { type ElementContextDraft, type ElementContextSelection, elementContextDedupKey, newElementContextId, } from "./lib/elementContext"; import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; import { createDeferredStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); const isSnapShotSource = Schema.is(SnapShotSource); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); 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; // Keep the immutable state until flush. Migration writebacks already have the persisted shape. type ComposerPersistState = | { capturedState: ComposerDraftStoreState } | PersistedComposerDraftStoreState; const composerDebouncedStorage = createDeferredStorage<StorageValue<ComposerPersistState>>( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), (value) => JSON.stringify({ state: "capturedState" in value.state ? partializeComposerDraftStoreState(value.state.capturedState) : value.state, version: value.version, }), COMPOSER_PERSIST_DEBOUNCE_MS, ); const composerPersistStorage: PersistStorage<ComposerPersistState> = { getItem: (name) => { // The base storage is localStorage (or in-memory), which is synchronous. const raw = composerDebouncedStorage.getItem(name); if (typeof raw !== "string") { return null; } // Parsed persisted JSON. `migrate` and `merge` normalize it from unknown, // so the cast mirrors the one zustand's createJSONStorage performs. return JSON.parse(raw) as StorageValue<ComposerPersistState>; }, setItem: (name, value) => composerDebouncedStorage.setItem(name, value), removeItem: (name) => composerDebouncedStorage.removeItem(name), }; // 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, source: Schema.optional(SnapShotSource), dataUrl: Schema.String, }); export type PersistedComposerImageAttachment = typeof PersistedComposerImageAttachment.Type; export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { previewUrl: string; file: File; } export interface ComposerFileAttachment extends Omit<ChatFileAttachment, "previewUrl"> { file: File | null; uploadedAttachmentId?: string; uploadEnvironmentId?: EnvironmentId; } /** * A hydrated draft file whose upload never finished before the reload has * neither bytes (`file` is only ever null after hydration) nor a server-side * upload. The composer renders it as a needs-reattach row: the user must * attach the file again or remove it before sending. */ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean { return file.file === null && file.uploadedAttachmentId === undefined; } function clearStaleFileUploadMetadata( draft: ComposerThreadDraftState, environmentId: EnvironmentId, ): ComposerThreadDraftState { let changed = false; const files = draft.files.map((file) => { if ( (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) || file.uploadEnvironmentId === environmentId ) { return file; } changed = true; const nextFile = { ...file }; delete nextFile.uploadedAttachmentId; delete nextFile.uploadEnvironmentId; return nextFile; }); return changed ? { ...draft, files } : draft; } export const PersistedComposerFileAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, attachmentId: Schema.String, environmentId: EnvironmentId, }); export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; /** * Draft-persisted file. Unlike a stash entry (which requires a finished * upload), a draft may hold a file whose upload never completed. Its `File` * handle cannot serialize, so it persists as a metadata-only marker (no * `attachmentId`) and hydrates as a needs-reattach row instead of vanishing. */ export const PersistedComposerDraftFileAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, attachmentId: Schema.optionalKey(Schema.String), environmentId: Schema.optionalKey(EnvironmentId), }); export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); 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 PersistedElementContextStackFrame = Schema.Struct({ functionName: Schema.NullOr(Schema.String), fileName: Schema.NullOr(Schema.String), lineNumber: Schema.NullOr(Schema.Number), columnNumber: Schema.NullOr(Schema.Number), }); const PersistedElementContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, pickedAt: Schema.String, pageUrl: Schema.String, pageTitle: Schema.NullOr(Schema.String), tagName: Schema.String, selector: Schema.NullOr(Schema.String), htmlPreview: Schema.String, componentName: Schema.NullOr(Schema.String), source: Schema.NullOr(PersistedElementContextStackFrame), styles: Schema.String, }); type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` // literal satisfies the `ProviderInstanceId` slug pattern, so existing // persisted drafts decode unchanged. // // The record's value schema is NOT wrapped in `Schema.optionalKey`: // that helper is only meaningful on property signatures with a known // key set, and `Schema.Record(<branded string>, …)` produces an index // signature at runtime (Schema rejects the combination). Absence of // an entry already encodes "no selection for this instance". modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), // True only when a human picked this selection in the composer. Seeded // selections (project default / sticky) leave it unset so later seeds can // replace them; legacy entries predate the flag and read as seeded too. modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), 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<string, 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), environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), 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(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), }); 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[]; files: ComposerFileAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; /** * Element-pick attachments captured from the in-app preview browser. The * full payload (selector / html / styles / source frame) is persisted * inline because — unlike terminal contexts — there's no live session to * re-derive the snapshot from on reload. */ elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored * `codex_personal` instance each persist their own selected model. Every * historical `ProviderDriverKind` literal (`codex` / `claudeAgent` / `cursor` / * `opencode`) also satisfies the `ProviderInstanceId` slug pattern, so * legacy kind-keyed drafts round-trip unchanged. */ modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; /** Routing key of the last picked instance (see `modelSelectionByProvider`). */ activeProvider: ProviderInstanceId | null; /** * True only when a human picked the active selection in the composer. * Absent/false means seeded (project default / sticky), so later seeds * may replace it. Legacy entries predate the flag and read as seeded. */ modelSelectionExplicit?: boolean; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } /** * True when the user has invested real content in the draft: typed text or * any attachment/context. Model selection and mode choices alone do not * count — those are ambient defaults, not work in progress. Used by the * sidebar draft rows (which draft sessions deserve a row) and by new-thread * resurrection (a draft with content keeps its settings instead of being * reset to defaults). */ export function composerDraftHasUserContent( draft: ComposerThreadDraftState | null | undefined, ): boolean { if (!draft) { return false; } return ( draft.prompt.trim().length > 0 || draft.images.length > 0 || draft.files.length > 0 || draft.persistedAttachments.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || draft.previewAnnotations.length > 0 || draft.reviewComments.length > 0 ); } /** * 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; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; 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. */ export 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>; backgroundSubmissionThreadKeys: Record<string, true>; stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; stickyActiveProvider: ProviderInstanceId | 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. * Reassigning an existing draft removes its previous logical-project * mapping so one session cannot resolve from two projects. */ setLogicalProjectDraftThreadId: (