diff --git a/mise.toml b/mise.toml
index b0e2a6e..9ee6a6e 100644
--- a/mise.toml
+++ b/mise.toml
@@ -38,6 +38,7 @@ outputs = ['node_modules/.pnpm/lock.yaml']
alias = 'f'
description = 'Format code'
run = 'pnpm run format'
+depends = ['install']
[tasks.lint]
alias = 'l'
diff --git a/src/components/chat/client.test.tsx b/src/components/chat/client.test.tsx
new file mode 100644
index 0000000..09c1b23
--- /dev/null
+++ b/src/components/chat/client.test.tsx
@@ -0,0 +1,184 @@
+/* eslint-disable react/display-name, @typescript-eslint/no-explicit-any */
+import { describe, expect, it, vi, beforeEach } from "vitest";
+
+import { render, act, fireEvent } from "@testing-library/react";
+
+import ChatWidgetClient from "./client";
+
+// Mocks
+const mockToggleChat = vi.fn();
+const mockHandleInputChange = vi.fn();
+const mockHandleSubmit = vi.fn();
+const mockStop = vi.fn();
+const mockSetMessages = vi.fn();
+const mockSetInput = vi.fn();
+const mockCopyToClipboard = vi.fn();
+const mockHandleEdit = vi.fn();
+const mockHandleSuggestedQuestion = vi.fn();
+
+let mockUseChatWidgetResult: any = {};
+
+vi.mock("./use-chat-widget", () => ({
+ useChatWidget: () => mockUseChatWidgetResult,
+}));
+
+vi.mock("@heroui/react", async (importOriginal) => {
+ const actual: any = await importOriginal();
+ const MockTooltip = ({ children }: any) =>
{children}
;
+ MockTooltip.Trigger = ({ children }: any) => <>{children}>;
+ MockTooltip.Content = ({ children }: any) => {children}
;
+ MockTooltip.Arrow = () => null;
+
+ return {
+ ...actual,
+ Tooltip: MockTooltip,
+ };
+});
+
+describe("ChatWidgetClient", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseChatWidgetResult = {
+ isOpen: false,
+ toggleChat: mockToggleChat,
+ input: "",
+ handleInputChange: mockHandleInputChange,
+ handleSubmit: mockHandleSubmit,
+ messages: [],
+ isLoading: false,
+ error: null,
+ getErrorMessage: () => "",
+ stop: mockStop,
+ scrollRef: { current: null },
+ inputRef: { current: null },
+ copiedId: null,
+ copyToClipboard: mockCopyToClipboard,
+ handleEdit: mockHandleEdit,
+ handleSuggestedQuestion: mockHandleSuggestedQuestion,
+ isFilterBarVisible: false,
+ status: "idle",
+ setMessages: mockSetMessages,
+ setInput: mockSetInput,
+ };
+ });
+
+ it("should render only toggle button when chat is closed", () => {
+ const { getByLabelText, queryByText } = render();
+
+ expect(getByLabelText("Open AI assistant")).toBeInTheDocument();
+ expect(queryByText("Miro — Amr's Assistant")).not.toBeInTheDocument();
+
+ act(() => {
+ getByLabelText("Open AI assistant").click();
+ });
+ expect(mockToggleChat).toHaveBeenCalled();
+ });
+
+ it("should render chat window and its contents when open", () => {
+ mockUseChatWidgetResult.isOpen = true;
+ const {
+ getAllByLabelText,
+ getByText,
+ getByPlaceholderText,
+ getByLabelText,
+ } = render();
+
+ expect(getByText("Miro — Amr's Assistant")).toBeInTheDocument();
+ expect(getAllByLabelText("Close AI assistant").length).toBe(2);
+ expect(getByPlaceholderText("Ask a question...")).toBeInTheDocument();
+ expect(getByLabelText("Ask a question to Miro")).toBeInTheDocument();
+
+ const textarea = getByLabelText("Ask a question to Miro");
+ act(() => {
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+ });
+ expect(mockHandleInputChange).toHaveBeenCalled();
+ });
+
+ it("should render suggested questions when empty and not loading", () => {
+ mockUseChatWidgetResult.isOpen = true;
+ mockUseChatWidgetResult.messages = [];
+ const { getByText } = render();
+
+ const suggestedQuestion = getByText("What was his PhD research about?");
+ expect(suggestedQuestion).toBeInTheDocument();
+
+ act(() => {
+ suggestedQuestion.click();
+ });
+ expect(mockHandleSuggestedQuestion).toHaveBeenCalledWith(
+ "What was his PhD research about?",
+ );
+ });
+
+ it("should render messages list, typing indicator and stop button when loading", () => {
+ mockUseChatWidgetResult.isOpen = true;
+ mockUseChatWidgetResult.isLoading = true;
+ mockUseChatWidgetResult.status = "submitted";
+ mockUseChatWidgetResult.messages = [
+ {
+ id: "1",
+ role: "user",
+ content: "Tell me about Amr",
+ parts: [{ type: "text", text: "Tell me about Amr" }],
+ },
+ ];
+
+ const { getByLabelText, getByText, queryByLabelText } = render(
+ ,
+ );
+
+ expect(getByText("Tell me about Amr")).toBeInTheDocument();
+ expect(getByLabelText("Stop generating")).toBeInTheDocument();
+ expect(queryByLabelText("Send message")).not.toBeInTheDocument();
+
+ // Reset button is visible since messages.length > 0
+ const resetBtn = getByLabelText("Reset conversation");
+ expect(resetBtn).toBeInTheDocument();
+
+ act(() => {
+ resetBtn.click();
+ });
+ expect(mockStop).toHaveBeenCalled();
+ expect(mockSetMessages).toHaveBeenCalledWith([]);
+ expect(mockSetInput).toHaveBeenCalledWith("");
+ });
+
+ it("should trigger form submit on key down Enter without Shift key", () => {
+ mockUseChatWidgetResult.isOpen = true;
+ mockUseChatWidgetResult.input = "Question";
+ const { getByLabelText } = render();
+
+ const textarea = getByLabelText("Ask a question to Miro");
+ act(() => {
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
+ });
+
+ expect(mockHandleSubmit).toHaveBeenCalled();
+ });
+
+ it("should allow shift Enter key down to add newlines without submit", () => {
+ mockUseChatWidgetResult.isOpen = true;
+ mockUseChatWidgetResult.input = "Question";
+ const { getByLabelText } = render();
+
+ const textarea = getByLabelText("Ask a question to Miro");
+ act(() => {
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
+ });
+
+ expect(mockHandleSubmit).not.toHaveBeenCalled();
+ });
+
+ it("should render error message when error is present", () => {
+ mockUseChatWidgetResult.isOpen = true;
+ mockUseChatWidgetResult.error = new Error("Failed to generate response");
+ mockUseChatWidgetResult.getErrorMessage = () =>
+ "An error occurred. Please try again later.";
+ const { getByText } = render();
+
+ expect(
+ getByText("An error occurred. Please try again later."),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/chat/client.tsx b/src/components/chat/client.tsx
index c9cd710..090ce0b 100644
--- a/src/components/chat/client.tsx
+++ b/src/components/chat/client.tsx
@@ -60,13 +60,14 @@ export default function ChatWidgetClient() {
{messages.length > 0 && (
-
+
Reset conversation
@@ -74,13 +75,22 @@ export default function ChatWidgetClient() {
)}
-
+
+
+
+
+
+ Close AI assistant
+
+
+
@@ -155,6 +165,7 @@ export default function ChatWidgetClient() {
value={input}
onChange={handleInputChange}
placeholder="Ask a question..."
+ aria-label="Ask a question to Miro"
rows={1}
className="chat-input-textarea"
onKeyDown={(e) => {
@@ -165,25 +176,45 @@ export default function ChatWidgetClient() {
}}
/>
{isLoading ? (
-
+
+
+
+
+
+ Stop generating
+
+
+
) : (
-
+
+
+
+
+
+ Send message
+
+
+
)}
@@ -191,18 +222,26 @@ export default function ChatWidgetClient() {
)}
{/* Toggle Button */}
-
+
+
+
+
+
+ {isOpen ? "Close AI assistant" : "Open AI assistant"}
+
+
+
);
}
diff --git a/vitest.config.ts b/vitest.config.ts
index cda306c..94b179a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -28,6 +28,7 @@ export default defineConfig({
"src/components/filterable-section.tsx",
"src/components/featured-section-container.tsx",
"src/components/header.tsx",
+ "src/components/chat/client.tsx",
],
},
},