From f122594419e499a1548f59b1f561f24e99ca2f4b Mon Sep 17 00:00:00 2001 From: MeloMei Date: Mon, 6 Jul 2026 15:44:24 +0800 Subject: [PATCH] refactor(frontend): extract placeholder detection utility with unit tests (follow-up to #3764) (#3783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(frontend): extract placeholder detection utility with unit tests * fix(frontend): pass prompt text directly to onSelectPlaceholder to avoid stale DOM read The onSelectPlaceholder callback was reading textarea.value immediately after textInput.setInput(prompt), but React state updates are async so the DOM had not yet reflected the new value. This caused the placeholder auto-selection to silently fail when the textarea was previously empty. Fix: accept the new text as a parameter instead of reading from the DOM. * fix(frontend): resolve duplicate findSuggestionTemplatePlaceholder identifier After rebasing onto main, the function existed both inline in input-box-helpers.ts (from #3764) and as an import from our new placeholders module, causing TS2300 duplicate identifier errors. - Remove duplicate import in input-box.tsx - Replace inline function in input-box-helpers with re-export from @/core/suggestions/placeholders - Export SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN from placeholders module * refactor(frontend): remove dead hasUnreplacedPlaceholder export No production call site uses this boolean wrapper — both existing checks need the {start,end} range from findSuggestionTemplatePlaceholder. Drop the function and its two unit tests. --- .../components/workspace/input-box-helpers.ts | 19 ++----- .../src/components/workspace/input-box.tsx | 32 ++++++----- frontend/src/core/suggestions/placeholders.ts | 30 ++++++++++ .../core/suggestions/placeholders.test.ts | 57 +++++++++++++++++++ 4 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 frontend/src/core/suggestions/placeholders.ts create mode 100644 frontend/tests/unit/core/suggestions/placeholders.test.ts diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index f89610eb6..d771654aa 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -1,10 +1,11 @@ import type { Skill } from "@/core/skills"; +export { + SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN, + findSuggestionTemplatePlaceholder, +} from "@/core/suggestions/placeholders"; export const MAX_SKILL_SUGGESTIONS = 6; -export const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN = - /\[(?:主题|来源|topic|source)\]/i; - export type SlashSuggestion = { name: string; description: string; @@ -100,18 +101,6 @@ export function isAbortError(error: unknown): boolean { ); } -export function findSuggestionTemplatePlaceholder(text: string) { - const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text); - if (!match) { - return null; - } - - return { - start: match.index, - end: match.index + match[0].length, - }; -} - export function getLeadingSlashSkillQuery(value: string): string | null { if (!value.startsWith("/")) { return null; diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index fcd4cb016..60403925a 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -23,7 +23,6 @@ import { useState, type ComponentProps, type KeyboardEvent, - type RefObject, } from "react"; import { toast } from "sonner"; @@ -614,7 +613,7 @@ export function InputBox({ } const placeholder = findSuggestionTemplatePlaceholder(message.text); if (placeholder) { - toast.error(t.inputBox.suggestionPlaceholderRequired); + toast.warning(t.inputBox.suggestionPlaceholderRequired); requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) { @@ -1069,6 +1068,18 @@ export function InputBox({ threadId, ]); + const onSelectPlaceholder = useCallback((newText: string) => { + const placeholder = findSuggestionTemplatePlaceholder(newText); + if (placeholder) { + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(placeholder.start, placeholder.end); + }); + } + }, []); + return (
- +
)} @@ -1598,9 +1609,9 @@ export function InputBox({ } function SuggestionList({ - textareaRef, + onSelectPlaceholder, }: { - textareaRef: RefObject; + onSelectPlaceholder: (newText: string) => void; }) { const { t } = useI18n(); const { textInput } = usePromptInputController(); @@ -1608,16 +1619,9 @@ function SuggestionList({ (prompt: string | undefined) => { if (!prompt) return; textInput.setInput(prompt); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - const placeholder = findSuggestionTemplatePlaceholder(prompt); - if (textarea && placeholder) { - textarea.focus(); - textarea.setSelectionRange(placeholder.start, placeholder.end); - } - }); + onSelectPlaceholder(prompt); }, - [textareaRef, textInput], + [textInput, onSelectPlaceholder], ); return ( diff --git a/frontend/src/core/suggestions/placeholders.ts b/frontend/src/core/suggestions/placeholders.ts new file mode 100644 index 000000000..70d5a6c85 --- /dev/null +++ b/frontend/src/core/suggestions/placeholders.ts @@ -0,0 +1,30 @@ +/** + * Regex matching known suggestion template placeholders. + * + * These are the exact placeholder tokens used in suggestion prompt templates + * defined in the i18n locale files (e.g., zh-CN.ts, en-US.ts). + * + * Update this pattern whenever new placeholder tokens are added to templates. + */ +export const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN = + /\[(?:主题|来源|topic|source)\]/i; + +/** + * Locates an unreplaced suggestion template placeholder in the given text. + * + * Returns the start/end character indices of the placeholder if found, + * or `null` if the text contains no known placeholder tokens. + */ +export function findSuggestionTemplatePlaceholder( + text: string, +): { start: number; end: number } | null { + const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text); + if (!match) { + return null; + } + + return { + start: match.index, + end: match.index + match[0].length, + }; +} diff --git a/frontend/tests/unit/core/suggestions/placeholders.test.ts b/frontend/tests/unit/core/suggestions/placeholders.test.ts new file mode 100644 index 000000000..3d6192492 --- /dev/null +++ b/frontend/tests/unit/core/suggestions/placeholders.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "@rstest/core"; + +import { findSuggestionTemplatePlaceholder } from "@/core/suggestions/placeholders"; + +describe("findSuggestionTemplatePlaceholder", () => { + test("finds Chinese [主题] and returns correct range", () => { + const result = findSuggestionTemplatePlaceholder( + "深入浅出的研究一下[主题],并总结发现。", + ); + expect(result).toEqual({ start: 9, end: 13 }); + }); + + test("finds English [topic] and returns correct range", () => { + const result = findSuggestionTemplatePlaceholder( + "Write a blog post about the latest trends on [topic]", + ); + expect(result).toEqual({ start: 45, end: 52 }); + }); + + test("finds Chinese [来源] placeholder", () => { + const result = + findSuggestionTemplatePlaceholder("从[来源]收集数据并创建报告。"); + expect(result).not.toBeNull(); + }); + + test("finds English [source] placeholder", () => { + const result = findSuggestionTemplatePlaceholder( + "Collect data from [source] and create a report.", + ); + expect(result).not.toBeNull(); + }); + + test("returns null for normal text without brackets", () => { + expect( + findSuggestionTemplatePlaceholder("研究一下2025年最流行的Python框架"), + ).toBeNull(); + }); + + test("returns null for text with unrelated brackets", () => { + expect( + findSuggestionTemplatePlaceholder("check [this link] for details"), + ).toBeNull(); + }); + + test("returns null for empty text", () => { + expect(findSuggestionTemplatePlaceholder("")).toBeNull(); + }); + + test("detects placeholder case-insensitively for English", () => { + expect( + findSuggestionTemplatePlaceholder("Research [Topic] deeply"), + ).not.toBeNull(); + expect( + findSuggestionTemplatePlaceholder("Research [TOPIC] deeply"), + ).not.toBeNull(); + }); +});