-
Notifications
You must be signed in to change notification settings - Fork 2.6k
support chat draft #5307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
support chat draft #5307
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| // npx vitest webview-ui/src/components/chat/hooks/__tests__/useChatTextDraft.spec.ts | ||
|
|
||
| import { renderHook, act } from "@testing-library/react" | ||
| import { useChatTextDraft } from "../useChatTextDraft" | ||
| import { vi } from "vitest" | ||
| import { vscode } from "@src/utils/vscode" | ||
|
|
||
| describe("useChatTextDraft (postMessage version)", () => { | ||
| let setInputValue: (v: string) => void | ||
| let onSend: () => void | ||
| let postMessageMock: ReturnType<typeof vi.fn> | ||
| let addEventListenerMock: ReturnType<typeof vi.fn> | ||
| let removeEventListenerMock: ReturnType<typeof vi.fn> | ||
| let eventListener: ((event: MessageEvent) => void) | undefined | ||
|
|
||
| beforeEach(() => { | ||
| setInputValue = vi.fn((_: string) => {}) | ||
| onSend = vi.fn() | ||
| postMessageMock = vi.fn() | ||
| addEventListenerMock = vi.fn((type, cb) => { | ||
| if (type === "message") eventListener = cb | ||
| }) | ||
| removeEventListenerMock = vi.fn((type, cb) => { | ||
| if (type === "message" && eventListener === cb) eventListener = undefined | ||
| }) | ||
|
|
||
| global.window.addEventListener = addEventListenerMock | ||
| global.window.removeEventListener = removeEventListenerMock | ||
| // mock vscode.postMessage | ||
| vi.resetModules() | ||
| vi.clearAllMocks() | ||
| vscode.postMessage = postMessageMock | ||
|
|
||
| vi.useFakeTimers() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.clearAllTimers() | ||
| vi.useRealTimers() | ||
| vi.restoreAllMocks() | ||
| eventListener = undefined | ||
| }) | ||
|
|
||
| it("should send getChatTextDraft on mount and set input value when chatTextDraftValue received", () => { | ||
| renderHook(() => useChatTextDraft("", setInputValue, onSend)) | ||
| expect(postMessageMock).toHaveBeenCalledWith({ type: "getChatTextDraft" }) | ||
| expect(setInputValue).not.toHaveBeenCalled() | ||
| // Simulate extension host response | ||
| act(() => { | ||
| eventListener?.({ data: { type: "chatTextDraftValue", text: "restored draft" } } as MessageEvent) | ||
| }) | ||
| expect(setInputValue).toHaveBeenCalledWith("restored draft") | ||
| }) | ||
|
|
||
| it("should not set input value if inputValue is not empty when chatTextDraftValue received", () => { | ||
| renderHook(() => useChatTextDraft("already typed", setInputValue, onSend)) | ||
| act(() => { | ||
| eventListener?.({ data: { type: "chatTextDraftValue", text: "restored draft" } } as MessageEvent) | ||
| }) | ||
| expect(setInputValue).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("should debounce and send updateChatTextDraft with text after 2s if inputValue is non-empty", () => { | ||
| renderHook(({ value }) => useChatTextDraft(value, setInputValue, onSend), { | ||
| initialProps: { value: "hello world" }, | ||
| }) | ||
| expect(postMessageMock).toHaveBeenCalledWith({ type: "getChatTextDraft" }) | ||
| postMessageMock.mockClear() | ||
| act(() => { | ||
| vi.advanceTimersByTime(1999) | ||
| }) | ||
| expect(postMessageMock).not.toHaveBeenCalled() | ||
| act(() => { | ||
| vi.advanceTimersByTime(1) | ||
| }) | ||
| expect(postMessageMock).toHaveBeenCalledWith({ type: "updateChatTextDraft", text: "hello world" }) | ||
| }) | ||
|
|
||
| it("should reset debounce timer when inputValue changes before debounce delay", () => { | ||
| const { rerender } = renderHook(({ value }) => useChatTextDraft(value, setInputValue, onSend), { | ||
| initialProps: { value: "foo" }, | ||
| }) | ||
| act(() => { | ||
| vi.advanceTimersByTime(1000) | ||
| }) | ||
| postMessageMock.mockClear() | ||
| rerender({ value: "bar" }) | ||
| act(() => { | ||
| vi.advanceTimersByTime(1999) | ||
| }) | ||
| expect(postMessageMock).not.toHaveBeenCalled() | ||
| act(() => { | ||
| vi.advanceTimersByTime(1) | ||
| }) | ||
| expect(postMessageMock).toHaveBeenCalledWith({ type: "updateChatTextDraft", text: "bar" }) | ||
| }) | ||
|
|
||
| it("should send clearChatTextDraft if inputValue is empty after user has input", () => { | ||
| const { rerender } = renderHook(({ value }) => useChatTextDraft(value, setInputValue, onSend), { | ||
| initialProps: { value: "foo" }, | ||
| }) | ||
| act(() => { | ||
| vi.advanceTimersByTime(2000) | ||
| }) | ||
| postMessageMock.mockClear() | ||
| rerender({ value: "" }) | ||
| expect(postMessageMock).toHaveBeenCalledWith({ type: "clearChatTextDraft" }) | ||
| }) | ||
|
|
||
| it("should send clearChatTextDraft and call onSend when handleSendAndClearDraft is called", () => { | ||
| const { result } = renderHook(() => useChatTextDraft("msg", setInputValue, onSend)) | ||
| postMessageMock.mockClear() | ||
| act(() => { | ||
| result.current.handleSendAndClearDraft() | ||
| }) | ||
| expect(postMessageMock).toHaveBeenCalledWith({ type: "clearChatTextDraft" }) | ||
| expect(onSend).toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("should not send updateChatTextDraft and should warn if inputValue exceeds 100KB (ASCII)", () => { | ||
| const MAX_DRAFT_BYTES = 102400 | ||
| const largeStr = "a".repeat(MAX_DRAFT_BYTES + 5000) | ||
| const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {}) | ||
| renderHook(() => useChatTextDraft(largeStr, setInputValue, onSend)) | ||
| act(() => { | ||
| vi.advanceTimersByTime(3000) | ||
| }) | ||
| expect(postMessageMock).not.toHaveBeenCalledWith({ type: "updateChatTextDraft", text: largeStr }) | ||
| expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("exceeds 100KB")) | ||
| warnMock.mockRestore() | ||
| }) | ||
|
|
||
| it("should not send updateChatTextDraft and should warn if inputValue exceeds 100KB (UTF-8 multi-byte)", () => { | ||
| const emoji = "😀" | ||
| const hanzi = "汉" | ||
| const utf8Str = emoji.repeat(20000) + hanzi.repeat(10000) + "abc" | ||
| const warnMock = vi.spyOn(console, "warn").mockImplementation(() => {}) | ||
| renderHook(() => useChatTextDraft(utf8Str, setInputValue, onSend)) | ||
| act(() => { | ||
| vi.advanceTimersByTime(3000) | ||
| }) | ||
| expect(postMessageMock).not.toHaveBeenCalledWith({ type: "updateChatTextDraft", text: utf8Str }) | ||
| expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("exceeds 100KB")) | ||
| warnMock.mockRestore() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { useCallback, useEffect, useRef } from "react" | ||
| import { vscode } from "@src/utils/vscode" | ||
|
|
||
| export const CHAT_DRAFT_SAVE_DEBOUNCE_MS = 2000 | ||
|
|
||
| /** | ||
| * Hook for chat textarea draft persistence (extension globalState). | ||
| * Handles auto-save, restore on mount, and clear on send via postMessage. | ||
| * @param inputValue current textarea value | ||
| * @param setInputValue setter for textarea value | ||
| * @param onSend send callback | ||
| */ | ||
| export function useChatTextDraft(inputValue: string, setInputValue: (value: string) => void, onSend: () => void) { | ||
| // Restore draft from extension host on mount | ||
| useEffect(() => { | ||
| const handleDraftValue = (event: MessageEvent) => { | ||
| const msg = event.data | ||
| if (msg && msg.type === "chatTextDraftValue") { | ||
| if (typeof msg.text === "string" && msg.text && !inputValue) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a potential race condition here? The draft is only restored if |
||
| setInputValue(msg.text) | ||
| } | ||
| } | ||
| } | ||
| window.addEventListener("message", handleDraftValue) | ||
| // Request draft from extension host | ||
| vscode.postMessage({ type: "getChatTextDraft" }) | ||
| return () => { | ||
| window.removeEventListener("message", handleDraftValue) | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []) | ||
|
|
||
| // Debounced save draft to extension host | ||
| const debounceTimerRef = useRef<NodeJS.Timeout | null>(null) | ||
|
|
||
| const hasHadUserInput = useRef(false) | ||
|
|
||
| useEffect(() => { | ||
| if (debounceTimerRef.current) { | ||
| clearTimeout(debounceTimerRef.current) | ||
| } | ||
qdaxb marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const MAX_DRAFT_BYTES = 102400 | ||
| if (inputValue && inputValue.trim()) { | ||
| hasHadUserInput.current = true | ||
| debounceTimerRef.current = setTimeout(() => { | ||
| try { | ||
| // Fast pre-check: if character count is much greater than max bytes, skip encoding | ||
| if (inputValue.length > MAX_DRAFT_BYTES * 2) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The pre-check using |
||
| console.warn(`[useChatTextDraft] Draft is too long (chars=${inputValue.length}), not saving.`) | ||
| return | ||
| } | ||
| const encoder = new TextEncoder() | ||
| const bytes = encoder.encode(inputValue) | ||
| if (bytes.length > MAX_DRAFT_BYTES) { | ||
| console.warn(`[useChatTextDraft] Draft exceeds 100KB, not saving.`) | ||
| return | ||
| } | ||
| vscode.postMessage({ type: "updateChatTextDraft", text: inputValue }) | ||
| } catch (err) { | ||
| console.warn(`[useChatTextDraft] Failed to save draft:`, err) | ||
| } | ||
| }, CHAT_DRAFT_SAVE_DEBOUNCE_MS) | ||
| } else { | ||
| if (hasHadUserInput.current) { | ||
| try { | ||
| vscode.postMessage({ type: "clearChatTextDraft" }) | ||
| } catch (err) { | ||
| console.warn(`[useChatTextDraft] Failed to clear draft:`, err) | ||
| } | ||
| } | ||
| } | ||
| return () => { | ||
| if (debounceTimerRef.current) { | ||
| clearTimeout(debounceTimerRef.current) | ||
| } | ||
| } | ||
| }, [inputValue]) | ||
|
|
||
| // Clear draft after send | ||
| const handleSendAndClearDraft = useCallback(() => { | ||
| try { | ||
| vscode.postMessage({ type: "clearChatTextDraft" }) | ||
| } catch (err) { | ||
| console.warn(`[useChatTextDraft] Failed to clear draft:`, err) | ||
| } | ||
| onSend() | ||
| }, [onSend]) | ||
|
|
||
| return { handleSendAndClearDraft } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.