diff --git a/packages/app/src/hooks/use-agent-input-draft-core.ts b/packages/app/src/hooks/use-agent-input-draft-core.ts new file mode 100644 index 000000000..f6a6d8e1a --- /dev/null +++ b/packages/app/src/hooks/use-agent-input-draft-core.ts @@ -0,0 +1,174 @@ +import type { UserComposerAttachment } from "@/attachments/types"; +import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; + +export interface DraftStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; +} + +export interface StoredDraft { + text: string; + attachments: UserComposerAttachment[]; + cwd: string; +} + +export interface AgentInputDraftCore { + load(initialCwd?: string): Promise; + save(draft: StoredDraft): Promise; + clear(): Promise; +} + +export function createAgentInputDraftCore(input: { + storage: DraftStorage; + storageKey: string; +}): AgentInputDraftCore { + return { + async load(initialCwd?: string) { + const json = await input.storage.getItem(input.storageKey); + if (!json) return null; + try { + const parsed = JSON.parse(json) as Record; + return { + text: typeof parsed.text === "string" ? parsed.text : "", + attachments: Array.isArray(parsed.attachments) + ? (parsed.attachments as UserComposerAttachment[]) + : [], + cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""), + }; + } catch { + return null; + } + }, + async save(draft) { + await input.storage.setItem(input.storageKey, JSON.stringify(draft)); + }, + async clear() { + await input.storage.removeItem(input.storageKey); + }, + }; +} + +export interface DraftKeyContext { + selectedServerId: string | null; +} + +export type DraftKeyInput = string | ((context: DraftKeyContext) => string); + +export function resolveDraftKey(input: { + draftKey: DraftKeyInput; + selectedServerId: string | null; +}): string { + if (typeof input.draftKey === "function") { + return input.draftKey({ selectedServerId: input.selectedServerId }); + } + return input.draftKey; +} + +export function resolveEffectiveComposerModelId(input: { + selectedModel: string; + availableModels: AgentModelDefinition[]; +}): string { + return input.selectedModel.trim(); +} + +export function resolveEffectiveComposerThinkingOptionId(input: { + selectedThinkingOptionId: string; + availableModels: AgentModelDefinition[]; + effectiveModelId: string; +}): string { + const selectedThinkingOptionId = input.selectedThinkingOptionId.trim(); + if (selectedThinkingOptionId) { + return selectedThinkingOptionId; + } + + const selectedModelDefinition = + input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null; + return selectedModelDefinition?.defaultThinkingOptionId ?? ""; +} + +export function buildDraftComposerCommandConfig(input: { + provider: AgentProvider | null; + cwd: string; + modeOptions: DraftAgentStatusBarProps["modeOptions"]; + selectedMode: string; + effectiveModelId: string; + effectiveThinkingOptionId: string; + featureValues?: Record; +}): DraftCommandConfig | undefined { + const cwd = input.cwd.trim(); + if (!input.provider || !cwd) { + return undefined; + } + + return { + provider: input.provider, + cwd, + ...(input.modeOptions.length > 0 && input.selectedMode !== "" + ? { modeId: input.selectedMode } + : {}), + ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), + ...(input.effectiveThinkingOptionId + ? { thinkingOptionId: input.effectiveThinkingOptionId } + : {}), + ...(input.featureValues ? { featureValues: input.featureValues } : {}), + }; +} + +export function buildDraftStatusControls(input: { + formState: UseAgentFormStateResult; + features?: DraftAgentStatusBarProps["features"]; + onSetFeature?: DraftAgentStatusBarProps["onSetFeature"]; + onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"]; +}): DraftAgentStatusBarProps { + const { formState, features, onSetFeature, onDropdownClose } = input; + return { + providerDefinitions: formState.providerDefinitions, + selectedProvider: formState.selectedProvider, + onSelectProvider: formState.setProviderFromUser, + modeOptions: formState.modeOptions, + selectedMode: formState.selectedMode, + onSelectMode: formState.setModeFromUser, + models: formState.availableModels, + selectedModel: formState.selectedModel, + onSelectModel: formState.setModelFromUser, + isModelLoading: formState.isModelLoading, + allProviderModels: formState.allProviderModels, + isAllModelsLoading: formState.isAllModelsLoading, + onSelectProviderAndModel: formState.setProviderAndModelFromUser, + thinkingOptions: formState.availableThinkingOptions, + selectedThinkingOptionId: formState.selectedThinkingOptionId, + onSelectThinkingOption: formState.setThinkingOptionFromUser, + features, + onSetFeature, + onDropdownClose, + onModelSelectorOpen: formState.refetchProviderModelsIfStale, + }; +} + +export function hasDraftContent(input: { + text: string; + attachments: UserComposerAttachment[]; + cwd: string; +}): boolean { + return ( + input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0 + ); +} + +export function areAttachmentsEqual(input: { + left: UserComposerAttachment[]; + right: UserComposerAttachment[]; +}): boolean { + if (input.left.length !== input.right.length) { + return false; + } + + return input.left.every((attachment, index) => { + const other = input.right[index]; + return JSON.stringify(attachment) === JSON.stringify(other); + }); +} diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts index b766b0ffd..b373626fb 100644 --- a/packages/app/src/hooks/use-agent-input-draft.test.ts +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -1,194 +1,188 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { + buildDraftComposerCommandConfig, + createAgentInputDraftCore, + resolveDraftKey, + resolveEffectiveComposerModelId, + resolveEffectiveComposerThinkingOptionId, +} from "./use-agent-input-draft-core"; -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: async () => null, - setItem: async () => undefined, - removeItem: async () => undefined, - }, -})); - -vi.mock("@/attachments/service", () => ({ - garbageCollectAttachments: async () => undefined, -})); - -vi.mock("./use-agent-form-state", () => ({ - useAgentFormState: () => ({ - selectedServerId: "host-1", - setSelectedServerId: () => undefined, - setSelectedServerIdFromUser: () => undefined, - selectedProvider: "codex", - setProviderFromUser: () => undefined, - selectedMode: "auto", - setModeFromUser: () => undefined, - selectedModel: "", - setModelFromUser: () => undefined, - selectedThinkingOptionId: "", - setThinkingOptionFromUser: () => undefined, - workingDir: "/repo", - setWorkingDir: () => undefined, - setWorkingDirFromUser: () => undefined, - providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], - providerDefinitionMap: new Map(), - agentDefinition: undefined, - modeOptions: [{ id: "auto", label: "Auto" }], - availableModels: [], - allProviderModels: new Map(), - isAllModelsLoading: false, - availableThinkingOptions: [], - isModelLoading: false, - modelError: null, - refreshProviderModels: () => undefined, - setProviderAndModelFromUser: () => undefined, - workingDirIsEmpty: false, - persistFormPreferences: async () => undefined, - }), -})); - -let __private__: typeof import("./use-agent-input-draft").__private__; - -beforeAll(async () => { - const storage = new Map(); - Object.defineProperty(globalThis, "window", { - value: { - localStorage: { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - storage.set(key, value); - }, - removeItem: (key: string) => { - storage.delete(key); - }, - }, +function makeStorage() { + const map = new Map(); + return { + getItem: async (key: string) => map.get(key) ?? null, + setItem: async (key: string, value: string) => { + map.set(key, value); }, - configurable: true, + removeItem: async (key: string) => { + map.delete(key); + }, + }; +} + +describe("resolveDraftKey", () => { + it("returns a string draft key unchanged", () => { + expect( + resolveDraftKey({ + draftKey: "draft:key", + selectedServerId: "host-1", + }), + ).toBe("draft:key"); }); - ({ __private__ } = await import("./use-agent-input-draft")); + it("resolves a computed draft key from the selected server", () => { + expect( + resolveDraftKey({ + draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`, + selectedServerId: "host-1", + }), + ).toBe("draft:host-1"); + }); }); -describe("useAgentInputDraft", () => { - describe("__private__.resolveDraftKey", () => { - it("returns an object draft key string unchanged", () => { - expect( - __private__.resolveDraftKey({ - draftKey: "draft:key", - selectedServerId: "host-1", - }), - ).toBe("draft:key"); - }); - - it("resolves a computed draft key from the selected server", () => { - expect( - __private__.resolveDraftKey({ - draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`, - selectedServerId: "host-1", - }), - ).toBe("draft:host-1"); - }); +describe("resolveEffectiveComposerModelId", () => { + it("returns the selected model trimmed", () => { + expect( + resolveEffectiveComposerModelId({ + selectedModel: " gpt-5.4-mini ", + availableModels: [], + }), + ).toBe("gpt-5.4-mini"); }); - describe("__private__.resolveEffectiveComposerModelId", () => { - const models = [ - { - provider: "codex", - id: "gpt-5.4", - label: "gpt-5.4", - isDefault: true, - }, - { - provider: "codex", - id: "gpt-5.4-mini", - label: "gpt-5.4-mini", - }, - ]; + it("returns empty string when no model selected", () => { + expect( + resolveEffectiveComposerModelId({ + selectedModel: "", + availableModels: [], + }), + ).toBe(""); + }); +}); - it("prefers the selected model when present", () => { - expect( - __private__.resolveEffectiveComposerModelId({ - selectedModel: "gpt-5.4-mini", - availableModels: models, - }), - ).toBe("gpt-5.4-mini"); - }); +describe("resolveEffectiveComposerThinkingOptionId", () => { + const models = [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ]; - it("returns empty string when no model selected", () => { - expect( - __private__.resolveEffectiveComposerModelId({ - selectedModel: "", - availableModels: models, - }), - ).toBe(""); - }); + it("prefers the selected thinking option when present", () => { + expect( + resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: "medium", + availableModels: models, + effectiveModelId: "gpt-5.4", + }), + ).toBe("medium"); }); - describe("__private__.resolveEffectiveComposerThinkingOptionId", () => { - const models = [ - { + it("falls back to the model default thinking option", () => { + expect( + resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: "", + availableModels: models, + effectiveModelId: "gpt-5.4", + }), + ).toBe("high"); + }); +}); + +describe("buildDraftComposerCommandConfig", () => { + it("returns undefined when cwd is empty", () => { + expect( + buildDraftComposerCommandConfig({ provider: "codex", - id: "gpt-5.4", - label: "gpt-5.4", - isDefault: true, - defaultThinkingOptionId: "high", - thinkingOptions: [ - { id: "medium", label: "Medium" }, - { id: "high", label: "High", isDefault: true }, - ], - }, - ]; - - it("prefers the selected thinking option when present", () => { - expect( - __private__.resolveEffectiveComposerThinkingOptionId({ - selectedThinkingOptionId: "medium", - availableModels: models, - effectiveModelId: "gpt-5.4", - }), - ).toBe("medium"); - }); - - it("falls back to the model default thinking option", () => { - expect( - __private__.resolveEffectiveComposerThinkingOptionId({ - selectedThinkingOptionId: "", - availableModels: models, - effectiveModelId: "gpt-5.4", - }), - ).toBe("high"); - }); + cwd: " ", + modeOptions: [], + selectedMode: "", + effectiveModelId: "gpt-5.4", + effectiveThinkingOptionId: "high", + }), + ).toBeUndefined(); }); - describe("__private__.buildDraftComposerCommandConfig", () => { - it("returns undefined when cwd is empty", () => { - expect( - __private__.buildDraftComposerCommandConfig({ - provider: "codex", - cwd: " ", - modeOptions: [], - selectedMode: "", - effectiveModelId: "gpt-5.4", - effectiveThinkingOptionId: "high", - }), - ).toBeUndefined(); - }); - - it("builds the draft command config from derived composer state", () => { - expect( - __private__.buildDraftComposerCommandConfig({ - provider: "codex", - cwd: "/repo", - modeOptions: [{ id: "auto", label: "Auto" }], - selectedMode: "auto", - effectiveModelId: "gpt-5.4", - effectiveThinkingOptionId: "high", - }), - ).toEqual({ + it("builds the draft command config from derived composer state", () => { + expect( + buildDraftComposerCommandConfig({ provider: "codex", cwd: "/repo", - modeId: "auto", - model: "gpt-5.4", - thinkingOptionId: "high", - }); + modeOptions: [{ id: "auto", label: "Auto" }], + selectedMode: "auto", + effectiveModelId: "gpt-5.4", + effectiveThinkingOptionId: "high", + }), + ).toEqual({ + provider: "codex", + cwd: "/repo", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", }); }); }); + +describe("createAgentInputDraftCore", () => { + it("load returns null when nothing has been saved", async () => { + const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); + expect(await core.load()).toBeNull(); + }); + + it("load returns the draft that was saved", async () => { + const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); + await core.save({ text: "hello world", attachments: [], cwd: "/repo" }); + expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" }); + }); + + it("clear removes the persisted draft", async () => { + const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); + await core.save({ text: "hello", attachments: [], cwd: "/repo" }); + await core.clear(); + expect(await core.load()).toBeNull(); + }); + + it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => { + const storage = makeStorage(); + await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] })); + + const core = createAgentInputDraftCore({ storage, storageKey: "test" }); + const draft = await core.load("/initial"); + expect(draft?.cwd).toBe("/initial"); + }); + + it("round-trips attachments unchanged", async () => { + const attachment = { + kind: "github_issue" as const, + item: { + kind: "issue" as const, + number: 42, + title: "Unify attachments", + url: "https://github.com/example/repo/issues/42", + state: "open" as const, + body: "body", + labels: ["composer"], + }, + }; + const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" }); + await core.save({ text: "", attachments: [attachment], cwd: "/repo" }); + const draft = await core.load(); + expect(draft?.attachments).toEqual([attachment]); + }); + + it("isolated keys do not share state", async () => { + const storage = makeStorage(); + const a = createAgentInputDraftCore({ storage, storageKey: "key-a" }); + const b = createAgentInputDraftCore({ storage, storageKey: "key-b" }); + await a.save({ text: "from a", attachments: [], cwd: "" }); + expect(await b.load()).toBeNull(); + expect(await a.load()).toMatchObject({ text: "from a" }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 0fab4135b..d1b0c1ef9 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -8,9 +8,17 @@ import { type UseAgentFormStateResult, } from "@/hooks/use-agent-form-state"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; +import { + areAttachmentsEqual, + buildDraftComposerCommandConfig, + buildDraftStatusControls, + hasDraftContent, + resolveDraftKey, + resolveEffectiveComposerModelId, + resolveEffectiveComposerThinkingOptionId, + type DraftKeyInput, +} from "@/hooks/use-agent-input-draft-core"; import { useDraftStore } from "@/stores/draft-store"; -import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; type AttachmentUpdater = | UserComposerAttachment[] @@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions { lockedWorkingDir?: string; } -interface DraftKeyContext { - selectedServerId: string | null; -} - -type DraftKeyInput = string | ((context: DraftKeyContext) => string); - interface UseAgentInputDraftInput { draftKey: DraftKeyInput; initialCwd?: string; @@ -57,121 +59,6 @@ interface AgentInputDraft { composerState: DraftComposerState | null; } -function hasDraftContent(input: { - text: string; - attachments: UserComposerAttachment[]; - cwd: string; -}): boolean { - return ( - input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0 - ); -} - -function areAttachmentsEqual(input: { - left: UserComposerAttachment[]; - right: UserComposerAttachment[]; -}): boolean { - if (input.left.length !== input.right.length) { - return false; - } - - return input.left.every((attachment, index) => { - const other = input.right[index]; - return JSON.stringify(attachment) === JSON.stringify(other); - }); -} - -function resolveDraftKey(input: { - draftKey: DraftKeyInput; - selectedServerId: string | null; -}): string { - if (typeof input.draftKey === "function") { - return input.draftKey({ selectedServerId: input.selectedServerId }); - } - return input.draftKey; -} - -function resolveEffectiveComposerModelId(input: { - selectedModel: string; - availableModels: AgentModelDefinition[]; -}): string { - return input.selectedModel.trim(); -} - -function resolveEffectiveComposerThinkingOptionId(input: { - selectedThinkingOptionId: string; - availableModels: AgentModelDefinition[]; - effectiveModelId: string; -}): string { - const selectedThinkingOptionId = input.selectedThinkingOptionId.trim(); - if (selectedThinkingOptionId) { - return selectedThinkingOptionId; - } - - const selectedModelDefinition = - input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null; - return selectedModelDefinition?.defaultThinkingOptionId ?? ""; -} - -function buildDraftComposerCommandConfig(input: { - provider: AgentProvider | null; - cwd: string; - modeOptions: DraftAgentStatusBarProps["modeOptions"]; - selectedMode: string; - effectiveModelId: string; - effectiveThinkingOptionId: string; - featureValues?: Record; -}): DraftCommandConfig | undefined { - const cwd = input.cwd.trim(); - if (!input.provider || !cwd) { - return undefined; - } - - return { - provider: input.provider, - cwd, - ...(input.modeOptions.length > 0 && input.selectedMode !== "" - ? { modeId: input.selectedMode } - : {}), - ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), - ...(input.effectiveThinkingOptionId - ? { thinkingOptionId: input.effectiveThinkingOptionId } - : {}), - ...(input.featureValues ? { featureValues: input.featureValues } : {}), - }; -} - -function buildDraftStatusControls(input: { - formState: UseAgentFormStateResult; - features?: DraftAgentStatusBarProps["features"]; - onSetFeature?: DraftAgentStatusBarProps["onSetFeature"]; - onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"]; -}): DraftAgentStatusBarProps { - const { formState, features, onSetFeature, onDropdownClose } = input; - return { - providerDefinitions: formState.providerDefinitions, - selectedProvider: formState.selectedProvider, - onSelectProvider: formState.setProviderFromUser, - modeOptions: formState.modeOptions, - selectedMode: formState.selectedMode, - onSelectMode: formState.setModeFromUser, - models: formState.availableModels, - selectedModel: formState.selectedModel, - onSelectModel: formState.setModelFromUser, - isModelLoading: formState.isModelLoading, - allProviderModels: formState.allProviderModels, - isAllModelsLoading: formState.isAllModelsLoading, - onSelectProviderAndModel: formState.setProviderAndModelFromUser, - thinkingOptions: formState.availableThinkingOptions, - selectedThinkingOptionId: formState.selectedThinkingOptionId, - onSelectThinkingOption: formState.setThinkingOptionFromUser, - features, - onSetFeature, - onDropdownClose, - onModelSelectorOpen: formState.refetchProviderModelsIfStale, - }; -} - export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { const composerOptions = input.composer ?? null; const formState = useAgentFormState({