diff --git a/CLAUDE.md b/CLAUDE.md index af1420a..acec2fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,8 @@ pnpm test # Run tests | `ChatInput` | Message input form with submit handling | | `ChatToggleButton` | Floating button to open/close chat | | `MessageBubble` | Renders individual messages with URL linking | +| `ChatSources` | Slide-out sidebar displaying grouped source links | +| `SourceIcon` | Icon button with badge count to trigger source sidebar | ### useChat Hook @@ -139,7 +141,10 @@ Manages chat state: // Response { - reply: "How can I help you today?" + reply: "How can I help you today?", + sources?: [ + { url: "https://...", title: "...", type: "blog" | "page" | "external" } + ] } ``` diff --git a/docs/plans/2026-03-24-chat-sources-design.md b/docs/plans/2026-03-24-chat-sources-design.md new file mode 100644 index 0000000..4c779fa --- /dev/null +++ b/docs/plans/2026-03-24-chat-sources-design.md @@ -0,0 +1,99 @@ +# ChatSources Component Design + +**Issue:** #4 - Source and search results display component +**Date:** 2026-03-24 + +## Overview + +A slide-out sidebar panel that displays source links associated with assistant messages. Sources are returned by the worker API, grouped by type, and triggered via an icon button on each message. + +## Types + +```typescript +interface Source { + url: string; + title: string; + type: "blog" | "page" | "external"; +} + +// ChatResponse expands: +interface ChatResponse { + reply: string; + sources?: Source[]; +} + +// ChatMessage expands: +interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + sources?: Source[]; +} +``` + +## Components + +### SourceIcon (new) + +Trigger button displayed on assistant messages that have sources. + +- Small link/document icon, bottom-left of assistant message bubble +- Tiny badge circle showing source count, colored `claudius-primary` +- Muted by default, more visible on hover +- Tooltip: "View sources" +- Clicking toggles the ChatSources sidebar for that message + +### ChatSources (new) + +Slide-out sidebar panel. + +- Slides from the left edge of ChatWindow, overlaying the message area +- Width: ~280px +- Background: `claudius-light` (dark mode aware) +- Border-right: 2px `claudius-border` +- Rounded corners on right side +- Smooth slide transition (~200ms ease) + +**Header:** +- "Sources" title +- "{n} sources found" subtext +- Close (X) button + +**Body:** +- Grouped by type: blogs first, then pages, then external +- Type group headers: small muted labels ("Blog", "Page", "External"), only shown if that type has entries +- Source cards: rounded-[12px], 2px border, subtle fill +- Each card shows title (truncated) and domain extracted from URL +- Click opens link in new tab +- Hover: slight background shift + +**No empty state needed** - icon only appears when sources exist. + +## Integration + +### Files to create + +- `widget/src/components/ChatSources.tsx` - Sidebar panel +- `widget/src/components/SourceIcon.tsx` - Trigger button with badge + +### Files to modify + +- `widget/src/api/types.ts` - Add `Source` type, update `ChatResponse` +- `widget/src/hooks/useChat.ts` - Pass sources through to `ChatMessage` +- `widget/src/components/MessageBubble.tsx` - Render SourceIcon for assistant messages with sources +- `widget/src/components/ChatWindow.tsx` - Manage sidebar state, render ChatSources overlay +- `widget/src/index.ts` - Export new types + +### Not in scope (this PR) + +- Worker changes to return source data. Widget will be built and testable with mock data in dev mode. + +## Filtering + +The worker handles domain filtering server-side using the client's `allowedDomains` config. The widget trusts and renders whatever sources the API returns. + +## Source grouping order + +1. Blog posts (`type: "blog"`) +2. Site pages (`type: "page"`) +3. External links (`type: "external"`) diff --git a/docs/plans/2026-03-24-chat-sources-plan.md b/docs/plans/2026-03-24-chat-sources-plan.md new file mode 100644 index 0000000..0143224 --- /dev/null +++ b/docs/plans/2026-03-24-chat-sources-plan.md @@ -0,0 +1,907 @@ +# ChatSources Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a slide-out sidebar that displays grouped source links on assistant messages, triggered by an icon button with badge count. + +**Architecture:** New `Source` type added to API types. `ChatSources` sidebar and `SourceIcon` button are new components. `MessageBubble` renders the icon for assistant messages with sources. `ChatWindow` manages sidebar open/close state and renders the overlay. Worker filtering is out of scope. + +**Tech Stack:** React, TypeScript, Tailwind CSS, Vitest, React Testing Library + +--- + +### Task 1: Add Source type and update API types + +**Files:** +- Modify: `widget/src/api/types.ts` +- Test: `widget/src/api/__tests__/types.test.ts` + +**Step 1: Write the failing test** + +Create `widget/src/api/__tests__/types.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import type { Source, ChatMessage, ChatResponse } from "../types"; + +describe("Source type", () => { + it("accepts valid source objects", () => { + const source: Source = { + url: "https://pmds.info/blog/seo-tips", + title: "SEO Tips for Small Businesses", + type: "blog", + }; + expect(source.type).toBe("blog"); + }); + + it("accepts all source types", () => { + const types: Source["type"][] = ["blog", "page", "external"]; + expect(types).toHaveLength(3); + }); +}); + +describe("ChatMessage with sources", () => { + it("supports optional sources field", () => { + const msg: ChatMessage = { + id: "msg-1", + role: "assistant", + content: "Here are some resources.", + sources: [ + { url: "https://pmds.info/blog/test", title: "Test", type: "blog" }, + ], + }; + expect(msg.sources).toHaveLength(1); + }); + + it("works without sources", () => { + const msg: ChatMessage = { + id: "msg-2", + role: "user", + content: "Hello", + }; + expect(msg.sources).toBeUndefined(); + }); +}); + +describe("ChatResponse with sources", () => { + it("supports optional sources field", () => { + const res: ChatResponse = { + reply: "Here you go.", + sources: [ + { url: "https://pmds.info/services", title: "Services", type: "page" }, + ], + }; + expect(res.sources).toHaveLength(1); + }); + + it("works without sources", () => { + const res: ChatResponse = { reply: "Hello!" }; + expect(res.sources).toBeUndefined(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd widget && pnpm test -- --run src/api/__tests__/types.test.ts` +Expected: FAIL - `Source` type does not exist + +**Step 3: Write minimal implementation** + +Update `widget/src/api/types.ts`: + +```typescript +export interface Source { + url: string; + title: string; + type: "blog" | "page" | "external"; +} + +export interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + sources?: Source[]; +} + +export interface ChatRequest { + messages: ChatMessage[]; +} + +export interface ChatResponse { + reply: string; + sources?: Source[]; +} + +export interface ChatErrorResponse { + error: string; + code?: string; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd widget && pnpm test -- --run src/api/__tests__/types.test.ts` +Expected: PASS + +**Step 5: Export Source type from index** + +Update `widget/src/index.ts` - add `Source` to the type exports from `./api/types`. + +**Step 6: Run full test suite** + +Run: `cd widget && pnpm test -- --run` +Expected: All tests PASS (no regressions) + +**Step 7: Commit** + +```bash +git add widget/src/api/types.ts widget/src/api/__tests__/types.test.ts widget/src/index.ts +git commit -m "feat: add Source type and update ChatMessage/ChatResponse" +``` + +--- + +### Task 2: Build SourceIcon component + +**Files:** +- Create: `widget/src/components/SourceIcon.tsx` +- Test: `widget/src/components/__tests__/SourceIcon.test.tsx` + +**Step 1: Write the failing test** + +Create `widget/src/components/__tests__/SourceIcon.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { SourceIcon } from "../SourceIcon"; + +describe("SourceIcon", () => { + it("renders with source count badge", () => { + render(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("has tooltip text", () => { + render(); + const button = screen.getByRole("button", { name: /view sources/i }); + expect(button).toBeInTheDocument(); + }); + + it("calls onClick when clicked", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + await user.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("applies active styling when isActive is true", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-claudius-primary"); + }); + + it("applies inactive styling when isActive is false", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).not.toContain("bg-claudius-primary"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/SourceIcon.test.tsx` +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `widget/src/components/SourceIcon.tsx`: + +```tsx +import { memo } from "react"; + +interface SourceIconProps { + count: number; + isActive: boolean; + onClick: () => void; +} + +export const SourceIcon = memo(function SourceIcon({ + count, + isActive, + onClick, +}: SourceIconProps) { + return ( + + ); +}); +``` + +**Step 4: Run test to verify it passes** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/SourceIcon.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add widget/src/components/SourceIcon.tsx widget/src/components/__tests__/SourceIcon.test.tsx +git commit -m "feat: add SourceIcon component with badge count" +``` + +--- + +### Task 3: Build ChatSources sidebar component + +**Files:** +- Create: `widget/src/components/ChatSources.tsx` +- Test: `widget/src/components/__tests__/ChatSources.test.tsx` + +**Step 1: Write the failing test** + +Create `widget/src/components/__tests__/ChatSources.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatSources } from "../ChatSources"; +import type { Source } from "../../api/types"; + +const mockSources: Source[] = [ + { url: "https://pmds.info/blog/seo-tips", title: "SEO Tips", type: "blog" }, + { url: "https://pmds.info/blog/web-design", title: "Web Design Guide", type: "blog" }, + { url: "https://pmds.info/services", title: "Our Services", type: "page" }, + { url: "https://example.com/resource", title: "External Resource", type: "external" }, +]; + +describe("ChatSources", () => { + it("renders source count header", () => { + render(); + expect(screen.getByText("4 sources found")).toBeInTheDocument(); + }); + + it("renders singular count for one source", () => { + render(); + expect(screen.getByText("1 source found")).toBeInTheDocument(); + }); + + it("renders Sources heading", () => { + render(); + expect(screen.getByText("Sources")).toBeInTheDocument(); + }); + + it("groups sources by type with blogs first", () => { + render(); + const headings = screen.getAllByRole("heading", { level: 4 }); + const texts = headings.map((h) => h.textContent); + expect(texts).toEqual(["Blog", "Page", "External"]); + }); + + it("does not render empty type groups", () => { + const blogOnly = mockSources.filter((s) => s.type === "blog"); + render(); + expect(screen.queryByText("Page")).not.toBeInTheDocument(); + expect(screen.queryByText("External")).not.toBeInTheDocument(); + }); + + it("renders source titles as links", () => { + render(); + const link = screen.getByRole("link", { name: /SEO Tips/i }); + expect(link).toHaveAttribute("href", "https://pmds.info/blog/seo-tips"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("displays domain for each source", () => { + render(); + expect(screen.getAllByText("pmds.info").length).toBeGreaterThanOrEqual(1); + }); + + it("calls onClose when close button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/ChatSources.test.tsx` +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `widget/src/components/ChatSources.tsx`: + +```tsx +import { memo } from "react"; +import type { Source } from "../api/types"; + +interface ChatSourcesProps { + sources: Source[]; + onClose: () => void; +} + +const TYPE_ORDER: Source["type"][] = ["blog", "page", "external"]; + +const TYPE_LABELS: Record = { + blog: "Blog", + page: "Page", + external: "External", +}; + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export const ChatSources = memo(function ChatSources({ + sources, + onClose, +}: ChatSourcesProps) { + const grouped = TYPE_ORDER.map((type) => ({ + type, + label: TYPE_LABELS[type], + items: sources.filter((s) => s.type === type), + })).filter((group) => group.items.length > 0); + + const countText = sources.length === 1 ? "1 source found" : `${sources.length} sources found`; + + return ( +
+ {/* Header */} +
+
+

