From 40ecd60198af9485762f43d13a35d5a300759576 Mon Sep 17 00:00:00 2001 From: PostboxRetinal <78338192+PostboxRetinal@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:20:42 +0000 Subject: [PATCH 01/17] feat(chat): refactor chat components into primitives and enhance UI - Extracted chat UI into reusable components: ChatBubble, ChatMessage, ChatList, and ChatComposer. - Updated MessageList to act as a thin pass-through to ChatList for backward compatibility. - Implemented markdown rendering for assistant messages in ChatBubble. - Added source citation handling in ChatMessage for assistant messages. - Introduced ChatComposer for message input handling, ensuring trimmed submissions. - Enhanced ConversationView to utilize ChatComposer alongside VoiceInput. - Updated AuthScreen to use toast notifications for error and success messages. - Added unit tests for new components and updated existing tests to ensure coverage. - Configured Vitest for coverage reporting with thresholds to maintain code quality. --- .gitignore | 1 + .../.openspec.yaml | 3 + .../2026-08-09-ui-ux-normalization/design.md | 33 ++++++ .../proposal.md | 38 +++++++ .../2026-08-09-ui-ux-normalization/tasks.md | 40 +++++++ package.json | 1 + src/components/AuthScreen.tsx | 57 +++------- src/components/ConversationView.tsx | 30 ++--- src/components/MessageList.tsx | 103 ++---------------- src/components/__tests__/AuthScreen.test.tsx | 101 +++++++++++++++++ src/components/chat/ChatBubble.tsx | 42 +++++++ src/components/chat/ChatComposer.tsx | 35 ++++++ src/components/chat/ChatList.tsx | 61 +++++++++++ src/components/chat/ChatMessage.tsx | 40 +++++++ .../chat/__tests__/ChatBubble.test.tsx | 31 ++++++ .../chat/__tests__/ChatComposer.test.tsx | 34 ++++++ .../chat/__tests__/ChatList.test.tsx | 51 +++++++++ .../chat/__tests__/ChatMessage.test.tsx | 33 ++++++ src/components/chat/index.ts | 4 + vitest.config.ts | 13 +++ 20 files changed, 591 insertions(+), 160 deletions(-) create mode 100644 openspec/changes/archive/2026-08-09-ui-ux-normalization/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-09-ui-ux-normalization/design.md create mode 100644 openspec/changes/archive/2026-08-09-ui-ux-normalization/proposal.md create mode 100644 openspec/changes/archive/2026-08-09-ui-ux-normalization/tasks.md create mode 100644 src/components/__tests__/AuthScreen.test.tsx create mode 100644 src/components/chat/ChatBubble.tsx create mode 100644 src/components/chat/ChatComposer.tsx create mode 100644 src/components/chat/ChatList.tsx create mode 100644 src/components/chat/ChatMessage.tsx create mode 100644 src/components/chat/__tests__/ChatBubble.test.tsx create mode 100644 src/components/chat/__tests__/ChatComposer.test.tsx create mode 100644 src/components/chat/__tests__/ChatList.test.tsx create mode 100644 src/components/chat/__tests__/ChatMessage.test.tsx create mode 100644 src/components/chat/index.ts diff --git a/.gitignore b/.gitignore index ec02ac2..6b0301e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ tsconfig.tsbuildinfo node_modules dist dist-ssr +coverage *.local .pnpm-store diff --git a/openspec/changes/archive/2026-08-09-ui-ux-normalization/.openspec.yaml b/openspec/changes/archive/2026-08-09-ui-ux-normalization/.openspec.yaml new file mode 100644 index 0000000..1268815 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-ui-ux-normalization/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-08-08 +skip_specs: true diff --git a/openspec/changes/archive/2026-08-09-ui-ux-normalization/design.md b/openspec/changes/archive/2026-08-09-ui-ux-normalization/design.md new file mode 100644 index 0000000..5266726 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-ui-ux-normalization/design.md @@ -0,0 +1,33 @@ +## Context + +The chat UI currently lives ad-hoc: `MessageList.tsx` renders role-aware bubbles, sources, the researching indicator, and auto-scroll, while `ConversationView.tsx` owns the input + Send composer beside `VoiceInput`. Both are bespoke with duplicated styling and no shared contract. The data layer is custom and working: conversations/messages persist in Supabase, `useResearch` calls the `research` Edge Function returning `{answer, sources}`. The app uses React 19, Vite, Tailwind 3, with CSP/SRI enforced, and must stay dependency-light (memory-limited container). + +External frameworks (assistant-ui, NLUX, chatscope) were researched and rejected because they force a runtime/thread state model that conflicts with the existing Supabase schema and a standard composer that fights the custom voice input. + +## Goals / Non-Goals + +**Goals:** +- One source of truth for bubble look, message-with-sources, the chat scroll/typing container, and the text composer. +- Reuse across the app by extracting the chat into `src/components/chat/` primitives. +- Preserve identical rendered behavior, props, and `data-testid`s so existing tests pass unchanged. +- Zero new dependencies. + +**Non-Goals:** +- No external chatbot framework. +- No data-layer, persistence, `research` EF, or `VoiceInput` changes. +- No streaming or generative tool-call UI. + +## Decisions + +1. **Internal component extraction over a framework.** Four small primitives expose the chat contract without a dependency tree or a foreign state model. Each maps to an isolated unit of UI. +2. **Keep the `MessageList` public name.** Existing consumers and `MessageList.test.tsx` reference `MessageList` and the `message-${role}` testids; the component becomes a thin wrapper over `ChatList` so nothing downstream changes. +3. **`ChatBubble` owns markdown + anchor override.** The `target="_blank" rel="noopener noreferrer"` anchor override moves from `MessageList` into `ChatBubble`; markdown uses `react-markdown` + `remark-gfm`, no `rehype-raw` (XSS-safe). +4. **`ChatComposer` owns the empty guard.** The `trim().length === 0` short-circuit moves from `ConversationView` into the composer, so the row is self-contained and testable. `ChatComposer` does NOT absorb `VoiceInput`. +5. **`ChatList` owns scroll + states.** Auto-scroll (`scrollIntoView` on `[messages, researching]`), empty state, loading state, and the `Researching…` indicator all live here. + +## Risks / Trade-offs + +- **Wrapper indirection**: `MessageList` delegating to `ChatList` adds one layer; kept intentionally thin to avoid breaking existing imports/tests. +- **Refactor risk**: moving styling/state must preserve pixel-identical output. Mitigation: existing `MessageList.test.tsx` (10 tests) acts as a regression guard; run full suite after rewire. +- **Guard ownership**: moving the empty-input guard changes where submit behavior is enforced. New `ChatComposer.test.tsx` covers it; `ConversationView` keeps its own `trim()` check harmlessly. +- **Voice composer gap**: the mic stays bespoke in `VoiceInput`, so the text and voice paths are normalized separately. Accepted trade-off to preserve hold-to-record UX. diff --git a/openspec/changes/archive/2026-08-09-ui-ux-normalization/proposal.md b/openspec/changes/archive/2026-08-09-ui-ux-normalization/proposal.md new file mode 100644 index 0000000..e4e2bd4 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-ui-ux-normalization/proposal.md @@ -0,0 +1,38 @@ +## Why + +The chat interface is hand-rolled and duplicated across `MessageList.tsx` and `ConversationView.tsx`, so bubble styling, the typing indicator, auto-scroll, and the composer are maintained in two places with no shared contract. This makes UX iteration fragile and prevents reuse. External chatbot frameworks (assistant-ui, NLUX, chatscope) were evaluated and rejected: they pull a large dependency tree and force a runtime/state model that conflicts with the existing Supabase-driven conversations/messages and the custom voice composer. + +## What Changes + +- Extract the chat UI into reusable internal primitives under `src/components/chat/`: `ChatBubble`, `ChatMessage`, `ChatList`, `ChatComposer`, plus a barrel `index.ts`. +- `ChatBubble` owns the role-aware bubble look (user right/blue, assistant left/grey + markdown) and the markdown anchor override. +- `ChatMessage` composes a `ChatBubble` with the assistant source-citation list. +- `ChatList` owns the scroll container, empty/loading states, and the `Researching…` typing indicator. +- `ChatComposer` owns the controlled text input + Send row with the empty/whitespace guard. +- `MessageList.tsx` becomes a thin pass-through to `ChatList`, preserving its props and `message-user`/`message-assistant` testids. +- `ConversationView.tsx` uses `ChatComposer` next to `VoiceInput`, preserving the current `items-center` alignment. +- No behavior change to data hooks (`useMessages`, `useResearch`, `useConversations`), Supabase schema, the `research` Edge Function, or `VoiceInput` internals. No new dependencies. + +## Capabilities + +### New Capabilities + +None. This is a pure refactor: behavior of the rendered chat is unchanged (bubbles, typing indicator, composer, sources render identically). `skip_specs: true` is set in `.openspec.yaml` per the spec-driven schema. + +### Modified Capabilities + +None. No spec-level behavior changes. + +## Non-Goals + +- Not adopting any external chatbot framework. +- Not changing the data layer, persistence, or the `research` Edge Function. +- Not absorbing `VoiceInput` (hold-to-record + language toggle) into the composer. +- Not adding streaming or generative tool call UI. + +## Impact + +- **Code**: `src/components/chat/` (new), `src/components/MessageList.tsx` (shrinks to wrapper), `src/components/ConversationView.tsx` (uses `ChatComposer`). +- **Tests**: new unit tests per primitive; existing `MessageList.test.tsx` and all current suites stay green. +- **Dependencies**: none added or removed. +- **Systems**: no backend, Supabase, or Edge Function changes. diff --git a/openspec/changes/archive/2026-08-09-ui-ux-normalization/tasks.md b/openspec/changes/archive/2026-08-09-ui-ux-normalization/tasks.md new file mode 100644 index 0000000..a76e480 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-ui-ux-normalization/tasks.md @@ -0,0 +1,40 @@ +## 1. Chat primitives scaffold + +- [x] 1.1 Create `src/components/chat/index.ts` barrel re-exporting ChatBubble, ChatMessage, ChatList, ChatComposer. +- [x] 1.2 Add `src/components/chat/__tests__/` dir to hold primitive unit tests. + +## 2. ChatBubble + +- [x] 2.1 Write failing test `src/components/chat/__tests__/ChatBubble.test.tsx`: assistant content renders as markdown; user content renders plain text; keeps `message-user`/`message-assistant` testids; run it to confirm FAIL. +- [x] 2.2 Implement `src/components/chat/ChatBubble.tsx`: props `{ role, content }`; user → `ml-auto bg-blue-600 text-white rounded-br-sm whitespace-pre-wrap`; assistant → `mr-auto bg-zinc-800 text-zinc-100 rounded-bl-sm assistant-markdown` + `` with `remark-gfm` and the `target="_blank" rel="noopener noreferrer"` anchor override (no `rehype-raw`). +- [x] 2.3 Run `bun run test src/components/chat/__tests__/ChatBubble.test.tsx -v` → PASS. + +## 3. ChatMessage + +- [x] 3.1 Write failing test `src/components/chat/__tests__/ChatMessage.test.tsx`: assistant with sources renders `SourceCitation` cards; user renders none; run to confirm FAIL. +- [x] 3.2 Implement `src/components/chat/ChatMessage.tsx`: composes `ChatBubble` + optional `SourceCitation` list; move `normalizeSources` here; props `{ role, content, sources? }`. +- [x] 3.3 Run the test → PASS. + +## 4. ChatList + +- [x] 4.1 Write failing test `src/components/chat/__tests__/ChatList.test.tsx`: renders messages, shows `Researching…` indicator when `researching=true`, renders empty and loading states; run to confirm FAIL. +- [x] 4.2 Implement `src/components/chat/ChatList.tsx`: props `{ messages, researching?, loading }`; scroll container with `scrollRef` + `scrollIntoView` on `[messages, researching]`; maps `messages` to `ChatMessage`; empty/loading blocks; `Researching…` indicator. +- [x] 4.3 Run the test → PASS. + +## 5. ChatComposer + +- [x] 5.1 Write failing test `src/components/chat/__tests__/ChatComposer.test.tsx`: no submit on empty/whitespace; submit fires with trimmed text; run to confirm FAIL. +- [x] 5.2 Implement `src/components/chat/ChatComposer.tsx`: props `{ value, onChange, onSubmit }`; controlled input + Send row; `trim().length === 0` short-circuit before calling `onSubmit`. +- [x] 5.3 Run the test → PASS. + +## 6. Rewire existing components + +- [x] 6.1 Shrink `src/components/MessageList.tsx` to a thin pass-through: keep exported `MessageList`, `MessageListProps`, and `data-testid="message-${role}"`, delegate rendering to `ChatList`. +- [x] 6.2 Update `src/components/ConversationView.tsx` to use `ChatComposer` for the text row; keep `VoiceInput` beside it and the `items-center` alignment; keep `key="message-input"`. +- [x] 6.3 Run full suite `bun run test` → all pass (existing `MessageList.test.tsx` 10 tests included); `bun run typecheck` → 0 errors. + +## 7. Polish UX + +- [x] 7.1 Confirm composer/mic alignment (`items-center`) is stable after rewire in `src/components/ConversationView.tsx`. +- [x] 7.2 Confirm `ChatList` auto-scroll anchors to newest message and stays pinned while `researching`. +- [x] 7.3 Final gate: `bun run typecheck` (0), `bun run test` (all pass), `bun run lint` (no new warnings), coverage at/above threshold. diff --git a/package.json b/package.json index 350f1de..709affb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@typescript-eslint/parser": "^8.46.3", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^3.2.7", "eslint": "^10.8.0", "eslint-plugin-security": "^4.0.1", "globals": "^16.5.0", diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index 55c9ab2..8d6a15f 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent } from "react"; import { useAuth } from "../contexts/AuthContext"; +import { toast } from "sonner"; import { isPasswordValid } from "../lib/password"; import PasswordRequirements from "./PasswordRequirements"; @@ -12,12 +13,10 @@ export default function AuthScreen() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); - const [successMessage, setSuccessMessage] = useState(""); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(""); - setSuccessMessage(""); if (!email.trim()) { setError("Please enter your email."); @@ -28,9 +27,9 @@ export default function AuthScreen() { setSubmitting(true); const result = await requestPasswordReset(email); if (result.error) { - setError(result.error); + toast.error(result.error); } else { - setSuccessMessage( + toast.success( "If an account exists for that email, a password reset link has been sent. Check your inbox.", ); } @@ -62,14 +61,14 @@ export default function AuthScreen() { if (mode === "signin") { const result = await signIn(email, password); if (result.error) { - setError(result.error); + toast.error(result.error); } } else { const result = await signUp(email, password); if (result.error) { setError(result.error); } else { - setSuccessMessage( + toast.success( "Account created! Check your email for a confirmation link. You can sign in once confirmed.", ); } @@ -85,13 +84,11 @@ export default function AuthScreen() { setMode(mode === "signin" ? "signup" : "signin"); } setError(""); - setSuccessMessage(""); }; const goForgot = () => { setMode("forgot"); setError(""); - setSuccessMessage(""); }; return ( @@ -137,17 +134,6 @@ export default function AuthScreen() { )} - {/* Success message (sign up) */} - {successMessage && ( -
- {successMessage} -
- )} - {/* Error */} {error && (
{ e.preventDefault(); setError(""); - setSuccessMessage(""); if (!isPasswordValid(password)) { setError( @@ -299,11 +283,9 @@ export function SetNewPassword() { setSubmitting(true); const result = await updatePassword(password); if (result.error) { - setError(result.error); + toast.error(result.error); } else { - setSuccessMessage( - "Password updated. You can now sign in with your new password.", - ); + toast.success("Password updated. You can now sign in with your new password."); } setSubmitting(false); }; @@ -321,26 +303,17 @@ export function SetNewPassword() {
- {successMessage ? ( + {error && (
- {successMessage} + {error}
- ) : ( - <> - {error && ( -
- {error} -
- )} + )} -
+
diff --git a/src/components/ConversationView.tsx b/src/components/ConversationView.tsx index 45825ca..1e0ad9b 100644 --- a/src/components/ConversationView.tsx +++ b/src/components/ConversationView.tsx @@ -5,6 +5,7 @@ import { useResearch } from "../hooks/useResearch"; import MessageList from "./MessageList"; import VoiceInput from "./VoiceInput"; import ModelSelector from "./ModelSelector"; +import ChatComposer from "./chat/ChatComposer"; interface ConversationViewProps { conversationId: string; @@ -43,13 +44,11 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac ); const handleSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - if (textInput.trim().length === 0) return; - sendMessage(textInput); + (text: string) => { + sendMessage(text); setTextInput(""); }, - [textInput, sendMessage], + [sendMessage], ); return ( @@ -74,22 +73,11 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac {/* Input */} diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index 2bc3ad0..1f38a6b 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -1,8 +1,5 @@ -import { useEffect, useRef } from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { Message, Source } from "../types/models"; -import SourceCitation from "./SourceCitation"; +import type { Message } from "../types/models"; +import ChatList from "./chat/ChatList"; interface MessageListProps { researching?: boolean; @@ -10,97 +7,11 @@ interface MessageListProps { loading: boolean; } -// Anchor override: open rendered markdown links in a new tab, matching the -// SourceCitation behavior. rel noopener+noreferrer prevents tab-nabbing. -const markdownComponents = { - a: (props: React.AnchorHTMLAttributes) => ( - - ), -}; - -function normalizeSources(sources: unknown): Source[] { - if (!Array.isArray(sources)) return []; - return sources.filter( - (s): s is Source => - typeof s === "object" && - s != null && - typeof (s as Source).url === "string", - ); -} - -function MessageList({ - researching = false, - messages, - loading, -}: MessageListProps): React.ReactNode { - const scrollRef = useRef(null); - - useEffect(() => { - scrollRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, researching]); - - if (loading) { - return ( -
- Loading messages... -
- ); - } - - if (messages.length === 0 && !researching) { - return ( -
- No messages yet -
- ); - } - - return ( -
- {messages.map((message) => ( -
-
- {message.role === "user" ? ( - message.content - ) : ( - - {message.content} - - )} -
- {message.role === "assistant" && ( -
- {normalizeSources(message.sources).map((s, i) => ( - - ))} -
- )} -
- ))} - {researching && ( -
- - Researching - - -
- )} -
-
- ); +// Thin pass-through to the shared chat-list primitive. Kept for backwards +// compatibility with existing imports and tests; bubble/source/scroll/typing +// rendering now lives in src/components/chat/. +function MessageList(props: MessageListProps) { + return ; } export default MessageList; diff --git a/src/components/__tests__/AuthScreen.test.tsx b/src/components/__tests__/AuthScreen.test.tsx new file mode 100644 index 0000000..aeb0a1e --- /dev/null +++ b/src/components/__tests__/AuthScreen.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AuthScreen, { SetNewPassword } from "../AuthScreen"; +import { useAuth } from "../../contexts/AuthContext"; + +vi.mock("../../contexts/AuthContext", () => ({ + useAuth: vi.fn(), +})); + +const toastError = vi.fn(); +const toastSuccess = vi.fn(); +vi.mock("sonner", () => ({ + toast: { + error: (...args: unknown[]) => toastError(...args), + success: (...args: unknown[]) => toastSuccess(...args), + }, +})); + +function mockAuth(overrides: Partial> = {}) { + (useAuth as ReturnType).mockReturnValue({ + signIn: vi.fn().mockResolvedValue({}), + signUp: vi.fn().mockResolvedValue({}), + requestPasswordReset: vi.fn().mockResolvedValue({}), + updatePassword: vi.fn().mockResolvedValue({}), + ...overrides, + }); +} + +describe("AuthScreen", () => { + beforeEach(() => { + toastError.mockClear(); + toastSuccess.mockClear(); + mockAuth(); + }); + + describe("sign in", () => { + it("shows a toast error on invalid credentials", async () => { + mockAuth({ + signIn: vi.fn().mockResolvedValue({ error: "Invalid login credentials" }), + }); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText("Email"), "a@b.com"); + await user.type(screen.getByLabelText("Password"), "Correct1!"); + const submit = screen.getAllByRole("button", { name: "Sign In" })[1]; + await user.click(submit); + expect(toastError).toHaveBeenCalledWith("Invalid login credentials"); + }); + }); + + describe("sign up", () => { + it("shows a success toast after creating an account", async () => { + mockAuth({ + signUp: vi.fn().mockResolvedValue({}), + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getAllByRole("button", { name: "Sign Up" })[0]); + await user.type(screen.getByLabelText("Email"), "a@b.com"); + await user.type(screen.getByLabelText("Password"), "Correct1!"); + await user.click(screen.getByRole("button", { name: "Create Account" })); + expect(toastSuccess).toHaveBeenCalledWith( + expect.stringMatching(/Account created/i), + ); + }); + }); + + describe("forgot password", () => { + it("shows a success toast after requesting a reset link", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Forgot password?" })); + await user.type(screen.getByLabelText("Email"), "a@b.com"); + await user.click(screen.getByRole("button", { name: "Send Reset Link" })); + expect(toastSuccess).toHaveBeenCalled(); + expect(toastSuccess.mock.calls[0][0]).toMatch(/reset link has been sent/i); + }); + }); +}); + +describe("SetNewPassword", () => { + beforeEach(() => { + toastError.mockClear(); + toastSuccess.mockClear(); + }); + + it("toasts password-updated success", async () => { + mockAuth({ + updatePassword: vi.fn().mockResolvedValue({}), + }); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText("New password"), "Correct1!"); + await user.type(screen.getByLabelText("Confirm password"), "Correct1!"); + await user.click(screen.getByRole("button", { name: "Update password" })); + expect(toastSuccess).toHaveBeenCalledWith( + expect.stringMatching(/password updated/i), + ); + }); +}); diff --git a/src/components/chat/ChatBubble.tsx b/src/components/chat/ChatBubble.tsx new file mode 100644 index 0000000..9e72803 --- /dev/null +++ b/src/components/chat/ChatBubble.tsx @@ -0,0 +1,42 @@ +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { Message } from "../../types/models"; + +type Role = Message["role"]; + +// Anchor override: open rendered markdown links in a new tab. rel +// noopener+noreferrer prevents tab-nabbing. Matches SourceCitation behavior. +const markdownComponents = { + a: (props: React.AnchorHTMLAttributes) => ( +
+ ), +}; + +interface ChatBubbleProps { + role: Role; + content: string; +} + +function ChatBubble({ role, content }: ChatBubbleProps) { + const isUser = role === "user"; + return ( +
+ {isUser ? ( + content + ) : ( + + {content} + + )} +
+ ); +} + +export default ChatBubble; diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx new file mode 100644 index 0000000..655582e --- /dev/null +++ b/src/components/chat/ChatComposer.tsx @@ -0,0 +1,35 @@ +interface ChatComposerProps { + value: string; + onChange: (value: string) => void; + onSubmit: (text: string) => void; +} + +function ChatComposer({ value, onChange, onSubmit }: ChatComposerProps) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = value.trim(); + if (trimmed.length === 0) return; + onSubmit(trimmed); + }; + + return ( +
+ onChange(e.target.value)} + placeholder="Type a message..." + className="flex-1 rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2 outline-none transition-colors focus:border-zinc-600" + /> + +
+ ); +} + +export default ChatComposer; diff --git a/src/components/chat/ChatList.tsx b/src/components/chat/ChatList.tsx new file mode 100644 index 0000000..982915e --- /dev/null +++ b/src/components/chat/ChatList.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from "react"; +import type { Message } from "../../types/models"; +import ChatMessage from "./ChatMessage"; + +interface ChatListProps { + researching?: boolean; + messages: Message[]; + loading: boolean; +} + +function ChatList({ + researching = false, + messages, + loading, +}: ChatListProps) { + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, researching]); + + if (loading) { + return ( +
+ Loading messages... +
+ ); + } + + if (messages.length === 0 && !researching) { + return ( +
+ No messages yet +
+ ); + } + + return ( +
+ {messages.map((message) => ( + + ))} + {researching && ( +
+ + Researching + + +
+ )} +
+
+ ); +} + +export default ChatList; diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx new file mode 100644 index 0000000..d2c0ba6 --- /dev/null +++ b/src/components/chat/ChatMessage.tsx @@ -0,0 +1,40 @@ +import type { Message, Source } from "../../types/models"; +import ChatBubble from "./ChatBubble"; +import SourceCitation from "../SourceCitation"; + +function normalizeSources(sources: unknown): Source[] { + if (!Array.isArray(sources)) return []; + return sources.filter( + (s): s is Source => + typeof s === "object" && + s != null && + typeof (s as Source).url === "string", + ); +} + +interface ChatMessageProps { + role: Message["role"]; + content: string; + sources?: unknown; +} + +function ChatMessage({ role, content, sources }: ChatMessageProps) { + const citations = role === "assistant" ? normalizeSources(sources) : []; + return ( +
+ + {citations.length > 0 && ( +
+ {citations.map((s, i) => ( + + ))} +
+ )} +
+ ); +} + +export default ChatMessage; diff --git a/src/components/chat/__tests__/ChatBubble.test.tsx b/src/components/chat/__tests__/ChatBubble.test.tsx new file mode 100644 index 0000000..08acd69 --- /dev/null +++ b/src/components/chat/__tests__/ChatBubble.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ChatBubble from "../ChatBubble"; + +describe("ChatBubble", () => { + it("renders assistant content as markdown", () => { + render(); + const target = screen.getByText("bold text"); + expect(target.tagName).toBe("STRONG"); + expect(target.closest('[data-testid="message-assistant"]')).not.toBeNull(); + }); + + it("renders user content as plain text, not markdown", () => { + render(); + const text = screen.getByText("**not bold**"); + expect(text.tagName).toBe("DIV"); + expect(text.closest('[data-testid="message-user"]')).not.toBeNull(); + }); + + it("opens rendered links in a new tab with noopener", () => { + render( + , + ); + const anchor = screen.getByRole("link", { name: "link" }); + expect(anchor).toHaveAttribute("target", "_blank"); + expect(anchor).toHaveAttribute("rel", "noopener noreferrer"); + }); +}); diff --git a/src/components/chat/__tests__/ChatComposer.test.tsx b/src/components/chat/__tests__/ChatComposer.test.tsx new file mode 100644 index 0000000..c5403a9 --- /dev/null +++ b/src/components/chat/__tests__/ChatComposer.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ChatComposer from "../ChatComposer"; + +function setup(value = "") { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + return { onChange, onSubmit }; +} + +describe("ChatComposer", () => { + it("does not call onSubmit for empty value", async () => { + const user = userEvent.setup(); + const { onSubmit } = setup(""); + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("does not call onSubmit for whitespace-only value", async () => { + const user = userEvent.setup(); + const { onSubmit } = setup(" "); + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("calls onSubmit with trimmed value", async () => { + const user = userEvent.setup(); + const { onSubmit } = setup(" hello "); + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSubmit).toHaveBeenCalledWith("hello"); + }); +}); diff --git a/src/components/chat/__tests__/ChatList.test.tsx b/src/components/chat/__tests__/ChatList.test.tsx new file mode 100644 index 0000000..7cf6bf8 --- /dev/null +++ b/src/components/chat/__tests__/ChatList.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ChatList from "../ChatList"; +import type { Message } from "../../../types/models"; + +const messages: Message[] = [ + { + id: "1", + role: "user", + content: "hi", + created_at: "2026-01-01T00:00:00Z", + order_index: 0, + sources: null, + }, + { + id: "2", + role: "assistant", + content: "hello", + created_at: "2026-01-01T00:00:01Z", + order_index: 1, + sources: [], + }, +]; + +describe("ChatList", () => { + it("renders all messages", () => { + render(); + expect(screen.getByText("hi")).toBeInTheDocument(); + expect(screen.getByText("hello")).toBeInTheDocument(); + }); + + it("shows the researching indicator when researching", () => { + render(); + expect(screen.getByText(/Researching/)).toBeInTheDocument(); + }); + + it("hides the researching indicator when not researching", () => { + render(); + expect(screen.queryByText(/Researching/)).not.toBeInTheDocument(); + }); + + it("shows the empty state when there are no messages", () => { + render(); + expect(screen.getByText(/No messages yet/)).toBeInTheDocument(); + }); + + it("shows the loading state while loading", () => { + render(); + expect(screen.getByText(/Loading messages/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/chat/__tests__/ChatMessage.test.tsx b/src/components/chat/__tests__/ChatMessage.test.tsx new file mode 100644 index 0000000..70f406e --- /dev/null +++ b/src/components/chat/__tests__/ChatMessage.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ChatMessage from "../ChatMessage"; +import type { Source } from "../../../types/models"; + +const sources: Source[] = [ + { title: "Alpha", url: "https://alpha.example.com" }, + { title: "Beta", url: "https://beta.example.com" }, +]; + +describe("ChatMessage", () => { + it("renders source citations for assistant messages with sources", () => { + render(); + expect(screen.getAllByTestId("source-citation")).toHaveLength(2); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); + }); + + it("renders no citations for user messages", () => { + render(); + expect(screen.queryAllByTestId("source-citation")).toHaveLength(0); + }); + + it("renders no citations when sources is undefined", () => { + render(); + expect(screen.queryAllByTestId("source-citation")).toHaveLength(0); + }); + + it("ignores malformed sources", () => { + render(); + expect(screen.getAllByTestId("source-citation")).toHaveLength(1); + }); +}); diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts new file mode 100644 index 0000000..4a8bdce --- /dev/null +++ b/src/components/chat/index.ts @@ -0,0 +1,4 @@ +export { default as ChatBubble } from "./ChatBubble"; +export { default as ChatMessage } from "./ChatMessage"; +export { default as ChatList } from "./ChatList"; +export { default as ChatComposer } from "./ChatComposer"; diff --git a/vitest.config.ts b/vitest.config.ts index 1c7d4c4..58da2bc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,5 +16,18 @@ export default defineConfig({ setupFiles: './vitest.setup.ts', globals: true, exclude: ['tests/e2e/**', 'node_modules/**', 'dist/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary'], + thresholds: { + // Baseline measured from current suite (see follow-up coverage plan). + // Set just under the real numbers so the gate is green today but fails + // on any regression. Raise as new component tests land. + statements: 25, + branches: 65, + functions: 60, + lines: 25, + }, + }, }, }) From ee8e6d99abee06f07a4dcbe2cb62c280b6da83ec Mon Sep 17 00:00:00 2001 From: PostboxRetinal <78338192+PostboxRetinal@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:51:22 +0000 Subject: [PATCH 02/17] feat(ux): added model selector --- src/components/ConversationView.tsx | 22 ++-- src/components/ModelSelector.tsx | 111 ++++++++++++++---- .../__tests__/ModelSelector.test.tsx | 28 +++++ 3 files changed, 127 insertions(+), 34 deletions(-) create mode 100644 src/components/__tests__/ModelSelector.test.tsx diff --git a/src/components/ConversationView.tsx b/src/components/ConversationView.tsx index 1e0ad9b..1d03ccf 100644 --- a/src/components/ConversationView.tsx +++ b/src/components/ConversationView.tsx @@ -56,9 +56,6 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac {/* Top bar */}

{currentTitle}

-
- -
{/* Messages */} @@ -72,13 +69,18 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac {/* Input */}
-
- - +
+
+ + +
+
+ +
diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index 01d939c..723a49a 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -1,13 +1,64 @@ // Candidate AI/ML models selectable for answer generation. These IDs are the // exact identifiers accepted by the AI/ML API (aimlapi.com), validated against -// the live /v1/models catalog. Keeping the OpenAI-compatible prefix form that -// the `research` Edge Function uses (e.g. "openai/gpt-5-2-chat-latest"). -export const RESEARCH_MODELS = [ - { id: "openai/gpt-5-2-chat-latest", label: "GPT-5.2" }, - { id: "openai/gpt-4o-mini", label: "GPT-4o mini" }, - { id: "deepseek/deepseek-r1", label: "DeepSeek R1" }, - { id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" }, -] as const; +// the live catalog on 2026-08-09 via the AIML MCP. Prices are USD per 1M tokens +// (chat-completions, AIML margin applied). Each model appears in exactly one +// category. Recency is prioritized: the `latest` group holds the newest 2026 +// flagship per lineage. +interface ModelOption { + id: string; + label: string; + priceIn: number; + priceOut: number; +} + +interface ModelCategory { + key: string; + label: string; + models: ModelOption[]; +} + +export const MODEL_CATEGORIES: ModelCategory[] = [ + { + key: "budget", + label: "Económicos", + models: [ + { id: "openai/gpt-4o-mini", label: "GPT-4o mini", priceIn: 0.195, priceOut: 0.78 }, + { id: "deepseek/deepseek-v4-flash", label: "DeepSeek V4 Flash", priceIn: 0.182, priceOut: 0.364 }, + { id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash", priceIn: 0.39, priceOut: 3.25 }, + { id: "z-ai/glm-4.7-flash", label: "GLM 4.7 Flash", priceIn: 0.1625, priceOut: 0.65 }, + { id: "alibaba/qwen3-next-80b-a3b-instruct", label: "Qwen3 Next 80B Instruct", priceIn: 0.195, priceOut: 1.56 }, + ], + }, + { + key: "latest", + label: "Más recientes", + models: [ + { id: "openai/gpt-5.6-luna", label: "GPT-5.6 Luna", priceIn: 1.3, priceOut: 7.8 }, + { id: "openai/gpt-5.6-sol", label: "GPT-5.6 Sol", priceIn: 6.5, priceOut: 39 }, + { id: "anthropic/claude-opus-4-8", label: "Claude 4.8 Opus", priceIn: 7.15, priceOut: 35.75 }, + { id: "alibaba/qwen3.8-max", label: "Qwen3.8 Max", priceIn: 2.6, priceOut: 7.8 }, + { id: "alibaba/glm-5.2", label: "GLM 5.2", priceIn: 1.82, priceOut: 5.72 }, + ], + }, + { + key: "reasoning", + label: "Reasoning", + models: [ + { id: "deepseek/deepseek-v4-pro", label: "DeepSeek V4 Pro", priceIn: 0.566, priceOut: 1.131 }, + { id: "alibaba/qwen3-next-80b-a3b-thinking", label: "Qwen3 Next 80B Thinking", priceIn: 0.195, priceOut: 1.56 }, + { id: "z-ai/glm-5-turbo", label: "GLM 5 Turbo", priceIn: 1.56, priceOut: 5.2 }, + { id: "openai/gpt-5.6-terra", label: "GPT-5.6 Terra", priceIn: 3.25, priceOut: 19.5 }, + { id: "google/gemini-3.5-flash", label: "Gemini 3.5 Flash", priceIn: 0.65, priceOut: 3.9 }, + ], + }, +] ; + +// Flat lookup list used for the price caption and default resolution. IDs are +// unique across categories, so a plain `.find` is safe. +export const RESEARCH_MODELS = MODEL_CATEGORIES.flatMap((c) => c.models); + +// The model the `Default` option resolves to (must match the EF's DEFAULT_MODEL). +const DEFAULT_MODEL_ID = "openai/gpt-5.6-luna"; interface ModelSelectorProps { value: string | null; @@ -18,23 +69,35 @@ function ModelSelector({ value, onChange, }: ModelSelectorProps): React.ReactNode { + const active = RESEARCH_MODELS.find((m) => m.id === (value ?? DEFAULT_MODEL_ID)); return ( - +
+ + {active && ( + + ${active.priceIn} in / ${active.priceOut} out (per 1M tokens) + + )} +
); } diff --git a/src/components/__tests__/ModelSelector.test.tsx b/src/components/__tests__/ModelSelector.test.tsx new file mode 100644 index 0000000..428eebd --- /dev/null +++ b/src/components/__tests__/ModelSelector.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ModelSelector, { RESEARCH_MODELS } from "../ModelSelector"; + +describe("ModelSelector", () => { + it("exposes an input and output price for every model", () => { + expect(RESEARCH_MODELS.length).toBeGreaterThan(0); + for (const m of RESEARCH_MODELS) { + expect(typeof m.priceIn).toBe("number"); + expect(typeof m.priceOut).toBe("number"); + expect(m.id).toBeTruthy(); + } + }); + + it("renders grouped options with a price caption for the current selection", () => { + const model = RESEARCH_MODELS[1]; + render( {}} />); + expect( + screen.getByText(new RegExp(`\\$${model.priceIn} in`)), + ).toBeInTheDocument(); + expect(screen.getByRole("option", { name: model.label })).toBeInTheDocument(); + }); + + it("renders the price caption for the default (null) selection too", () => { + render( {}} />); + expect(screen.getByText(/in \/ \$.*out/i)).toBeInTheDocument(); + }); +}); From 5c2425430c9acd27eac04e775be1bec90437ca96 Mon Sep 17 00:00:00 2001 From: PostboxRetinal <78338192+PostboxRetinal@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:35:19 +0000 Subject: [PATCH 03/17] feat(ui): normalize UI components with updated styles and improved user experience - Updated App.tsx to enhance loading states and conversation selection prompts. - Refactored ConversationSidebar.tsx for consistent styling and added a new header for the DevVoice branding. - Enhanced ConversationView.tsx to display active model information and improved layout. - Modified ModelSelector.tsx to export DEFAULT_MODEL_ID for better accessibility. - Improved SourceCitation.tsx to include an index for citations and updated styles. - Updated MessageList.test.tsx to ensure proper rendering of messages and markdown content. - Enhanced SourceCitation.test.tsx to validate rendering of titles and index numbers. - Refactored ChatBubble.tsx to unify styles and ensure markdown rendering. - Updated ChatComposer.tsx to handle Enter and Shift+Enter for message submission and newlines. - Enhanced ChatList.tsx to improve loading and empty states with visual indicators. - Refactored ChatMessage.tsx to include timestamps and improved source citation rendering. - Updated CSS styles in index.css for new UI elements and animations. - Modified tailwind.config.js to extend color palette for better theme support. --- src/App.tsx | 20 +++++-- src/components/ConversationSidebar.tsx | 60 +++++++++++-------- src/components/ConversationView.tsx | 31 ++++++++-- src/components/ModelSelector.tsx | 8 +-- src/components/SourceCitation.tsx | 23 ++++--- src/components/__tests__/MessageList.test.tsx | 8 +-- .../__tests__/SourceCitation.test.tsx | 17 ++++-- src/components/chat/ChatBubble.tsx | 16 ++--- src/components/chat/ChatComposer.tsx | 35 ++++++++--- src/components/chat/ChatList.tsx | 45 +++++++++----- src/components/chat/ChatMessage.tsx | 32 +++++++++- .../chat/__tests__/ChatBubble.test.tsx | 46 +++++++------- .../chat/__tests__/ChatComposer.test.tsx | 46 ++++++++++++++ .../chat/__tests__/ChatMessage.test.tsx | 55 +++++++++++++++++ src/index.css | 43 +++++++++++++ tailwind.config.js | 5 +- 16 files changed, 374 insertions(+), 116 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1106653..6a2cea4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,9 +33,9 @@ function AppContent(): React.ReactNode { if (loading) { return ( -
+
@@ -52,7 +52,7 @@ function AppContent(): React.ReactNode { } return ( -
+
) : ( -
- Select a conversation or start a new one +
+
)}
diff --git a/src/components/ConversationSidebar.tsx b/src/components/ConversationSidebar.tsx index 8be581d..1c7b732 100644 --- a/src/components/ConversationSidebar.tsx +++ b/src/components/ConversationSidebar.tsx @@ -89,13 +89,13 @@ function ConversationSidebar({ if (loading) { return ( -
+
-
+
{[...Array(5)].map((_, i) => ( -
+
))}
@@ -103,11 +103,19 @@ function ConversationSidebar({ } return ( -
+
+
+
@@ -122,29 +130,33 @@ function ConversationSidebar({ key={conv.id} onClick={() => handleSelect(conv.id)} className={`group relative cursor-pointer rounded-md px-3 py-2 transition-colors ${ - isSelected ? "bg-zinc-800" : "hover:bg-zinc-800" + isSelected ? "bg-secondary" : "hover:bg-secondary" }`} >
- + {truncate(conv.title, 40)}
- + {formatRelativeTime(conv.created_at)}
); })} {conversations.length === 0 && ( -
+
No conversations found
)} @@ -152,33 +164,33 @@ function ConversationSidebar({
{/* Account footer */} -
+
-
+
{user?.email?.slice(0, 2).toUpperCase() ?? "U"}
-
+
{user?.email ?? "Account"}
-
+
{user?.email ?? "Signed in"}
-
+
v{__APP_VERSION__} @@ -192,21 +204,21 @@ function ConversationSidebar({ aria-labelledby="delete-account-title" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" > -
+

Delete account?

-

+

This permanently deletes your account and all conversations. This action cannot be undone.

{deleteError != null && (

{deleteError}

@@ -215,7 +227,7 @@ function ConversationSidebar({ @@ -223,7 +235,7 @@ function ConversationSidebar({ onClick={handleDeleteAccount} disabled={deleting} data-testid="confirm-delete-account" - className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-500 disabled:opacity-50" + className="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-white transition-colors hover:opacity-90 disabled:opacity-50" > {deleting ? "Deleting..." : "Delete"} diff --git a/src/components/ConversationView.tsx b/src/components/ConversationView.tsx index 1d03ccf..3d87182 100644 --- a/src/components/ConversationView.tsx +++ b/src/components/ConversationView.tsx @@ -4,7 +4,7 @@ import { useConversations } from "../contexts/ConversationsContext"; import { useResearch } from "../hooks/useResearch"; import MessageList from "./MessageList"; import VoiceInput from "./VoiceInput"; -import ModelSelector from "./ModelSelector"; +import ModelSelector, { DEFAULT_MODEL_ID, RESEARCH_MODELS } from "./ModelSelector"; import ChatComposer from "./chat/ChatComposer"; interface ConversationViewProps { @@ -21,6 +21,7 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac const conversation = conversations.find((c) => c.id === conversationId); const currentTitle = conversation?.title ?? "New Conversation"; + const activeModel = RESEARCH_MODELS.find((m) => m.id === (model ?? DEFAULT_MODEL_ID)); const sendMessage = useCallback( async (text: string) => { @@ -52,14 +53,31 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac ); return ( -
+
{/* Top bar */} -
-

{currentTitle}

+
+

{currentTitle}

+
+ {researching && ( + + + + + + + Researching + + )} + {activeModel && ( + + {activeModel.label} + + )} +
{/* Messages */} -
+
{/* Input */} -