From 7557992dffb24bd8b23667134866a235d20d5608 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 16 Sep 2026 00:05:19 +0200 Subject: [PATCH] fix(app): stabilize composer height and isolate typing updates (#4902) * fix(app): preserve composer capacity when the keyboard closes Releasing the keyboard reservation enlarged an unchanged capped draft. Retain the last editing reservation while still recalculating capacity for viewport changes and subsequent keyboard openings. * perf(app): isolate composer editing and resize updates Draft and cursor subscriptions rerendered the chat and full composer on every native edit. Give editing consumers a live text source, preserve attachment identity on text updates, and read viewport settling state without a render. Read live input text for web resize measurement so removing per-keystroke props does not collapse a draft when its width changes. * fix(app): restore current draft when the composer remounts The last replacement command does not include subsequent edits. Seed remounted editors from the live draft snapshot so switching launch modes restores the saved text without subscribing the composer to every edit. * fix(app): synchronize composer resolution state before paint Avoid an extra passive-effect delay when forwarding forge resolution state from the isolated binding. Exercise live-text width measurement through the actual composer E2E journey instead of a hook-only test component. --- docs/floating-panels.md | 8 +- .../e2e/browser/composer-whitespace.spec.ts | 13 + .../agent-stream/bottom-anchor-controller.ts | 8 +- .../app/src/agent-stream/strategy-native.tsx | 16 +- .../components/rewind/composer-restore.tsx | 28 +- .../src/components/workspace-setup-dialog.tsx | 2 +- .../composer/draft/input-draft.live.test.tsx | 28 +- .../app/src/composer/draft/input-draft.ts | 49 +++- .../app/src/composer/draft/workspace-tab.tsx | 2 +- packages/app/src/composer/index.tsx | 245 ++++++++++++------ .../app/src/composer/input/height.native.ts | 2 +- packages/app/src/composer/input/height.web.ts | 16 +- packages/app/src/composer/input/input.tsx | 3 +- packages/app/src/composer/text-source.ts | 5 + .../src/composer/viewport/capacity.test.ts | 37 ++- .../app/src/composer/viewport/capacity.ts | 27 +- .../src/composer/viewport/index.native.tsx | 18 +- packages/app/src/panels/agent-panel.tsx | 10 +- .../app/src/screens/new-workspace-screen.tsx | 11 +- packages/app/src/stores/draft-store/index.ts | 10 + .../app/src/stores/draft-store/state.test.ts | 28 +- packages/app/src/stores/draft-store/state.ts | 15 ++ 22 files changed, 422 insertions(+), 159 deletions(-) create mode 100644 packages/app/src/composer/text-source.ts diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 54f3c92630..77b3f9104c 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -160,9 +160,11 @@ motion settles; never drive it per frame. The translated dock's height is not the composer's available height. Bound the composer against the stationary space below the header through `composer/viewport`, including its controls and attachments. Reserve the -keyboard destination at move start and release space at move end on the UI -thread. Waiting for a settled JS update lets a long draft disappear behind the -header during opening. This is a boundary-time layout change, not an animated +keyboard destination at move start on the UI thread. Keep the last nonzero +reservation when the keyboard closes so an unchanged draft does not expand. +Viewport changes and subsequent keyboard openings recalculate the capacity. +Waiting for a settled JS update lets a long draft disappear behind the header +during opening. This is a boundary-time layout change, not an animated layout prop. Keep the input's native intrinsic sizing and internal scrolling. New workspace also needs its setup fields to scroll when the form exceeds this space. The Android regression must check both the header and IME boundaries; diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index f935676c3b..024446967f 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -65,6 +65,19 @@ test("blank composer lines remain present and keep their measured height", async const blankLines = "\n\n\n\n\n"; const collapsedHeight = await composerHeight(page); + await test.step("remeasure the current draft when the window narrows", async () => { + await page.setViewportSize({ width: 1280, height: 1200 }); + const text = "A sentence that wraps when the editor becomes narrower. ".repeat(8); + await composer.fill(text); + await expect(composer).toHaveValue(text); + await expect.poll(() => composerHeight(page)).toBeGreaterThan(collapsedHeight); + const wideHeight = await composerHeight(page); + await page.setViewportSize({ width: 480, height: 1200 }); + await expect.poll(() => composerHeight(page)).toBeGreaterThan(wideHeight); + await expect(composer).toHaveValue(text); + await page.setViewportSize({ width: 1280, height: 720 }); + }); + await test.step("grow the composer with blank lines followed by text", async () => { await composer.fill(`${blankLines}x`); await expect(composer).toHaveValue(`${blankLines}x`); diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index 7533699815..93b3c075e7 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -726,7 +726,7 @@ export function useBottomAnchorController(input: { routeRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; renderStrategy: string; - transportBehavior: BottomAnchorTransportBehavior; + getTransportBehavior: () => BottomAnchorTransportBehavior; getMeasurementState: () => ControllerMeasurementState; isNearBottom: () => boolean; scrollToBottom: (animated: boolean) => void; @@ -735,7 +735,7 @@ export function useBottomAnchorController(input: { const agentIdRef = useRef(input.agentId); const readinessRef = useRef(input.isAuthoritativeHistoryReady); const renderStrategyRef = useRef(input.renderStrategy); - const transportBehaviorRef = useRef(input.transportBehavior); + const getTransportBehaviorRef = useRef(input.getTransportBehavior); const getMeasurementStateRef = useRef(input.getMeasurementState); const isNearBottomRef = useRef(input.isNearBottom); const scrollToBottomRef = useRef(input.scrollToBottom); @@ -744,7 +744,7 @@ export function useBottomAnchorController(input: { agentIdRef.current = input.agentId; readinessRef.current = input.isAuthoritativeHistoryReady; renderStrategyRef.current = input.renderStrategy; - transportBehaviorRef.current = input.transportBehavior; + getTransportBehaviorRef.current = input.getTransportBehavior; getMeasurementStateRef.current = input.getMeasurementState; isNearBottomRef.current = input.isNearBottom; scrollToBottomRef.current = input.scrollToBottom; @@ -754,7 +754,7 @@ export function useBottomAnchorController(input: { getAgentId: () => agentIdRef.current, getIsAuthoritativeHistoryReady: () => readinessRef.current, getRenderStrategy: () => renderStrategyRef.current, - getTransportBehavior: () => transportBehaviorRef.current, + getTransportBehavior: () => getTransportBehaviorRef.current(), getMeasurementState: () => getMeasurementStateRef.current(), isNearBottom: () => isNearBottomRef.current(), scrollToBottom: (animated) => scrollToBottomRef.current(animated), diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 8acdd92f4f..03e61ef599 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -108,7 +108,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const settledKeyboardShift = useSettledKeyboardShift(); const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); - const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); + const isNativeViewportSettlingRef = useRef(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); const [historyStartPaginationState, setHistoryStartPaginationState] = useState( @@ -210,12 +210,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const markNativeViewportSettling = useCallback(() => { clearNativeViewportSettling(); - setIsNativeViewportSettling(true); + isNativeViewportSettlingRef.current = true; let remainingFrames = 4; const tick = () => { if (remainingFrames <= 0) { nativeViewportSettlingFrameIdRef.current = null; - setIsNativeViewportSettling(false); + isNativeViewportSettlingRef.current = false; return; } remainingFrames -= 1; @@ -224,13 +224,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick); }, [clearNativeViewportSettling]); - const bottomAnchorTransportBehavior = useMemo( + const getBottomAnchorTransportBehavior = useCallback( () => resolveBottomAnchorTransportBehavior({ strategy, - isViewportSettling: isNativeViewportSettling, + isViewportSettling: isNativeViewportSettlingRef.current, }), - [isNativeViewportSettling, strategy], + [strategy], ); const scrollToBottom = useCallback( @@ -255,7 +255,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat routeRequest: routeBottomAnchorRequest, isAuthoritativeHistoryReady, renderStrategy: "inverted-stream", - transportBehavior: bottomAnchorTransportBehavior, + getTransportBehavior: getBottomAnchorTransportBehavior, getMeasurementState: () => streamViewportMetricsRef.current, isNearBottom: () => { const metrics = streamViewportMetricsRef.current; @@ -318,7 +318,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat isUserScrollActiveRef.current = false; clearPendingUserScrollEnd(); clearNativeViewportSettling(); - setIsNativeViewportSettling(false); + isNativeViewportSettlingRef.current = false; historyStartReadyRef.current = false; const initialHistoryStartState = createHistoryStartPaginationState(); historyStartPaginationStateRef.current = initialHistoryStartState; diff --git a/packages/app/src/components/rewind/composer-restore.tsx b/packages/app/src/components/rewind/composer-restore.tsx index e615db5912..07e645d225 100644 --- a/packages/app/src/components/rewind/composer-restore.tsx +++ b/packages/app/src/components/rewind/composer-restore.tsx @@ -1,19 +1,12 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - type ReactNode, -} from "react"; +import type { ComposerTextSource } from "@/composer/text-source"; +import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; interface RewindComposerRestoreContextValue { completeRewind: (text: string) => void; } interface RewindComposerRestoreProviderProps { - text: string; + textSource: ComposerTextSource; setText: (text: string) => void; onRewindComplete: () => void; children: ReactNode; @@ -32,29 +25,24 @@ export function restoreComposerTextIfEmpty(input: { } export function RewindComposerRestoreProvider({ - text, + textSource, setText, onRewindComplete, children, }: RewindComposerRestoreProviderProps) { - const textRef = useRef(text); - - useEffect(() => { - textRef.current = text; - }, [text]); - const completeRewind = useCallback( (rewoundText: string) => { + const currentText = textSource.getSnapshot(); const nextText = restoreComposerTextIfEmpty({ - currentText: textRef.current, + currentText: currentText, rewoundText, }); - if (nextText !== textRef.current) { + if (nextText !== currentText) { setText(nextText); } onRewindComplete(); }, - [onRewindComplete, setText], + [onRewindComplete, setText, textSource], ); const value = useMemo(() => ({ completeRewind }), [completeRewind]); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 4b2da86ea5..362d90c2da 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -452,7 +452,7 @@ export function WorkspaceSetupDialog() { onSubmitMessage={handleCreateChatAgent} isSubmitLoading={pendingAction === "chat"} blurOnSubmit={true} - value={chatDraft.text} + textSource={chatDraft.textSource} onChangeText={chatDraft.editText} textReplacement={chatDraft.textReplacement} attachments={chatDraft.attachments} 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 ef26aaf6fe..b6dd735207 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -239,7 +239,7 @@ describe("useAgentInputDraft live contract", () => { ); }); - expect(getLatest().text).toBe("hello world"); + expect(getLatest().textSource.getSnapshot()).toBe("hello world"); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); }); @@ -297,7 +297,7 @@ describe("useAgentInputDraft live contract", () => { ); }); - expect(getLatest().text).toBe("legacy text"); + expect(getLatest().textSource.getSnapshot()).toBe("legacy text"); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({ text: "legacy text", @@ -363,7 +363,7 @@ describe("useAgentInputDraft live contract", () => { ); }); - expect(getLatest().text).toBe("new text"); + expect(getLatest().textSource.getSnapshot()).toBe("new text"); expect(getLatest().attachments).toEqual([githubIssue]); await act(async () => { @@ -394,7 +394,9 @@ describe("useAgentInputDraft live contract", () => { return latest; } + let renders = 0; function Probe() { + renders += 1; latest = useAgentInputDraft({ draftKey: "draft:attachments" }); return null; } @@ -428,6 +430,22 @@ describe("useAgentInputDraft live contract", () => { attachments: [{ kind: "image", metadata: image }], }); }); + const settledRenders = renders; + const attached = getLatest().attachments; + const source = getLatest().textSource; + let textNotifications = 0; + const unsubscribe = source.subscribe(() => { + textNotifications += 1; + }); + await act(async () => { + getLatest().editText("with attachment\n"); + await expect.poll(source.getSnapshot).toBe("with attachment\n"); + }); + expect(renders).toBe(settledRenders); + expect(getLatest().attachments).toBe(attached); + expect(getLatest().textSource).toBe(source); + expect(textNotifications).toBe(1); + unsubscribe(); await act(async () => { root.unmount(); }); @@ -512,7 +530,7 @@ describe("useAgentInputDraft live contract", () => { getLatest().clear("sent"); }); - expect(getLatest().text).toBe(""); + expect(getLatest().textSource.getSnapshot()).toBe(""); expect(getLatest().attachments).toEqual([]); expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({ text: "", @@ -566,7 +584,7 @@ describe("useAgentInputDraft live contract", () => { getLatest().clear("sent"); }); - expect(getLatest().text).toBe(""); + expect(getLatest().textSource.getSnapshot()).toBe(""); expect(getLatest().attachments).toEqual([]); expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ lifecycle: "sent", diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 2bfaab0745..64b50fce0c 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -22,8 +22,9 @@ import { 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 { useShallow } from "zustand/shallow"; +import type { ComposerTextSource } from "@/composer/text-source"; import { isWeb } from "@/constants/platform"; type AttachmentUpdater = @@ -53,7 +54,7 @@ type DraftComposerState = UseAgentFormStateResult & { }; export interface AgentInputDraft { - text: string; + textSource: ComposerTextSource; editText: (text: string) => void; replaceText: (text: string) => void; textReplacement: TextReplacement; @@ -83,19 +84,39 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }), [formState.selectedServerId, input.draftKey], ); - const draftRecord = useDraftStore((state) => state.drafts[draftKey]); - const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]); + const attachments = useDraftStore( + useShallow((state) => + state.drafts[draftKey]?.lifecycle === "active" + ? (state.drafts[draftKey].input.attachments ?? []) + : [], + ), + ); + const textSource = useMemo( + () => ({ + 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(null); - const text = draft?.text ?? ""; - const attachments = draft?.attachments ?? []; const isHydrated = hydratedDraftKey === draftKey; const textReplacementRevisionRef = useRef(0); const [textReplacement, setTextReplacement] = useState(() => ({ key: `${draftKey}:0`, - text, + text: textSource.getSnapshot(), })); const publishTextReplacement = useCallback( @@ -131,9 +152,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const textPublication = useMemo( () => new AfterPaintPublication((nextText) => { - saveDraft((current) => ({ ...current, text: nextText })); + useDraftStore.getState().editDraftText({ draftKey, text: nextText }); }), - [saveDraft], + [draftKey], ); const editText = useCallback( @@ -141,19 +162,19 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr if (isWeb) { textPublication.stage(nextText); } else { - saveDraft((current) => ({ ...current, text: nextText })); + useDraftStore.getState().editDraftText({ draftKey, text: nextText }); } }, - [saveDraft, textPublication], + [draftKey, textPublication], ); const replaceText = useCallback( (nextText: string) => { textPublication.cancel(); - saveDraft((current) => ({ ...current, text: nextText })); + useDraftStore.getState().editDraftText({ draftKey, text: nextText }); publishTextReplacement(nextText); }, - [publishTextReplacement, saveDraft, textPublication], + [draftKey, publishTextReplacement, textPublication], ); const setAttachments = useCallback( @@ -320,7 +341,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ]); return { - text, + textSource, editText, replaceText, textReplacement, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 0457dfb36f..b5bc446506 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -680,7 +680,7 @@ export function WorkspaceDraftAgentTab({ onSubmitMessage={handleCreateFromInput} isSubmitLoading={isSubmitting} blurOnSubmit={true} - value={draftInput.text} + textSource={draftInput.textSource} onChangeText={draftInput.editText} textReplacement={draftInput.textReplacement} attachments={draftInput.attachments} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index fa2f994fc9..2440b9eab0 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,6 @@ +import type { ComposerTextSource } from "./text-source"; +import { createStore, type StoreApi } from "zustand/vanilla"; +import { useStore } from "zustand"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { @@ -11,9 +14,12 @@ import type { TFunction } from "i18next"; import { useState, useEffect, + useLayoutEffect, useRef, useCallback, useMemo, + useSyncExternalStore, + useImperativeHandle, memo, type ReactElement, type ReactNode, @@ -934,7 +940,7 @@ interface ComposerProps { submitBehavior?: "clear" | "preserve-and-lock"; /** When true, blurs the input immediately when submitting. */ blurOnSubmit?: boolean; - value: string; + textSource: ComposerTextSource; onChangeText: (text: string) => void; textReplacement: TextReplacement; attachments: UserComposerAttachment[]; @@ -972,7 +978,7 @@ interface ComposerProps { * and nothing else — never branch on it at the call site. */ inputMode?: ComposerInputMode; - /** Renders `value` as static text on the same surface, for content there is nothing to type into. */ + /** Renders the current text as static text on the same surface, for content there is nothing to type into. */ readOnly?: boolean; /** Replaces the submit icon with this label, still inside the composer's own toolbar row. */ submitLabel?: string; @@ -995,6 +1001,82 @@ function resolveContextWindowValues( return { contextWindowMaxTokens: null, contextWindowUsedTokens: null }; } +interface ComposerAutocompleteHandle { + onKeyPress: (event: ComposerKeyPressEvent) => boolean; +} + +function ComposerAutocompleteBinding({ + text, + cursor, + configuration, + inputRef, + anchorRef, + show, + ref, +}: { + text: ComposerTextSource; + cursor: StoreApi; + configuration: Omit< + Parameters[0], + "userInput" | "cursorIndex" | "onAutocompleteApplied" + >; + inputRef: React.RefObject; + anchorRef: React.RefObject; + show: boolean; + ref: React.Ref; +}) { + const userInput = useSyncExternalStore(text.subscribe, text.getSnapshot, text.getSnapshot); + const cursorIndex = useStore(cursor); + const autocomplete = useAgentAutocomplete({ + ...configuration, + userInput, + cursorIndex: Math.min(cursorIndex, userInput.length), + onAutocompleteApplied: () => inputRef.current?.focus(), + }); + useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [ + autocomplete.onKeyPress, + ]); + const selectOption = autocomplete.onSelectOption; + const onSelect = useCallback( + (option: AutocompleteOption) => selectOption(option, inputRef.current?.getInputSnapshot()), + [selectOption, inputRef], + ); + return ( + + ); +} + +function ComposerForgeBinding({ + text, + configuration, + ref, + onResolvingChange, +}: { + text: ComposerTextSource; + configuration: Omit[0], "text">; + ref: React.Ref>; + onResolvingChange: (resolving: boolean) => void; +}) { + const value = useSyncExternalStore(text.subscribe, text.getSnapshot, text.getSnapshot); + const binding = useComposerForgeAutoAttach({ ...configuration, text: value }); + useImperativeHandle(ref, () => binding, [binding]); + useLayoutEffect( + () => onResolvingChange(binding.isResolving), + [binding.isResolving, onResolvingChange], + ); + return null; +} + interface ComposerCancelButtonProps { buttonIconSize: number; cancelButtonStyle: (object | undefined)[]; @@ -1156,7 +1238,7 @@ function ComposerContentImpl({ waitForForgeAutoAttachOnSubmit = false, submitBehavior = "clear", blurOnSubmit = false, - value, + textSource, onChangeText, textReplacement, attachments, @@ -1218,7 +1300,11 @@ function ComposerContentImpl({ const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); - const userInput = value; + const hasText = useSyncExternalStore( + textSource.subscribe, + () => textSource.getSnapshot().trim().length > 0, + () => textSource.getSnapshot().trim().length > 0, + ); const setUserInput = onChangeText; const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys); const { @@ -1240,21 +1326,41 @@ function ComposerContentImpl({ const supportsForgeSearch = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, ); - const forgeAutoAttach = useComposerForgeAutoAttach({ - text: userInput, - remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), - attachments, - client, - isConnected, - serverId, - cwd, - supportsForgeSearch, - setAttachments: setSelectedAttachments, - onChangeRequestDetected: onForgeChangeRequestDetected, - onChangeRequestAdded: onForgeChangeRequestAutoAttach, - }); - const [cursorIndex, setCursorIndex] = useState(0); - const cursorPublication = useMemo(() => new AfterPaintPublication(setCursorIndex), []); + const forgeAutoAttachRef = useRef>(null); + const [isForgeResolving, setIsForgeResolving] = useState(false); + const forgeConfiguration = useMemo( + () => ({ + remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), + attachments, + client, + isConnected, + serverId, + cwd, + supportsForgeSearch, + setAttachments: setSelectedAttachments, + onChangeRequestDetected: onForgeChangeRequestDetected, + onChangeRequestAdded: onForgeChangeRequestAutoAttach, + }), + [ + checkoutStatusQuery.status, + attachments, + client, + isConnected, + serverId, + cwd, + supportsForgeSearch, + setSelectedAttachments, + onForgeChangeRequestDetected, + onForgeChangeRequestAutoAttach, + ], + ); + const cursor = useMemo(() => createStore(() => 0), []); + const cursorPublication = useMemo( + () => new AfterPaintPublication((position) => cursor.setState(position)), + [cursor], + ); + useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); + const autocompleteRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false); const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); @@ -1350,36 +1456,6 @@ function ComposerContentImpl({ [blurOnSubmit, clearDraft, replaceUserInput, resetSuppression, setSelectedAttachments], ); - const autocomplete = useAgentAutocomplete({ - userInput, - cursorIndex, - setUserInput: replaceUserInput, - serverId, - agentId, - draftConfig: commandDraftConfig, - canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0, - onClientSlashCommand: runClientSlashCommand, - pluginClientSlashCommands, - onAutocompleteApplied: () => { - messageInputRef.current?.focus(); - }, - }); - const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress); - autocompleteOnKeyPressRef.current = autocomplete.onKeyPress; - const selectAutocompleteOption = autocomplete.onSelectOption; - const handleAutocompleteSelect = useCallback( - (option: AutocompleteOption) => - selectAutocompleteOption(option, messageInputRef.current?.getInputSnapshot()), - [selectAutocompleteOption], - ); - - // Clear send error when user edits the input - useEffect(() => { - setCursorIndex((current) => Math.min(current, userInput.length)); - }, [userInput.length]); - - useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); - const { pickImages } = useImageAttachmentPicker(); const { pickFiles } = useFilePicker(); const agentIdRef = useRef(agentId); @@ -1774,7 +1850,7 @@ function ComposerContentImpl({ const handleRemoveAttachment = useCallback( (index: number) => { - forgeAutoAttach.markForgeAttachmentRemoved(selectedAttachments[index]); + forgeAutoAttachRef.current?.markForgeAttachmentRemoved(selectedAttachments[index]); const didRemoveWorkspaceAttachment = removeAttachment({ selectedAttachments, index, @@ -1786,7 +1862,7 @@ function ComposerContentImpl({ removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), ); }, - [forgeAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], + [removeAttachment, selectedAttachments, setSelectedAttachments], ); const handleOpenAttachment = useCallback( @@ -1905,11 +1981,11 @@ function ComposerContentImpl({ ], ); - const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; + const hasSendableContent = hasText || selectedAttachments.length > 0; // Handle keyboard navigation for command autocomplete. const handleCommandKeyPress = useCallback( - (event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event), + (event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false, [], ); @@ -2125,19 +2201,14 @@ function ComposerContentImpl({ const nextAttachments = toggleForgeAttachmentFromPicker({ current: attachments, item, - markForgeAttachmentRemoved: forgeAutoAttach.markForgeAttachmentRemoved, + markForgeAttachmentRemoved: (attachment) => + forgeAutoAttachRef.current?.markForgeAttachmentRemoved(attachment), }); setSelectedAttachments(nextAttachments); setIsGithubPickerOpen(false); setGithubSearchQuery(""); }, - [ - attachments, - forgeAutoAttach, - setSelectedAttachments, - setGithubSearchQuery, - setIsGithubPickerOpen, - ], + [attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], ); const leftContent = useMemo( @@ -2162,10 +2233,10 @@ function ComposerContentImpl({ if (isWeb) { cursorPublication.stage(selection.start); } else { - setCursorIndex(selection.start); + cursor.setState(selection.start); } }, - [cursorPublication], + [cursorPublication, cursor], ); const handleFocusChange = useCallback( @@ -2255,12 +2326,33 @@ function ComposerContentImpl({ [handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t], ); + const autocompleteConfiguration = useMemo( + () => ({ + setUserInput: replaceUserInput, + serverId, + agentId, + draftConfig: commandDraftConfig, + canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0, + onClientSlashCommand: runClientSlashCommand, + pluginClientSlashCommands, + }), + [ + replaceUserInput, + serverId, + agentId, + commandDraftConfig, + buildOutgoingAttachments, + attachments, + runClientSlashCommand, + pluginClientSlashCommands, + ], + ); const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; const isSubmitDisabled = - isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && forgeAutoAttach.isResolving); + isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && isForgeResolving); // Disable drops while submitting/uploading: the submit path clears and restores attachments, // so a drop in that window would be lost or land on a locked draft. `disabled` hides the @@ -2288,7 +2380,6 @@ function ComposerContentImpl({ const githubEmptyText = githubSearchResultsQuery.isFetching ? t("composer.github.searching") : t("composer.github.noResults"); - const autocompleteVisible = autocomplete.isVisible && mode.showAutocomplete; return ( <> @@ -2314,23 +2405,27 @@ function ComposerContentImpl({ {sendErrorNode} - + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} string; textareaRef: RefObject; minHeight: number; maxHeight: number; diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts index 4e3bef363d..c732bf3599 100644 --- a/packages/app/src/composer/input/height.web.ts +++ b/packages/app/src/composer/input/height.web.ts @@ -3,7 +3,7 @@ import type { RefObject } from "react"; import type { ComposerHeightResult } from "./height.types"; interface ComposerHeightArgs { - value: string; + getText: () => string; textareaRef: RefObject; minHeight: number; maxHeight: number; @@ -32,15 +32,15 @@ const COPIED_STYLES = [ ] as const; export function useComposerHeight({ - value, + getText, textareaRef, minHeight, maxHeight, }: ComposerHeightArgs): ComposerHeightResult { const [height, setHeight] = useState(minHeight); const heightRef = useRef(minHeight); - const paramsRef = useRef({ value, minHeight, maxHeight }); - paramsRef.current = { value, minHeight, maxHeight }; + const paramsRef = useRef({ getText, minHeight, maxHeight }); + paramsRef.current = { getText, minHeight, maxHeight }; const mirrorRef = useRef(null); const setBoundedHeight = useCallback((nextHeight: number) => { @@ -92,7 +92,7 @@ export function useComposerHeight({ }); document.body.appendChild(mirror); mirrorRef.current = mirror; - measure(paramsRef.current.value); + measure(paramsRef.current.getText()); return () => { mirror.remove(); mirrorRef.current = null; @@ -100,8 +100,8 @@ export function useComposerHeight({ }, [measure]); useLayoutEffect(() => { - measure(value); - }, [maxHeight, minHeight, value, measure]); + measure(getText()); + }, [maxHeight, minHeight, getText, measure]); useEffect(() => { const source = textareaRef.current; @@ -111,7 +111,7 @@ export function useComposerHeight({ const nextWidth = source.clientWidth; if (Math.abs(nextWidth - previousWidth) < 1) return; previousWidth = nextWidth; - measure(paramsRef.current.value); + measure(paramsRef.current.getText()); }); observer.observe(source); return () => observer.disconnect(); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 27bd9b8a92..2ae33eb9f3 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1211,8 +1211,9 @@ export const MessageInput = forwardRef( const selectionRef = useRef({ start: value.length, end: value.length }); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); + const getLiveText = useCallback(() => valueRef.current, []); const composerHeight = useComposerHeight({ - value, + getText: getLiveText, textareaRef: webTextareaRef, minHeight: MIN_INPUT_HEIGHT, maxHeight: maxInputHeight, diff --git a/packages/app/src/composer/text-source.ts b/packages/app/src/composer/text-source.ts new file mode 100644 index 0000000000..9046fd5744 --- /dev/null +++ b/packages/app/src/composer/text-source.ts @@ -0,0 +1,5 @@ +/** Live text is read by editing consumers; containers subscribe only to what they display. */ +export interface ComposerTextSource { + getSnapshot: () => string; + subscribe: (listener: () => void) => () => void; +} diff --git a/packages/app/src/composer/viewport/capacity.test.ts b/packages/app/src/composer/viewport/capacity.test.ts index 04b82e04ea..d6fa3f9606 100644 --- a/packages/app/src/composer/viewport/capacity.test.ts +++ b/packages/app/src/composer/viewport/capacity.test.ts @@ -1,7 +1,40 @@ import { describe, expect, it } from "vitest"; -import { resolveComposerCapacity } from "./capacity"; +import { resolveComposerCapacity, updateComposerCapacity } from "./capacity"; describe("composer viewport", () => { + it("preserves the editing capacity when the keyboard closes", () => { + const viewport = { height: 582, bottomInset: 24, centered: false }; + const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); + const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }); + expect(open.capacity).toBe(245); + expect(closed.capacity).toBe(open.capacity); + expect(updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }).capacity).toBe(303); + }); + + it("remeasures the viewport without forgetting the keyboard reservation", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + centered: false, + keyboardShift: 308, + }); + expect( + updateComposerCapacity(open, { + height: 650, + bottomInset: 24, + centered: false, + keyboardShift: 0, + }).capacity, + ).toBe(313); + expect( + updateComposerCapacity(open, { + height: 0, + bottomInset: 24, + centered: false, + keyboardShift: 0, + }), + ).toEqual(open); + }); it("leaves five points below the header for a bottom-anchored composer", () => { const height = resolveComposerCapacity({ height: 582, @@ -24,7 +57,7 @@ describe("composer viewport", () => { expect((1000 - 80 - height) / 2 - 300).toBe(5); }); - it("releases space after closing and responds to rotation instead of using window height", () => { + it("uses the measured viewport before the first keyboard opening", () => { expect( resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), ).toBe(553); diff --git a/packages/app/src/composer/viewport/capacity.ts b/packages/app/src/composer/viewport/capacity.ts index 7e80b22964..d42537579e 100644 --- a/packages/app/src/composer/viewport/capacity.ts +++ b/packages/app/src/composer/viewport/capacity.ts @@ -1,9 +1,16 @@ -export function resolveComposerCapacity(input: { +interface ComposerGeometry { height: number; bottomInset: number; keyboardShift: number; centered: boolean; -}): number { +} + +export interface ComposerCapacity { + keyboardReserve: number; + capacity: number; +} + +export function resolveComposerCapacity(input: ComposerGeometry): number { "worklet"; // A centered form grows upward by half its height. Reserve both halves so // translating it still leaves five layout points below the header. @@ -11,3 +18,19 @@ export function resolveComposerCapacity(input: { const reservedSpace = input.centered ? clearance * 2 : clearance; return Math.max(0, input.height - input.bottomInset - reservedSpace); } + +export function updateComposerCapacity( + previous: ComposerCapacity | undefined, + input: ComposerGeometry, +): ComposerCapacity { + "worklet"; + if (input.height <= 0 && previous) return previous; + // Closing the keyboard moves the composer; it does not enlarge its editing + // capacity. A subsequent keyboard opening supplies the next reservation. + const keyboardReserve = + input.keyboardShift > 0 ? input.keyboardShift : (previous?.keyboardReserve ?? 0); + return { + keyboardReserve, + capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), + }; +} diff --git a/packages/app/src/composer/viewport/index.native.tsx b/packages/app/src/composer/viewport/index.native.tsx index 0ee128a0f8..93d50e532b 100644 --- a/packages/app/src/composer/viewport/index.native.tsx +++ b/packages/app/src/composer/viewport/index.native.tsx @@ -4,10 +4,11 @@ import Animated, { useAnimatedStyle, useSharedValue, useDerivedValue, + useAnimatedReaction, type SharedValue, } from "react-native-reanimated"; import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; -import { resolveComposerCapacity } from "./capacity"; +import { updateComposerCapacity, type ComposerCapacity } from "./capacity"; const ViewportCapacity = createContext | null>(null); @@ -25,16 +26,21 @@ export function ComposerViewport({ ...props }: ComposerViewportProps) { const measuredHeight = useSharedValue(0); + const sizing = useSharedValue(undefined); const { layoutShift } = useKeyboardShift(); - const capacity = useDerivedValue(() => { - if (measuredHeight.value === 0) return undefined; - return resolveComposerCapacity({ + useAnimatedReaction( + () => ({ height: measuredHeight.value, bottomInset, keyboardShift: layoutShift.value, centered, - }); - }); + }), + (geometry) => { + if (geometry.height <= 0) return; + sizing.value = updateComposerCapacity(sizing.value, geometry); + }, + ); + const capacity = useDerivedValue(() => sizing.value?.capacity); const measureViewport = useCallback( (event: LayoutChangeEvent) => { measuredHeight.value = event.nativeEvent.layout.height; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 7a695ef058..284aee0b7a 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1185,7 +1185,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ // Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out // when only toast state changes (which does not affect any draft field). const { - text, + textSource, editText, replaceText, textReplacement, @@ -1198,7 +1198,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ } = rawAgentInputDraft; const agentInputDraft = useMemo( (): AgentInputDraft => ({ - text, + textSource, editText, replaceText, textReplacement, @@ -1210,7 +1210,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, }), [ - text, + textSource, editText, replaceText, textReplacement, @@ -1276,7 +1276,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ return ( @@ -1607,7 +1607,7 @@ function ActiveAgentComposer({ externalKeyboardShift blurOnSubmit={isNative} isPaneFocused={isPaneFocused} - value={agentInputDraft.text} + textSource={agentInputDraft.textSource} onChangeText={agentInputDraft.editText} textReplacement={agentInputDraft.textReplacement} attachments={agentInputDraft.attachments} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ba858297f4..8fd3705104 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1716,6 +1716,13 @@ export function NewWorkspaceScreen({ terminalSubmitLabel, launchFocusKey, } = useTerminalComposerState({ launchTarget, terminalProfiles, terminalPromptText }); + const terminalTextSource = useMemo( + () => ({ + getSnapshot: () => terminalComposerValue, + subscribe: (_listener: () => void) => () => {}, + }), + [terminalComposerValue], + ); const terminalTextReplacement = useMemo( () => ({ key: launchFocusKey, text: terminalComposerValue }), [launchFocusKey, terminalComposerValue], @@ -2403,7 +2410,7 @@ export function NewWorkspaceScreen({ isSubmitLoading={isPending} submitBehavior="preserve-and-lock" blurOnSubmit={true} - value={terminalComposerValue} + textSource={terminalTextSource} onChangeText={setTerminalPromptText} textReplacement={terminalTextReplacement} attachments={NO_TERMINAL_ATTACHMENTS} @@ -2429,7 +2436,7 @@ export function NewWorkspaceScreen({ waitForForgeAutoAttachOnSubmit submitBehavior="preserve-and-lock" blurOnSubmit={true} - value={chatDraft.text} + textSource={chatDraft.textSource} onChangeText={chatDraft.editText} textReplacement={chatDraft.textReplacement} attachments={chatDraft.attachments} diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 4b89ee98c0..9836d3dfec 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -14,6 +14,7 @@ import { useSessionStore, type SessionState } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { applyClearDraftRecord, + editDraftRecordText, collectReferencedAttachmentIdsFromState, DRAFT_STORE_VERSION, isAttachmentMetadata, @@ -42,6 +43,7 @@ interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; hydrateDraftInput: (input: { draftKey: string }) => Promise; saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void; + editDraftText: (input: { draftKey: string; text: string }) => void; markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void; clearDraftInput: (input: { draftKey: string; @@ -316,6 +318,14 @@ export const useDraftStore = create()( scheduleAttachmentGc(); }, + editDraftText: ({ draftKey, text }) => { + set((state) => { + const previous = state.drafts[draftKey]; + const next = editDraftRecordText(previous, text, Date.now()); + return next === previous ? state : { drafts: { ...state.drafts, [draftKey]: next } }; + }); + }, + markDraftLifecycle: ({ draftKey, lifecycle }) => { set((state) => { const existing = state.drafts[draftKey]; diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index a71ea96668..f157a89d67 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -1,7 +1,33 @@ import { describe, expect, it } from "vitest"; -import { applyClearDraftRecord, pruneFinalizedDraftRecords, toDraftInputIfReady } from "./state"; +import { + applyClearDraftRecord, + pruneFinalizedDraftRecords, + toDraftInputIfReady, + editDraftRecordText, + type DraftRecord, +} from "./state"; describe("draft-store lifecycle", () => { + it("edits text without invalidating attachment subscribers", () => { + const draft: DraftRecord = { + input: { + text: "hello", + attachments: [ + { kind: "workspace_file", path: "README.md", selection: { kind: "whole_file" } }, + ], + }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }; + const edited = editDraftRecordText(draft, "hello\n", 2); + expect(edited.input.text).toBe("hello\n"); + expect(edited.input.attachments).toBe(draft.input.attachments); + expect(edited.version).toBe(2); + expect(editDraftRecordText(edited, "hello\n", 3)).toBe(edited); + expect(editDraftRecordText(edited, "", 3).lifecycle).toBe("active"); + expect(editDraftRecordText(undefined, "", 3).lifecycle).toBe("abandoned"); + }); it("prunes finalized tombstones after TTL", () => { const nowMs = 1_000_000; const drafts = { diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index 5d9fecd52f..4d88e507b4 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -32,6 +32,21 @@ export interface DraftRecord { version: number; } +export function editDraftRecordText( + record: DraftRecord | undefined, + text: string, + now: number, +): DraftRecord { + if (record?.lifecycle === "active" && record.input.text === text) return record; + const attachments = record?.lifecycle === "active" ? record.input.attachments : []; + return { + input: { text, attachments }, + lifecycle: text.length > 0 || attachments.length > 0 ? "active" : "abandoned", + updatedAt: now, + version: (record?.version ?? 0) + 1, + }; +} + export interface DraftStoreState { drafts: Record; createModalDraft: DraftRecord | null;