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.
319 lines
8.6 KiB
TypeScript
319 lines
8.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { UserComposerAttachment } from "@/attachments/types";
|
|
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
|
import {
|
|
useAgentFormState,
|
|
type CreateAgentInitialValues,
|
|
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";
|
|
|
|
type AttachmentUpdater =
|
|
| UserComposerAttachment[]
|
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
|
|
|
interface AgentInputDraftComposerOptions {
|
|
initialServerId: string | null;
|
|
initialValues?: CreateAgentInitialValues;
|
|
isVisible?: boolean;
|
|
onlineServerIds?: string[];
|
|
lockedWorkingDir?: string;
|
|
}
|
|
|
|
interface UseAgentInputDraftInput {
|
|
draftKey: DraftKeyInput;
|
|
initialCwd?: string;
|
|
composer?: AgentInputDraftComposerOptions;
|
|
}
|
|
|
|
type DraftComposerState = UseAgentFormStateResult & {
|
|
workingDir: string;
|
|
effectiveModelId: string;
|
|
effectiveThinkingOptionId: string;
|
|
featureValues: Record<string, unknown> | undefined;
|
|
statusControls: DraftAgentStatusBarProps;
|
|
commandDraftConfig: DraftCommandConfig | undefined;
|
|
};
|
|
|
|
interface AgentInputDraft {
|
|
text: string;
|
|
setText: (text: string) => void;
|
|
attachments: UserComposerAttachment[];
|
|
setAttachments: (updater: AttachmentUpdater) => void;
|
|
cwd: string;
|
|
setCwd: (cwd: string) => void;
|
|
clear: (lifecycle: "sent" | "abandoned") => void;
|
|
isHydrated: boolean;
|
|
composerState: DraftComposerState | null;
|
|
}
|
|
|
|
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
|
const composerOptions = input.composer ?? null;
|
|
const formState = useAgentFormState({
|
|
initialServerId: composerOptions?.initialServerId ?? null,
|
|
initialValues: composerOptions?.initialValues,
|
|
isVisible: composerOptions?.isVisible ?? false,
|
|
isCreateFlow: true,
|
|
onlineServerIds: composerOptions?.onlineServerIds ?? [],
|
|
});
|
|
const draftKey = useMemo(
|
|
() =>
|
|
resolveDraftKey({
|
|
draftKey: input.draftKey,
|
|
selectedServerId: formState.selectedServerId,
|
|
}),
|
|
[formState.selectedServerId, input.draftKey],
|
|
);
|
|
const [text, setText] = useState("");
|
|
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
|
const [cwd, setCwd] = useState(input.initialCwd ?? "");
|
|
const [isHydrated, setIsHydrated] = useState(false);
|
|
const draftGenerationRef = useRef(0);
|
|
const hydratedGenerationRef = useRef(0);
|
|
|
|
const setAttachments = useCallback((updater: AttachmentUpdater) => {
|
|
setAttachmentsState((previousAttachments) => {
|
|
if (typeof updater === "function") {
|
|
return updater(previousAttachments);
|
|
}
|
|
return updater;
|
|
});
|
|
}, []);
|
|
|
|
const clear = useCallback(
|
|
(lifecycle: "sent" | "abandoned") => {
|
|
const store = useDraftStore.getState();
|
|
store.clearDraftInput({ draftKey, lifecycle });
|
|
|
|
const generation = store.beginDraftGeneration(draftKey);
|
|
draftGenerationRef.current = generation;
|
|
hydratedGenerationRef.current = generation;
|
|
|
|
setText("");
|
|
setAttachmentsState([]);
|
|
setCwd("");
|
|
setIsHydrated(true);
|
|
},
|
|
[draftKey],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const store = useDraftStore.getState();
|
|
const generation = store.beginDraftGeneration(draftKey);
|
|
draftGenerationRef.current = generation;
|
|
hydratedGenerationRef.current = 0;
|
|
|
|
setText("");
|
|
setAttachmentsState([]);
|
|
setCwd(input.initialCwd ?? "");
|
|
setIsHydrated(false);
|
|
|
|
let cancelled = false;
|
|
|
|
void (async () => {
|
|
const draft = await store.hydrateDraftInput({
|
|
draftKey,
|
|
initialCwd: input.initialCwd,
|
|
});
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
|
return;
|
|
}
|
|
|
|
if (draft) {
|
|
setText(draft.text);
|
|
setAttachmentsState(draft.attachments);
|
|
setCwd(draft.cwd);
|
|
}
|
|
|
|
hydratedGenerationRef.current = generation;
|
|
setIsHydrated(true);
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [draftKey, input.initialCwd]);
|
|
|
|
useEffect(() => {
|
|
const currentGeneration = draftGenerationRef.current;
|
|
if (currentGeneration <= 0) {
|
|
return;
|
|
}
|
|
|
|
const store = useDraftStore.getState();
|
|
const isCurrentGeneration = store.isDraftGenerationCurrent({
|
|
draftKey,
|
|
generation: currentGeneration,
|
|
});
|
|
if (!isCurrentGeneration) {
|
|
return;
|
|
}
|
|
if (hydratedGenerationRef.current !== currentGeneration) {
|
|
return;
|
|
}
|
|
|
|
const existing = store.getDraftInput(draftKey);
|
|
const isSameDraft =
|
|
existing !== undefined &&
|
|
existing.text === text &&
|
|
existing.cwd === cwd &&
|
|
areAttachmentsEqual({
|
|
left: existing.attachments,
|
|
right: attachments,
|
|
});
|
|
if (isSameDraft) {
|
|
return;
|
|
}
|
|
|
|
if (!hasDraftContent({ text, attachments, cwd })) {
|
|
if (existing) {
|
|
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
store.saveDraftInput({
|
|
draftKey,
|
|
draft: {
|
|
text,
|
|
attachments,
|
|
cwd,
|
|
},
|
|
});
|
|
}, [attachments, cwd, draftKey, text]);
|
|
|
|
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
|
useEffect(() => {
|
|
if (!composerOptions || !lockedWorkingDir) {
|
|
return;
|
|
}
|
|
if (formState.workingDir.trim() === lockedWorkingDir) {
|
|
return;
|
|
}
|
|
formState.setWorkingDir(lockedWorkingDir);
|
|
}, [composerOptions, formState, lockedWorkingDir]);
|
|
|
|
const effectiveModelId = useMemo(
|
|
() =>
|
|
resolveEffectiveComposerModelId({
|
|
selectedModel: formState.selectedModel,
|
|
availableModels: formState.availableModels,
|
|
}),
|
|
[formState.availableModels, formState.selectedModel],
|
|
);
|
|
|
|
const effectiveThinkingOptionId = useMemo(
|
|
() =>
|
|
resolveEffectiveComposerThinkingOptionId({
|
|
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
|
availableModels: formState.availableModels,
|
|
effectiveModelId,
|
|
}),
|
|
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
|
);
|
|
|
|
const workingDir = lockedWorkingDir || formState.workingDir;
|
|
const {
|
|
features: draftFeatures,
|
|
featureValues: draftFeatureValues,
|
|
setFeatureValue: setDraftFeatureValue,
|
|
} = useDraftAgentFeatures({
|
|
serverId: formState.selectedServerId,
|
|
provider: formState.selectedProvider,
|
|
cwd: workingDir,
|
|
modeId: formState.selectedMode,
|
|
modelId: effectiveModelId,
|
|
thinkingOptionId: effectiveThinkingOptionId,
|
|
});
|
|
|
|
const commandDraftConfig = useMemo(
|
|
() =>
|
|
composerOptions
|
|
? buildDraftComposerCommandConfig({
|
|
provider: formState.selectedProvider,
|
|
cwd: workingDir,
|
|
modeOptions: formState.modeOptions,
|
|
selectedMode: formState.selectedMode,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
featureValues: draftFeatureValues,
|
|
})
|
|
: undefined,
|
|
[
|
|
composerOptions,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
draftFeatureValues,
|
|
workingDir,
|
|
formState.modeOptions,
|
|
formState.selectedMode,
|
|
formState.selectedProvider,
|
|
],
|
|
);
|
|
|
|
const composerState = useMemo<DraftComposerState | null>(() => {
|
|
if (!composerOptions) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...formState,
|
|
workingDir,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
featureValues: draftFeatureValues,
|
|
statusControls: buildDraftStatusControls({
|
|
formState,
|
|
features: draftFeatures,
|
|
onSetFeature: setDraftFeatureValue,
|
|
}),
|
|
commandDraftConfig,
|
|
};
|
|
}, [
|
|
commandDraftConfig,
|
|
composerOptions,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
draftFeatures,
|
|
draftFeatureValues,
|
|
formState,
|
|
setDraftFeatureValue,
|
|
workingDir,
|
|
]);
|
|
|
|
return {
|
|
text,
|
|
setText,
|
|
attachments,
|
|
setAttachments,
|
|
cwd,
|
|
setCwd,
|
|
clear,
|
|
isHydrated,
|
|
composerState,
|
|
};
|
|
}
|
|
|
|
export const __private__ = {
|
|
resolveDraftKey,
|
|
resolveEffectiveComposerModelId,
|
|
resolveEffectiveComposerThinkingOptionId,
|
|
buildDraftComposerCommandConfig,
|
|
buildDraftStatusControls,
|
|
};
|