+ Sources +

+

{countText}

+
+ +
+ + {/* Source list */} +
+ {grouped.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((source) => ( + +

+ {source.title} +

+

+ {extractDomain(source.url)} +

+
+ ))} +
+
+ ))} +
+
+ ); +}); +``` + +**Step 4: Run test to verify it passes** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/ChatSources.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add widget/src/components/ChatSources.tsx widget/src/components/__tests__/ChatSources.test.tsx +git commit -m "feat: add ChatSources sidebar with grouped source links" +``` + +--- + +### Task 4: Integrate SourceIcon into MessageBubble + +**Files:** +- Modify: `widget/src/components/MessageBubble.tsx` +- Modify: `widget/src/components/__tests__/MessageBubble.test.tsx` + +**Step 1: Write the failing tests** + +Add to `widget/src/components/__tests__/MessageBubble.test.tsx`: + +```tsx +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import type { Source } from "../../api/types"; + +const mockSources: Source[] = [ + { url: "https://pmds.info/blog/test", title: "Test Post", type: "blog" }, + { url: "https://pmds.info/services", title: "Services", type: "page" }, +]; + +// Add these test cases to the existing describe block: + +it("renders source icon for assistant messages with sources", () => { + render( + + ); + expect(screen.getByRole("button", { name: /view sources/i })).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); +}); + +it("does not render source icon for user messages", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /view sources/i })).not.toBeInTheDocument(); +}); + +it("does not render source icon when no sources", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /view sources/i })).not.toBeInTheDocument(); +}); + +it("calls onSourceClick when source icon is clicked", async () => { + const user = userEvent.setup(); + const onSourceClick = vi.fn(); + render( + + ); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(onSourceClick).toHaveBeenCalledOnce(); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/MessageBubble.test.tsx` +Expected: FAIL - props not accepted + +**Step 3: Update MessageBubble implementation** + +Update `widget/src/components/MessageBubble.tsx`: + +- Add optional props: `sources?: Source[]`, `onSourceClick?: () => void`, `isSourceActive?: boolean` +- Import `SourceIcon` and `Source` type +- Below the message content div, conditionally render `SourceIcon` when `role === "assistant"` and `sources` has items +- Wrap the bubble + icon in a container div + +The updated component structure: + +```tsx +interface MessageBubbleProps { + role: "user" | "assistant"; + content: string; + sources?: Source[]; + onSourceClick?: () => void; + isSourceActive?: boolean; +} + +// In the render, wrap existing div and add icon below: +
+
+ {renderFormattedContent(content)} +
+ {!isUser && sources && sources.length > 0 && onSourceClick && ( +
+ +
+ )} +
+``` + +**Step 4: Run test to verify it passes** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/MessageBubble.test.tsx` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd widget && pnpm test -- --run` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add widget/src/components/MessageBubble.tsx widget/src/components/__tests__/MessageBubble.test.tsx +git commit -m "feat: integrate SourceIcon into MessageBubble for assistant messages" +``` + +--- + +### Task 5: Wire sources through useChat hook + +**Files:** +- Modify: `widget/src/hooks/useChat.ts:128-135` +- Modify: `widget/src/hooks/__tests__/useChat.test.ts` + +**Step 1: Write the failing test** + +Add to `widget/src/hooks/__tests__/useChat.test.ts` (find the existing test that verifies assistant messages are added - add a new test near it): + +```typescript +it("includes sources from API response in assistant message", async () => { + // Mock fetch to return response with sources + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + reply: "Here are resources.", + sources: [ + { url: "https://pmds.info/blog/test", title: "Test", type: "blog" }, + ], + }), + }); + + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev/api/chat" }) + ); + + await act(async () => { + await result.current.sendMessage("Help me"); + }); + + const assistantMsg = result.current.messages.find((m) => m.role === "assistant"); + expect(assistantMsg?.sources).toEqual([ + { url: "https://pmds.info/blog/test", title: "Test", type: "blog" }, + ]); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd widget && pnpm test -- --run src/hooks/__tests__/useChat.test.ts` +Expected: FAIL - sources is undefined on assistant message + +**Step 3: Update useChat implementation** + +In `widget/src/hooks/useChat.ts`, update the assistant message creation (around line 131-135): + +```typescript +const assistantMessage: ChatMessage = { + id: nextId(), + role: "assistant", + content: data.reply, + sources: data.sources, +}; +``` + +No other changes needed - `sources` is optional on `ChatMessage` so existing messages without sources still work. + +**Step 4: Run test to verify it passes** + +Run: `cd widget && pnpm test -- --run src/hooks/__tests__/useChat.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add widget/src/hooks/useChat.ts widget/src/hooks/__tests__/useChat.test.ts +git commit -m "feat: pass sources from API response into ChatMessage" +``` + +--- + +### Task 6: Integrate ChatSources sidebar into ChatWindow + +**Files:** +- Modify: `widget/src/components/ChatWindow.tsx` +- Modify: `widget/src/components/__tests__/ChatWindow.test.tsx` + +**Step 1: Write the failing tests** + +Add to `widget/src/components/__tests__/ChatWindow.test.tsx`: + +```tsx +import userEvent from "@testing-library/user-event"; + +const messagesWithSources = [ + { id: "msg-1", role: "user" as const, content: "Help me" }, + { + id: "msg-2", + role: "assistant" as const, + content: "Here are resources.", + sources: [ + { url: "https://pmds.info/blog/seo", title: "SEO Tips", type: "blog" as const }, + { url: "https://pmds.info/services", title: "Services", type: "page" as const }, + ], + }, +]; + +it("renders source icon on assistant messages with sources", () => { + render( + + ); + expect(screen.getByRole("button", { name: /view sources/i })).toBeInTheDocument(); +}); + +it("opens sources sidebar when source icon is clicked", async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(screen.getByText("Sources")).toBeInTheDocument(); + expect(screen.getByText("2 sources found")).toBeInTheDocument(); + expect(screen.getByText("SEO Tips")).toBeInTheDocument(); +}); + +it("closes sources sidebar when close button is clicked", async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(screen.getByText("Sources")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /close sources/i })); + expect(screen.queryByText("Sources")).not.toBeInTheDocument(); +}); + +it("toggles sidebar off when same source icon is clicked again", async () => { + const user = userEvent.setup(); + render( + + ); + const icon = screen.getByRole("button", { name: /view sources/i }); + await user.click(icon); + expect(screen.getByText("Sources")).toBeInTheDocument(); + await user.click(icon); + expect(screen.queryByText("Sources")).not.toBeInTheDocument(); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/ChatWindow.test.tsx` +Expected: FAIL + +**Step 3: Update ChatWindow implementation** + +In `widget/src/components/ChatWindow.tsx`: + +1. Add `useState` import (already imported via `useEffect, useRef` - add `useState`) +2. Import `ChatSources` and `Source` type +3. Add state: `const [activeSources, setActiveSources] = useState<{ messageId: string; sources: Source[] } | null>(null)` +4. Update the `ChatMessage` interface to include `sources?: Source[]` +5. In the messages map, pass source props to `MessageBubble`: + +```tsx +{messages.map((msg) => ( + { + if (activeSources?.messageId === msg.id) { + setActiveSources(null); + } else if (msg.sources && msg.sources.length > 0) { + setActiveSources({ messageId: msg.id, sources: msg.sources }); + } + }} + /> +))} +``` + +6. Render `ChatSources` inside the main container, positioned over the messages area. Add it as a sibling to the messages div, wrapped in a relative container: + +The messages section needs `relative` added. Then add: + +```tsx +{activeSources && ( + setActiveSources(null)} + /> +)} +``` + +Place this inside a wrapper that has `relative overflow-hidden` around the messages area so the absolute-positioned sidebar stays contained. + +**Step 4: Run test to verify it passes** + +Run: `cd widget && pnpm test -- --run src/components/__tests__/ChatWindow.test.tsx` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd widget && pnpm test -- --run` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add widget/src/components/ChatWindow.tsx widget/src/components/__tests__/ChatWindow.test.tsx +git commit -m "feat: integrate ChatSources sidebar into ChatWindow" +``` + +--- + +### Task 7: Visual verification with dev server + +**Files:** +- Modify: `widget/src/main.tsx` (temporarily add mock sources for dev testing) + +**Step 1: Add mock sources to dev app** + +In `widget/src/main.tsx`, find where the dev app renders and add mock source data to test the visual appearance. This is temporary dev-only code. + +**Step 2: Start dev server and verify** + +Run: `cd widget && pnpm dev` + +Verify: +- Source icon appears on assistant messages (with badge count) +- Clicking icon slides sidebar in from left +- Sources are grouped: blogs first, then pages, then external +- Source cards show title and domain +- Links open in new tab +- Close button and toggle work +- Dark mode works (if applicable) +- Sidebar doesn't overflow the chat window + +**Step 3: Remove mock data from dev app** + +Clean up temporary mock sources from `main.tsx`. + +**Step 4: Commit final state** + +```bash +git add -A +git commit -m "chore: clean up after visual verification" +``` + +--- + +### Task 8: Update CLAUDE.md documentation + +**Files:** +- Modify: `CLAUDE.md` + +**Step 1: Update component table** + +Add `ChatSources` and `SourceIcon` to the Widget Components table in CLAUDE.md: + +| Component | Purpose | +|-----------|---------| +| `ChatSources` | Slide-out sidebar displaying grouped source links | +| `SourceIcon` | Icon button with badge count to trigger source sidebar | + +**Step 2: Update ChatMessage type documentation** + +Update the Chat Request/Response section to show the `sources` field: + +```typescript +// Response +{ + reply: "How can I help you today?", + sources?: [ + { url: "https://...", title: "...", type: "blog" | "page" | "external" } + ] +} +``` + +**Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add ChatSources and SourceIcon to component documentation" +``` diff --git a/widget/src/api/__tests__/types.test.ts b/widget/src/api/__tests__/types.test.ts new file mode 100644 index 0000000..5ab5274 --- /dev/null +++ b/widget/src/api/__tests__/types.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import type { Source, ChatMessage, ChatResponse } from "../types"; + +describe("Source type", () => { + it("accepts valid source objects", () => { + const source: Source = { + url: "https://pmds.info/blog/seo-tips", + title: "SEO Tips for Small Businesses", + type: "blog", + }; + expect(source.type).toBe("blog"); + }); + + it("accepts all source types", () => { + const types: Source["type"][] = ["blog", "page", "external"]; + expect(types).toHaveLength(3); + }); +}); + +describe("ChatMessage with sources", () => { + it("supports optional sources field", () => { + const msg: ChatMessage = { + id: "msg-1", + role: "assistant", + content: "Here are some resources.", + sources: [ + { url: "https://pmds.info/blog/test", title: "Test", type: "blog" }, + ], + }; + expect(msg.sources).toHaveLength(1); + }); + + it("works without sources", () => { + const msg: ChatMessage = { + id: "msg-2", + role: "user", + content: "Hello", + }; + expect(msg.sources).toBeUndefined(); + }); +}); + +describe("ChatResponse with sources", () => { + it("supports optional sources field", () => { + const res: ChatResponse = { + reply: "Here you go.", + sources: [ + { url: "https://pmds.info/services", title: "Services", type: "page" }, + ], + }; + expect(res.sources).toHaveLength(1); + }); + + it("works without sources", () => { + const res: ChatResponse = { reply: "Hello!" }; + expect(res.sources).toBeUndefined(); + }); +}); diff --git a/widget/src/api/types.ts b/widget/src/api/types.ts index d32a01d..ca3b214 100644 --- a/widget/src/api/types.ts +++ b/widget/src/api/types.ts @@ -1,7 +1,14 @@ +export interface Source { + url: string; + title: string; + type: "blog" | "page" | "external"; +} + export interface ChatMessage { id: string; role: "user" | "assistant"; content: string; + sources?: Source[]; } export interface ChatRequest { @@ -10,6 +17,7 @@ export interface ChatRequest { export interface ChatResponse { reply: string; + sources?: Source[]; } export interface ChatErrorResponse { diff --git a/widget/src/components/ChatSources.tsx b/widget/src/components/ChatSources.tsx new file mode 100644 index 0000000..419734f --- /dev/null +++ b/widget/src/components/ChatSources.tsx @@ -0,0 +1,102 @@ +import { memo } from "react"; +import type { Source } from "../api/types"; + +interface ChatSourcesProps { + sources: Source[]; + onClose: () => void; +} + +const TYPE_ORDER: Source["type"][] = ["blog", "page", "external"]; + +const TYPE_LABELS: Record = { + blog: "Blog", + page: "Page", + external: "External", +}; + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export const ChatSources = memo(function ChatSources({ + sources, + onClose, +}: ChatSourcesProps) { + const grouped = TYPE_ORDER.map((type) => ({ + type, + label: TYPE_LABELS[type], + items: sources.filter((s) => s.type === type), + })).filter((group) => group.items.length > 0); + + const countText = + sources.length === 1 + ? "1 source found" + : `${sources.length} sources found`; + + return ( +
+ {/* Header */} +
+
+

