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.
This commit is contained in:
Mohamed Boudra
2026-09-16 00:05:19 +02:00
committed by GitHub
parent 4251575950
commit 7557992dff
22 changed files with 422 additions and 159 deletions
+5 -3
View File
@@ -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;
@@ -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`);
@@ -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),
@@ -108,7 +108,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const settledKeyboardShift = useSettledKeyboardShift();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const isNativeViewportSettlingRef = useRef(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(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;
@@ -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]);
@@ -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}
@@ -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",
+35 -14
View File
@@ -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<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 text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
const textReplacementRevisionRef = useRef(0);
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
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<string>((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,
@@ -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}
+170 -75
View File
@@ -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<number>;
configuration: Omit<
Parameters<typeof useAgentAutocomplete>[0],
"userInput" | "cursorIndex" | "onAutocompleteApplied"
>;
inputRef: React.RefObject<MessageInputRef | null>;
anchorRef: React.RefObject<View | null>;
show: boolean;
ref: React.Ref<ComposerAutocompleteHandle>;
}) {
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 (
<ComposerAutocomplete
visible={autocomplete.isVisible && show}
anchorRef={anchorRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={onSelect}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
);
}
function ComposerForgeBinding({
text,
configuration,
ref,
onResolvingChange,
}: {
text: ComposerTextSource;
configuration: Omit<Parameters<typeof useComposerForgeAutoAttach>[0], "text">;
ref: React.Ref<ReturnType<typeof useComposerForgeAutoAttach>>;
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<number>(setCursorIndex), []);
const forgeAutoAttachRef = useRef<ReturnType<typeof useComposerForgeAutoAttach>>(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<number>(() => 0), []);
const cursorPublication = useMemo(
() => new AfterPaintPublication<number>((position) => cursor.setState(position)),
[cursor],
);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const autocompleteRef = useRef<ComposerAutocompleteHandle>(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<View>(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}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<ComposerAutocomplete
visible={autocompleteVisible}
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={handleAutocompleteSelect}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
show={mode.showAutocomplete}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
/>
<ComposerForgeBinding
text={textSource}
configuration={forgeConfiguration}
ref={forgeAutoAttachRef}
onResolvingChange={setIsForgeResolving}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
value={userInput}
value={textSource.getSnapshot()}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
@@ -3,7 +3,7 @@ import type { RefObject } from "react";
import type { ComposerHeightResult } from "./height.types";
interface ComposerHeightArgs {
value: string;
getText: () => string;
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
@@ -3,7 +3,7 @@ import type { RefObject } from "react";
import type { ComposerHeightResult } from "./height.types";
interface ComposerHeightArgs {
value: string;
getText: () => string;
textareaRef: RefObject<HTMLElement | null>;
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<HTMLTextAreaElement | null>(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();
+2 -1
View File
@@ -1211,8 +1211,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const webTextareaRef = useRef<HTMLElement | null>(null);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
value,
getText: getLiveText,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
+5
View File
@@ -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;
}
@@ -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);
+25 -2
View File
@@ -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 }),
};
}
@@ -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<SharedValue<number | undefined> | null>(null);
@@ -25,16 +26,21 @@ export function ComposerViewport({
...props
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
const sizing = useSharedValue<ComposerCapacity | undefined>(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;
+5 -5
View File
@@ -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 (
<RewindComposerRestoreProvider
text={agentInputDraft.text}
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
>
@@ -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}
@@ -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}
@@ -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<DraftInput | undefined>;
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<DraftStore>()(
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];
@@ -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 = {
@@ -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<string, DraftRecord>;
createModalDraft: DraftRecord | null;