mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
refactor(app): make use-agent-input-draft storage injectable (#716)
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.
The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
This commit is contained in:
174
packages/app/src/hooks/use-agent-input-draft-core.ts
Normal file
174
packages/app/src/hooks/use-agent-input-draft-core.ts
Normal file
@@ -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<string | null>;
|
||||||
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDraft {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInputDraftCore {
|
||||||
|
load(initialCwd?: string): Promise<StoredDraft | null>;
|
||||||
|
save(draft: StoredDraft): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
}): 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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", () => ({
|
function makeStorage() {
|
||||||
default: {
|
const map = new Map<string, string>();
|
||||||
getItem: async () => null,
|
return {
|
||||||
setItem: async () => undefined,
|
getItem: async (key: string) => map.get(key) ?? null,
|
||||||
removeItem: async () => undefined,
|
setItem: async (key: string, value: string) => {
|
||||||
},
|
map.set(key, value);
|
||||||
}));
|
|
||||||
|
|
||||||
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<string, string>();
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
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("resolveEffectiveComposerModelId", () => {
|
||||||
describe("__private__.resolveDraftKey", () => {
|
it("returns the selected model trimmed", () => {
|
||||||
it("returns an object draft key string unchanged", () => {
|
expect(
|
||||||
expect(
|
resolveEffectiveComposerModelId({
|
||||||
__private__.resolveDraftKey({
|
selectedModel: " gpt-5.4-mini ",
|
||||||
draftKey: "draft:key",
|
availableModels: [],
|
||||||
selectedServerId: "host-1",
|
}),
|
||||||
}),
|
).toBe("gpt-5.4-mini");
|
||||||
).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("__private__.resolveEffectiveComposerModelId", () => {
|
it("returns empty string when no model selected", () => {
|
||||||
const models = [
|
expect(
|
||||||
{
|
resolveEffectiveComposerModelId({
|
||||||
provider: "codex",
|
selectedModel: "",
|
||||||
id: "gpt-5.4",
|
availableModels: [],
|
||||||
label: "gpt-5.4",
|
}),
|
||||||
isDefault: true,
|
).toBe("");
|
||||||
},
|
});
|
||||||
{
|
});
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4-mini",
|
|
||||||
label: "gpt-5.4-mini",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("prefers the selected model when present", () => {
|
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||||
expect(
|
const models = [
|
||||||
__private__.resolveEffectiveComposerModelId({
|
{
|
||||||
selectedModel: "gpt-5.4-mini",
|
provider: "codex",
|
||||||
availableModels: models,
|
id: "gpt-5.4",
|
||||||
}),
|
label: "gpt-5.4",
|
||||||
).toBe("gpt-5.4-mini");
|
isDefault: true,
|
||||||
});
|
defaultThinkingOptionId: "high",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it("returns empty string when no model selected", () => {
|
it("prefers the selected thinking option when present", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.resolveEffectiveComposerModelId({
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
selectedModel: "",
|
selectedThinkingOptionId: "medium",
|
||||||
availableModels: models,
|
availableModels: models,
|
||||||
}),
|
effectiveModelId: "gpt-5.4",
|
||||||
).toBe("");
|
}),
|
||||||
});
|
).toBe("medium");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
it("falls back to the model default thinking option", () => {
|
||||||
const models = [
|
expect(
|
||||||
{
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
|
selectedThinkingOptionId: "",
|
||||||
|
availableModels: models,
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toBe("high");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDraftComposerCommandConfig", () => {
|
||||||
|
it("returns undefined when cwd is empty", () => {
|
||||||
|
expect(
|
||||||
|
buildDraftComposerCommandConfig({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
id: "gpt-5.4",
|
cwd: " ",
|
||||||
label: "gpt-5.4",
|
modeOptions: [],
|
||||||
isDefault: true,
|
selectedMode: "",
|
||||||
defaultThinkingOptionId: "high",
|
effectiveModelId: "gpt-5.4",
|
||||||
thinkingOptions: [
|
effectiveThinkingOptionId: "high",
|
||||||
{ id: "medium", label: "Medium" },
|
}),
|
||||||
{ id: "high", label: "High", isDefault: true },
|
).toBeUndefined();
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
it("builds the draft command config from derived composer state", () => {
|
||||||
it("returns undefined when cwd is empty", () => {
|
expect(
|
||||||
expect(
|
buildDraftComposerCommandConfig({
|
||||||
__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({
|
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
modeId: "auto",
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
model: "gpt-5.4",
|
selectedMode: "auto",
|
||||||
thinkingOptionId: "high",
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ import {
|
|||||||
type UseAgentFormStateResult,
|
type UseAgentFormStateResult,
|
||||||
} from "@/hooks/use-agent-form-state";
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
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 { 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 =
|
type AttachmentUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions {
|
|||||||
lockedWorkingDir?: string;
|
lockedWorkingDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftKeyContext {
|
|
||||||
selectedServerId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
|
||||||
|
|
||||||
interface UseAgentInputDraftInput {
|
interface UseAgentInputDraftInput {
|
||||||
draftKey: DraftKeyInput;
|
draftKey: DraftKeyInput;
|
||||||
initialCwd?: string;
|
initialCwd?: string;
|
||||||
@@ -57,121 +59,6 @@ interface AgentInputDraft {
|
|||||||
composerState: DraftComposerState | null;
|
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<string, unknown>;
|
|
||||||
}): 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 {
|
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||||
const composerOptions = input.composer ?? null;
|
const composerOptions = input.composer ?? null;
|
||||||
const formState = useAgentFormState({
|
const formState = useAgentFormState({
|
||||||
|
|||||||
Reference in New Issue
Block a user