mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
* perf(app): keep composer typing responsive The controlled web textarea pushed every keystroke through the draft store and broad composer tree before paint. Keep live text in the focused DOM input and publish draft and selection state after paint so persistence and reactive features remain intact without blocking the typed character. * fix(app): preserve live composer state at actions * fix(app): use live input for autocomplete clicks * fix(app): keep submit activity atomic
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import type { UserComposerAttachment } from "@/attachments/types";
|
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
|
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 {
|
|
buildDraftAgentControls,
|
|
hasDraftContent,
|
|
resolveDraftKey,
|
|
type DraftKeyInput,
|
|
} from "@/composer/draft/input-draft-core";
|
|
import {
|
|
buildDraftCommandConfig,
|
|
resolveEffectiveComposerModelId,
|
|
resolveEffectiveComposerThinkingOptionId,
|
|
type ProviderSelectionState,
|
|
} from "@/provider-selection/provider-selection";
|
|
import { useDraftStore } from "@/stores/draft-store";
|
|
import { toDraftInputIfReady } from "@/stores/draft-store/state";
|
|
import { AfterPaintPublication } from "@/composer/after-paint-publication";
|
|
import { isWeb } from "@/constants/platform";
|
|
|
|
type AttachmentUpdater =
|
|
| UserComposerAttachment[]
|
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
|
|
|
interface AgentInputDraftComposerOptions {
|
|
initialServerId: string | null;
|
|
initialValues?: CreateAgentInitialValues;
|
|
initialFeatureValues?: Record<string, unknown>;
|
|
isVisible?: boolean;
|
|
onlineServerIds?: string[];
|
|
lockedWorkingDir?: string;
|
|
}
|
|
|
|
interface UseAgentInputDraftInput {
|
|
draftKey: DraftKeyInput;
|
|
composer?: AgentInputDraftComposerOptions;
|
|
}
|
|
|
|
type DraftComposerState = UseAgentFormStateResult & {
|
|
workingDir: string;
|
|
effectiveModelId: string;
|
|
effectiveThinkingOptionId: string;
|
|
featureValues: Record<string, unknown> | undefined;
|
|
agentControls: DraftAgentControlsProps;
|
|
commandDraftConfig: DraftCommandConfig | undefined;
|
|
};
|
|
|
|
export interface AgentInputDraft {
|
|
text: string;
|
|
editText: (text: string) => void;
|
|
replaceText: (text: string) => void;
|
|
textReplacementKey: string;
|
|
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 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 draftRecord = useDraftStore((state) => state.drafts[draftKey]);
|
|
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
|
|
const attachmentFocusRequestId = useDraftStore(
|
|
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
|
|
);
|
|
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
|
const [textReplacementRevision, setTextReplacementRevision] = useState(0);
|
|
const text = draft?.text ?? "";
|
|
const attachments = draft?.attachments ?? [];
|
|
const isHydrated = hydratedDraftKey === 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) => {
|
|
saveDraft((current) => ({ ...current, text: nextText }));
|
|
}),
|
|
[saveDraft],
|
|
);
|
|
|
|
const editText = useCallback(
|
|
(nextText: string) => {
|
|
if (isWeb) {
|
|
textPublication.stage(nextText);
|
|
} else {
|
|
saveDraft((current) => ({ ...current, text: nextText }));
|
|
}
|
|
},
|
|
[saveDraft, textPublication],
|
|
);
|
|
|
|
const replaceText = useCallback(
|
|
(nextText: string) => {
|
|
textPublication.cancel();
|
|
saveDraft((current) => ({ ...current, text: nextText }));
|
|
setTextReplacementRevision((revision) => revision + 1);
|
|
},
|
|
[saveDraft, textPublication],
|
|
);
|
|
|
|
const setAttachments = useCallback(
|
|
(updater: AttachmentUpdater) => {
|
|
saveDraft((current) => ({
|
|
...current,
|
|
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
|
|
}));
|
|
},
|
|
[saveDraft],
|
|
);
|
|
|
|
const clear = useCallback(
|
|
(lifecycle: "sent" | "abandoned") => {
|
|
textPublication.cancel();
|
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
|
},
|
|
[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) {
|
|
setTextReplacementRevision((revision) => revision + 1);
|
|
setHydratedDraftKey(draftKey);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [draftKey]);
|
|
|
|
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
|
useEffect(() => {
|
|
if (!composerOptions || !lockedWorkingDir) {
|
|
return;
|
|
}
|
|
if (formState.workingDir.trim() === lockedWorkingDir) {
|
|
return;
|
|
}
|
|
formState.setWorkingDir(lockedWorkingDir);
|
|
}, [composerOptions, formState, lockedWorkingDir]);
|
|
|
|
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 effectiveModelId = useMemo(
|
|
() => resolveEffectiveComposerModelId(providerSelection),
|
|
[providerSelection],
|
|
);
|
|
|
|
const effectiveThinkingOptionId = useMemo(
|
|
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
|
|
[effectiveModelId, providerSelection],
|
|
);
|
|
|
|
const workingDir = lockedWorkingDir || formState.workingDir;
|
|
const {
|
|
features: draftFeatures,
|
|
featureValues: draftFeatureValues,
|
|
setFeatureValue: setDraftFeatureValue,
|
|
applyProfileFeatureValues,
|
|
} = useDraftAgentFeatures({
|
|
serverId: formState.selectedServerId,
|
|
provider: formState.selectedProvider,
|
|
cwd: workingDir,
|
|
modeId: formState.selectedMode,
|
|
modelId: effectiveModelId,
|
|
thinkingOptionId: effectiveThinkingOptionId,
|
|
initialFeatureValues: composerOptions?.initialFeatureValues,
|
|
});
|
|
|
|
const applyDraftAgentProfile = useCallback(
|
|
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
|
|
formState.applyProfileFromUser(profile);
|
|
applyProfileFeatureValues(profile.featureValues);
|
|
},
|
|
[applyProfileFeatureValues, formState],
|
|
);
|
|
|
|
const commandDraftConfig = useMemo(
|
|
() =>
|
|
composerOptions
|
|
? buildDraftCommandConfig({
|
|
selection: providerSelection,
|
|
cwd: workingDir,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
featureValues: draftFeatureValues,
|
|
})
|
|
: undefined,
|
|
[
|
|
composerOptions,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
draftFeatureValues,
|
|
providerSelection,
|
|
workingDir,
|
|
],
|
|
);
|
|
|
|
const composerState = useMemo<DraftComposerState | null>(() => {
|
|
if (!composerOptions) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...formState,
|
|
workingDir,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
featureValues: draftFeatureValues,
|
|
agentControls: buildDraftAgentControls({
|
|
formState,
|
|
features: draftFeatures,
|
|
onSetFeature: setDraftFeatureValue,
|
|
onApplyAgentProfile: applyDraftAgentProfile,
|
|
}),
|
|
commandDraftConfig,
|
|
};
|
|
}, [
|
|
commandDraftConfig,
|
|
composerOptions,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
draftFeatures,
|
|
draftFeatureValues,
|
|
applyDraftAgentProfile,
|
|
formState,
|
|
setDraftFeatureValue,
|
|
workingDir,
|
|
]);
|
|
|
|
return {
|
|
text,
|
|
editText,
|
|
replaceText,
|
|
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
};
|
|
}
|
|
|
|
export const __private__ = {
|
|
resolveDraftKey,
|
|
resolveEffectiveComposerModelId,
|
|
resolveEffectiveComposerThinkingOptionId,
|
|
buildDraftCommandConfig,
|
|
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
|
buildDraftAgentControls,
|
|
};
|