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/README.md b/README.md index dd95dc3..828bb24 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DevVoice -A voice-powered developer research assistant built for the native.builder hackathon. Speak a question, hear it transcribed, and get a researched answer with clickable source citations: all in a dark, AMOLED-first interface. +A voice-powered developer research assistant built for the native.builder hackathon. Speak a question, hear it transcribed, and get a researched answer with clickable source citations — all in a dark, AMOLED-first interface. ## Highlights @@ -13,7 +13,8 @@ A voice-powered developer research assistant built for the native.builder hackat - **Voice input**: tap-to-record microphone with real-time partial transcripts, editable before submit, and re-record. - **Speechmatics STT**: official real-time client; the session JWT is passed via `client.start()` and never embedded in a URL. -- **Research pipeline**: a Supabase Edge Function runs a search→read→synthesize loop and returns an answer plus clickable source citations; the AI/ML model is selectable per session. +- **Research pipeline**: a Supabase Edge Function (`supabase/functions/research/index.ts`) runs a search→read→synthesize loop and returns an answer plus clickable source citations; the AI/ML model is selectable per session. All inferences are served serverless via AI/ML API. +- **Web search integration**: Bright Data SERP API (Google searches) + Web Unlocker (page fetching) power the research agent. - **Markdown answers**: assistant responses render as formatted markdown (headings, lists, code, tables, links) with source citations, XSS-safe by default. - **Persistent conversations**: auth-gated chat history with sequential ordering and real-time updates. - **Auth**: email/password via Supabase, enforced password policy, and server-side rate limiting on auth endpoints (Supabase-managed). @@ -33,11 +34,14 @@ flowchart LR U -->|sign in| Auth[Supabase Auth] ``` +The Edge Function source lives in `supabase/functions/research/index.ts` and is deployed via `supabase functions deploy research`. + ## Tech Stack | Layer | Technology | | ----- | ---------- | -| UI | React, TypeScript, Vite, Tailwind CSS | +| UI | React 19, TypeScript 7 (via @typescript/native), Vite 8, Tailwind CSS 3 | +| Testing | Vitest 4 | | Auth & data | Supabase (Auth, Postgres, Realtime, Edge Functions) | | Speech-to-text | @speechmatics/real-time-client | | Research | Bright Data (SERP + Web Unlocker), AI/ML API | @@ -50,10 +54,24 @@ bun install bun dev ``` -Requirements: a Supabase project with the `speechmatics-token` and `research` Edge Functions and their secrets, plus a valid Bright Data and AI/ML API key. Database types are generated from the Supabase schema in `src/lib/database.types.ts`. +Requirements: a Supabase project with the `research` Edge Function deployed and its secrets (`AIML_API_KEY`, `BRIGHTDATA_API_KEY`), plus a valid Bright Data and AI/ML API key. Database types are generated from the Supabase schema in `src/lib/database.types.ts`. + +### Deploying the Edge Function + +```bash +supabase functions deploy research +``` + +The function source is versioned at `supabase/functions/research/index.ts`. ## Validation +- **Typecheck**: `bun run typecheck` — 0 errors +- **Tests**: `bun run test` — 122 tests passing +- **Coverage**: `bun run test -- --coverage` — ~60%+ statements, ~79% branches (thresholds: 25/65/60/25) +- **Lint**: `bun run lint` — 0 errors +- **Build**: `bun run build` — OK + Security posture and system behavior are specified and verified through the SDD in `openspec/` (spec-driven development): - `security-posture`, `authentication`, `voice-input`, `research-pipeline`, `conversation-management`, `supabase-foundation`, `account-management`, `test-framework` diff --git a/openspec/changes/archive/2026-08-09-auth-login-rework/.openspec.yaml b/openspec/changes/archive/2026-08-09-auth-login-rework/.openspec.yaml new file mode 100644 index 0000000..9c6d985 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-auth-login-rework/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-08-09 +skip_specs: true diff --git a/openspec/changes/archive/2026-08-09-auth-login-rework/design.md b/openspec/changes/archive/2026-08-09-auth-login-rework/design.md new file mode 100644 index 0000000..551f9e3 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-auth-login-rework/design.md @@ -0,0 +1,32 @@ +## Context + +The app now uses a unified green-accent design system: `accent` #22C55E as the sole identity color, `accent-muted`, `on-accent` (near-black #02140B), `background`, `foreground`, `muted`, `border`, `destructive`. The animated `.waveform`/`.waveform-bar` classes in `src/index.css` are the signature for waiting/idle states. `AuthScreen.tsx` (three modes: signin/signup/forgot) and the `SetNewPassword` component are the only screens still on the legacy slate palette (`bg-dotgrid-glow`, `primary`, `on-primary`, `ring`). See proposal.md - Why. + +## Goals / Non-Goals + +**Goals:** +- Bring auth screens onto the green-accent token set so they read as part of the same product. +- Give auth a distinct, memorable brand moment (the waveform) without hurting form usability. +- Keep the change TDD-gated with class-level assertions. + +**Non-Goals:** +- No change to auth flow, password policy, rate limiting, or validation logic (behavior covered by the `authentication` spec, unchanged). +- No new dependencies, no icon library, no OAuth. +- No redesign of the DevVoice wordmark itself. + +## Decisions + +- **Retheme rather than rebuild the card.** The existing card layout (tabs, fields, footer links) already works; we swap token classes only. Lower risk than restructuring. Alternative (full redesign) rejected for scope. +- **`bg-dotgrid-glow` -> `bg-background`.** For consistency with the rest of the app (user-selected "consistencia"). The dot-grid texture is removed; a `bg-background` wrapper matches the app shell. Alternative (keep texture) rejected for consistency. +- **Submit button `bg-primary text-on-primary` -> `bg-accent text-on-accent`.** Matches the composer/footer CTA. Keep `hover:opacity-90 active:scale-[0.97]` affordances. +- **Active tab `border-primary` -> `border-accent`** so the selected tab uses the identity color. +- **Waveform as brand moment.** Reuse `.waveform`/`.waveform-bar` above the wordmark. Respect reduced motion: add a `motion-reduce:hidden` guard (or a `prefers-reduced-motion` media query toggle in index.css) so the animation doesn't play for users who disable motion. +- **Contrast risk on `on-accent`.** `on-accent` is near-black; on green it may read as lower contrast than white-on-slate. Default decision: keep `on-accent`. Verify visually; if contrast is poor, flip submit text to `text-background` (near-black, already on the palette) rather than introducing a new color. +- **Consistent control height.** Fields to `h-11` to match the footer composer bar; keep label spacing. + +## Risks / Trade-offs + +- [Reduced contrast on green button] -> Verify in the browser; fall back to `text-background` if `on-accent` reads too dark on `accent`. +- [Removing dot-grid may flatten the auth screen] -> Acceptable; consistency wins and the waveform restores visual interest. +- [Waveform animation on auth] -> Gate behind `motion-reduce:hidden` to respect reduced-motion preferences. +- [Test assertions on class strings are brittle] -> Scope assertions to the presence of the token substring (`bg-accent`, not the full class list) and disambiguate the "Sign In" tab from the submit button by `querySelector('button[type="submit"]')`. diff --git a/openspec/changes/archive/2026-08-09-auth-login-rework/proposal.md b/openspec/changes/archive/2026-08-09-auth-login-rework/proposal.md new file mode 100644 index 0000000..0f3d6e0 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-auth-login-rework/proposal.md @@ -0,0 +1,27 @@ +## Why + +The login and signup screens are the first thing a user sees, but they use a leftover slate/"primary" palette (`bg-dotgrid-glow`, `bg-primary`, `border-primary`) that is inconsistent with the green-accent design system now used across the whole app. The auth screens look like they belong to a different product and undercut the cohesive brand moment. + +## What Changes + +- Retheme `AuthScreen.tsx` and the `SetNewPassword` component off the slate `primary` palette onto the green-accent token set (`accent`, `on-accent`, `border-accent`, `bg-background`). +- Add the animated voice waveform (the app's signature for waiting states) as a brand moment above the DevVoice wordmark on both auth screens. +- Tighten form controls to consistent 44px heights matching the composer bar, and normalize focus affordances to the accent ring. +- No auth flow, validation, or rate-limit behavior changes. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- None (visual presentation only; the `authentication` spec's behavioral requirements are unchanged, so this change declares `skip_specs: true`). + +## Impact + +- `src/components/AuthScreen.tsx` - retheme submit button, active tabs, wrapper background; add waveform brand element to the branding block (both `AuthScreen` and `SetNewPassword`). +- `src/components/__tests__/AuthScreen.test.tsx` - add class-level assertions so the retheme is TDD-gated. +- Possibly `src/index.css` (reuse existing `.waveform`/`.waveform-bar`. If carried over the auth card, ensure reduced-motion respect). +- No dependency or backend changes. UI copy stays English. diff --git a/openspec/changes/archive/2026-08-09-auth-login-rework/tasks.md b/openspec/changes/archive/2026-08-09-auth-login-rework/tasks.md new file mode 100644 index 0000000..91ae72b --- /dev/null +++ b/openspec/changes/archive/2026-08-09-auth-login-rework/tasks.md @@ -0,0 +1,18 @@ +## 1. Auth retheme to green accent + +- [x] 1.1 Add class-level test asserting the auth submit button uses `bg-accent` (disambiguate from the "Sign In" tab via `querySelector('button[type="submit"]')`); confirm it fails on the current `bg-primary`. +- [x] 1.2 Retheme `AuthScreen.tsx` submit button `bg-primary text-on-primary` -> `bg-accent text-on-accent` (keep hover/active/disabled affordances); active tab `border-primary` -> `border-accent`; wrapper `bg-dotgrid-glow` -> `bg-background`. +- [x] 1.3 Retheme the `SetNewPassword` component the same way (submit button and wrapper) in `AuthScreen.tsx`. +- [x] 1.4 Run `bun run test AuthScreen`, `bun run typecheck`, `bun run lint`; confirm green. + +## 2. Waveform brand moment + +- [x] 2.1 Add the animated `.waveform`/`.waveform-bar` element above the DevVoice wordmark in both `AuthScreen` and `SetNewPassword` branding blocks, gated with `motion-reduce:hidden`. +- [x] 2.2 Verify reduced-motion guard (add a `prefers-reduced-motion` toggle in `src/index.css` if `motion-reduce` alone is not enough). Tailwind's `motion-reduce:hidden` maps to a `@media (prefers-reduced-motion: reduce)` rule natively - no extra CSS needed. +- [x] 2.3 Run `bun run test AuthScreen`, `bun run typecheck`, `bun run build -- --outDir /tmp/devvoice-dist`; confirm green. + +## 3. Form polish and gate + +- [x] 3.1 Normalize auth field inputs to consistent `h-11` height to match the composer bar; keep label spacing. +- [x] 3.2 Run the full gate: `bun run typecheck`, `bun run test -- --coverage` (above thresholds 25/65/60/25), `bun run lint`, `bun run build -- --outDir /tmp/devvoice-dist`. +- [x] 3.3 Residue scan: confirm no `bg-primary`/`border-primary`/`on-primary`/`bg-dotgrid-glow` remain in `src/components/AuthScreen.tsx`. diff --git a/openspec/changes/qol-markdown-model-selector-sidebar/.openspec.yaml b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/.openspec.yaml similarity index 100% rename from openspec/changes/qol-markdown-model-selector-sidebar/.openspec.yaml rename to openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/.openspec.yaml diff --git a/openspec/changes/qol-markdown-model-selector-sidebar/design.md b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/design.md similarity index 100% rename from openspec/changes/qol-markdown-model-selector-sidebar/design.md rename to openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/design.md diff --git a/openspec/changes/qol-markdown-model-selector-sidebar/proposal.md b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/proposal.md similarity index 100% rename from openspec/changes/qol-markdown-model-selector-sidebar/proposal.md rename to openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/proposal.md diff --git a/openspec/changes/qol-markdown-model-selector-sidebar/specs/conversation-management/spec.md b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/specs/conversation-management/spec.md similarity index 100% rename from openspec/changes/qol-markdown-model-selector-sidebar/specs/conversation-management/spec.md rename to openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/specs/conversation-management/spec.md diff --git a/openspec/changes/qol-markdown-model-selector-sidebar/specs/research-pipeline/spec.md b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/specs/research-pipeline/spec.md similarity index 100% rename from openspec/changes/qol-markdown-model-selector-sidebar/specs/research-pipeline/spec.md rename to openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/specs/research-pipeline/spec.md diff --git a/openspec/changes/qol-markdown-model-selector-sidebar/tasks.md b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/tasks.md similarity index 52% rename from openspec/changes/qol-markdown-model-selector-sidebar/tasks.md rename to openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/tasks.md index b3b6af1..6bdedc5 100644 --- a/openspec/changes/qol-markdown-model-selector-sidebar/tasks.md +++ b/openspec/changes/archive/2026-08-09-qol-markdown-model-selector-sidebar/tasks.md @@ -1,26 +1,26 @@ ## 1. Dependencies -- [ ] 1.1 Add `react-markdown` and `remark-gfm` via `bun add react-markdown remark-gfm` +- [x] 1.1 Add `react-markdown` and `remark-gfm` via `bun add react-markdown remark-gfm` ## 2. Markdown rendering -- [ ] 2.1 Modify `src/components/MessageList.tsx` to render assistant content with `` and keep user bubbles plain text -- [ ] 2.2 Add a `components` override so assistant markdown links open in a new tab with `rel="noopener noreferrer"` -- [ ] 2.3 Adjust assistant bubble whitespace and add minimal dark-theme markdown styling in `src/index.css` -- [ ] 2.4 Update `src/components/__tests__/MessageList.test.tsx` to assert markdown is interpreted (e.g. `**bold**` renders as ``) and user content stays plain +- [x] 2.1 Modify `src/components/MessageList.tsx` to render assistant content with `` and keep user bubbles plain text +- [x] 2.2 Add a `components` override so assistant markdown links open in a new tab with `rel="noopener noreferrer"` +- [x] 2.3 Adjust assistant bubble whitespace and add minimal dark-theme markdown styling in `src/index.css` +- [x] 2.4 Update `src/components/__tests__/MessageList.test.tsx` to assert markdown is interpreted (e.g. `**bold**` renders as ``) and user content stays plain ## 3. Model selector -- [ ] 3.1 Add optional `model` param to `runResearch` in `src/hooks/useResearch.ts` and append it to the invoke body only when set -- [ ] 3.2 Update `src/hooks/__tests__/useResearch.test.ts` to cover model forwarding and the no-model case -- [ ] 3.3 Create `src/components/ModelSelector.tsx` with a `` of candidate AI/ML models mirroring LanguageToggle +- [x] 3.4 Add per-session model state to `src/components/ConversationView.tsx`, pass it to `runResearch`, and render `` ## 4. Optimistic sidebar -- [ ] 4.1 Modify `createConversation` in `src/hooks/useConversations.ts` to select the inserted row and prepend it to local `conversations` state -- [ ] 4.2 Create `src/hooks/__tests__/useConversations.test.ts` asserting the new item appears immediately after create +- [x] 4.1 Modify `createConversation` in `src/hooks/useConversations.ts` to select the inserted row and prepend it to local `conversations` state +- [x] 4.2 Create `src/hooks/__tests__/useConversations.test.ts` asserting the new item appears immediately after create ## 5. Verification -- [ ] 5.1 Run `bun run typecheck`, `bun run lint`, `bun run test` and confirm all pass +- [x] 5.1 Run `bun run typecheck`, `bun run lint`, `bun run test` and confirm all pass 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/openspec/changes/chat-composer-stt-copy-polish/.openspec.yaml b/openspec/changes/chat-composer-stt-copy-polish/.openspec.yaml new file mode 100644 index 0000000..9c6d985 --- /dev/null +++ b/openspec/changes/chat-composer-stt-copy-polish/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-08-09 +skip_specs: true diff --git a/openspec/changes/chat-composer-stt-copy-polish/design.md b/openspec/changes/chat-composer-stt-copy-polish/design.md new file mode 100644 index 0000000..db262e6 --- /dev/null +++ b/openspec/changes/chat-composer-stt-copy-polish/design.md @@ -0,0 +1,46 @@ +## Context + +See proposal.md - Why. The composer bar previously mixed ModelSelector + textarea + voice without a unified row; the sidebar lacked collapsibility; header/scroll/footer zones were indistinguishable; the model selector had no pricing or categories; and coverage was below 60%. + +## Goals / Non-Goals + +**Goals:** +- Single 44px composer bar row with ModelSelector + ChatComposer + VoiceInput. +- Collapsible sidebar with localStorage persistence and grouped navigation blocks. +- Distinct visual zones (header/footer `bg-surface`, scroll `bg-background`). +- Centered chat window at `max-w-3xl`. +- Model chip with IN/OUT pricing and Budget/Latest/Reasoning categories. +- Copy toast via sonner. +- 60%+ coverage with real tests (no threshold lowering, no c8 ignore). +- Flat UI without borders between zones — background differentiation only. + +**Non-Goals:** +- Changing Speechmatics/`useSpeechmatics` audio behavior. +- Streaming token-by-token rendering. +- Backend or auth changes. +- `useSpeechmatics` and `AuthScreen` coverage (deferred to post-hackathon). + +## Decisions + +- **Unified composer bar (44px row):** ModelSelector (max-w-[9.5rem]) + ChatComposer (min-w-0 flex-1) + VoiceInput (h-11 w-11) in a single `flex items-center gap-2` row. Keeps the input zone compact and prevents layout shifts from STT blocks. +- **Collapsible sidebar with localStorage:** `src/lib/uiPrefs.ts` owns `getSidebarCollapsed`/`setSidebarCollapsed` with try/catch. Collapsed rail shows waveform brand, toggle + "+" tiles, conversation initials with native `title` tooltips. +- **Zones via surface token:** `surface: #0B1120` (one step lighter than `background: #020617`) applied to header, footer, cards, menus. Dark-UI elevation = lighter surfaces, not shadows (validated with Hoverify/daisyUI). +- **Centered chat window:** `mx-auto w-full max-w-3xl` on ConversationView root + empty-state in App.tsx. 768px max width. +- **Model chip with pricing:** Compact inline `` h-11 max-w-[9.5rem] with pricing. + +## 2. STT blocks above composer + +- [x] 2.1 Extract `TranscriptionPreview.tsx` (live preview) rendered above composer in footer. +- [x] 2.2 Extract `VoiceTranscriptEditor.tsx` (edit + Submit / Re-record / Close card). +- [x] 2.3 Render STT blocks above composer row inside footer (`space-y-2`) in `ConversationView.tsx`. +- [x] 2.4 Add Close (X) affordance to `VoiceTranscriptEditor` wired to `voice.reset()`. + +## 3. Sidebar collapsible + +- [x] 3.1 Create `src/lib/uiPrefs.ts` for sidebar collapse persistence (localStorage + try/catch). +- [x] 3.2 Restructure `ConversationSidebar.tsx` into 3 grouped blocks with border-t dividers. +- [x] 3.3 Add collapsed rail (w-[3.25rem]) with waveform brand, toggle + "+" tiles, initials + tooltips. + +## 4. Visual zones + centering + +- [x] 4.1 Add `surface: #0B1120` token to tailwind.config.js. +- [x] 4.2 Apply header `bg-surface`, message scroll `bg-background`, footer `bg-surface`. +- [x] 4.3 Center chat window with `mx-auto w-full max-w-3xl` on ConversationView + empty-state. + +## 5. Model chip + categories + +- [x] 5.1 Add IN/OUT pricing via MCP models_compare to `ModelSelector.tsx`. +- [x] 5.2 Categorize models into Budget / Latest / Reasoning (English labels). + +## 6. Copy UX + +- [x] 6.1 Move copy button inline beside bubble (bottom-right, `items-end gap-2`) in `ChatMessage.tsx`. +- [x] 6.2 Remove `mr-auto` from assistant bubble in `ChatBubble.tsx` for copy adjacency. +- [x] 6.3 Align user messages right (`flex-row-reverse` + `justify-end` meta). +- [x] 6.4 Add copy toast via sonner with `execCommand` fallback. + +## 7. Coverage (Fase 1) + +- [x] 7.1 Create `TranscriptionPreview.test.tsx` (5 tests: null, final-only, partial-only, both, listening). +- [x] 7.2 Create `useVoiceComposer.test.tsx` (6 tests: idle/recording/done/reset/submitEdit/reRecord). +- [x] 7.3 Add `VoiceInput.test.tsx` (6 tests: idle/start/stop/processing/error/retry). +- [x] 7.4 Create `AuthContext.test.tsx` (5 tests: initial/signIn-success/signIn-error/signOut/PASSWORD_RECOVERY). +- [x] 7.5 Exclude type-only files from coverage denominator in `vitest.config.ts`. + +## 8. Coverage (Fase 2) + +- [x] 8.1 Create `ConversationsContext.test.tsx` (4 tests: initial/optimistic-create/delete/updateTitle). +- [x] 8.2 Add useMessages error-branch tests (2 tests: read-fail no-insert, insert-fail no-append). +- [x] 8.3 Add ConversationView recording preview test. + +## 9. Flat zones (no borders) + +- [x] 9.1 Remove `border-b border-border` from header in `ConversationView.tsx:69`. +- [x] 9.2 Remove `border-t border-border` from footer in `ConversationView.tsx:95`. +- [x] 9.3 Verify ConversationView test passes (no border assertions). + +## 9. Gate final + +- [x] 9.1 Run `bun run typecheck` (0 errors), `bun run test -- --coverage` (above 25/65/60/25), `bun run lint` (0 errors, 1 legacy warning), `bun run build` (OK). + +## 10. Editable conversation title + +- [x] 10.1 Add `editingTitle` + `draftTitle` state to `ConversationView.tsx`. +- [x] 10.2 Replace static `

