From 99d2651dd5f82eeed800672e371427d7266dfe72 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 20:46:38 -0300 Subject: [PATCH] fix(web): pending requests wait until you stop typing (#17637) --- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 55 ++++++++++-- .../chat/composerTypingGuard.test.ts | 89 +++++++++++++++++++ .../components/chat/composerTypingGuard.ts | 51 +++++++++++ .../components/chat/useComposerTypingGuard.ts | 51 +++++++++++ 5 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/chat/composerTypingGuard.test.ts create mode 100644 apps/web/src/components/chat/composerTypingGuard.ts create mode 100644 apps/web/src/components/chat/useComposerTypingGuard.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1fe19f6bc2f2892caddf04b0ff502ec64d826cb5..c8c202f5cadc13415c461cd1a4da6701086f0fb2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8808,7 +8808,8 @@ export default function ChatView(props: ChatViewProps) { }); return; } - if (activePendingProgress) { + const sendCtx = composerRef.current?.getSendContext(); + if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) { if (directAnnotation) { notifyDirectAnnotationAttached(); return; @@ -8816,7 +8817,6 @@ export default function ChatView(props: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { notifyDirectAnnotationAttached(); return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82a34a840c2fc0b94a0ba5d0adce408f099fe022..221b7a57738b7b4e917307e7630940ea348fd009 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,4 +1,5 @@ import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; +import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; @@ -1485,6 +1486,7 @@ export interface ChatComposerHandle { addTerminalContext: (selection: TerminalContextSelection) => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { + answeringPendingUserInput: boolean; prompt: string; images: ComposerImageAttachment[]; files: ComposerFileAttachment[]; @@ -1737,12 +1739,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) sendDisabledReason: externalSendDisabledReason, isPreparingWorktree, environmentUnavailable, - activePendingApproval, + activePendingApproval: incomingPendingApproval, pendingApprovals, - pendingUserInputs, - activePendingProgress, + pendingUserInputs: incomingPendingUserInputs, + activePendingProgress: incomingPendingProgress, activePendingResolvedAnswers, - activePendingIsResponding, + activePendingIsResponding: incomingPendingIsResponding, activePendingDraftAnswers, activePendingQuestionIndex, respondingRequestIds, @@ -1808,9 +1810,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) editingQueuedAttachments, onRemoveEditingQueuedAttachment, } = props; - const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); + const { + heldRequestIds, + onDraftChange: onTypingGuardDraftChange, + onFocus: onTypingGuardFocus, + onBlur: onTypingGuardBlur, + onSend: onTypingGuardSend, + } = useComposerTypingGuard(composerDraftTargetKey, [ + ...pendingApprovals.map((request) => request.requestId), + ...incomingPendingUserInputs.map((request) => request.requestId), + ]); + // Hold composer takeover while the user finishes their thread draft. + const activePendingApproval = + incomingPendingApproval && !heldRequestIds.has(incomingPendingApproval.requestId) + ? incomingPendingApproval + : null; + const holdingUserInput = incomingPendingUserInputs[0] + ? heldRequestIds.has(incomingPendingUserInputs[0].requestId) + : false; + const pendingUserInputs = holdingUserInput ? [] : incomingPendingUserInputs; + const activePendingProgress = holdingUserInput ? null : incomingPendingProgress; + const activePendingIsResponding = !holdingUserInput && incomingPendingIsResponding; + const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; // Opening a running thread resyncs for a few frames. Show the sync row, and // hide the tasks row for it, only when the sync lasts. Logic that depends on // the real phase keeps reading `props.threadSyncPhase`. @@ -3712,6 +3735,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return; } + onTypingGuardDraftChange(); promptRef.current = nextPrompt; setPrompt(nextPrompt); // Any edit ends browsing, even one later undone by hand: typing a @@ -3818,6 +3842,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activePendingProgress?.activeQuestion, expandComposerForEditorChange, + onTypingGuardDraftChange, pendingUserInputs.length, onChangeActivePendingUserInputCustomAnswer, promptRef, @@ -3909,6 +3934,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } else { setPrompt(next.text); + onTypingGuardDraftChange(); } setComposerCursor(nextCursor); setComposerTrigger( @@ -3931,6 +3957,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingUserInput, isLiteralPendingAnswer, onChangeActivePendingUserInputCustomAnswer, + onTypingGuardDraftChange, promptRef, setPrompt, setComposerTrigger, @@ -4269,6 +4296,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); setComposerSubmissionError(submission.validationMessage); if (!submission.didDispatch) return; + onTypingGuardSend(); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } @@ -4277,6 +4305,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, activePendingProgress, attachmentTargetKey, + onTypingGuardSend, blurMobileComposerAfterSend, environmentId, isSendDisabled, @@ -6532,6 +6561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }, getSendContext: () => ({ + answeringPendingUserInput: activePendingProgress !== null, prompt: promptRef.current, images: composerImagesRef.current, files: composerFilesRef.current, @@ -6578,6 +6608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) foldPastedText, composerDraftTarget, composerCursor, + activePendingProgress, composerTerminalContexts, insertComposerDraftTerminalContext, insertComposerText, @@ -6652,6 +6683,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }} onFocusCapture={(event) => { const activeElement = event.target; + if ( + activeElement instanceof Element && + activeElement.closest('[data-testid="composer-editor"]') + ) { + onTypingGuardFocus(); + } if (composerControlsCollapsed && isInsideRestingComposerControlScope(activeElement)) { return; } @@ -6670,7 +6707,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setIsComposerFocused(true); }} - onBlurCapture={() => { + onBlurCapture={(event) => { + if ( + event.target instanceof Element && + event.target.closest('[data-testid="composer-editor"]') + ) { + onTypingGuardBlur(); + } scheduleComposerCollapseCheck(); }} onDragEnterCapture={(event) => { diff --git a/apps/web/src/components/chat/composerTypingGuard.test.ts b/apps/web/src/components/chat/composerTypingGuard.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c43665e307813d794ecba480887a2af12c88c05 --- /dev/null +++ b/apps/web/src/components/chat/composerTypingGuard.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createComposerTypingGuardState, updateComposerTypingGuard } from "./composerTypingGuard"; + +function typing() { + return updateComposerTypingGuard( + updateComposerTypingGuard(createComposerTypingGuardState(), { type: "focus" }), + { type: "type", now: 1000 }, + ); +} + +describe("composer typing guard", () => { + it("holds new requests while the focused draft was recently edited", () => { + const state = updateComposerTypingGuard(typing(), { + type: "requests", + requestIds: ["approval", "question"], + now: 2499, + }); + expect([...state.held]).toEqual(["approval", "question"]); + }); + + it.each(["idle", "send", "blur"] as const)( + "releases on %s and never re-holds the request", + (type) => { + let state = updateComposerTypingGuard(typing(), { + type: "requests", + requestIds: ["question"], + now: 1200, + }); + state = updateComposerTypingGuard(state, { type }); + expect([...state.held]).toEqual([]); + state = updateComposerTypingGuard(state, { type: "focus" }); + state = updateComposerTypingGuard(state, { type: "type", now: 2000 }); + state = updateComposerTypingGuard(state, { type: "requests", requestIds: [], now: 2100 }); + state = updateComposerTypingGuard(state, { + type: "requests", + requestIds: ["question", "new-question"], + now: 2200, + }); + expect([...state.held]).toEqual(["new-question"]); + }, + ); + + it("shows requests immediately when idle or unfocused", () => { + for (const state of [ + createComposerTypingGuardState(), + typing(), + updateComposerTypingGuard(typing(), { type: "blur" }), + ]) { + expect([ + ...updateComposerTypingGuard(state, { + type: "requests", + requestIds: ["question"], + now: 2500, + }).held, + ]).toEqual([]); + } + }); + + it("does not hide a request that already took over when typing starts", () => { + let state = updateComposerTypingGuard(createComposerTypingGuardState(), { + type: "requests", + requestIds: ["question"], + now: 0, + }); + state = updateComposerTypingGuard(state, { type: "focus" }); + state = updateComposerTypingGuard(state, { type: "type", now: 1000 }); + state = updateComposerTypingGuard(state, { + type: "requests", + requestIds: ["question"], + now: 1100, + }); + expect([...state.held]).toEqual([]); + }); + + it("drops a hold when its request is resolved", () => { + const held = updateComposerTypingGuard(typing(), { + type: "requests", + requestIds: ["question"], + now: 1100, + }); + expect([ + ...updateComposerTypingGuard(held, { + type: "requests", + requestIds: [], + now: 1200, + }).held, + ]).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/composerTypingGuard.ts b/apps/web/src/components/chat/composerTypingGuard.ts new file mode 100644 index 0000000000000000000000000000000000000000..01c9cca77d4c3f3e75a1d2ac6c2c4c737b879dd7 --- /dev/null +++ b/apps/web/src/components/chat/composerTypingGuard.ts @@ -0,0 +1,51 @@ +export const COMPOSER_TYPING_IDLE_MS = 1500; + +export function createComposerTypingGuardState() { + return { + seen: new Set(), + held: new Set(), + focused: false, + lastTypedAt: -Infinity, + }; +} + +type TypingGuardEvent = + | { type: "requests"; requestIds: readonly string[]; now: number } + | { type: "type"; now: number } + | { type: "focus" | "blur" | "send" | "idle" }; + +export function updateComposerTypingGuard( + state: ReturnType, + event: TypingGuardEvent, +) { + switch (event.type) { + case "requests": { + const seen = new Set(state.seen); + const held = new Set([...state.held].filter((id) => event.requestIds.includes(id))); + for (const id of event.requestIds) { + if ( + !seen.has(id) && + state.focused && + event.now - state.lastTypedAt < COMPOSER_TYPING_IDLE_MS + ) { + held.add(id); + } + seen.add(id); + } + return { ...state, seen, held }; + } + case "type": + return { ...state, lastTypedAt: event.now }; + case "focus": + return { ...state, focused: true }; + case "blur": + case "send": + case "idle": + return { + ...state, + held: new Set(), + lastTypedAt: -Infinity, + focused: event.type === "blur" ? false : state.focused, + }; + } +} diff --git a/apps/web/src/components/chat/useComposerTypingGuard.ts b/apps/web/src/components/chat/useComposerTypingGuard.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf3de772b3782f0a351020054fb8851b87ae6f0e --- /dev/null +++ b/apps/web/src/components/chat/useComposerTypingGuard.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from "react"; +import { + COMPOSER_TYPING_IDLE_MS, + createComposerTypingGuardState, + updateComposerTypingGuard, +} from "./composerTypingGuard"; + +export function useComposerTypingGuard(scope: string, requestIds: readonly string[]) { + const requestsKey = JSON.stringify(requestIds); + const [snapshot, setSnapshot] = useState(() => ({ + scope, + requestsKey, + state: updateComposerTypingGuard(createComposerTypingGuardState(), { + type: "requests", + requestIds, + now: Date.now(), + }), + })); + const state = snapshot.state; + if (snapshot.scope !== scope || snapshot.requestsKey !== requestsKey) { + setSnapshot((current) => ({ + scope, + requestsKey, + state: updateComposerTypingGuard( + current.scope === scope + ? current.state + : { ...createComposerTypingGuardState(), focused: current.state.focused }, + { type: "requests", requestIds, now: Date.now() }, + ), + })); + } + const dispatch = useCallback((event: Parameters[1]) => { + setSnapshot((current) => ({ + ...current, + state: updateComposerTypingGuard(current.state, event), + })); + }, []); + const onDraftChange = useCallback(() => dispatch({ type: "type", now: Date.now() }), [dispatch]); + const onFocus = useCallback(() => dispatch({ type: "focus" }), [dispatch]); + const onBlur = useCallback(() => dispatch({ type: "blur" }), [dispatch]); + const onSend = useCallback(() => dispatch({ type: "send" }), [dispatch]); + useEffect(() => { + if (state.held.size === 0) return; + const timeout = window.setTimeout( + () => dispatch({ type: "idle" }), + Math.max(0, state.lastTypedAt + COMPOSER_TYPING_IDLE_MS - Date.now()), + ); + return () => window.clearTimeout(timeout); + }, [state.held, state.lastTypedAt, dispatch]); + return { heldRequestIds: state.held, onDraftChange, onFocus, onBlur, onSend }; +} -- 2.54.0