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) {
88088808 });
88098809 return;
88108810 }
8811 if (activePendingProgress) {
8811 const sendCtx = composerRef.current?.getSendContext();
8812 if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) {
88128813 if (directAnnotation) {
88138814 notifyDirectAnnotationAttached();
88148815 return;
......@@ -8816,7 +8817,6 @@ export default function ChatView(props: ChatViewProps) {
88168817 onAdvanceActivePendingUserInput();
88178818 return;
88188819 }
8819 const sendCtx = composerRef.current?.getSendContext();
88208820 if (!sendCtx?.providerAvailable) {
88218821 notifyDirectAnnotationAttached();
88228822 return;
apps/web/src/components/chat/ChatComposer.tsx+49-6
......@@ -1,4 +1,5 @@
11import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import { useComposerTypingGuard } from "./useComposerTypingGuard";
23import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
34import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
45import { isLocalEnvironmentDisabled } from "../../localEnvironment";
......@@ -1485,6 +1486,7 @@ export interface ChatComposerHandle {
14851486 addTerminalContext: (selection: TerminalContextSelection) => void;
14861487 /** Get the current prompt/effort/model state for use in send. */
14871488 getSendContext: () => {
1489 answeringPendingUserInput: boolean;
14881490 prompt: string;
14891491 images: ComposerImageAttachment[];
14901492 files: ComposerFileAttachment[];
......@@ -1737,12 +1739,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17371739 sendDisabledReason: externalSendDisabledReason,
17381740 isPreparingWorktree,
17391741 environmentUnavailable,
1740 activePendingApproval,
1742 activePendingApproval: incomingPendingApproval,
17411743 pendingApprovals,
1742 pendingUserInputs,
1743 activePendingProgress,
1744 pendingUserInputs: incomingPendingUserInputs,
1745 activePendingProgress: incomingPendingProgress,
17441746 activePendingResolvedAnswers,
1745 activePendingIsResponding,
1747 activePendingIsResponding: incomingPendingIsResponding,
17461748 activePendingDraftAnswers,
17471749 activePendingQuestionIndex,
17481750 respondingRequestIds,
......@@ -1808,9 +1810,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18081810 editingQueuedAttachments,
18091811 onRemoveEditingQueuedAttachment,
18101812 } = props;
1811 const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined;
18121813 const primaryEnvironmentId = usePrimaryEnvironmentId();
18131814 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;
18141837 // Opening a running thread resyncs for a few frames. Show the sync row, and
18151838 // hide the tasks row for it, only when the sync lasts. Logic that depends on
18161839 // the real phase keeps reading `props.threadSyncPhase`.
......@@ -3712,6 +3735,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37123735 );
37133736 return;
37143737 }
3738 onTypingGuardDraftChange();
37153739 promptRef.current = nextPrompt;
37163740 setPrompt(nextPrompt);
37173741 // Any edit ends browsing, even one later undone by hand: typing a
......@@ -3818,6 +3842,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38183842 [
38193843 activePendingProgress?.activeQuestion,
38203844 expandComposerForEditorChange,
3845 onTypingGuardDraftChange,
38213846 pendingUserInputs.length,
38223847 onChangeActivePendingUserInputCustomAnswer,
38233848 promptRef,
......@@ -3909,6 +3934,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39093934 );
39103935 } else {
39113936 setPrompt(next.text);
3937 onTypingGuardDraftChange();
39123938 }
39133939 setComposerCursor(nextCursor);
39143940 setComposerTrigger(
......@@ -3931,6 +3957,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39313957 activePendingUserInput,
39323958 isLiteralPendingAnswer,
39333959 onChangeActivePendingUserInputCustomAnswer,
3960 onTypingGuardDraftChange,
39343961 promptRef,
39353962 setPrompt,
39363963 setComposerTrigger,
......@@ -4269,6 +4296,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42694296 });
42704297 setComposerSubmissionError(submission.validationMessage);
42714298 if (!submission.didDispatch) return;
4299 onTypingGuardSend();
42724300 if (shouldBlurMobileComposerOnSubmit()) {
42734301 blurMobileComposerAfterSend();
42744302 }
......@@ -4277,6 +4305,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42774305 activeThreadId,
42784306 activePendingProgress,
42794307 attachmentTargetKey,
4308 onTypingGuardSend,
42804309 blurMobileComposerAfterSend,
42814310 environmentId,
42824311 isSendDisabled,
......@@ -6532,6 +6561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
65326561 });
65336562 },
65346563 getSendContext: () => ({
6564 answeringPendingUserInput: activePendingProgress !== null,
65356565 prompt: promptRef.current,
65366566 images: composerImagesRef.current,
65376567 files: composerFilesRef.current,
......@@ -6578,6 +6608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
65786608 foldPastedText,
65796609 composerDraftTarget,
65806610 composerCursor,
6611 activePendingProgress,
65816612 composerTerminalContexts,
65826613 insertComposerDraftTerminalContext,
65836614 insertComposerText,
......@@ -6652,6 +6683,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66526683 }}
66536684 onFocusCapture={(event) => {
66546685 const activeElement = event.target;
6686 if (
6687 activeElement instanceof Element &&
6688 activeElement.closest('[data-testid="composer-editor"]')
6689 ) {
6690 onTypingGuardFocus();
6691 }
66556692 if (composerControlsCollapsed && isInsideRestingComposerControlScope(activeElement)) {
66566693 return;
66576694 }
......@@ -6670,7 +6707,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66706707 }
66716708 setIsComposerFocused(true);
66726709 }}
6673 onBlurCapture={() => {
6710 onBlurCapture={(event) => {
6711 if (
6712 event.target instanceof Element &&
6713 event.target.closest('[data-testid="composer-editor"]')
6714 ) {
6715 onTypingGuardBlur();
6716 }
66746717 scheduleComposerCollapseCheck();
66756718 }}
66766719 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}