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:
Mohamed Boudra
2026-08-27 16:50:18 +02:00
committed by GitHub
parent a8734a9724
commit 96cb70f5ce
9 changed files with 66 additions and 37 deletions
@@ -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");
+25 -8
View File
@@ -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}
+4 -4
View File
@@ -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>
+14 -14
View File
@@ -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 () => {
+5
View File
@@ -8,3 +8,8 @@ export interface MessagePayload {
cwd: string;
forceSend?: boolean;
}
export interface TextReplacement {
key: string;
text: string;
}
+4 -4
View File
@@ -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}