` with click-to-edit input (autoFocus, Enter to save, Escape to cancel, blur to save). +- [x] 10.3 Wire save to `updateTitle(conversationId, trimmedTitle)` from ConversationsContext. +- [x] 10.4 Create `ConversationView.title.test.tsx` with 6 tests: initial h1, click-to-edit, Enter-save, Escape-cancel, blur-save, empty-fallback. + +## 11. Pencil icon indicator + +- [x] 11.1 Add `PencilIcon` SVG component to `ConversationView.tsx` (14px, inline, `aria-hidden`). +- [x] 11.2 Render icon next to title with `opacity-40 hover:opacity-70` and `shrink-0`. +- [x] 11.3 Use `inline-flex items-center gap-1.5` alignment on title row. + +## 12. Copy button flat design + +- [x] 12.1 Remove `border border-border` from copy button in `ChatMessage.tsx:109`. +- [x] 12.2 Verify copy functionality still works (visual regression only, no behavioral change). + +## 13. Edge Function source in repo + +- [x] 13.1 Save `research` Edge Function source to `supabase/functions/research/index.ts` (418 lines). +- [x] 13.2 Document deployment command in README. + +## 14. Gate final + +- [x] 14.1 Run `bun run typecheck` (0 errors), `bun run test` (122 tests passing), `bun run lint` (0 errors, 1 legacy warning), `bun run build` (OK). diff --git a/package.json b/package.json index 350f1de..4bc0894 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "devvoice", "private": true, - "version": "0.3.4", + "version": "0.4.4", "type": "module", "scripts": { "dev": "vite", @@ -18,33 +18,35 @@ "autoprefixer": "^10.5.4", "csp-toolkit": "^1.5.0", "postcss": "^8.5.25", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "sonner": "^2.0.7", + "sonner": "^2.0.8", "tailwindcss": "^3.4.19", "vite-plugin-csp-guard": "^4.0.1" }, "devDependencies": { - "@eslint/js": "^9.39.5", + "@eslint/js": "^10.0.1", "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", - "@types/node": "^24.10.1", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@typescript-eslint/eslint-plugin": "^8.46.3", - "@typescript-eslint/parser": "^8.46.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@typescript-eslint/eslint-plugin": "^8.66.0", + "@typescript-eslint/parser": "^8.66.0", "@typescript/native": "npm:typescript@^7.0.2", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^10.8.0", + "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.10", + "esbuild": "^0.28.2", + "eslint": "^10.8.1", "eslint-plugin-security": "^4.0.1", - "globals": "^16.5.0", + "globals": "^17.9.0", "jsdom": "^30.0.1", "typescript": "^6.0.2", - "vite": "^7.2.4", - "vitest": "^3.2" + "vite": "^8.2.1", + "vitest": "^4.1.10" } } diff --git a/postcss.config.js b/postcss.config.js index 2e7af2b..2aa7205 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/src/App.tsx b/src/App.tsx index 1106653..f9e5c5a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { ConversationsProvider, useConversations } from "./contexts/Conversation import AuthScreen, { SetNewPassword } from "./components/AuthScreen"; import ConversationSidebar from "./components/ConversationSidebar"; import ConversationView from "./components/ConversationView"; +import { getSidebarCollapsed, setSidebarCollapsed } from "./lib/uiPrefs"; export default function App() { return ( @@ -23,6 +24,17 @@ function AppContent(): React.ReactNode { const { user, loading, isRecovering } = useAuth(); const { createConversation } = useConversations(); const [selectedId, setSelectedId] = useState(null); + const [sidebarCollapsed, setSidebarCollapsedState] = useState(() => + getSidebarCollapsed(), + ); + + const handleToggleSidebar = useCallback(() => { + setSidebarCollapsedState((c) => { + const next = !c; + setSidebarCollapsed(next); + return next; + }); + }, []); const handleCreateNew = useCallback(async () => { const id = await createConversation(); @@ -33,9 +45,9 @@ function AppContent(): React.ReactNode { if (loading) { return ( -
+
@@ -52,18 +64,30 @@ function AppContent(): React.ReactNode { } return ( -
+
{selectedId != null ? ( ) : ( -
- Select a conversation or start a new one +
+
)}
diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index 55c9ab2..f00233b 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,20 +84,31 @@ export default function AuthScreen() { setMode(mode === "signin" ? "signup" : "signin"); } setError(""); - setSuccessMessage(""); }; const goForgot = () => { setMode("forgot"); setError(""); - setSuccessMessage(""); }; return ( -
+
{/* Logo / Branding */}
+

DevVoice

@@ -108,7 +118,7 @@ export default function AuthScreen() {
{/* Card */} -
+
{/* Tabs */} {mode !== "forgot" && (
@@ -117,7 +127,7 @@ export default function AuthScreen() { onClick={() => setMode("signin")} className={`flex-1 pb-3 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring rounded-sm ${ mode === "signin" - ? "text-foreground border-b-2 border-primary" + ? "text-foreground border-b-2 border-accent" : "text-muted-foreground hover:text-foreground" }`} > @@ -128,7 +138,7 @@ export default function AuthScreen() { onClick={() => setMode("signup")} className={`flex-1 pb-3 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring rounded-sm ${ mode === "signup" - ? "text-foreground border-b-2 border-primary" + ? "text-foreground border-b-2 border-accent" : "text-muted-foreground hover:text-foreground" }`} > @@ -137,17 +147,6 @@ export default function AuthScreen() {
)} - {/* Success message (sign up) */} - {successMessage && ( -
- {successMessage} -
- )} - {/* Error */} {error && (
setEmail(e.target.value)} aria-describedby={error ? "auth-error" : undefined} aria-invalid={!!error} - className="w-full px-3 py-2.5 rounded-lg bg-muted border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring transition-colors text-sm" + className="w-full h-11 px-3 rounded-lg bg-muted border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring transition-colors text-sm" />
@@ -200,7 +199,7 @@ export default function AuthScreen() { onChange={(e) => setPassword(e.target.value)} aria-describedby={error ? "auth-error" : undefined} aria-invalid={!!error} - className="w-full px-3 py-2.5 rounded-lg bg-muted border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring transition-colors text-sm" + className="w-full h-11 px-3 rounded-lg bg-muted border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring transition-colors text-sm" /> {mode === "signup" && password.length > 0 && ( @@ -211,7 +210,7 @@ export default function AuthScreen() { - - )}
diff --git a/src/components/ConversationSidebar.tsx b/src/components/ConversationSidebar.tsx index 8be581d..c251993 100644 --- a/src/components/ConversationSidebar.tsx +++ b/src/components/ConversationSidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useId, useState } from "react"; import { useConversations } from "../contexts/ConversationsContext"; import { useAuth } from "../contexts/AuthContext"; @@ -6,6 +6,8 @@ interface ConversationSidebarProps { selectedConversationId: string | null; onSelectConversation: (id: string) => void; onCreateNew: () => void; + collapsed: boolean; + onToggle: () => void; } function formatRelativeTime(dateString: string | null): string { @@ -39,9 +41,12 @@ function ConversationSidebar({ selectedConversationId, onSelectConversation, onCreateNew, + collapsed, + onToggle, }: ConversationSidebarProps): React.ReactNode { const { conversations, loading, deleteConversation } = useConversations(); const { user, signOut, deleteAccount } = useAuth(); + const sidebarId = useId(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); @@ -89,13 +94,17 @@ function ConversationSidebar({ if (loading) { return ( -
+
-
+
{[...Array(5)].map((_, i) => ( -
+
))}
@@ -103,17 +112,104 @@ function ConversationSidebar({ } return ( -
-
- +
+ {/* Logo */} +
+ {collapsed ? ( + + + + + + ) : ( +
+
+ )} +
+ + {/* Separator */} +
+ + {/* Collapse + New chat */} +
+ {collapsed ? ( + <> + + + + ) : ( + <> + + + + )}
-
+ {/* Separator */} +
+ + {/* Conversations */} +
{conversations.map((conv) => { const isSelected = selectedConversationId === conv.id; @@ -121,30 +217,47 @@ function ConversationSidebar({
handleSelect(conv.id)} - className={`group relative cursor-pointer rounded-md px-3 py-2 transition-colors ${ - isSelected ? "bg-zinc-800" : "hover:bg-zinc-800" + className={`group relative cursor-pointer rounded-md transition-colors ${ + isSelected ? "bg-secondary" : "hover:bg-secondary" + } ${collapsed ? "flex h-9 w-9 items-center justify-center p-0 mx-auto" : "px-3 py-2"} ${ + isSelected && collapsed ? "ring-1 ring-inset ring-accent/60" : "" }`} > -
- - {truncate(conv.title, 40)} - - -
- - {formatRelativeTime(conv.created_at)} - + {(conv.title.trim().charAt(0) || "?").toUpperCase()} + + ) : ( +
+ + {truncate(conv.title, 40)} + + +
+ )} + {!collapsed && ( + + {formatRelativeTime(conv.created_at)} + + )}
); })} {conversations.length === 0 && ( -
+
No conversations found
)} @@ -152,37 +265,58 @@ function ConversationSidebar({
{/* Account footer */} -
-
-
- {user?.email?.slice(0, 2).toUpperCase() ?? "U"} +
+ {collapsed ? ( +
+ + v{__APP_VERSION__}
-
-
- {user?.email ?? "Account"} + ) : ( + <> +
+
+ {user?.email?.slice(0, 2).toUpperCase() ?? "U"} +
+
+
+ {user?.email ?? "Account"} +
+
+ {user?.email ?? "Signed in"} +
+
+
-
- {user?.email ?? "Signed in"} +
+ v{__APP_VERSION__} +
-
- -
-
- v{__APP_VERSION__} - -
+ + )}
{showDeleteConfirm && ( @@ -192,21 +326,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 +349,7 @@ function ConversationSidebar({ @@ -223,7 +357,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 45825ca..a582c81 100644 --- a/src/components/ConversationView.tsx +++ b/src/components/ConversationView.tsx @@ -2,9 +2,34 @@ import { useCallback, useRef, useState } from "react"; import { useMessages } from "../hooks/useMessages"; import { useConversations } from "../contexts/ConversationsContext"; import { useResearch } from "../hooks/useResearch"; +import { useVoiceComposer } from "../hooks/useVoiceComposer"; import MessageList from "./MessageList"; import VoiceInput from "./VoiceInput"; -import ModelSelector from "./ModelSelector"; +import ModelSelector, { getActiveModel } from "./ModelSelector"; +import ChatComposer from "./chat/ChatComposer"; +import TranscriptionPreview from "./chat/TranscriptionPreview"; +import VoiceTranscriptEditor from "./chat/VoiceTranscriptEditor"; + +function PencilIcon({ className }: { className?: string }) { + return ( + + ); +} interface ConversationViewProps { conversationId: string; @@ -16,10 +41,18 @@ function ConversationView({ conversationId }: ConversationViewProps): React.Reac const { researching, runResearch } = useResearch(); const [textInput, setTextInput] = useState(""); const [model, setModel] = useState(null); + const [editingTitle, setEditingTitle] = useState(false); + const [draftTitle, setDraftTitle] = useState(""); const isFirstMessage = useRef(true); + const voice = useVoiceComposer((text) => void sendMessage(text)); + const conversation = conversations.find((c) => c.id === conversationId); const currentTitle = conversation?.title ?? "New Conversation"; + const activeModel = getActiveModel(model); + const activeModelLabel = activeModel + ? `${activeModel.label} · $${activeModel.priceIn} in / $${activeModel.priceOut} out (per 1M tokens)` + : ""; const sendMessage = useCallback( async (text: string) => { @@ -43,27 +76,71 @@ 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], ); + const showPreview = + voice.state === "recording" || !!voice.partialText || !!voice.finalText; + return ( -
+
{/* Top bar */} -
-

{currentTitle}

-
- +
+
+ {editingTitle ? ( + setDraftTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + updateTitle(conversationId, draftTitle.trim() || currentTitle); + setEditingTitle(false); + } + if (e.key === "Escape") setEditingTitle(false); + }} + onBlur={() => { + updateTitle(conversationId, draftTitle.trim() || currentTitle); + setEditingTitle(false); + }} + autoFocus + className="w-full rounded bg-transparent text-base font-semibold tracking-tight outline-none ring-1 ring-border focus:ring-accent" + aria-label="Conversation title" + /> + ) : ( +

{ + setDraftTitle(currentTitle); + setEditingTitle(true); + }} + > + {currentTitle} + +

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