| author | |
| committer | |
| log | a4bc7deb992cc3d48dd4a40ee1f84254f190f318 |
| tree | 5ff38dd6a55658018620fbb3561a5035b2e1bf16 |
| parent | a9ab9049bbbab89fe23dcfb26c3169ef70a0597e |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 230 insertions(+), 15 deletions(-)
apps/web/src/components/ComposerPromptEditorTiptap.tsx+4-2| ... | ... | @@ -137,7 +137,7 @@ export interface ComposerPromptEditorProps { |
| 137 | 137 | ) => void; |
| 138 | 138 | onVisibleSelectionChange?: () => void; |
| 139 | 139 | onCommandKeyDown?: ( |
| 140 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | |
| 140 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", | |
| 141 | 141 | event: KeyboardEvent, |
| 142 | 142 | isTaskItem?: boolean, |
| 143 | 143 | ) => boolean; |
| ... | ... | @@ -912,7 +912,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 912 | 912 | ? ("ArrowDown" as const) |
| 913 | 913 | : event.key === "ArrowUp" |
| 914 | 914 | ? ("ArrowUp" as const) |
| 915 | : null; | |
| 915 | : event.key === "Escape" | |
| 916 | ? ("Escape" as const) | |
| 917 | : null; | |
| 916 | 918 | if (!key) return false; |
| 917 | 919 | const handled = handler(key, event); |
| 918 | 920 | if (handled) { |
apps/web/src/components/chat/ChatComposer.tsx+39-13| ... | ... | @@ -124,6 +124,7 @@ import { |
| 124 | 124 | import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 125 | 125 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 126 | 126 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 127 | import { useComposerTriggerState } from "./useComposerTriggerState"; | |
| 127 | 128 | import { useComposerFocusState } from "./useComposerFocusState"; |
| 128 | 129 | import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; |
| 129 | 130 | import { |
| ... | ... | @@ -2065,9 +2066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2065 | 2066 | const [composerCursor, setComposerCursor] = useState(() => |
| 2066 | 2067 | collapseExpandedComposerCursor(prompt, prompt.length), |
| 2067 | 2068 | ); |
| 2068 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | |
| 2069 | detectComposerTrigger(prompt, prompt.length), | |
| 2070 | ); | |
| 2069 | const { | |
| 2070 | trigger: composerTrigger, | |
| 2071 | setTrigger: setComposerTrigger, | |
| 2072 | resolveTrigger: resolveComposerTrigger, | |
| 2073 | dismissTrigger: dismissComposerTrigger, | |
| 2074 | resetTrigger: resetComposerTrigger, | |
| 2075 | } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); | |
| 2071 | 2076 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 2072 | 2077 | // Active ArrowUp recall. Cleared on edit and on thread switch. |
| 2073 | 2078 | const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null); |
| ... | ... | @@ -2586,7 +2591,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2586 | 2591 | setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); |
| 2587 | 2592 | scheduleComposerFocus(); |
| 2588 | 2593 | }, |
| 2589 | [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], | |
| 2594 | [ | |
| 2595 | composerDraftTarget, | |
| 2596 | promptRef, | |
| 2597 | scheduleComposerFocus, | |
| 2598 | setComposerDraftPrompt, | |
| 2599 | setComposerTrigger, | |
| 2600 | ], | |
| 2590 | 2601 | ); |
| 2591 | 2602 | |
| 2592 | 2603 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ |
| ... | ... | @@ -3090,7 +3101,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3090 | 3101 | promptRef.current = prompt; |
| 3091 | 3102 | const { cursor, trigger } = composerStateAtPromptEnd(prompt); |
| 3092 | 3103 | setComposerCursor(cursor); |
| 3093 | setComposerTrigger(trigger); | |
| 3104 | resetComposerTrigger(trigger); | |
| 3094 | 3105 | } |
| 3095 | 3106 | lastSyncedPendingInputRef.current = null; |
| 3096 | 3107 | return; |
| ... | ... | @@ -3115,7 +3126,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3115 | 3126 | promptRef.current = nextCustomAnswer; |
| 3116 | 3127 | const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); |
| 3117 | 3128 | setComposerCursor(cursor); |
| 3118 | setComposerTrigger(trigger); | |
| 3129 | resetComposerTrigger(trigger); | |
| 3119 | 3130 | setComposerHighlightedItemId(null); |
| 3120 | 3131 | }, [ |
| 3121 | 3132 | activePendingProgress?.customAnswer, |
| ... | ... | @@ -3123,6 +3134,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3123 | 3134 | activePendingUserInput?.requestId, |
| 3124 | 3135 | prompt, |
| 3125 | 3136 | promptRef, |
| 3137 | resetComposerTrigger, | |
| 3126 | 3138 | ]); |
| 3127 | 3139 | |
| 3128 | 3140 | // ------------------------------------------------------------------ |
| ... | ... | @@ -3133,10 +3145,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3133 | 3145 | setComposerSubmissionError(null); |
| 3134 | 3146 | setProviderInputSubmissionError(null); |
| 3135 | 3147 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); |
| 3136 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | |
| 3148 | resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | |
| 3137 | 3149 | setIsDragOverComposer(false); |
| 3138 | 3150 | setIsComposerScrollCollapsed(false); |
| 3139 | }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); | |
| 3151 | }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); | |
| 3140 | 3152 | |
| 3141 | 3153 | // ------------------------------------------------------------------ |
| 3142 | 3154 | // Footer compact layout observation |
| ... | ... | @@ -3419,6 +3431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3419 | 3431 | onChangeActivePendingUserInputCustomAnswer, |
| 3420 | 3432 | promptRef, |
| 3421 | 3433 | setPrompt, |
| 3434 | setComposerTrigger, | |
| 3422 | 3435 | composerDraftTarget, |
| 3423 | 3436 | composerTerminalContexts, |
| 3424 | 3437 | setComposerDraftTerminalContexts, |
| ... | ... | @@ -3514,6 +3527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3514 | 3527 | onChangeActivePendingUserInputCustomAnswer, |
| 3515 | 3528 | promptRef, |
| 3516 | 3529 | setPrompt, |
| 3530 | setComposerTrigger, | |
| 3517 | 3531 | ], |
| 3518 | 3532 | ); |
| 3519 | 3533 | |
| ... | ... | @@ -3542,9 +3556,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3542 | 3556 | const snapshot = readComposerSnapshot(); |
| 3543 | 3557 | return { |
| 3544 | 3558 | snapshot, |
| 3545 | trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | |
| 3559 | trigger: resolveComposerTrigger( | |
| 3560 | detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | |
| 3561 | ), | |
| 3546 | 3562 | }; |
| 3547 | }, [readComposerSnapshot]); | |
| 3563 | }, [readComposerSnapshot, resolveComposerTrigger]); | |
| 3548 | 3564 | |
| 3549 | 3565 | const { onUsageLimitsCommand } = props; |
| 3550 | 3566 | const onSelectComposerItem = useCallback( |
| ... | ... | @@ -3897,7 +3913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3897 | 3913 | setComposerTrigger(null); |
| 3898 | 3914 | setComposerHighlightedItemId(null); |
| 3899 | 3915 | }, |
| 3900 | [composerDraftTarget, promptRef, setComposerDraftPrompt], | |
| 3916 | [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger], | |
| 3901 | 3917 | ); |
| 3902 | 3918 | |
| 3903 | 3919 | const navigatePromptHistory = useCallback( |
| ... | ... | @@ -3956,7 +3972,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3956 | 3972 | // Callbacks: command key |
| 3957 | 3973 | // ------------------------------------------------------------------ |
| 3958 | 3974 | const onComposerCommandKey = ( |
| 3959 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", | |
| 3975 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", | |
| 3960 | 3976 | event: KeyboardEvent, |
| 3961 | 3977 | isTaskItem = false, |
| 3962 | 3978 | ) => { |
| ... | ... | @@ -3967,6 +3983,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3967 | 3983 | } |
| 3968 | 3984 | const { trigger } = resolveActiveComposerTrigger(); |
| 3969 | 3985 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; |
| 3986 | if (key === "Escape") { | |
| 3987 | if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; | |
| 3988 | dismissComposerTrigger(trigger); | |
| 3989 | composerMenuOpenRef.current = false; | |
| 3990 | return true; | |
| 3991 | } | |
| 3970 | 3992 | if (menuIsActive) { |
| 3971 | 3993 | const currentItems = composerMenuItemsRef.current; |
| 3972 | 3994 | const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; |
| ... | ... | @@ -4356,6 +4378,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4356 | 4378 | environmentId, |
| 4357 | 4379 | promptRef, |
| 4358 | 4380 | setComposerDraftPrompt, |
| 4381 | setComposerTrigger, | |
| 4359 | 4382 | takeStashEntry, |
| 4360 | 4383 | importContextRecords, |
| 4361 | 4384 | ], |
| ... | ... | @@ -4619,6 +4642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4619 | 4642 | clearComposerDraftPromptAndImages, |
| 4620 | 4643 | clearComposerDraftTerminalContexts, |
| 4621 | 4644 | setComposerDraftPrompt, |
| 4645 | setComposerTrigger, | |
| 4622 | 4646 | composerDraftTarget, |
| 4623 | 4647 | composerFilesRef, |
| 4624 | 4648 | composerImagesRef, |
| ... | ... | @@ -5932,7 +5956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5932 | 5956 | const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); |
| 5933 | 5957 | setComposerHighlightedItemId(null); |
| 5934 | 5958 | setComposerCursor(cursor); |
| 5935 | setComposerTrigger( | |
| 5959 | resetComposerTrigger( | |
| 5936 | 5960 | options?.detectTrigger |
| 5937 | 5961 | ? detectComposerTrigger( |
| 5938 | 5962 | promptForState, |
| ... | ... | @@ -6041,6 +6065,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6041 | 6065 | isComposerModelPickerOpen, |
| 6042 | 6066 | openModelPicker, |
| 6043 | 6067 | readComposerSnapshot, |
| 6068 | resetComposerTrigger, | |
| 6069 | setComposerTrigger, | |
| 6044 | 6070 | selectedModel, |
| 6045 | 6071 | selectedModelOptionsForDispatch, |
| 6046 | 6072 | selectedModelSelection, |
apps/web/src/components/chat/useComposerTriggerState.test.tsx created+145| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | import { act, StrictMode, useLayoutEffect } from "react"; | |
| 2 | import { createRoot, type Root } from "react-dom/client"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { detectComposerTrigger } from "../../composer-logic"; | |
| 6 | import { useComposerTriggerState } from "./useComposerTriggerState"; | |
| 7 | ||
| 8 | const command = "pnpm install -g @openai/codex@latest"; | |
| 9 | const initialPrompt = "pnpm install -g @openai"; | |
| 10 | let root: Root; | |
| 11 | let composer: ReturnType<typeof useComposerTriggerState>; | |
| 12 | ||
| 13 | function ComposerProbe() { | |
| 14 | const state = useComposerTriggerState(() => | |
| 15 | detectComposerTrigger(initialPrompt, initialPrompt.length), | |
| 16 | ); | |
| 17 | useLayoutEffect(() => { | |
| 18 | composer = state; | |
| 19 | }); | |
| 20 | return null; | |
| 21 | } | |
| 22 | ||
| 23 | async function updatePrompt(text: string, cursor = text.length) { | |
| 24 | await act(() => composer.setTrigger(detectComposerTrigger(text, cursor))); | |
| 25 | } | |
| 26 | ||
| 27 | beforeEach(async () => { | |
| 28 | // The probe renders no DOM nodes, but ReactDOM still needs an event target. | |
| 29 | const document = { | |
| 30 | nodeType: 9, | |
| 31 | addEventListener() {}, | |
| 32 | removeEventListener() {}, | |
| 33 | }; | |
| 34 | const container = { | |
| 35 | nodeType: 1, | |
| 36 | tagName: "DIV", | |
| 37 | namespaceURI: "http://www.w3.org/1999/xhtml", | |
| 38 | ownerDocument: document, | |
| 39 | addEventListener() {}, | |
| 40 | removeEventListener() {}, | |
| 41 | }; | |
| 42 | vi.stubGlobal("document", document); | |
| 43 | vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); | |
| 44 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 45 | root = createRoot(container as unknown as HTMLElement); | |
| 46 | await act(() => | |
| 47 | root.render( | |
| 48 | <StrictMode> | |
| 49 | <ComposerProbe /> | |
| 50 | </StrictMode>, | |
| 51 | ), | |
| 52 | ); | |
| 53 | }); | |
| 54 | ||
| 55 | afterEach(async () => { | |
| 56 | await act(() => root.unmount()); | |
| 57 | vi.unstubAllGlobals(); | |
| 58 | }); | |
| 59 | ||
| 60 | describe("composer suggestion dismissal", () => { | |
| 61 | it("closes suggestions and rejects keyboard selection before the next render", async () => { | |
| 62 | const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); | |
| 63 | expect(composer.trigger).toEqual(candidate); | |
| 64 | ||
| 65 | await act(() => { | |
| 66 | composer.dismissTrigger(candidate); | |
| 67 | expect(composer.resolveTrigger(candidate)).toBeNull(); | |
| 68 | }); | |
| 69 | expect(composer.trigger).toBeNull(); | |
| 70 | }); | |
| 71 | ||
| 72 | it("stays dismissed while typing a scoped package, including its second @", async () => { | |
| 73 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 74 | ||
| 75 | for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) { | |
| 76 | const text = command.slice(0, cursor); | |
| 77 | await updatePrompt(text); | |
| 78 | expect(composer.trigger).toBeNull(); | |
| 79 | expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull(); | |
| 80 | } | |
| 81 | }); | |
| 82 | ||
| 83 | it("stays dismissed while deleting characters or moving within the same word", async () => { | |
| 84 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 85 | ||
| 86 | for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) { | |
| 87 | await updatePrompt(initialPrompt, cursor); | |
| 88 | expect(composer.trigger).toBeNull(); | |
| 89 | await updatePrompt(initialPrompt.slice(0, cursor)); | |
| 90 | expect(composer.trigger).toBeNull(); | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | it("opens suggestions for a new @ word after a space", async () => { | |
| 95 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 96 | await updatePrompt(`${command} `); | |
| 97 | await updatePrompt(`${command} @src`); | |
| 98 | ||
| 99 | expect(composer.trigger?.query).toBe("src"); | |
| 100 | expect(composer.trigger?.rangeStart).toBe(command.length + 1); | |
| 101 | }); | |
| 102 | ||
| 103 | it("opens a different token when the caret moves directly to it", async () => { | |
| 104 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 105 | await updatePrompt(`${command} @src`); | |
| 106 | ||
| 107 | expect(composer.trigger?.query).toBe("src"); | |
| 108 | }); | |
| 109 | ||
| 110 | it("can reopen after the caret leaves the dismissed word", async () => { | |
| 111 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 112 | await updatePrompt(initialPrompt, 0); | |
| 113 | await updatePrompt(initialPrompt); | |
| 114 | ||
| 115 | expect(composer.trigger?.query).toBe("openai"); | |
| 116 | }); | |
| 117 | ||
| 118 | it("can reopen at the same position after deleting and retyping @", async () => { | |
| 119 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 120 | const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@")); | |
| 121 | await updatePrompt(prefix); | |
| 122 | await updatePrompt(`${prefix}@`); | |
| 123 | ||
| 124 | expect(composer.trigger?.query).toBe(""); | |
| 125 | }); | |
| 126 | ||
| 127 | it("clears dismissal when switching drafts or pending questions", async () => { | |
| 128 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 129 | const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length); | |
| 130 | await act(() => composer.resetTrigger(candidate)); | |
| 131 | ||
| 132 | expect(composer.trigger).toEqual(candidate); | |
| 133 | expect(composer.resolveTrigger(candidate)).toEqual(candidate); | |
| 134 | }); | |
| 135 | ||
| 136 | it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => { | |
| 137 | await updatePrompt(text); | |
| 138 | await act(() => composer.dismissTrigger(composer.trigger)); | |
| 139 | await updatePrompt(`${text}x`); | |
| 140 | ||
| 141 | expect(composer.trigger).toBeNull(); | |
| 142 | await updatePrompt("@src"); | |
| 143 | expect(composer.trigger?.kind).toBe("path"); | |
| 144 | }); | |
| 145 | }); |
apps/web/src/components/chat/useComposerTriggerState.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { useCallback, useRef, useState } from "react"; | |
| 2 | ||
| 3 | import type { ComposerTrigger } from "../../composer-logic"; | |
| 4 | ||
| 5 | /** Keep a dismissed suggestion closed until the caret leaves its token. */ | |
| 6 | export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) { | |
| 7 | const [trigger, setActiveTrigger] = useState(initialTrigger); | |
| 8 | const dismissedTriggerRef = useRef<ComposerTrigger | null>(null); | |
| 9 | ||
| 10 | const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => { | |
| 11 | const dismissed = dismissedTriggerRef.current; | |
| 12 | return candidate && | |
| 13 | dismissed && | |
| 14 | candidate.kind === dismissed.kind && | |
| 15 | candidate.rangeStart === dismissed.rangeStart | |
| 16 | ? null | |
| 17 | : candidate; | |
| 18 | }, []); | |
| 19 | ||
| 20 | const setTrigger = useCallback( | |
| 21 | (candidate: ComposerTrigger | null) => { | |
| 22 | const activeTrigger = resolveTrigger(candidate); | |
| 23 | if (candidate === null || activeTrigger !== null) { | |
| 24 | dismissedTriggerRef.current = null; | |
| 25 | } | |
| 26 | setActiveTrigger(activeTrigger); | |
| 27 | }, | |
| 28 | [resolveTrigger], | |
| 29 | ); | |
| 30 | ||
| 31 | const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => { | |
| 32 | dismissedTriggerRef.current = candidate; | |
| 33 | setActiveTrigger(null); | |
| 34 | }, []); | |
| 35 | ||
| 36 | const resetTrigger = useCallback((candidate: ComposerTrigger | null) => { | |
| 37 | dismissedTriggerRef.current = null; | |
| 38 | setActiveTrigger(candidate); | |
| 39 | }, []); | |
| 40 | ||
| 41 | return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger }; | |
| 42 | } |