diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 7041fc5..99b4a82 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -1,6 +1,13 @@ // @vitest-environment jsdom import React from "react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -51,7 +58,7 @@ describe("ChatComposer", () => { expect(onLoginClick).toHaveBeenCalledOnce(); }); - it("inserts expert templates and highlights bracket placeholders", async () => { + it("inserts expert templates and selects the first placeholder for replacement", async () => { const user = userEvent.setup(); render(React.createElement(ChatComposer)); @@ -64,12 +71,71 @@ describe("ChatComposer", () => { const input = screen.getByPlaceholderText( "Ask a question about your documents…", ) as HTMLTextAreaElement; + const placeholderStart = input.value.indexOf("[Company Name]"); + const placeholderEnd = placeholderStart + "[Company Name]".length; + expect(input.value).toContain("prospectus of [Company Name]"); + await waitFor(() => { + expect(input.selectionStart).toBe(placeholderStart); + expect(input.selectionEnd).toBe(placeholderEnd); + }); + expect(screen.queryByTestId("chat-composer-highlight-layer")).toBeNull(); + + await user.type(input, "Acme Robotics", { skipClick: true }); + + expect(input.value).toContain("prospectus of Acme Robotics"); + expect(input.value).not.toContain("[Company Name]"); + }); + + it("highlights placeholders when text is not selected", async () => { + const user = userEvent.setup(); + + render(React.createElement(ChatComposer)); + + await user.click(screen.getByRole("button", { name: "Create" })); + await user.click( + screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }), + ); + + const input = screen.getByPlaceholderText( + "Ask a question about your documents…", + ) as HTMLTextAreaElement; + await waitFor(() => expect(input.value).toContain("[Company Name]")); + + input.setSelectionRange(input.value.length, input.value.length); + fireEvent.select(input); + expect(screen.getByText("[Company Name]").className).toContain( "text-primary", ); }); + it("selects a placeholder with one click when the caret lands inside it", async () => { + const user = userEvent.setup(); + + render(React.createElement(ChatComposer)); + + await user.click(screen.getByRole("button", { name: "Create" })); + await user.click( + screen.getByRole("menuitem", { + name: /Earnings Call Transcript Analysis/, + }), + ); + + const input = screen.getByPlaceholderText( + "Ask a question about your documents…", + ) as HTMLTextAreaElement; + await waitFor(() => expect(input.value).toContain("[Company Name]")); + + const placeholderStart = input.value.indexOf("[Company Name]"); + const placeholderEnd = placeholderStart + "[Company Name]".length; + input.setSelectionRange(placeholderStart + 3, placeholderStart + 3); + fireEvent.click(input); + + expect(input.selectionStart).toBe(placeholderStart); + expect(input.selectionEnd).toBe(placeholderEnd); + }); + it("renders a larger embedded composer input surface", () => { render(React.createElement(ChatComposer)); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 807f298..75a9a18 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -5,6 +5,7 @@ import { useState, type ChangeEvent, type KeyboardEvent, + type MouseEvent, type ReactElement, type UIEvent, } from "react"; @@ -25,6 +26,12 @@ import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; const chatComposerId = "chat-composer"; const placeholderPattern = /(\[[^\]\r\n]{1,80}\])/gu; const placeholderSegmentPattern = /^\[[^\]\r\n]{1,80}\]$/u; +const placeholderRangePattern = /\[[^\]\r\n]{1,80}\]/gu; + +type TextRange = { + readonly start: number; + readonly end: number; +}; export type ChatComposerProps = { readonly canCreateDiagram?: boolean; @@ -46,13 +53,16 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [hasActiveTextSelection, setHasActiveTextSelection] = useState(false); const [textareaScrollTop, setTextareaScrollTop] = useState(0); const textareaRef = useRef(null); const trimmedInput = input.trim(); const canSend = !isDisabled && !isSending && trimmedInput.length > 0; + const shouldShowHighlightLayer = input.length > 0 && !hasActiveTextSelection; function handleInputChange(event: ChangeEvent): void { setInput(event.target.value); + setHasActiveTextSelection(false); } function handleKeyDown(event: KeyboardEvent): void { @@ -73,8 +83,19 @@ export function ChatComposer({ setInput(prompt); setTextareaScrollTop(0); requestAnimationFrame(() => { - textareaRef.current?.focus(); - textareaRef.current?.setSelectionRange(prompt.length, prompt.length); + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.focus(); + const placeholderRange = getFirstPlaceholderRange(prompt); + if (!placeholderRange) { + textarea.setSelectionRange(prompt.length, prompt.length); + setHasActiveTextSelection(false); + return; + } + + textarea.setSelectionRange(placeholderRange.start, placeholderRange.end); + setHasActiveTextSelection(true); }); } @@ -82,6 +103,33 @@ export function ChatComposer({ setTextareaScrollTop(event.currentTarget.scrollTop); } + function handleTextareaClick(event: MouseEvent): void { + const textarea = event.currentTarget; + if (textarea.selectionStart !== textarea.selectionEnd) { + setHasActiveTextSelection(true); + return; + } + + const placeholderRange = getPlaceholderRangeAtPosition( + textarea.value, + textarea.selectionStart, + ); + if (!placeholderRange) { + setHasActiveTextSelection(false); + return; + } + + textarea.setSelectionRange(placeholderRange.start, placeholderRange.end); + setHasActiveTextSelection(true); + } + + function refreshTextareaSelectionState(): void { + const textarea = textareaRef.current; + setHasActiveTextSelection( + Boolean(textarea && textarea.selectionStart !== textarea.selectionEnd), + ); + } + return (
- {input.length > 0 && ( + {shouldShowHighlightLayer && (
@@ -239,3 +294,24 @@ function renderHighlightedInput(value: string): readonly ReactElement[] { return {segment}; }); } + +function getFirstPlaceholderRange(value: string): TextRange | null { + const [firstMatch] = value.matchAll(placeholderRangePattern); + if (!firstMatch) return null; + const start = firstMatch.index; + return { start, end: start + firstMatch[0].length }; +} + +function getPlaceholderRangeAtPosition( + value: string, + position: number, +): TextRange | null { + for (const match of value.matchAll(placeholderRangePattern)) { + const start = match.index; + const end = start + match[0].length; + if (position >= start && position <= end) { + return { start, end }; + } + } + return null; +}