+ Sources +

+

{countText}

+
+ +
+ + {/* Source list */} +
+ {grouped.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((source) => ( + +

+ {source.title} +

+

+ {extractDomain(source.url)} +

+
+ ))} +
+
+ ))} +
+
+ ); +}); diff --git a/widget/src/components/ChatWindow.tsx b/widget/src/components/ChatWindow.tsx index 0d9e025..a21e79b 100644 --- a/widget/src/components/ChatWindow.tsx +++ b/widget/src/components/ChatWindow.tsx @@ -1,13 +1,16 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { MessageBubble } from "./MessageBubble"; import { ChatInput } from "./ChatInput"; +import { ChatSources } from "./ChatSources"; import type { WidgetPosition } from "./ChatWidget"; import type { ClaudiusTranslations } from "../i18n"; +import type { Source } from "../api/types"; interface ChatMessage { id: string; role: "user" | "assistant"; content: string; + sources?: Source[]; } interface ChatWindowProps { @@ -57,6 +60,7 @@ export function ChatWindow({ translations, }: ChatWindowProps) { const messagesContainerRef = useRef(null); + const [activeSources, setActiveSources] = useState<{ messageId: string; sources: Source[] } | null>(null); useEffect(() => { const container = messagesContainerRef.current; @@ -108,36 +112,60 @@ export function ChatWindow({ - {/* Messages */} -
- {messages.length === 0 && !error && ( -
-
- {welcomeMessage} -
-
+ {/* Messages area */} +
+ {/* Sources sidebar */} + {activeSources && ( + setActiveSources(null)} + /> )} - {messages.map((msg) => ( - - ))} + {/* Messages */} +
+ {messages.length === 0 && !error && ( +
+
+ {welcomeMessage} +
+
+ )} - {isLoading && } + {messages.map((msg) => ( + { + if (activeSources?.messageId === msg.id) { + setActiveSources(null); + } else if (msg.sources && msg.sources.length > 0) { + setActiveSources({ messageId: msg.id, sources: msg.sources }); + } + }} + /> + ))} - {error && ( -
- {error} -
- )} + {isLoading && } + + {error && ( +
+ {error} +
+ )} +
{/* Input */} diff --git a/widget/src/components/MessageBubble.tsx b/widget/src/components/MessageBubble.tsx index cc3e41a..b57c5de 100644 --- a/widget/src/components/MessageBubble.tsx +++ b/widget/src/components/MessageBubble.tsx @@ -1,8 +1,13 @@ import { memo, type ReactNode } from "react"; +import { SourceIcon } from "./SourceIcon"; +import type { Source } from "../api/types"; interface MessageBubbleProps { role: "user" | "assistant"; content: string; + sources?: Source[]; + onSourceClick?: () => void; + isSourceActive?: boolean; } const URL_REGEX = /(https?:\/\/[^\s)]+)/; @@ -85,18 +90,32 @@ function renderFormattedContent(content: string): ReactNode[] { export const MessageBubble = memo(function MessageBubble({ role, content, + sources, + onSourceClick, + isSourceActive, }: MessageBubbleProps) { const isUser = role === "user"; return ( -
- {renderFormattedContent(content)} +
+
+ {renderFormattedContent(content)} +
+ {!isUser && sources && sources.length > 0 && onSourceClick && ( +
+ +
+ )}
); }); diff --git a/widget/src/components/SourceIcon.tsx b/widget/src/components/SourceIcon.tsx new file mode 100644 index 0000000..0e203c4 --- /dev/null +++ b/widget/src/components/SourceIcon.tsx @@ -0,0 +1,46 @@ +import { memo } from "react"; + +interface SourceIconProps { + count: number; + isActive: boolean; + onClick: () => void; +} + +export const SourceIcon = memo(function SourceIcon({ + count, + isActive, + onClick, +}: SourceIconProps) { + return ( + + ); +}); diff --git a/widget/src/components/__tests__/ChatSources.test.tsx b/widget/src/components/__tests__/ChatSources.test.tsx new file mode 100644 index 0000000..67ddc0e --- /dev/null +++ b/widget/src/components/__tests__/ChatSources.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { ChatSources } from "../ChatSources"; +import type { Source } from "../../api/types"; + +const mockSources: Source[] = [ + { url: "https://pmds.info/blog/seo-tips", title: "SEO Tips", type: "blog" }, + { url: "https://pmds.info/blog/web-design", title: "Web Design Guide", type: "blog" }, + { url: "https://pmds.info/services", title: "Our Services", type: "page" }, + { url: "https://example.com/resource", title: "External Resource", type: "external" }, +]; + +describe("ChatSources", () => { + it("renders source count header", () => { + render(); + expect(screen.getByText("4 sources found")).toBeInTheDocument(); + }); + + it("renders singular count for one source", () => { + render(); + expect(screen.getByText("1 source found")).toBeInTheDocument(); + }); + + it("renders Sources heading", () => { + render(); + expect(screen.getByText("Sources")).toBeInTheDocument(); + }); + + it("groups sources by type with blogs first", () => { + render(); + const headings = screen.getAllByRole("heading", { level: 4 }); + const texts = headings.map((h) => h.textContent); + expect(texts).toEqual(["Blog", "Page", "External"]); + }); + + it("does not render empty type groups", () => { + const blogOnly = mockSources.filter((s) => s.type === "blog"); + render(); + expect(screen.queryByText("Page")).not.toBeInTheDocument(); + expect(screen.queryByText("External")).not.toBeInTheDocument(); + }); + + it("renders source titles as links", () => { + render(); + const link = screen.getByRole("link", { name: /SEO Tips/i }); + expect(link).toHaveAttribute("href", "https://pmds.info/blog/seo-tips"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("displays domain for each source", () => { + render(); + expect(screen.getAllByText("pmds.info").length).toBeGreaterThanOrEqual(1); + }); + + it("calls onClose when close button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/widget/src/components/__tests__/ChatWindow.test.tsx b/widget/src/components/__tests__/ChatWindow.test.tsx index 94cbc1a..b5b8eac 100644 --- a/widget/src/components/__tests__/ChatWindow.test.tsx +++ b/widget/src/components/__tests__/ChatWindow.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import { ChatWindow } from "../ChatWindow"; @@ -56,6 +57,84 @@ describe("ChatWindow", () => { expect(screen.getByText(/Connection failed/i)).toBeInTheDocument(); }); + const messagesWithSources = [ + { id: "msg-1", role: "user" as const, content: "Help me" }, + { + id: "msg-2", + role: "assistant" as const, + content: "Here are resources.", + sources: [ + { url: "https://pmds.info/blog/seo", title: "SEO Tips", type: "blog" as const }, + { url: "https://pmds.info/services", title: "Services", type: "page" as const }, + ], + }, + ]; + + it("renders source icon on assistant messages with sources", () => { + render( + + ); + expect(screen.getByRole("button", { name: /view sources/i })).toBeInTheDocument(); + }); + + it("opens sources sidebar when source icon is clicked", async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(screen.getByText("Sources")).toBeInTheDocument(); + expect(screen.getByText("2 sources found")).toBeInTheDocument(); + expect(screen.getByText("SEO Tips")).toBeInTheDocument(); + }); + + it("closes sources sidebar when close button is clicked", async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(screen.getByText("Sources")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /close sources/i })); + expect(screen.queryByText("Sources")).not.toBeInTheDocument(); + }); + + it("toggles sidebar off when same source icon is clicked again", async () => { + const user = userEvent.setup(); + render( + + ); + const icon = screen.getByRole("button", { name: /view sources/i }); + await user.click(icon); + expect(screen.getByText("Sources")).toBeInTheDocument(); + await user.click(icon); + expect(screen.queryByText("Sources")).not.toBeInTheDocument(); + }); + it("renders header with title", () => { render( { it("renders user message with correct styling", () => { render(); const bubble = screen.getByText("Hello!"); expect(bubble).toBeInTheDocument(); - expect(bubble.closest("div")).toHaveClass("ml-auto"); + // ml-auto is on the outer wrapper div (parent of the bubble div) + const innerDiv = bubble.closest("div"); + expect(innerDiv?.parentElement).toHaveClass("ml-auto"); }); it("renders assistant message with correct styling", () => { render(); const bubble = screen.getByText("How can I help?"); expect(bubble).toBeInTheDocument(); - expect(bubble.closest("div")).toHaveClass("mr-auto"); + // mr-auto is on the outer wrapper div (parent of the bubble div) + const innerDiv = bubble.closest("div"); + expect(innerDiv?.parentElement).toHaveClass("mr-auto"); }); it("renders links as clickable anchors", () => { @@ -29,4 +40,54 @@ describe("MessageBubble", () => { expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); }); + + it("renders source icon for assistant messages with sources", () => { + render( + + ); + expect(screen.getByRole("button", { name: /view sources/i })).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + it("does not render source icon for user messages", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /view sources/i })).not.toBeInTheDocument(); + }); + + it("does not render source icon when no sources", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /view sources/i })).not.toBeInTheDocument(); + }); + + it("calls onSourceClick when source icon is clicked", async () => { + const user = userEvent.setup(); + const onSourceClick = vi.fn(); + render( + + ); + await user.click(screen.getByRole("button", { name: /view sources/i })); + expect(onSourceClick).toHaveBeenCalledOnce(); + }); }); diff --git a/widget/src/components/__tests__/SourceIcon.test.tsx b/widget/src/components/__tests__/SourceIcon.test.tsx new file mode 100644 index 0000000..6b350e4 --- /dev/null +++ b/widget/src/components/__tests__/SourceIcon.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { SourceIcon } from "../SourceIcon"; + +describe("SourceIcon", () => { + it("renders with source count badge", () => { + render(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("has tooltip text", () => { + render(); + const button = screen.getByRole("button", { name: /view sources/i }); + expect(button).toBeInTheDocument(); + }); + + it("calls onClick when clicked", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + await user.click(screen.getByRole("button")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("applies active styling when isActive is true", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-claudius-primary"); + }); + + it("applies inactive styling when isActive is false", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).not.toContain("bg-claudius-primary"); + }); +}); diff --git a/widget/src/hooks/__tests__/useChat.test.ts b/widget/src/hooks/__tests__/useChat.test.ts index 61fe804..9d68ad7 100644 --- a/widget/src/hooks/__tests__/useChat.test.ts +++ b/widget/src/hooks/__tests__/useChat.test.ts @@ -50,6 +50,36 @@ describe("useChat", () => { expect(result.current.messages[1].id).toBeDefined(); }); + it("includes sources from API response in assistant message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve({ + reply: "Here are resources.", + sources: [ + { url: "https://pmds.info/blog/test", title: "Test", type: "blog" }, + ], + }), + }); + + const { result } = renderHook(() => + useChat({ apiUrl: "https://test.workers.dev" }) + ); + + await act(async () => { + await result.current.sendMessage("Help me"); + }); + + const assistantMsg = result.current.messages.find( + (m) => m.role === "assistant" + ); + expect(assistantMsg?.sources).toEqual([ + { url: "https://pmds.info/blog/test", title: "Test", type: "blog" }, + ]); + }); + it("sets error on failed fetch", async () => { mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/widget/src/hooks/useChat.ts b/widget/src/hooks/useChat.ts index 569bcda..12ad00a 100644 --- a/widget/src/hooks/useChat.ts +++ b/widget/src/hooks/useChat.ts @@ -132,6 +132,7 @@ export function useChat({ id: nextId(), role: "assistant", content: data.reply, + sources: data.sources, }; const withReply = [...updatedMessages, assistantMessage]; messagesRef.current = withReply; diff --git a/widget/src/index.ts b/widget/src/index.ts index 7e9b97e..5a63e94 100644 --- a/widget/src/index.ts +++ b/widget/src/index.ts @@ -9,6 +9,7 @@ export { ChatApiClient } from "./api/client"; export type { ChatApiClientOptions } from "./api/client"; export { ChatApiError, DebounceError } from "./api/errors"; export type { + Source, ChatMessage, ChatRequest, ChatResponse,