diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index e4711a0075..501d9c20db 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2456,7 +2456,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
? parseStandaloneComposerSlashCommand(trimmed)
: null;
if (standaloneSlashCommand) {
- await handleInteractionModeChange(standaloneSlashCommand);
+ handleInteractionModeChange(standaloneSlashCommand);
promptRef.current = "";
clearComposerDraftContent(activeThread.id);
setComposerHighlightedItemId(null);
@@ -2512,7 +2512,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const outgoingMessageText = formatOutgoingPrompt({
provider: selectedProvider,
effort: selectedPromptEffort,
- text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
+ text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
});
const turnAttachmentsPromise = Promise.all(
composerImagesSnapshot.map(async (image) => ({
@@ -3670,8 +3670,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
/>
) : null}
-
- {/* Textarea area */}
void onImplementPlanInNewThread()}
>
- Implement in new thread
+ Implement in a new thread
@@ -4079,9 +4077,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
type="submit"
className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
disabled={
- isSendBusy ||
- isConnecting ||
- (!prompt.trim() && composerImages.length === 0)
+ isSendBusy || isConnecting || !composerSendState.hasSendableContent
}
aria-label={
isConnecting
diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx
index 3c11fa5a05..fd80e10473 100644
--- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx
+++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx
@@ -16,27 +16,29 @@ async function mountPicker(props?: {
fastModeEnabled?: boolean;
}) {
const threadId = ThreadId.makeUnsafe("thread-claude-traits");
- useComposerDraftStore.setState({
- draftsByThreadId: {
- [threadId]: {
- prompt: props?.prompt ?? "",
- images: [],
- nonPersistedImageIds: [],
- persistedAttachments: [],
- terminalContexts: [],
- provider: "claudeAgent",
- model: props?.model ?? "claude-opus-4-6",
- modelOptions: {
- claudeAgent: {
- ...(props?.effort ? { effort: props.effort } : {}),
- ...(props?.thinkingEnabled === false ? { thinking: false } : {}),
- ...(props?.fastModeEnabled ? { fastMode: true } : {}),
- },
- },
- runtimeMode: null,
- interactionMode: null,
+ const draftsByThreadId = {} as ReturnType<
+ typeof useComposerDraftStore.getState
+ >["draftsByThreadId"];
+ draftsByThreadId[threadId] = {
+ prompt: props?.prompt ?? "",
+ images: [],
+ nonPersistedImageIds: [],
+ persistedAttachments: [],
+ terminalContexts: [],
+ provider: "claudeAgent",
+ model: props?.model ?? "claude-opus-4-6",
+ modelOptions: {
+ claudeAgent: {
+ ...(props?.effort ? { effort: props.effort } : {}),
+ ...(props?.thinkingEnabled === false ? { thinking: false } : {}),
+ ...(props?.fastModeEnabled ? { fastMode: true } : {}),
},
},
+ runtimeMode: null,
+ interactionMode: null,
+ };
+ useComposerDraftStore.setState({
+ draftsByThreadId,
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx
index 494913dc76..d717f91923 100644
--- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx
+++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx
@@ -13,26 +13,28 @@ async function mountPicker(props: {
fastModeEnabled: boolean;
}) {
const threadId = ThreadId.makeUnsafe("thread-codex-traits");
- useComposerDraftStore.setState({
- draftsByThreadId: {
- [threadId]: {
- prompt: "",
- images: [],
- nonPersistedImageIds: [],
- persistedAttachments: [],
- terminalContexts: [],
- provider: "codex",
- model: null,
- modelOptions: {
- codex: {
- ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}),
- ...(props.fastModeEnabled ? { fastMode: true } : {}),
- },
- },
- runtimeMode: null,
- interactionMode: null,
+ const draftsByThreadId = {} as ReturnType<
+ typeof useComposerDraftStore.getState
+ >["draftsByThreadId"];
+ draftsByThreadId[threadId] = {
+ prompt: "",
+ images: [],
+ nonPersistedImageIds: [],
+ persistedAttachments: [],
+ terminalContexts: [],
+ provider: "codex",
+ model: null,
+ modelOptions: {
+ codex: {
+ ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}),
+ ...(props.fastModeEnabled ? { fastMode: true } : {}),
},
},
+ runtimeMode: null,
+ interactionMode: null,
+ };
+ useComposerDraftStore.setState({
+ draftsByThreadId,
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {
[ProjectId.makeUnsafe("project-codex-traits")]: threadId,
diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx
index 914eca5540..641d39277e 100644
--- a/apps/web/src/components/chat/CodexTraitsPicker.tsx
+++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx
@@ -1,8 +1,8 @@
-import {
- type CodexModelOptions,
- type CodexReasoningEffort,
- type ProviderModelOptions,
- type ThreadId,
+import type {
+ CodexModelOptions,
+ CodexReasoningEffort,
+ ProviderModelOptions,
+ ThreadId,
} from "@t3tools/contracts";
import {
getDefaultReasoningEffort,
@@ -132,12 +132,14 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { thread
}
>
-
{triggerLabel}
-
+
+ {triggerLabel}
+
+
diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx
index 9035c3da55..83716d619a 100644
--- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx
+++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx
@@ -1,4 +1,4 @@
-import { ThreadId } from "@t3tools/contracts";
+import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts";
import "../../index.css";
import { page } from "vitest/browser";
@@ -14,47 +14,27 @@ async function mountMenu(props?: {
model?: string;
prompt?: string;
provider?: "codex" | "claudeAgent";
- codexEffort?: "low" | "medium" | "high" | "xhigh";
- codexFastMode?: boolean;
- claudeEffort?: "low" | "medium" | "high" | "max" | "ultrathink";
- claudeThinking?: boolean;
- claudeFastMode?: boolean;
+ modelOptions?: ProviderModelOptions | null;
}) {
const threadId = ThreadId.makeUnsafe("thread-compact-menu");
const provider = props?.provider ?? "claudeAgent";
+ const draftsByThreadId = {} as ReturnType<
+ typeof useComposerDraftStore.getState
+ >["draftsByThreadId"];
+ draftsByThreadId[threadId] = {
+ prompt: props?.prompt ?? "",
+ images: [],
+ nonPersistedImageIds: [],
+ persistedAttachments: [],
+ terminalContexts: [],
+ provider,
+ model: props?.model ?? "claude-opus-4-6",
+ modelOptions: props?.modelOptions ?? null,
+ runtimeMode: null,
+ interactionMode: null,
+ };
useComposerDraftStore.setState({
- draftsByThreadId: {
- [threadId]: {
- prompt: props?.prompt ?? "",
- images: [],
- nonPersistedImageIds: [],
- persistedAttachments: [],
- terminalContexts: [],
- provider,
- model: props?.model ?? "claude-opus-4-6",
- modelOptions: {
- ...(provider === "codex"
- ? {
- codex: {
- ...(props?.codexEffort ? { reasoningEffort: props.codexEffort } : {}),
- ...(props?.codexFastMode ? { fastMode: true } : {}),
- },
- }
- : {}),
- ...(provider === "claudeAgent"
- ? {
- claudeAgent: {
- ...(props?.claudeEffort ? { effort: props.claudeEffort } : {}),
- ...(props?.claudeThinking === false ? { thinking: false } : {}),
- ...(props?.claudeFastMode ? { fastMode: true } : {}),
- },
- }
- : {}),
- },
- runtimeMode: null,
- interactionMode: null,
- },
- },
+ draftsByThreadId,
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
@@ -158,7 +138,11 @@ describe("CompactComposerControlsMenu", () => {
it("shows a Claude thinking on/off section for Haiku", async () => {
const mounted = await mountMenu({
model: "claude-haiku-4-5",
- claudeThinking: true,
+ modelOptions: {
+ claudeAgent: {
+ thinking: true,
+ },
+ },
});
try {
@@ -179,7 +163,11 @@ describe("CompactComposerControlsMenu", () => {
const mounted = await mountMenu({
model: "claude-opus-4-6",
prompt: "Ultrathink:\nInvestigate this",
- claudeEffort: "high",
+ modelOptions: {
+ claudeAgent: {
+ effort: "high",
+ },
+ },
});
try {
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 13a62f60ef..868ab67d8b 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -136,15 +136,18 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
size="sm"
variant="ghost"
className={cn(
- "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80",
- props.compact ? "max-w-42" : "sm:px-3",
+ "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",
+ props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",
)}
disabled={props.disabled}
/>
}
>
- {selectedModelLabel}
+ {selectedModelLabel}
{props.bedrockActive && activeProvider === "claudeAgent" && (
Bedrock
)}
-
+
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index f2be713757..773f16ceab 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -1,16 +1,19 @@
+import * as Schema from "effect/Schema";
import { ProjectId, ThreadId } from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
+ COMPOSER_DRAFT_STORAGE_KEY,
type ComposerImageAttachment,
- createDebouncedStorage,
useComposerDraftStore,
} from "./composerDraftStore";
+import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
import {
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
insertInlineTerminalContextPlaceholder,
type TerminalContextDraft,
} from "./lib/terminalContext";
+import { createDebouncedStorage } from "./lib/storage";
function makeImage(input: {
id: string;
@@ -183,6 +186,63 @@ describe("composerDraftStore clearComposerContent", () => {
});
});
+describe("composerDraftStore syncPersistedAttachments", () => {
+ const threadId = ThreadId.makeUnsafe("thread-sync-persisted");
+
+ beforeEach(() => {
+ removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
+ useComposerDraftStore.setState({
+ draftsByThreadId: {},
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectId: {},
+ });
+ });
+
+ afterEach(() => {
+ removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
+ });
+
+ it("treats malformed persisted draft storage as empty", async () => {
+ const image = makeImage({
+ id: "img-persisted",
+ previewUrl: "blob:persisted",
+ });
+ useComposerDraftStore.getState().addImage(threadId, image);
+ setLocalStorageItem(
+ COMPOSER_DRAFT_STORAGE_KEY,
+ {
+ version: 2,
+ state: {
+ draftsByThreadId: {
+ [threadId]: {
+ attachments: "not-an-array",
+ },
+ },
+ },
+ },
+ Schema.Unknown,
+ );
+
+ useComposerDraftStore.getState().syncPersistedAttachments(threadId, [
+ {
+ id: image.id,
+ name: image.name,
+ mimeType: image.mimeType,
+ sizeBytes: image.sizeBytes,
+ dataUrl: image.previewUrl,
+ },
+ ]);
+ await Promise.resolve();
+
+ expect(
+ useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments,
+ ).toEqual([]);
+ expect(
+ useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds,
+ ).toEqual([image.id]);
+ });
+});
+
describe("composerDraftStore terminal contexts", () => {
const threadId = ThreadId.makeUnsafe("thread-dedupe");
@@ -323,6 +383,37 @@ describe("composerDraftStore terminal contexts", () => {
},
]);
});
+
+ it("sanitizes malformed persisted drafts during merge", () => {
+ const persistApi = useComposerDraftStore.persist as unknown as {
+ getOptions: () => {
+ merge: (
+ persistedState: unknown,
+ currentState: ReturnType,
+ ) => ReturnType;
+ };
+ };
+ const mergedState = persistApi.getOptions().merge(
+ {
+ draftsByThreadId: {
+ [threadId]: {
+ prompt: "",
+ attachments: "not-an-array",
+ terminalContexts: "not-an-array",
+ provider: "bogus-provider",
+ modelOptions: "not-an-object",
+ },
+ },
+ draftThreadsByThreadId: "not-an-object",
+ projectDraftThreadIdByProjectId: "not-an-object",
+ },
+ useComposerDraftStore.getInitialState(),
+ );
+
+ expect(mergedState.draftsByThreadId[threadId]).toBeUndefined();
+ expect(mergedState.draftThreadsByThreadId).toEqual({});
+ expect(mergedState.projectDraftThreadIdByProjectId).toEqual({});
+ });
});
describe("composerDraftStore project draft thread mapping", () => {
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index fa1ec3e212..6d72c94724 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -1,62 +1,40 @@
import {
+ CODEX_REASONING_EFFORT_OPTIONS,
type ClaudeCodeEffort,
type CodexReasoningEffort,
DEFAULT_REASONING_EFFORT_BY_PROVIDER,
ProjectId,
+ ProviderInteractionMode,
+ ProviderKind,
+ ProviderModelOptions,
+ RuntimeMode,
ThreadId,
- type ProviderInteractionMode,
- type ProviderKind,
- type ProviderModelOptions,
- type RuntimeMode,
} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { DeepMutable } from "effect/Types";
import { normalizeModelSlug } from "@t3tools/shared/model";
+import { getLocalStorageItem } from "./hooks/useLocalStorage";
import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
import {
type TerminalContextDraft,
ensureInlineTerminalContextPlaceholders,
normalizeTerminalContextText,
} from "./lib/terminalContext";
-import { Debouncer } from "@tanstack/react-pacer";
import { create } from "zustand";
-import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
+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 = 2;
-export type DraftThreadEnvMode = "local" | "worktree";
+const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
+export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
const COMPOSER_PERSIST_DEBOUNCE_MS = 300;
-interface DebouncedStorage extends StateStorage {
- flush: () => void;
-}
-
-export function createDebouncedStorage(baseStorage: StateStorage): DebouncedStorage {
- const debouncedSetItem = new Debouncer(
- (name: string, value: string) => {
- baseStorage.setItem(name, value);
- },
- { wait: COMPOSER_PERSIST_DEBOUNCE_MS },
- );
-
- return {
- getItem: (name) => baseStorage.getItem(name),
- setItem: (name, value) => {
- debouncedSetItem.maybeExecute(name, value);
- },
- removeItem: (name) => {
- debouncedSetItem.cancel();
- baseStorage.removeItem(name);
- },
- flush: () => {
- debouncedSetItem.flush();
- },
- };
-}
-
-const composerDebouncedStorage: DebouncedStorage =
- typeof localStorage !== "undefined"
- ? createDebouncedStorage(localStorage)
- : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} };
+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") {
@@ -65,58 +43,74 @@ if (typeof window !== "undefined") {
});
}
-export interface PersistedComposerImageAttachment {
- id: string;
- name: string;
- mimeType: string;
- sizeBytes: number;
- dataUrl: string;
-}
+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 {
previewUrl: string;
file: File;
}
-interface PersistedTerminalContextDraft {
- id: string;
- threadId: ThreadId;
- createdAt: string;
- terminalId: string;
- terminalLabel: string;
- lineStart: number;
- lineEnd: number;
-}
+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;
-interface PersistedComposerThreadDraftState {
- prompt: string;
- attachments: PersistedComposerImageAttachment[];
- terminalContexts?: PersistedTerminalContextDraft[];
- provider?: ProviderKind | null;
- model?: string | null;
- modelOptions?: ProviderModelOptions | null;
- runtimeMode?: RuntimeMode | null;
- interactionMode?: ProviderInteractionMode | null;
- effort?: CodexReasoningEffort | null;
- codexFastMode?: boolean | null;
- serviceTier?: string | null;
-}
+const PersistedComposerThreadDraftState = Schema.Struct({
+ prompt: Schema.String,
+ attachments: Schema.Array(PersistedComposerImageAttachment),
+ terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
+ provider: Schema.optionalKey(ProviderKind),
+ model: Schema.optionalKey(Schema.String),
+ modelOptions: Schema.optionalKey(ProviderModelOptions),
+ runtimeMode: Schema.optionalKey(RuntimeMode),
+ interactionMode: Schema.optionalKey(ProviderInteractionMode),
+});
+type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type;
-interface PersistedDraftThreadState {
- projectId: ProjectId;
- createdAt: string;
- runtimeMode: RuntimeMode;
- interactionMode: ProviderInteractionMode;
- branch: string | null;
- worktreePath: string | null;
- envMode: DraftThreadEnvMode;
-}
+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;
-interface PersistedComposerDraftStoreState {
- draftsByThreadId: Record;
- draftThreadsByThreadId: Record;
- projectDraftThreadIdByProjectId: Record;
-}
+type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields;
+
+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),
+});
+type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
+
+const PersistedComposerDraftStoreStorage = Schema.Struct({
+ version: Schema.Number,
+ state: PersistedComposerDraftStoreState,
+});
interface ComposerThreadDraftState {
prompt: string;
@@ -333,11 +327,7 @@ function normalizeProviderKind(value: unknown): ProviderKind | null {
function normalizeProviderModelOptions(
value: unknown,
provider?: ProviderKind | null,
- legacy?: {
- effort?: unknown;
- codexFastMode?: unknown;
- serviceTier?: unknown;
- },
+ legacy?: LegacyCodexFields,
): ProviderModelOptions | null {
const candidate = value && typeof value === "object" ? (value as Record) : null;
const codexCandidate =
@@ -504,18 +494,14 @@ function normalizeDraftThreadEnvMode(
return fallbackWorktreePath ? "worktree" : "local";
}
-function migratePersistedComposerDraftStoreState(
- persistedState: unknown,
- persistedVersion: number,
-): PersistedComposerDraftStoreState {
- if (!persistedState || typeof persistedState !== "object") {
- return EMPTY_PERSISTED_DRAFT_STORE_STATE;
- }
- const candidate = persistedState as Record;
- const rawDraftMap = candidate.draftsByThreadId;
- const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
- const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
- const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {};
+function normalizePersistedDraftThreads(
+ rawDraftThreadsByThreadId: unknown,
+ rawProjectDraftThreadIdByProjectId: unknown,
+): Pick<
+ PersistedComposerDraftStoreState,
+ "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"
+> {
+ const draftThreadsByThreadId: Record = {};
if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
for (const [threadId, rawDraftThread] of Object.entries(
rawDraftThreadsByThreadId as Record,
@@ -557,8 +543,8 @@ function migratePersistedComposerDraftStoreState(
};
}
}
- const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
- {};
+
+ const projectDraftThreadIdByProjectId: Record = {};
if (
rawProjectDraftThreadIdByProjectId &&
typeof rawProjectDraftThreadIdByProjectId === "object"
@@ -592,10 +578,23 @@ function migratePersistedComposerDraftStoreState(
}
}
}
+
+ return { draftThreadsByThreadId, projectDraftThreadIdByProjectId };
+}
+
+function normalizePersistedDraftsByThreadId(
+ rawDraftMap: unknown,
+ resolveModelOptions: (
+ draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState,
+ provider: ProviderKind | null,
+ ) => ProviderModelOptions | null,
+): PersistedComposerDraftStoreState["draftsByThreadId"] {
if (!rawDraftMap || typeof rawDraftMap !== "object") {
- return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
+ return {};
}
- const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
+
+ const nextDraftsByThreadId: DeepMutable =
+ {};
for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) {
if (typeof threadId !== "string" || threadId.length === 0) {
continue;
@@ -603,7 +602,9 @@ function migratePersistedComposerDraftStoreState(
if (!draftValue || typeof draftValue !== "object") {
continue;
}
- const draftCandidate = draftValue as Record;
+ const draftCandidate = draftValue as
+ | PersistedComposerThreadDraftState
+ | LegacyPersistedCodexThreadDraftState;
const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
const attachments = Array.isArray(draftCandidate.attachments)
? draftCandidate.attachments.flatMap((entry) => {
@@ -631,14 +632,7 @@ function migratePersistedComposerDraftStoreState(
draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
? draftCandidate.interactionMode
: null;
- const modelOptions =
- persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION
- ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider)
- : normalizeProviderModelOptions(draftCandidate.modelOptions, provider, {
- effort: draftCandidate.effort,
- codexFastMode: draftCandidate.codexFastMode,
- serviceTier: draftCandidate.serviceTier,
- });
+ const modelOptions = resolveModelOptions(draftCandidate, provider);
const prompt = ensureInlineTerminalContextPlaceholders(
promptCandidate,
terminalContexts.length,
@@ -666,32 +660,115 @@ function migratePersistedComposerDraftStoreState(
...(interactionMode ? { interactionMode } : {}),
};
}
+
+ return nextDraftsByThreadId;
+}
+
+function migratePersistedComposerDraftStoreState(
+ persistedState: unknown,
+ persistedVersion: number,
+): PersistedComposerDraftStoreState {
+ if (!persistedState || typeof persistedState !== "object") {
+ return EMPTY_PERSISTED_DRAFT_STORE_STATE;
+ }
+ const candidate = persistedState as Record;
+ const rawDraftMap = candidate.draftsByThreadId;
+ const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
+ const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
+ const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
+ normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
+ const draftsByThreadId = normalizePersistedDraftsByThreadId(
+ rawDraftMap,
+ (draftCandidate, provider) =>
+ persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION
+ ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider)
+ : normalizeProviderModelOptions(
+ draftCandidate.modelOptions,
+ provider,
+ draftCandidate as LegacyPersistedCodexThreadDraftState,
+ ),
+ );
return {
- draftsByThreadId: nextDraftsByThreadId,
+ draftsByThreadId,
draftThreadsByThreadId,
projectDraftThreadIdByProjectId,
};
}
-function readPersistedComposerDraftStoreState(
- raw: string | null,
+function partializeComposerDraftStoreState(
+ state: ComposerDraftStoreState,
): PersistedComposerDraftStoreState {
- if (!raw) {
- return EMPTY_PERSISTED_DRAFT_STORE_STATE;
- }
- try {
- const parsed = JSON.parse(raw) as unknown;
- if (parsed && typeof parsed === "object" && "state" in parsed) {
- const candidate = parsed as { state?: unknown; version?: unknown };
- return migratePersistedComposerDraftStoreState(
- candidate.state,
- typeof candidate.version === "number" ? candidate.version : 0,
- );
+ const persistedDraftsByThreadId: DeepMutable<
+ PersistedComposerDraftStoreState["draftsByThreadId"]
+ > = {};
+ for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
+ if (typeof threadId !== "string" || threadId.length === 0) {
+ continue;
}
- return migratePersistedComposerDraftStoreState(parsed, 0);
- } catch {
+ if (
+ draft.prompt.length === 0 &&
+ draft.persistedAttachments.length === 0 &&
+ draft.terminalContexts.length === 0 &&
+ draft.provider === null &&
+ draft.model === null &&
+ draft.modelOptions === null &&
+ draft.runtimeMode === null &&
+ draft.interactionMode === null
+ ) {
+ continue;
+ }
+ const persistedDraft: DeepMutable = {
+ prompt: draft.prompt,
+ attachments: draft.persistedAttachments,
+ ...(draft.terminalContexts.length > 0
+ ? {
+ terminalContexts: draft.terminalContexts.map((context) => ({
+ id: context.id,
+ threadId: context.threadId,
+ createdAt: context.createdAt,
+ terminalId: context.terminalId,
+ terminalLabel: context.terminalLabel,
+ lineStart: context.lineStart,
+ lineEnd: context.lineEnd,
+ })),
+ }
+ : {}),
+ ...(draft.model ? { model: draft.model } : {}),
+ ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}),
+ ...(draft.provider ? { provider: draft.provider } : {}),
+ ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
+ ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
+ };
+ persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
+ }
+ return {
+ draftsByThreadId: persistedDraftsByThreadId,
+ draftThreadsByThreadId: state.draftThreadsByThreadId,
+ projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
+ };
+}
+
+function normalizeCurrentPersistedComposerDraftStoreState(
+ persistedState: unknown,
+): PersistedComposerDraftStoreState {
+ if (!persistedState || typeof persistedState !== "object") {
return EMPTY_PERSISTED_DRAFT_STORE_STATE;
}
+ const normalizedPersistedState = persistedState as Record;
+ const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
+ normalizePersistedDraftThreads(
+ normalizedPersistedState.draftThreadsByThreadId,
+ normalizedPersistedState.projectDraftThreadIdByProjectId,
+ );
+ return {
+ draftsByThreadId: normalizePersistedDraftsByThreadId(
+ normalizedPersistedState.draftsByThreadId,
+ (draftCandidate, provider) =>
+ normalizeProviderModelOptions(draftCandidate.modelOptions, provider),
+ ),
+ draftThreadsByThreadId,
+ projectDraftThreadIdByProjectId,
+ };
}
function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
@@ -699,9 +776,14 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
return [];
}
try {
- const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
- const persisted = readPersistedComposerDraftStoreState(raw);
- return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map(
+ const persisted = getLocalStorageItem(
+ COMPOSER_DRAFT_STORAGE_KEY,
+ PersistedComposerDraftStoreStorage,
+ );
+ if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) {
+ return [];
+ }
+ return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map(
(attachment) => attachment.id,
);
} catch {
@@ -742,7 +824,7 @@ function hydreatePersistedComposerImageAttachment(
}
function hydrateImagesFromPersisted(
- attachments: PersistedComposerImageAttachment[],
+ attachments: ReadonlyArray,
): ComposerImageAttachment[] {
return attachments.flatMap((attachment) => {
const file = hydreatePersistedComposerImageAttachment(attachment);
@@ -769,7 +851,7 @@ function toHydratedThreadDraft(
prompt: persistedDraft.prompt,
images: hydrateImagesFromPersisted(persistedDraft.attachments),
nonPersistedImageIds: [],
- persistedAttachments: persistedDraft.attachments,
+ persistedAttachments: [...persistedDraft.attachments],
terminalContexts:
persistedDraft.terminalContexts?.map((context) => ({
...context,
@@ -1547,66 +1629,11 @@ export const useComposerDraftStore = create()(
name: COMPOSER_DRAFT_STORAGE_KEY,
version: COMPOSER_DRAFT_STORAGE_VERSION,
storage: createJSONStorage(() => composerDebouncedStorage),
- migrate: (persistedState, version) =>
- migratePersistedComposerDraftStoreState(persistedState, version),
- partialize: (state) => {
- const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
- for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
- if (typeof threadId !== "string" || threadId.length === 0) {
- continue;
- }
- if (
- draft.prompt.length === 0 &&
- draft.persistedAttachments.length === 0 &&
- draft.terminalContexts.length === 0 &&
- draft.provider === null &&
- draft.model === null &&
- draft.modelOptions === null &&
- draft.runtimeMode === null &&
- draft.interactionMode === null
- ) {
- continue;
- }
- const persistedDraft: PersistedComposerThreadDraftState = {
- prompt: draft.prompt,
- attachments: draft.persistedAttachments,
- };
- if (draft.terminalContexts.length > 0) {
- persistedDraft.terminalContexts = draft.terminalContexts.map((context) => ({
- id: context.id,
- threadId: context.threadId,
- createdAt: context.createdAt,
- terminalId: context.terminalId,
- terminalLabel: context.terminalLabel,
- lineStart: context.lineStart,
- lineEnd: context.lineEnd,
- }));
- }
- if (draft.model) {
- persistedDraft.model = draft.model;
- }
- if (draft.modelOptions) {
- persistedDraft.modelOptions = draft.modelOptions;
- }
- if (draft.provider) {
- persistedDraft.provider = draft.provider;
- }
- if (draft.runtimeMode) {
- persistedDraft.runtimeMode = draft.runtimeMode;
- }
- if (draft.interactionMode) {
- persistedDraft.interactionMode = draft.interactionMode;
- }
- persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
- }
- return {
- draftsByThreadId: persistedDraftsByThreadId,
- draftThreadsByThreadId: state.draftThreadsByThreadId,
- projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
- };
- },
+ migrate: migratePersistedComposerDraftStoreState,
+ partialize: partializeComposerDraftStoreState,
merge: (persistedState, currentState) => {
- const normalizedPersisted = persistedState as PersistedComposerDraftStoreState;
+ const normalizedPersisted =
+ normalizeCurrentPersistedComposerDraftStoreState(persistedState);
const draftsByThreadId = Object.fromEntries(
Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
threadId,
diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts
new file mode 100644
index 0000000000..eeb3a03a82
--- /dev/null
+++ b/apps/web/src/lib/storage.ts
@@ -0,0 +1,50 @@
+import { Debouncer } from "@tanstack/react-pacer";
+
+export interface StateStorage {
+ getItem: (name: string) => string | null | Promise;
+ setItem: (name: string, value: string) => R;
+ removeItem: (name: string) => R;
+}
+
+export interface DebouncedStorage extends StateStorage {
+ flush: () => void;
+}
+
+export function createMemoryStorage(): StateStorage {
+ const store = new Map();
+ return {
+ getItem: (name) => store.get(name) ?? null,
+ setItem: (name, value) => {
+ store.set(name, value);
+ },
+ removeItem: (name) => {
+ store.delete(name);
+ },
+ };
+}
+
+export function createDebouncedStorage(
+ baseStorage: StateStorage,
+ debounceMs: number = 300,
+): DebouncedStorage {
+ const debouncedSetItem = new Debouncer(
+ (name: string, value: string) => {
+ baseStorage.setItem(name, value);
+ },
+ { wait: debounceMs },
+ );
+
+ return {
+ getItem: (name) => baseStorage.getItem(name),
+ setItem: (name, value) => {
+ debouncedSetItem.maybeExecute(name, value);
+ },
+ removeItem: (name) => {
+ debouncedSetItem.cancel();
+ baseStorage.removeItem(name);
+ },
+ flush: () => {
+ debouncedSetItem.flush();
+ },
+ };
+}
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index c446e05240..7d2f0b9044 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -56,11 +56,10 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = {
claudeAgent: "claude-sonnet-4-6",
};
-export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "claude-haiku-4-5" as const;
-
// Backward compatibility for existing Codex-only call sites.
export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex;
export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
+export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "claude-haiku-4-5" as const;
export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = {
codex: {