Files
paseo/packages/app/src/composer/draft/input-draft.ts
T
Mohamed Boudra f9fde693cf fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock
Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.

While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.

The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.
2026-11-05 21:55:10 +01:00

392 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandTarget } from "@/agent-commands";
import {
useAgentFormState,
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
import {
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandTarget,
resolveEffectiveComposerModeId,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
import { useShallow } from "zustand/shallow";
import type { ComposerTextSource } from "@/composer/text-source";
import { isWeb } from "@/constants/platform";
const NO_METADATA: Record<string, JsonValue> = {};
type AttachmentUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
interface AgentInputDraftComposerOptions {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>;
/** Which composer a plugin's `useComposer()` reports. */
context?: "draft" | "new-workspace";
isVisible?: boolean;
lockedWorkingDir?: string;
}
interface UseAgentInputDraftInput {
draftKey: DraftKeyInput;
composer?: AgentInputDraftComposerOptions;
}
type DraftComposerState = UseAgentFormStateResult & {
workingDir: string;
effectiveModeId: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined;
/** What plugins set on the agent this draft creates. */
metadata: Record<string, JsonValue>;
agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget;
};
export interface AgentInputDraft {
textSource: ComposerTextSource;
editText: (text: string) => void;
replaceText: (text: string) => void;
textReplacement: TextReplacement;
attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const workingDir = composerOptions?.lockedWorkingDir?.trim() || "";
const formState = useAgentFormState({
workingDir,
serverId: composerOptions?.initialServerId ?? null,
initialValues: composerOptions?.initialValues,
isVisible: composerOptions?.isVisible ?? false,
isCreateFlow: true,
});
const draftKey = useMemo(
() =>
resolveDraftKey({
draftKey: input.draftKey,
selectedServerId: formState.selectedServerId,
}),
[formState.selectedServerId, input.draftKey],
);
const attachments = useDraftStore(
useShallow((state) =>
state.drafts[draftKey]?.lifecycle === "active"
? (state.drafts[draftKey].input.attachments ?? [])
: [],
),
);
const textSource = useMemo<ComposerTextSource>(
() => ({
getSnapshot: () => {
const record = useDraftStore.getState().drafts[draftKey];
return record?.lifecycle === "active" ? record.input.text : "";
},
subscribe: (listener) =>
useDraftStore.subscribe((state, previous) => {
if (
state.drafts[draftKey]?.input.text !== previous.drafts[draftKey]?.input.text ||
state.drafts[draftKey]?.lifecycle !== previous.drafts[draftKey]?.lifecycle
)
listener();
}),
}),
[draftKey],
);
const attachmentFocusRequestId = useDraftStore(
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const isHydrated = hydratedDraftKey === draftKey;
const textReplacementRevisionRef = useRef(0);
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
kind: "initial",
key: `${draftKey}:0`,
text: textSource.getSnapshot(),
}));
const publishTextReplacement = useCallback(
(kind: "hydrated" | "replace", nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind,
key: `${draftKey}:${textReplacementRevisionRef.current}`,
text: nextText,
});
},
[draftKey],
);
const saveDraft = useCallback(
(
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
text: string;
attachments: UserComposerAttachment[];
},
) => {
const store = useDraftStore.getState();
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
const next = update(current);
if (!hasDraftContent(next)) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
return;
}
store.saveDraftInput({ draftKey, draft: next });
},
[draftKey],
);
const textPublication = useMemo(
() =>
new AfterPaintPublication<string>((nextText) => {
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
}),
[draftKey],
);
const editText = useCallback(
(nextText: string) => {
if (isWeb) {
textPublication.stage(nextText);
} else {
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
}
},
[draftKey, textPublication],
);
const replaceText = useCallback(
(nextText: string) => {
textPublication.cancel();
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
publishTextReplacement("replace", nextText);
},
[draftKey, publishTextReplacement, textPublication],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
...current,
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
}));
},
[saveDraft],
);
const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA);
const setMetadata = useCallback(
(patch: Record<string, JsonValue>) =>
useDraftStore.getState().patchDraftMetadata({ draftKey, patch }),
[draftKey],
);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
textPublication.cancel();
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
},
[draftKey, textPublication],
);
useEffect(() => {
const flushWhenHidden = () => {
if (document.visibilityState === "hidden") textPublication.flush();
};
const flush = () => textPublication.flush();
const canListenForPageHide =
isWeb && typeof window !== "undefined" && typeof window.addEventListener === "function";
if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", flushWhenHidden);
}
if (canListenForPageHide) {
window.addEventListener("pagehide", flush);
}
return () => {
if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", flushWhenHidden);
}
if (canListenForPageHide) {
window.removeEventListener("pagehide", flush);
}
textPublication.flush();
};
}, [textPublication]);
useEffect(() => {
let cancelled = false;
void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
publishTextReplacement("hydrated", hydratedText);
setHydratedDraftKey(draftKey);
}
})();
return () => {
cancelled = true;
};
}, [draftKey, publishTextReplacement]);
const providerSelection = useMemo<ProviderSelectionState>(
() => ({
provider: formState.selectedProvider,
modelId: formState.selectedModel,
modeId: formState.selectedMode,
thinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
modeOptions: formState.modeOptions,
}),
[
formState.availableModels,
formState.modeOptions,
formState.selectedMode,
formState.selectedModel,
formState.selectedProvider,
formState.selectedThinkingOptionId,
],
);
const effectiveModeId = useMemo(
() => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading),
[formState.isModelLoading, providerSelection],
);
const effectiveModelId = useMemo(
() => resolveEffectiveComposerModelId(providerSelection),
[providerSelection],
);
const effectiveThinkingOptionId = useMemo(
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
[effectiveModelId, providerSelection],
);
const {
optionControls: draftOptionControls,
options: draftOptions,
shownOptions: draftShownOptions,
setOption: setDraftOption,
applyProfile: applyProfileOptions,
} = useDraftProviderOptions({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
getProvider: formState.getSelectedProvider,
cwd: workingDir,
modeId: formState.selectedMode,
modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId,
initialOptions: composerOptions?.initialProviderOptions,
});
const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile);
applyProfileOptions({ provider: profile.provider, options: profile.options });
},
[applyProfileOptions, formState],
);
const commandDraft = useMemo(
() =>
buildDraftCommandTarget({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
}),
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir],
);
const composerState = useMemo<DraftComposerState | null>(() => {
if (!composerOptions) {
return null;
}
return {
...formState,
workingDir,
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
metadata,
agentControls: buildDraftAgentControls({
formState,
optionControls: draftOptionControls,
options: draftShownOptions,
onSetOption: setDraftOption,
onApplyAgentProfile: applyDraftAgentProfile,
context: composerOptions.context ?? "draft",
metadata,
onSetMetadata: setMetadata,
}),
commandDraft,
};
}, [
commandDraft,
composerOptions,
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
draftOptionControls,
draftOptions,
draftShownOptions,
setDraftOption,
applyDraftAgentProfile,
formState,
metadata,
setMetadata,
workingDir,
]);
return {
textSource,
editText,
replaceText,
textReplacement,
attachments,
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
};
}
export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModeId,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandTarget,
buildDraftAgentControls,
};