Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const globalSettingsSchema = z.object({
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
lastModeImportPath: z.string().optional(),
chatTextDrafts: z.record(z.string(), z.string()).optional(),
})

export type GlobalSettings = z.infer<typeof globalSettingsSchema>
Expand Down
34 changes: 34 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,40 @@ export const webviewMessageHandler = async (
await provider.contextProxy.setValue(key, value)

switch (message.type) {
case "updateChatTextDraft": {
// Update or remove the chat text draft in globalState under the fixed key "chatTextDraft"
const text = typeof message.text === "string" ? message.text : ""
const drafts = (await getGlobalState("chatTextDrafts")) ?? {}
if (text && text.trim()) {
// Set/Update
const updated = { ...drafts, chatTextDraft: text }
await updateGlobalState("chatTextDrafts", updated)
} else if (drafts.chatTextDraft) {
// Remove if empty
const { chatTextDraft: _, ...rest } = drafts
await updateGlobalState("chatTextDrafts", rest)
}
break
}
case "getChatTextDraft": {
// Return the chat text draft for the fixed key "chatTextDraft"
const drafts = (await getGlobalState("chatTextDrafts")) ?? {}
const text = drafts.chatTextDraft ?? ""
await provider.postMessageToWebview({
type: "chatTextDraftValue",
text,
})
break
}
case "clearChatTextDraft": {
// Remove the chat text draft from globalState under the fixed key "chatTextDraft"
const drafts = (await getGlobalState("chatTextDrafts")) ?? {}
if (drafts.chatTextDraft) {
const { chatTextDraft: _, ...rest } = drafts
await updateGlobalState("chatTextDrafts", rest)
}
break
}
case "webviewDidLaunch":
// Load custom modes first
const customModes = await provider.customModesManager.getCustomModes()
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export interface ExtensionMessage {
| "shareTaskSuccess"
| "codeIndexSettingsSaved"
| "codeIndexSecretStatus"
| "chatTextDraftValue"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
Expand Down
3 changes: 3 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ export interface WebviewMessage {
| "checkRulesDirectoryResult"
| "saveCodeIndexSettingsAtomic"
| "requestCodeIndexSecretStatus"
| "getChatTextDraft"
| "updateChatTextDraft"
| "clearChatTextDraft"
text?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
disabled?: boolean
Expand Down
29 changes: 17 additions & 12 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ExtensionMessage } from "@roo/ExtensionMessage"

import { vscode } from "@/utils/vscode"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useChatTextDraft } from "./hooks/useChatTextDraft"
import { useAppTranslation } from "@/i18n/TranslationContext"
import {
ContextMenuOptionType,
Expand Down Expand Up @@ -69,6 +70,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
ref,
) => {
const { t } = useAppTranslation()

// Chat draft persistence
const { handleSendAndClearDraft } = useChatTextDraft(inputValue, setInputValue, onSend)

const {
filePaths,
openedTabs,
Expand Down Expand Up @@ -389,7 +394,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (!sendingDisabled) {
// Reset history navigation state when sending
resetHistoryNavigation()
onSend()
handleSendAndClearDraft()
}
}

Expand Down Expand Up @@ -438,22 +443,22 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
},
[
sendingDisabled,
onSend,
showContextMenu,
searchQuery,
handleHistoryNavigation,
selectedMenuIndex,
handleMentionSelect,
selectedType,
searchQuery,
inputValue,
cursorPosition,
setInputValue,
justDeletedSpaceAfterMention,
selectedType,
queryItems,
allModes,
fileSearchResults,
handleHistoryNavigation,
allModes,
handleMentionSelect,
sendingDisabled,
resetHistoryNavigation,
handleSendAndClearDraft,
cursorPosition,
justDeletedSpaceAfterMention,
setInputValue,
],
)

Expand Down Expand Up @@ -1151,7 +1156,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
iconClass="codicon-send"
title={t("chat:sendMessage")}
disabled={sendingDisabled}
onClick={onSend}
onClick={handleSendAndClearDraft}
/>
</div>
</div>
Expand Down
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()
})
})
90 changes: 90 additions & 0 deletions webview-ui/src/components/chat/hooks/useChatTextDraft.ts
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) {
Copy link
Member

Choose a reason for hiding this comment

The 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 !inputValue, but what happens if the component receives an initial value before the message handler is set up? Could we miss restoring a valid draft in that case?

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)
}
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pre-check using inputValue.length > MAX_DRAFT_BYTES * 2 might be too conservative. For strings with many multi-byte characters (like emojis or non-Latin scripts), this could reject valid drafts prematurely. Would it make sense to adjust this heuristic or document why 2x was chosen?

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 }
}