mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
* fix(app): keep fullscreen composer in the keyboard dock * fix(app): preserve composer caret and scope keyboard actions
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { UserComposerAttachment } from "@/attachments/types";
|
|
import type { TextReplacement } from "@/composer/types";
|
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
|
import type { DraftCommandTarget } 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 {
|
|
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";
|
|
|
|
type AttachmentUpdater =
|
|
| UserComposerAttachment[]
|
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
|
|
|
interface AgentInputDraftComposerOptions {
|
|
initialServerId: string | null;
|
|
initialValues?: CreateAgentInitialValues;
|
|
initialFeatureValues?: Record<string, unknown>;
|
|
isVisible?: boolean;
|
|
lockedWorkingDir?: string;
|
|
}
|
|
|
|
interface UseAgentInputDraftInput {
|
|
draftKey: DraftKeyInput;
|
|
composer?: AgentInputDraftComposerOptions;
|
|
}
|
|
|
|
type DraftComposerState = UseAgentFormStateResult & {
|
|
workingDir: string;
|
|
effectiveModeId: string;
|
|
effectiveModelId: string;
|
|
effectiveThinkingOptionId: string;
|
|
featureValues: Record<string, unknown> | undefined;
|
|
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 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) {
|
|
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 {
|
|
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 commandDraft = useMemo(
|
|
() =>
|
|
buildDraftCommandTarget({
|
|
selection: providerSelection,
|
|
cwd: workingDir,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
featureValues: draftFeatureValues,
|
|
}),
|
|
[
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
draftFeatureValues,
|
|
providerSelection,
|
|
workingDir,
|
|
],
|
|
);
|
|
|
|
const composerState = useMemo<DraftComposerState | null>(() => {
|
|
if (!composerOptions) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...formState,
|
|
workingDir,
|
|
effectiveModeId,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
featureValues: draftFeatureValues,
|
|
agentControls: buildDraftAgentControls({
|
|
formState,
|
|
features: draftFeatures,
|
|
onSetFeature: setDraftFeatureValue,
|
|
onApplyAgentProfile: applyDraftAgentProfile,
|
|
}),
|
|
commandDraft,
|
|
};
|
|
}, [
|
|
commandDraft,
|
|
composerOptions,
|
|
effectiveModeId,
|
|
effectiveModelId,
|
|
effectiveThinkingOptionId,
|
|
draftFeatures,
|
|
draftFeatureValues,
|
|
applyDraftAgentProfile,
|
|
formState,
|
|
setDraftFeatureValue,
|
|
workingDir,
|
|
]);
|
|
|
|
return {
|
|
textSource,
|
|
editText,
|
|
replaceText,
|
|
textReplacement,
|
|
attachments,
|
|
setAttachments,
|
|
clear,
|
|
isHydrated,
|
|
attachmentFocusRequestId,
|
|
composerState,
|
|
};
|
|
}
|
|
|
|
export const __private__ = {
|
|
resolveDraftKey,
|
|
resolveEffectiveComposerModeId,
|
|
resolveEffectiveComposerModelId,
|
|
resolveEffectiveComposerThinkingOptionId,
|
|
buildDraftCommandTarget,
|
|
buildDraftAgentControls,
|
|
};
|