mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Restore rewound prompts immediately on iOS (#3946)
* fix(app): restore rewound text atomically The native composer could consume a replacement revision before the matching draft-store value arrived, leaving the mounted input blank until remount. Carry the replacement text with its revision so scheduling order cannot split the command from its payload. * fix(app): isolate launch composer drafts Give chat and terminal launch composers separate React identities so each uncontrolled editor lifetime remains owned by its draft mode.
This commit is contained in:
@@ -439,7 +439,7 @@ export function WorkspaceSetupDialog() {
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
|
||||
@@ -224,20 +224,21 @@ describe("useAgentInputDraft live contract", () => {
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
const hydratedReplacementKey = getLatest().textReplacementKey;
|
||||
const hydratedTextReplacement = getLatest().textReplacement;
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("hello world");
|
||||
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacementKey).toBe(hydratedReplacementKey);
|
||||
expect(getLatest().textReplacement).toBe(hydratedTextReplacement);
|
||||
|
||||
await act(async () => {
|
||||
getLatest().replaceText("replacement text");
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacementKey).not.toBe(hydratedReplacementKey);
|
||||
expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement);
|
||||
expect(getLatest().textReplacement.text).toBe("replacement text");
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("hello world");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
@@ -56,7 +57,7 @@ export interface AgentInputDraft {
|
||||
text: string;
|
||||
editText: (text: string) => void;
|
||||
replaceText: (text: string) => void;
|
||||
textReplacementKey: string;
|
||||
textReplacement: TextReplacement;
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
@@ -88,10 +89,25 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
(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 textReplacementRevisionRef = useRef(0);
|
||||
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
|
||||
key: `${draftKey}:0`,
|
||||
text,
|
||||
}));
|
||||
|
||||
const publishTextReplacement = useCallback(
|
||||
(nextText: string) => {
|
||||
textReplacementRevisionRef.current += 1;
|
||||
setTextReplacement({
|
||||
key: `${draftKey}:${textReplacementRevisionRef.current}`,
|
||||
text: nextText,
|
||||
});
|
||||
},
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(
|
||||
@@ -135,9 +151,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
(nextText: string) => {
|
||||
textPublication.cancel();
|
||||
saveDraft((current) => ({ ...current, text: nextText }));
|
||||
setTextReplacementRevision((revision) => revision + 1);
|
||||
publishTextReplacement(nextText);
|
||||
},
|
||||
[saveDraft, textPublication],
|
||||
[publishTextReplacement, saveDraft, textPublication],
|
||||
);
|
||||
|
||||
const setAttachments = useCallback(
|
||||
@@ -187,7 +203,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
void (async () => {
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
setTextReplacementRevision((revision) => revision + 1);
|
||||
const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
|
||||
publishTextReplacement(hydratedText);
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
})();
|
||||
@@ -195,7 +212,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [draftKey]);
|
||||
}, [draftKey, publishTextReplacement]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
@@ -318,7 +335,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
|
||||
textReplacement,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
|
||||
@@ -697,7 +697,7 @@ export function WorkspaceDraftAgentTab({
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacementKey={draftInput.textReplacementKey}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
type ComposerKeyPressEvent,
|
||||
type MessageInputRef,
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload } from "./types";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
@@ -931,7 +931,7 @@ interface ComposerProps {
|
||||
blurOnSubmit?: boolean;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
textReplacementKey: string;
|
||||
textReplacement: TextReplacement;
|
||||
attachments: UserComposerAttachment[];
|
||||
attachmentScopeKeys?: readonly string[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
@@ -1153,7 +1153,7 @@ function ComposerContentImpl({
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
onChangeText,
|
||||
textReplacementKey,
|
||||
textReplacement,
|
||||
attachments,
|
||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||
onOpenWorkspaceAttachment,
|
||||
@@ -2328,7 +2328,7 @@ function ComposerContentImpl({
|
||||
attachmentSlot={attachmentTray}
|
||||
inputMode={inputMode}
|
||||
readOnly={readOnly}
|
||||
textReplacementKey={textReplacementKey}
|
||||
textReplacement={textReplacement}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
</RenderProfile>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
filesToImageAttachments,
|
||||
} from "@/utils/image-attachments-from-files";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { ImageAttachment, MessagePayload } from "@/composer/types";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "@/composer/types";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -169,8 +169,8 @@ export interface MessageInputProps {
|
||||
inputMode?: ComposerInputMode;
|
||||
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
|
||||
readOnly?: boolean;
|
||||
/** Changes only when application state must replace native-owned text. */
|
||||
textReplacementKey: string;
|
||||
/** Command issued when application state must replace native-owned text. */
|
||||
textReplacement: TextReplacement;
|
||||
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
|
||||
submitLabel?: string;
|
||||
}
|
||||
@@ -1081,7 +1081,7 @@ interface ResolvedMessageInputProps {
|
||||
attachmentSlot: React.ReactNode;
|
||||
inputMode: ComposerInputMode;
|
||||
readOnly: boolean;
|
||||
textReplacementKey: string;
|
||||
textReplacement: TextReplacement;
|
||||
submitLabel: string | undefined;
|
||||
}
|
||||
|
||||
@@ -1128,7 +1128,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
attachmentSlot: props.attachmentSlot,
|
||||
inputMode: props.inputMode ?? "chat",
|
||||
readOnly: props.readOnly ?? false,
|
||||
textReplacementKey: props.textReplacementKey,
|
||||
textReplacement: props.textReplacement,
|
||||
submitLabel: props.submitLabel,
|
||||
};
|
||||
}
|
||||
@@ -1183,7 +1183,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
attachmentSlot,
|
||||
inputMode,
|
||||
readOnly,
|
||||
textReplacementKey,
|
||||
textReplacement,
|
||||
submitLabel,
|
||||
} = resolveMessageInputProps(props);
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
@@ -1209,7 +1209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const isInputFocusedRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
const composerHeight = useComposerHeight({
|
||||
value,
|
||||
@@ -1287,13 +1287,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
|
||||
appliedTextReplacementKeyRef.current = textReplacementKey;
|
||||
updateComposerHeightForText?.(valueRef.current, value);
|
||||
valueRef.current = value;
|
||||
updateLiveTextPresence(value);
|
||||
textInputRef.current?.replaceText(value);
|
||||
}, [textReplacementKey, updateComposerHeightForText, updateLiveTextPresence, value]);
|
||||
if (appliedTextReplacementKeyRef.current === textReplacement.key) return;
|
||||
appliedTextReplacementKeyRef.current = textReplacement.key;
|
||||
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
|
||||
valueRef.current = textReplacement.text;
|
||||
updateLiveTextPresence(textReplacement.text);
|
||||
textInputRef.current?.replaceText(textReplacement.text);
|
||||
}, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -8,3 +8,8 @@ export interface MessagePayload {
|
||||
cwd: string;
|
||||
forceSend?: boolean;
|
||||
}
|
||||
|
||||
export interface TextReplacement {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -1306,7 +1306,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
textReplacement,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1319,7 +1319,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
textReplacement,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1331,7 +1331,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
text,
|
||||
editText,
|
||||
replaceText,
|
||||
textReplacementKey,
|
||||
textReplacement,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
@@ -1720,7 +1720,7 @@ function ActiveAgentComposer({
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.editText}
|
||||
textReplacementKey={agentInputDraft.textReplacementKey}
|
||||
textReplacement={agentInputDraft.textReplacement}
|
||||
attachments={agentInputDraft.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
|
||||
@@ -1620,6 +1620,10 @@ export function NewWorkspaceScreen({
|
||||
terminalSubmitLabel,
|
||||
launchFocusKey,
|
||||
} = useTerminalComposerState({ launchTarget, terminalProfiles, terminalPromptText });
|
||||
const terminalTextReplacement = useMemo(
|
||||
() => ({ key: launchFocusKey, text: terminalComposerValue }),
|
||||
[launchFocusKey, terminalComposerValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = pickerSearchQuery.trim();
|
||||
@@ -2275,6 +2279,7 @@ export function NewWorkspaceScreen({
|
||||
{formStack}
|
||||
{isTerminalLaunch ? (
|
||||
<Composer
|
||||
key="terminal"
|
||||
externalKeyboardShift
|
||||
inputMode="terminal"
|
||||
readOnly={!terminalTakesPrompt}
|
||||
@@ -2292,7 +2297,7 @@ export function NewWorkspaceScreen({
|
||||
blurOnSubmit={true}
|
||||
value={terminalComposerValue}
|
||||
onChangeText={setTerminalPromptText}
|
||||
textReplacementKey={launchFocusKey}
|
||||
textReplacement={terminalTextReplacement}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
@@ -2302,6 +2307,7 @@ export function NewWorkspaceScreen({
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
key="chat"
|
||||
externalKeyboardShift
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
@@ -2317,7 +2323,7 @@ export function NewWorkspaceScreen({
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
|
||||
Reference in New Issue
Block a user