Skip to content
Merged
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
70 changes: 68 additions & 2 deletions src/components/chat-composer.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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));
Expand All @@ -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));

Expand Down
84 changes: 80 additions & 4 deletions src/components/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useState,
type ChangeEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
type UIEvent,
} from "react";
Expand All @@ -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;
Expand All @@ -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<HTMLTextAreaElement | null>(null);
const trimmedInput = input.trim();
const canSend = !isDisabled && !isSending && trimmedInput.length > 0;
const shouldShowHighlightLayer = input.length > 0 && !hasActiveTextSelection;

function handleInputChange(event: ChangeEvent<HTMLTextAreaElement>): void {
setInput(event.target.value);
setHasActiveTextSelection(false);
}

function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
Expand All @@ -73,15 +83,53 @@ 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);
});
}

function handleTextareaScroll(event: UIEvent<HTMLTextAreaElement>): void {
setTextareaScrollTop(event.currentTarget.scrollTop);
}

function handleTextareaClick(event: MouseEvent<HTMLTextAreaElement>): 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 (
<div
data-testid="chat-composer"
Expand All @@ -99,9 +147,10 @@ export function ChatComposer({
) : (
<>
<div className="relative overflow-hidden bg-background">
{input.length > 0 && (
{shouldShowHighlightLayer && (
<pre
aria-hidden="true"
data-testid="chat-composer-highlight-layer"
className="pointer-events-none absolute inset-0 h-[128px] min-w-0 whitespace-pre-wrap break-words border border-transparent px-4 py-3 font-sans text-sm leading-5 text-foreground sm:px-5 sm:py-4"
>
<span
Expand All @@ -117,7 +166,9 @@ export function ChatComposer({
id={chatComposerId}
name={chatComposerId}
className={`relative h-[128px] w-full min-w-0 resize-none border-0 bg-transparent px-4 py-3 text-sm leading-5 shadow-none transition-all placeholder:text-muted-foreground focus-visible:ring-0 sm:px-5 sm:py-4 ${
input.length > 0 ? "text-transparent caret-foreground" : "text-foreground"
shouldShowHighlightLayer
? "text-transparent caret-foreground"
: "text-foreground"
}`}
placeholder={
isDisabled
Expand All @@ -127,8 +178,12 @@ export function ChatComposer({
value={input}
onChange={handleInputChange}
disabled={isDisabled}
onBlur={() => setHasActiveTextSelection(false)}
onClick={handleTextareaClick}
onKeyDown={handleKeyDown}
onKeyUp={refreshTextareaSelectionState}
onScroll={handleTextareaScroll}
onSelect={refreshTextareaSelectionState}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 pb-4 sm:px-5">
Expand Down Expand Up @@ -239,3 +294,24 @@ function renderHighlightedInput(value: string): readonly ReactElement[] {
return <span key={key}>{segment}</span>;
});
}

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