authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 20:46:38-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 20:46:38-03:00
log99d2651dd5f82eeed800672e371427d7266dfe72
tree7a17af81ee7d1cac61aa00185b1eb89b36bfdfb1
parent0c4012055deacd43876b80902caad6dd7d6a2eef
signature Signed by PGP key B5690EEEBB952194

fix(web): pending requests wait until you stop typing (#17637)


5 files changed, 242 insertions(+), 8 deletions(-)

apps/web/src/components/ChatView.tsx+2-2
...@@ -8808,7 +8808,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -8808,7 +8808,8 @@ export default function ChatView(props: ChatViewProps) {
8808 });8808 });
8809 return;8809 return;
8810 }8810 }
8811 if (activePendingProgress) {8811 const sendCtx = composerRef.current?.getSendContext();
8812 if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) {
8812 if (directAnnotation) {8813 if (directAnnotation) {
8813 notifyDirectAnnotationAttached();8814 notifyDirectAnnotationAttached();
8814 return;8815 return;
...@@ -8816,7 +8817,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -8816,7 +8817,6 @@ export default function ChatView(props: ChatViewProps) {
8816 onAdvanceActivePendingUserInput();8817 onAdvanceActivePendingUserInput();
8817 return;8818 return;
8818 }8819 }
8819 const sendCtx = composerRef.current?.getSendContext();
8820 if (!sendCtx?.providerAvailable) {8820 if (!sendCtx?.providerAvailable) {
8821 notifyDirectAnnotationAttached();8821 notifyDirectAnnotationAttached();
8822 return;8822 return;
apps/web/src/components/chat/ChatComposer.tsx+49-6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import { useComposerTypingGuard } from "./useComposerTypingGuard";
2import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";3import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
3import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";4import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
4import { isLocalEnvironmentDisabled } from "../../localEnvironment";5import { isLocalEnvironmentDisabled } from "../../localEnvironment";
...@@ -1485,6 +1486,7 @@ export interface ChatComposerHandle {...@@ -1485,6 +1486,7 @@ export interface ChatComposerHandle {
1485 addTerminalContext: (selection: TerminalContextSelection) => void;1486 addTerminalContext: (selection: TerminalContextSelection) => void;
1486 /** Get the current prompt/effort/model state for use in send. */1487 /** Get the current prompt/effort/model state for use in send. */
1487 getSendContext: () => {1488 getSendContext: () => {
1489 answeringPendingUserInput: boolean;
1488 prompt: string;1490 prompt: string;
1489 images: ComposerImageAttachment[];1491 images: ComposerImageAttachment[];
1490 files: ComposerFileAttachment[];1492 files: ComposerFileAttachment[];
...@@ -1737,12 +1739,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1737,12 +1739,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1737 sendDisabledReason: externalSendDisabledReason,1739 sendDisabledReason: externalSendDisabledReason,
1738 isPreparingWorktree,1740 isPreparingWorktree,
1739 environmentUnavailable,1741 environmentUnavailable,
1740 activePendingApproval,1742 activePendingApproval: incomingPendingApproval,
1741 pendingApprovals,1743 pendingApprovals,
1742 pendingUserInputs,1744 pendingUserInputs: incomingPendingUserInputs,
1743 activePendingProgress,1745 activePendingProgress: incomingPendingProgress,
1744 activePendingResolvedAnswers,1746 activePendingResolvedAnswers,
1745 activePendingIsResponding,1747 activePendingIsResponding: incomingPendingIsResponding,
1746 activePendingDraftAnswers,1748 activePendingDraftAnswers,
1747 activePendingQuestionIndex,1749 activePendingQuestionIndex,
1748 respondingRequestIds,1750 respondingRequestIds,
...@@ -1808,9 +1810,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1808,9 +1810,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1808 editingQueuedAttachments,1810 editingQueuedAttachments,
1809 onRemoveEditingQueuedAttachment,1811 onRemoveEditingQueuedAttachment,
1810 } = props;1812 } = props;
1811 const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined;
1812 const primaryEnvironmentId = usePrimaryEnvironmentId();1813 const primaryEnvironmentId = usePrimaryEnvironmentId();
1813 const composerDraftTargetKey = composerTargetKey(composerDraftTarget);1814 const composerDraftTargetKey = composerTargetKey(composerDraftTarget);
1815 const {
1816 heldRequestIds,
1817 onDraftChange: onTypingGuardDraftChange,
1818 onFocus: onTypingGuardFocus,
1819 onBlur: onTypingGuardBlur,
1820 onSend: onTypingGuardSend,
1821 } = useComposerTypingGuard(composerDraftTargetKey, [
1822 ...pendingApprovals.map((request) => request.requestId),
1823 ...incomingPendingUserInputs.map((request) => request.requestId),
1824 ]);
1825 // Hold composer takeover while the user finishes their thread draft.
1826 const activePendingApproval =
1827 incomingPendingApproval && !heldRequestIds.has(incomingPendingApproval.requestId)
1828 ? incomingPendingApproval
1829 : null;
1830 const holdingUserInput = incomingPendingUserInputs[0]
1831 ? heldRequestIds.has(incomingPendingUserInputs[0].requestId)
1832 : false;
1833 const pendingUserInputs = holdingUserInput ? [] : incomingPendingUserInputs;
1834 const activePendingProgress = holdingUserInput ? null : incomingPendingProgress;
1835 const activePendingIsResponding = !holdingUserInput && incomingPendingIsResponding;
1836 const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined;
1814 // Opening a running thread resyncs for a few frames. Show the sync row, and1837 // Opening a running thread resyncs for a few frames. Show the sync row, and
1815 // hide the tasks row for it, only when the sync lasts. Logic that depends on1838 // hide the tasks row for it, only when the sync lasts. Logic that depends on
1816 // the real phase keeps reading `props.threadSyncPhase`.1839 // the real phase keeps reading `props.threadSyncPhase`.
...@@ -3712,6 +3735,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3712,6 +3735,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3712 );3735 );
3713 return;3736 return;
3714 }3737 }
3738 onTypingGuardDraftChange();
3715 promptRef.current = nextPrompt;3739 promptRef.current = nextPrompt;
3716 setPrompt(nextPrompt);3740 setPrompt(nextPrompt);
3717 // Any edit ends browsing, even one later undone by hand: typing a3741 // Any edit ends browsing, even one later undone by hand: typing a
...@@ -3818,6 +3842,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3818,6 +3842,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3818 [3842 [
3819 activePendingProgress?.activeQuestion,3843 activePendingProgress?.activeQuestion,
3820 expandComposerForEditorChange,3844 expandComposerForEditorChange,
3845 onTypingGuardDraftChange,
3821 pendingUserInputs.length,3846 pendingUserInputs.length,
3822 onChangeActivePendingUserInputCustomAnswer,3847 onChangeActivePendingUserInputCustomAnswer,
3823 promptRef,3848 promptRef,
...@@ -3909,6 +3934,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3909,6 +3934,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3909 );3934 );
3910 } else {3935 } else {
3911 setPrompt(next.text);3936 setPrompt(next.text);
3937 onTypingGuardDraftChange();
3912 }3938 }
3913 setComposerCursor(nextCursor);3939 setComposerCursor(nextCursor);
3914 setComposerTrigger(3940 setComposerTrigger(
...@@ -3931,6 +3957,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3931,6 +3957,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3931 activePendingUserInput,3957 activePendingUserInput,
3932 isLiteralPendingAnswer,3958 isLiteralPendingAnswer,
3933 onChangeActivePendingUserInputCustomAnswer,3959 onChangeActivePendingUserInputCustomAnswer,
3960 onTypingGuardDraftChange,
3934 promptRef,3961 promptRef,
3935 setPrompt,3962 setPrompt,
3936 setComposerTrigger,3963 setComposerTrigger,
...@@ -4269,6 +4296,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4269,6 +4296,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4269 });4296 });
4270 setComposerSubmissionError(submission.validationMessage);4297 setComposerSubmissionError(submission.validationMessage);
4271 if (!submission.didDispatch) return;4298 if (!submission.didDispatch) return;
4299 onTypingGuardSend();
4272 if (shouldBlurMobileComposerOnSubmit()) {4300 if (shouldBlurMobileComposerOnSubmit()) {
4273 blurMobileComposerAfterSend();4301 blurMobileComposerAfterSend();
4274 }4302 }
...@@ -4277,6 +4305,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4277,6 +4305,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4277 activeThreadId,4305 activeThreadId,
4278 activePendingProgress,4306 activePendingProgress,
4279 attachmentTargetKey,4307 attachmentTargetKey,
4308 onTypingGuardSend,
4280 blurMobileComposerAfterSend,4309 blurMobileComposerAfterSend,
4281 environmentId,4310 environmentId,
4282 isSendDisabled,4311 isSendDisabled,
...@@ -6532,6 +6561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6532,6 +6561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6532 });6561 });
6533 },6562 },
6534 getSendContext: () => ({6563 getSendContext: () => ({
6564 answeringPendingUserInput: activePendingProgress !== null,
6535 prompt: promptRef.current,6565 prompt: promptRef.current,
6536 images: composerImagesRef.current,6566 images: composerImagesRef.current,
6537 files: composerFilesRef.current,6567 files: composerFilesRef.current,
...@@ -6578,6 +6608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6578,6 +6608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6578 foldPastedText,6608 foldPastedText,
6579 composerDraftTarget,6609 composerDraftTarget,
6580 composerCursor,6610 composerCursor,
6611 activePendingProgress,
6581 composerTerminalContexts,6612 composerTerminalContexts,
6582 insertComposerDraftTerminalContext,6613 insertComposerDraftTerminalContext,
6583 insertComposerText,6614 insertComposerText,
...@@ -6652,6 +6683,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6652,6 +6683,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6652 }}6683 }}
6653 onFocusCapture={(event) => {6684 onFocusCapture={(event) => {
6654 const activeElement = event.target;6685 const activeElement = event.target;
6686 if (
6687 activeElement instanceof Element &&
6688 activeElement.closest('[data-testid="composer-editor"]')
6689 ) {
6690 onTypingGuardFocus();
6691 }
6655 if (composerControlsCollapsed && isInsideRestingComposerControlScope(activeElement)) {6692 if (composerControlsCollapsed && isInsideRestingComposerControlScope(activeElement)) {
6656 return;6693 return;
6657 }6694 }
...@@ -6670,7 +6707,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6670,7 +6707,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6670 }6707 }
6671 setIsComposerFocused(true);6708 setIsComposerFocused(true);
6672 }}6709 }}
6673 onBlurCapture={() => {6710 onBlurCapture={(event) => {
6711 if (
6712 event.target instanceof Element &&
6713 event.target.closest('[data-testid="composer-editor"]')
6714 ) {
6715 onTypingGuardBlur();
6716 }
6674 scheduleComposerCollapseCheck();6717 scheduleComposerCollapseCheck();
6675 }}6718 }}
6676 onDragEnterCapture={(event) => {6719 onDragEnterCapture={(event) => {
apps/web/src/components/chat/composerTypingGuard.test.ts created+89
...@@ -0,0 +1,89 @@
1import { describe, expect, it } from "vite-plus/test";
2import { createComposerTypingGuardState, updateComposerTypingGuard } from "./composerTypingGuard";
3
4function typing() {
5 return updateComposerTypingGuard(
6 updateComposerTypingGuard(createComposerTypingGuardState(), { type: "focus" }),
7 { type: "type", now: 1000 },
8 );
9}
10
11describe("composer typing guard", () => {
12 it("holds new requests while the focused draft was recently edited", () => {
13 const state = updateComposerTypingGuard(typing(), {
14 type: "requests",
15 requestIds: ["approval", "question"],
16 now: 2499,
17 });
18 expect([...state.held]).toEqual(["approval", "question"]);
19 });
20
21 it.each(["idle", "send", "blur"] as const)(
22 "releases on %s and never re-holds the request",
23 (type) => {
24 let state = updateComposerTypingGuard(typing(), {
25 type: "requests",
26 requestIds: ["question"],
27 now: 1200,
28 });
29 state = updateComposerTypingGuard(state, { type });
30 expect([...state.held]).toEqual([]);
31 state = updateComposerTypingGuard(state, { type: "focus" });
32 state = updateComposerTypingGuard(state, { type: "type", now: 2000 });
33 state = updateComposerTypingGuard(state, { type: "requests", requestIds: [], now: 2100 });
34 state = updateComposerTypingGuard(state, {
35 type: "requests",
36 requestIds: ["question", "new-question"],
37 now: 2200,
38 });
39 expect([...state.held]).toEqual(["new-question"]);
40 },
41 );
42
43 it("shows requests immediately when idle or unfocused", () => {
44 for (const state of [
45 createComposerTypingGuardState(),
46 typing(),
47 updateComposerTypingGuard(typing(), { type: "blur" }),
48 ]) {
49 expect([
50 ...updateComposerTypingGuard(state, {
51 type: "requests",
52 requestIds: ["question"],
53 now: 2500,
54 }).held,
55 ]).toEqual([]);
56 }
57 });
58
59 it("does not hide a request that already took over when typing starts", () => {
60 let state = updateComposerTypingGuard(createComposerTypingGuardState(), {
61 type: "requests",
62 requestIds: ["question"],
63 now: 0,
64 });
65 state = updateComposerTypingGuard(state, { type: "focus" });
66 state = updateComposerTypingGuard(state, { type: "type", now: 1000 });
67 state = updateComposerTypingGuard(state, {
68 type: "requests",
69 requestIds: ["question"],
70 now: 1100,
71 });
72 expect([...state.held]).toEqual([]);
73 });
74
75 it("drops a hold when its request is resolved", () => {
76 const held = updateComposerTypingGuard(typing(), {
77 type: "requests",
78 requestIds: ["question"],
79 now: 1100,
80 });
81 expect([
82 ...updateComposerTypingGuard(held, {
83 type: "requests",
84 requestIds: [],
85 now: 1200,
86 }).held,
87 ]).toEqual([]);
88 });
89});
apps/web/src/components/chat/composerTypingGuard.ts created+51
...@@ -0,0 +1,51 @@
1export const COMPOSER_TYPING_IDLE_MS = 1500;
2
3export function createComposerTypingGuardState() {
4 return {
5 seen: new Set<string>(),
6 held: new Set<string>(),
7 focused: false,
8 lastTypedAt: -Infinity,
9 };
10}
11
12type TypingGuardEvent =
13 | { type: "requests"; requestIds: readonly string[]; now: number }
14 | { type: "type"; now: number }
15 | { type: "focus" | "blur" | "send" | "idle" };
16
17export function updateComposerTypingGuard(
18 state: ReturnType<typeof createComposerTypingGuardState>,
19 event: TypingGuardEvent,
20) {
21 switch (event.type) {
22 case "requests": {
23 const seen = new Set(state.seen);
24 const held = new Set([...state.held].filter((id) => event.requestIds.includes(id)));
25 for (const id of event.requestIds) {
26 if (
27 !seen.has(id) &&
28 state.focused &&
29 event.now - state.lastTypedAt < COMPOSER_TYPING_IDLE_MS
30 ) {
31 held.add(id);
32 }
33 seen.add(id);
34 }
35 return { ...state, seen, held };
36 }
37 case "type":
38 return { ...state, lastTypedAt: event.now };
39 case "focus":
40 return { ...state, focused: true };
41 case "blur":
42 case "send":
43 case "idle":
44 return {
45 ...state,
46 held: new Set<string>(),
47 lastTypedAt: -Infinity,
48 focused: event.type === "blur" ? false : state.focused,
49 };
50 }
51}
apps/web/src/components/chat/useComposerTypingGuard.ts created+51
...@@ -0,0 +1,51 @@
1import { useCallback, useEffect, useState } from "react";
2import {
3 COMPOSER_TYPING_IDLE_MS,
4 createComposerTypingGuardState,
5 updateComposerTypingGuard,
6} from "./composerTypingGuard";
7
8export function useComposerTypingGuard(scope: string, requestIds: readonly string[]) {
9 const requestsKey = JSON.stringify(requestIds);
10 const [snapshot, setSnapshot] = useState(() => ({
11 scope,
12 requestsKey,
13 state: updateComposerTypingGuard(createComposerTypingGuardState(), {
14 type: "requests",
15 requestIds,
16 now: Date.now(),
17 }),
18 }));
19 const state = snapshot.state;
20 if (snapshot.scope !== scope || snapshot.requestsKey !== requestsKey) {
21 setSnapshot((current) => ({
22 scope,
23 requestsKey,
24 state: updateComposerTypingGuard(
25 current.scope === scope
26 ? current.state
27 : { ...createComposerTypingGuardState(), focused: current.state.focused },
28 { type: "requests", requestIds, now: Date.now() },
29 ),
30 }));
31 }
32 const dispatch = useCallback((event: Parameters<typeof updateComposerTypingGuard>[1]) => {
33 setSnapshot((current) => ({
34 ...current,
35 state: updateComposerTypingGuard(current.state, event),
36 }));
37 }, []);
38 const onDraftChange = useCallback(() => dispatch({ type: "type", now: Date.now() }), [dispatch]);
39 const onFocus = useCallback(() => dispatch({ type: "focus" }), [dispatch]);
40 const onBlur = useCallback(() => dispatch({ type: "blur" }), [dispatch]);
41 const onSend = useCallback(() => dispatch({ type: "send" }), [dispatch]);
42 useEffect(() => {
43 if (state.held.size === 0) return;
44 const timeout = window.setTimeout(
45 () => dispatch({ type: "idle" }),
46 Math.max(0, state.lastTypedAt + COMPOSER_TYPING_IDLE_MS - Date.now()),
47 );
48 return () => window.clearTimeout(timeout);
49 }, [state.held, state.lastTypedAt, dispatch]);
50 return { heldRequestIds: state.held, onDraftChange, onFocus, onBlur, onSend };
51}