From b500a36a94524f9829b0675e602789c4a56f6477 Mon Sep 17 00:00:00 2001
From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>
Date: Wed, 18 Mar 2026 22:16:09 +0530
Subject: [PATCH 1/2] fix: implement button overflow (#1193)
---
apps/web/src/components/ChatView.tsx | 2 +-
.../src/components/chat/CodexTraitsPicker.tsx | 25 ++++++++++++-------
.../components/chat/ProviderModelPicker.tsx | 13 ++++++----
3 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 77cdb0ea19..baba4b0cb8 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -3695,7 +3695,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
"flex min-w-0 flex-1 items-center",
isComposerFooterCompact
? "gap-1 overflow-hidden"
- : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
+ : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{/* Provider/model picker */}
diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx
index 6c72f497ba..a8a65250ed 100644
--- a/apps/web/src/components/chat/CodexTraitsPicker.tsx
+++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx
@@ -28,12 +28,7 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
high: "High",
xhigh: "Extra High",
};
- const triggerLabel = [
- reasoningLabelByOption[props.effort],
- ...(props.fastModeEnabled ? ["Fast"] : []),
- ]
- .filter(Boolean)
- .join(" · ");
+ const effortLabel = reasoningLabelByOption[props.effort];
return (
}
>
- {triggerLabel}
-
+
+ {props.fastModeEnabled ? (
+
+ {effortLabel}
+
+ ·
+
+ Fast
+
+ ) : (
+ {effortLabel}
+ )}
+
+
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 9bc034991e..0db4cdf289 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -106,19 +106,22 @@ 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}
+
From 321251907a296b1d0932a42bc20ab2c08f8015ad Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Wed, 18 Mar 2026 11:00:07 -0700
Subject: [PATCH 2/2] refactor storage
---
apps/web/src/composerDraftStore.test.ts | 93 ++++++-
apps/web/src/composerDraftStore.ts | 348 +++++++++++-------------
apps/web/src/lib/storage.ts | 50 ++++
3 files changed, 303 insertions(+), 188 deletions(-)
create mode 100644 apps/web/src/lib/storage.ts
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 1ca4108484..7733bfd62f 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,64 +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;
-}
+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 LegacyCodexFields {
- effort?: CodexReasoningEffort | null;
- codexFastMode?: boolean | null;
- serviceTier?: string | null;
-}
+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 LegacyPersistedCodexThreadDraftState
- extends PersistedComposerThreadDraftState, LegacyCodexFields {}
+type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields;
-interface PersistedDraftThreadState {
- projectId: ProjectId;
- createdAt: string;
- runtimeMode: RuntimeMode;
- interactionMode: ProviderInteractionMode;
- branch: string | null;
- worktreePath: string | null;
- envMode: DraftThreadEnvMode;
-}
+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;
-interface PersistedComposerDraftStoreState {
- draftsByThreadId: Record;
- draftThreadsByThreadId: Record;
- projectDraftThreadIdByProjectId: Record;
-}
+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;
@@ -512,7 +500,7 @@ function normalizePersistedDraftThreads(
PersistedComposerDraftStoreState,
"draftThreadsByThreadId" | "projectDraftThreadIdByProjectId"
> {
- const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {};
+ const draftThreadsByThreadId: Record = {};
if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") {
for (const [threadId, rawDraftThread] of Object.entries(
rawDraftThreadsByThreadId as Record,
@@ -555,8 +543,7 @@ function normalizePersistedDraftThreads(
}
}
- const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
- {};
+ const projectDraftThreadIdByProjectId: Record = {};
if (
rawProjectDraftThreadIdByProjectId &&
typeof rawProjectDraftThreadIdByProjectId === "object"
@@ -605,7 +592,8 @@ function normalizePersistedDraftsByThreadId(
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;
@@ -706,44 +694,80 @@ function migratePersistedComposerDraftStoreState(
};
}
-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 };
- const version = typeof candidate.version === "number" ? candidate.version : 0;
- if (version !== COMPOSER_DRAFT_STORAGE_VERSION) {
- return EMPTY_PERSISTED_DRAFT_STORE_STATE;
- }
- const persistedState = candidate.state;
- 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,
- };
+ const persistedDraftsByThreadId: DeepMutable<
+ PersistedComposerDraftStoreState["draftsByThreadId"]
+ > = {};
+ for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
+ if (typeof threadId !== "string" || threadId.length === 0) {
+ continue;
}
- return EMPTY_PERSISTED_DRAFT_STORE_STATE;
- } 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[] {
@@ -751,9 +775,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 {
@@ -794,7 +823,7 @@ function hydreatePersistedComposerImageAttachment(
}
function hydrateImagesFromPersisted(
- attachments: PersistedComposerImageAttachment[],
+ attachments: ReadonlyArray,
): ComposerImageAttachment[] {
return attachments.flatMap((attachment) => {
const file = hydreatePersistedComposerImageAttachment(attachment);
@@ -821,7 +850,7 @@ function toHydratedThreadDraft(
prompt: persistedDraft.prompt,
images: hydrateImagesFromPersisted(persistedDraft.attachments),
nonPersistedImageIds: [],
- persistedAttachments: persistedDraft.attachments,
+ persistedAttachments: [...persistedDraft.attachments],
terminalContexts:
persistedDraft.terminalContexts?.map((context) => ({
...context,
@@ -1599,66 +1628,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();
+ },
+ };
+}