| author | |
| committer | |
| log | 99d2651dd5f82eeed800672e371427d7266dfe72 |
| tree | 7a17af81ee7d1cac61aa00185b1eb89b36bfdfb1 |
| parent | 0c4012055deacd43876b80902caad6dd7d6a2eef |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 8808 | }); |
| 8809 | 8809 | return; |
| 8810 | 8810 | } |
| 8811 | if (activePendingProgress) { | |
| 8811 | const sendCtx = composerRef.current?.getSendContext(); | |
| 8812 | if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) { | |
| 8812 | 8813 | if (directAnnotation) { |
| 8813 | 8814 | notifyDirectAnnotationAttached(); |
| 8814 | 8815 | return; |
| ... | ... | @@ -8816,7 +8817,6 @@ export default function ChatView(props: ChatViewProps) { |
| 8816 | 8817 | onAdvanceActivePendingUserInput(); |
| 8817 | 8818 | return; |
| 8818 | 8819 | } |
| 8819 | const sendCtx = composerRef.current?.getSendContext(); | |
| 8820 | 8820 | if (!sendCtx?.providerAvailable) { |
| 8821 | 8821 | notifyDirectAnnotationAttached(); |
| 8822 | 8822 | return; |
apps/web/src/components/chat/ChatComposer.tsx+49-6| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 2 | import { useComposerTypingGuard } from "./useComposerTypingGuard"; | |
| 2 | 3 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; |
| 3 | 4 | import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; |
| 4 | 5 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; |
| ... | ... | @@ -1485,6 +1486,7 @@ export interface ChatComposerHandle { |
| 1485 | 1486 | addTerminalContext: (selection: TerminalContextSelection) => void; |
| 1486 | 1487 | /** Get the current prompt/effort/model state for use in send. */ |
| 1487 | 1488 | getSendContext: () => { |
| 1489 | answeringPendingUserInput: boolean; | |
| 1488 | 1490 | prompt: string; |
| 1489 | 1491 | images: ComposerImageAttachment[]; |
| 1490 | 1492 | files: ComposerFileAttachment[]; |
| ... | ... | @@ -1737,12 +1739,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1737 | 1739 | sendDisabledReason: externalSendDisabledReason, |
| 1738 | 1740 | isPreparingWorktree, |
| 1739 | 1741 | environmentUnavailable, |
| 1740 | activePendingApproval, | |
| 1742 | activePendingApproval: incomingPendingApproval, | |
| 1741 | 1743 | pendingApprovals, |
| 1742 | pendingUserInputs, | |
| 1743 | activePendingProgress, | |
| 1744 | pendingUserInputs: incomingPendingUserInputs, | |
| 1745 | activePendingProgress: incomingPendingProgress, | |
| 1744 | 1746 | activePendingResolvedAnswers, |
| 1745 | activePendingIsResponding, | |
| 1747 | activePendingIsResponding: incomingPendingIsResponding, | |
| 1746 | 1748 | activePendingDraftAnswers, |
| 1747 | 1749 | activePendingQuestionIndex, |
| 1748 | 1750 | respondingRequestIds, |
| ... | ... | @@ -1808,9 +1810,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1808 | 1810 | editingQueuedAttachments, |
| 1809 | 1811 | onRemoveEditingQueuedAttachment, |
| 1810 | 1812 | } = props; |
| 1811 | const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; | |
| 1812 | 1813 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1813 | 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 | 1837 | // Opening a running thread resyncs for a few frames. Show the sync row, and |
| 1815 | 1838 | // hide the tasks row for it, only when the sync lasts. Logic that depends on |
| 1816 | 1839 | // the real phase keeps reading `props.threadSyncPhase`. |
| ... | ... | @@ -3712,6 +3735,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3712 | 3735 | ); |
| 3713 | 3736 | return; |
| 3714 | 3737 | } |
| 3738 | onTypingGuardDraftChange(); | |
| 3715 | 3739 | promptRef.current = nextPrompt; |
| 3716 | 3740 | setPrompt(nextPrompt); |
| 3717 | 3741 | // 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 | 3842 | [ |
| 3819 | 3843 | activePendingProgress?.activeQuestion, |
| 3820 | 3844 | expandComposerForEditorChange, |
| 3845 | onTypingGuardDraftChange, | |
| 3821 | 3846 | pendingUserInputs.length, |
| 3822 | 3847 | onChangeActivePendingUserInputCustomAnswer, |
| 3823 | 3848 | promptRef, |
| ... | ... | @@ -3909,6 +3934,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3909 | 3934 | ); |
| 3910 | 3935 | } else { |
| 3911 | 3936 | setPrompt(next.text); |
| 3937 | onTypingGuardDraftChange(); | |
| 3912 | 3938 | } |
| 3913 | 3939 | setComposerCursor(nextCursor); |
| 3914 | 3940 | setComposerTrigger( |
| ... | ... | @@ -3931,6 +3957,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3931 | 3957 | activePendingUserInput, |
| 3932 | 3958 | isLiteralPendingAnswer, |
| 3933 | 3959 | onChangeActivePendingUserInputCustomAnswer, |
| 3960 | onTypingGuardDraftChange, | |
| 3934 | 3961 | promptRef, |
| 3935 | 3962 | setPrompt, |
| 3936 | 3963 | setComposerTrigger, |
| ... | ... | @@ -4269,6 +4296,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4269 | 4296 | }); |
| 4270 | 4297 | setComposerSubmissionError(submission.validationMessage); |
| 4271 | 4298 | if (!submission.didDispatch) return; |
| 4299 | onTypingGuardSend(); | |
| 4272 | 4300 | if (shouldBlurMobileComposerOnSubmit()) { |
| 4273 | 4301 | blurMobileComposerAfterSend(); |
| 4274 | 4302 | } |
| ... | ... | @@ -4277,6 +4305,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4277 | 4305 | activeThreadId, |
| 4278 | 4306 | activePendingProgress, |
| 4279 | 4307 | attachmentTargetKey, |
| 4308 | onTypingGuardSend, | |
| 4280 | 4309 | blurMobileComposerAfterSend, |
| 4281 | 4310 | environmentId, |
| 4282 | 4311 | isSendDisabled, |
| ... | ... | @@ -6532,6 +6561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6532 | 6561 | }); |
| 6533 | 6562 | }, |
| 6534 | 6563 | getSendContext: () => ({ |
| 6564 | answeringPendingUserInput: activePendingProgress !== null, | |
| 6535 | 6565 | prompt: promptRef.current, |
| 6536 | 6566 | images: composerImagesRef.current, |
| 6537 | 6567 | files: composerFilesRef.current, |
| ... | ... | @@ -6578,6 +6608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6578 | 6608 | foldPastedText, |
| 6579 | 6609 | composerDraftTarget, |
| 6580 | 6610 | composerCursor, |
| 6611 | activePendingProgress, | |
| 6581 | 6612 | composerTerminalContexts, |
| 6582 | 6613 | insertComposerDraftTerminalContext, |
| 6583 | 6614 | insertComposerText, |
| ... | ... | @@ -6652,6 +6683,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6652 | 6683 | }} |
| 6653 | 6684 | onFocusCapture={(event) => { |
| 6654 | 6685 | const activeElement = event.target; |
| 6686 | if ( | |
| 6687 | activeElement instanceof Element && | |
| 6688 | activeElement.closest('[data-testid="composer-editor"]') | |
| 6689 | ) { | |
| 6690 | onTypingGuardFocus(); | |
| 6691 | } | |
| 6655 | 6692 | if (composerControlsCollapsed && isInsideRestingComposerControlScope(activeElement)) { |
| 6656 | 6693 | return; |
| 6657 | 6694 | } |
| ... | ... | @@ -6670,7 +6707,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6670 | 6707 | } |
| 6671 | 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 | 6717 | scheduleComposerCollapseCheck(); |
| 6675 | 6718 | }} |
| 6676 | 6719 | onDragEnterCapture={(event) => { |
apps/web/src/components/chat/composerTypingGuard.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { createComposerTypingGuardState, updateComposerTypingGuard } from "./composerTypingGuard"; | |
| 3 | ||
| 4 | function typing() { | |
| 5 | return updateComposerTypingGuard( | |
| 6 | updateComposerTypingGuard(createComposerTypingGuardState(), { type: "focus" }), | |
| 7 | { type: "type", now: 1000 }, | |
| 8 | ); | |
| 9 | } | |
| 10 | ||
| 11 | describe("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 @@ |
| 1 | export const COMPOSER_TYPING_IDLE_MS = 1500; | |
| 2 | ||
| 3 | export function createComposerTypingGuardState() { | |
| 4 | return { | |
| 5 | seen: new Set<string>(), | |
| 6 | held: new Set<string>(), | |
| 7 | focused: false, | |
| 8 | lastTypedAt: -Infinity, | |
| 9 | }; | |
| 10 | } | |
| 11 | ||
| 12 | type TypingGuardEvent = | |
| 13 | | { type: "requests"; requestIds: readonly string[]; now: number } | |
| 14 | | { type: "type"; now: number } | |
| 15 | | { type: "focus" | "blur" | "send" | "idle" }; | |
| 16 | ||
| 17 | export 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 @@ |
| 1 | import { useCallback, useEffect, useState } from "react"; | |
| 2 | import { | |
| 3 | COMPOSER_TYPING_IDLE_MS, | |
| 4 | createComposerTypingGuardState, | |
| 5 | updateComposerTypingGuard, | |
| 6 | } from "./composerTypingGuard"; | |
| 7 | ||
| 8 | export 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 | } |