diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index aa64d0e403..4dabf067ba 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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} diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index ed7a332fb2..65180dce35 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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"); diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 78587c95e7..c26e18696f 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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(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(() => ({ + 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, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4d6f555fec..167a8886b8 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index b20ffc9a9e..a16fdb7087 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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} /> diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index b29905d48a..a660d8cce5 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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( attachmentSlot, inputMode, readOnly, - textReplacementKey, + textReplacement, submitLabel, } = resolveMessageInputProps(props); const mode = resolveComposerInputMode(inputMode); @@ -1209,7 +1209,7 @@ export const MessageInput = forwardRef( 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(null); const composerHeight = useComposerHeight({ value, @@ -1287,13 +1287,13 @@ export const MessageInput = forwardRef( ); 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 () => { diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index c266d8cba8..2f03b7e89a 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -8,3 +8,8 @@ export interface MessagePayload { cwd: string; forceSend?: boolean; } + +export interface TextReplacement { + key: string; + text: string; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index abb1d8685f..e3b54f1085 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5ccb5fcd9a..947190ccd0 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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 ? ( ) : (