diff --git a/frontend/src/app/console/page.tsx b/frontend/src/app/console/page.tsx new file mode 100644 index 0000000..eb89f32 --- /dev/null +++ b/frontend/src/app/console/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next" +import { Header } from "@/components/header" +import { EmergencyDashboard } from "@/components/emergency-dashboard" + +export const metadata: Metadata = { + title: "Dispatcher console · FirstWave", +} + +export default function ConsolePage() { + return ( +
+
+
+ +
+
+ ) +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a799056..8895191 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,155 +1,371 @@ @import "tailwindcss"; -@custom-variant dark (&:is(.dark *)); +/* FirstWave is dark only (see design.md §1). `dark:` utilities stay usable but + always apply, so shadcn components that ship with them keep working. */ +@custom-variant dark (&:where(:root, :root *)); @theme inline { + /* Surfaces & text */ --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-emerald-400: oklch(0.765 0.177 163.223); - --color-emerald-500: oklch(0.696 0.17 162.48); - --color-emerald-600: oklch(0.596 0.145 163.225); - --color-emerald-900: oklch(0.359 0.078 165.612); - --color-emerald-950: oklch(0.262 0.051 166.109); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-card-raised: var(--card-raised); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); --color-input: var(--input); --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); + --color-border-strong: var(--border-strong); --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-subtle-foreground: var(--subtle-foreground); + --color-ring: var(--ring); + + /* Accent */ --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); + --color-primary-foreground: var(--primary-foreground); + --color-primary-soft: var(--primary-soft); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + /* Status */ + --color-success: var(--success); + --color-warning: var(--warning); + --color-danger: var(--danger); + --color-destructive: var(--destructive); + + /* Priority scale (P1–P4) */ + --color-p1: var(--p1); + --color-p2: var(--p2); + --color-p3: var(--p3); + --color-p4: var(--p4); + + /* Resource types */ + --color-resource-medical: var(--resource-medical); + --color-resource-fire: var(--resource-fire); + --color-resource-police: var(--resource-police); + --color-resource-pharmacy: var(--resource-pharmacy); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + /* Type */ + --font-sans: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif; + --font-display: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, monospace; + + /* Radius: inputs/tiles 10px, cards 14px, buttons/chips pill */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); + --radius-tile: 0.625rem; + --radius-card: 0.875rem; + + --shadow-elevated: 0 20px 60px rgb(0 0 0 / 0.45); + + --animate-pulse-dot: pulse-dot 1.6s ease-in-out infinite; + --animate-wave-ring: wave-ring 2.4s ease-out infinite; } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.12 0.01 240); - --foreground: oklch(0.985 0 0); - --card: oklch(0.16 0.01 240); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.16 0.01 240); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.696 0.17 162.48); - --primary-foreground: oklch(0.12 0.01 240); - --secondary: oklch(0.22 0.01 240); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.22 0.01 240); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.696 0.17 162.48); - --accent-foreground: oklch(0.12 0.01 240); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 12%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.696 0.17 162.48); - --chart-1: oklch(0.696 0.17 162.48); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.16 0.01 240); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.696 0.17 162.48); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.22 0.01 240); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 12%); - --sidebar-ring: oklch(0.696 0.17 162.48); + color-scheme: dark; + + --radius: 0.625rem; /* 10px */ + + --background: #070b12; + --foreground: #e8ecf4; + --card: #0c1220; + --card-foreground: var(--foreground); + --card-raised: #111a2b; + --popover: var(--card-raised); + --popover-foreground: var(--foreground); + --input: #060a11; + --border: #1a2335; + --border-strong: #26324a; + --muted: var(--card-raised); + --muted-foreground: #8b95a8; + --subtle-foreground: #5f6b80; + + --primary: #38d4f2; + --primary-foreground: #06121a; + --primary-soft: color-mix(in srgb, var(--primary) 12%, transparent); + --ring: var(--primary); + --secondary: var(--card-raised); + --secondary-foreground: var(--foreground); + --accent: var(--card-raised); + --accent-foreground: var(--foreground); + + --success: #5ee6c8; + --warning: #f5a94a; + --danger: #f2708a; + --destructive: var(--danger); + + --p1: #f2708a; + --p2: #f5a94a; + --p3: #a7a9ff; + --p4: #8b95a8; + + --resource-medical: #38d4f2; + --resource-fire: #ff7a45; + --resource-police: #a7a9ff; + --resource-pharmacy: #8b95a8; + + --chart-1: var(--primary); + --chart-2: var(--success); + --chart-3: var(--warning); + --chart-4: var(--p3); + --chart-5: var(--danger); +} + +/* Accent options (Settings → Appearance). Only the primary changes. */ +:root[data-accent="indigo"] { + --primary: #8b8cf8; +} +:root[data-accent="amber"] { + --primary: #f5a94a; +} +:root[data-accent="mint"] { + --primary: #5ee6c8; } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; + } + html { + -webkit-text-size-adjust: 100%; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground font-sans antialiased; + font-size: 15px; + line-height: 1.55; + } + h1, + h2, + h3 { + @apply font-display text-foreground; + font-weight: 700; + } + :focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + ::selection { + background: var(--primary-soft); + color: var(--foreground); } } -/* Leaflet custom marker styles */ +@layer components { + /* 11px mono uppercase, primary color. "AI EMERGENCY DISPATCH ASSISTANT" */ + .eyebrow { + @apply font-mono text-primary uppercase; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.18em; + } + /* 11px sans uppercase, muted. "LOCATION", "RATIONALE" */ + .field-label { + @apply text-muted-foreground uppercase; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.08em; + } +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.45; + transform: scale(0.8); + } +} + +@keyframes wave-ring { + 0% { + opacity: 0.6; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(2.4); + } +} + +/* Decorative motion (pulsing dots, wave rings) stops under the OS setting or + Settings → "Reduce motion" (data-reduce-motion on ). Spinners keep going. */ +@media (prefers-reduced-motion: reduce) { + .animate-pulse-dot, + .animate-wave-ring, + .animate-ping, + .animate-pulse { + animation: none !important; + } +} +:root[data-reduce-motion] :is(.animate-pulse-dot, .animate-wave-ring, .animate-ping, .animate-pulse) { + animation: none !important; +} + +/* Leaflet */ .custom-marker, .user-marker { background: transparent !important; border: none !important; } +.leaflet-container { + font-family: inherit; + background: var(--input); +} + .leaflet-popup-content-wrapper { - background: var(--card); + background: var(--card-raised); color: var(--foreground); - border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: 0 20px 60px rgb(0 0 0 / 0.45); } .leaflet-popup-tip { - background: var(--card); + background: var(--card-raised); } .leaflet-popup-close-button { color: var(--muted-foreground) !important; } -.leaflet-container { - font-family: inherit; +.leaflet-bar a { + background: var(--card) !important; + color: var(--foreground) !important; + border-color: var(--border) !important; +} + +.leaflet-control-attribution { + background: color-mix(in srgb, var(--card) 85%, transparent) !important; + color: var(--subtle-foreground) !important; +} + +.leaflet-control-attribution a { + color: var(--muted-foreground) !important; +} + +/* ========================================================================== + Console map: Leaflet overrides (design.md §5) + Added by the console restyle. Markers, incident rings, zoom control and + popup content. Uses existing tokens only. + ========================================================================== */ +/* Dark basemap: invert the standard OSM tiles so the map doesn't glare. */ +.fw-map .leaflet-tile-pane { + filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9) saturate(0.35); +} + +.fw-marker { + background: transparent !important; + border: none !important; +} + +.fw-marker__dot { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 9999px; + border: 2px solid var(--card); + box-shadow: 0 2px 6px rgb(0 0 0 / 0.5); +} + +.fw-incident { + position: relative; + display: block; + width: 44px; + height: 44px; +} + +.fw-incident__ring, +.fw-incident__dot { + position: absolute; + inset: 0; + margin: auto; + border-radius: 9999px; +} + +.fw-incident__ring { + width: 30px; + height: 30px; + border: 1.5px solid color-mix(in srgb, var(--fw-incident) 70%, transparent); +} + +.fw-incident__ring--outer { + width: 44px; + height: 44px; + border-color: color-mix(in srgb, var(--fw-incident) 40%, transparent); + transform-origin: center; +} + +/* The shared wave-ring keyframes scale to 2.4x, too big for a map pin. + Keeps the .animate-wave-ring class so the reduce-motion rules above apply. */ +.fw-incident__ring--outer.animate-wave-ring { + animation-name: fw-incident-ring; +} + +@keyframes fw-incident-ring { + 0% { + opacity: 0.9; + transform: scale(0.7); + } + 100% { + opacity: 0; + transform: scale(1.35); + } +} + +.fw-incident__dot { + width: 14px; + height: 14px; + background: var(--fw-incident); + border: 2px solid var(--card); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--fw-incident) 60%, transparent); +} + +.leaflet-container .leaflet-bar { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + box-shadow: none; +} + +.leaflet-container .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; +} + +.leaflet-container .leaflet-bar a:hover { + background: var(--card-raised) !important; +} + +.leaflet-container .leaflet-bar a.leaflet-disabled { + color: var(--subtle-foreground) !important; +} + +.leaflet-popup-content { + margin: 10px 14px; + font-size: 13px; + line-height: 1.45; +} + +.leaflet-control-attribution { + font-size: 10px; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 6f63757..029d40b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,11 +1,11 @@ import type React from "react" import type { Metadata, Viewport } from "next" -import { Inter, Geist_Mono } from "next/font/google" +import { Bricolage_Grotesque, Geist_Mono } from "next/font/google" import { Analytics } from "@vercel/analytics/next" import "./globals.css" -const inter = Inter({ subsets: ["latin"] }) -const _geistMono = Geist_Mono({ subsets: ["latin"] }) +const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage" }) +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" }) export const metadata: Metadata = { title: "FirstWave", @@ -14,7 +14,7 @@ export const metadata: Metadata = { } export const viewport: Viewport = { - themeColor: "#0f172a", + themeColor: "#070b12", width: "device-width", initialScale: 1, } @@ -25,8 +25,8 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - + + {children} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 94235f5..68c873d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,11 +1,19 @@ -import { Header } from "@/components/header" -import { EmergencyDashboard } from "@/components/emergency-dashboard" +import { Hero } from "@/components/landing/hero" +import { LandingNav } from "@/components/landing/landing-nav" +import { CtaBand, LandingFooter, MapSection, PipelineSection, PrioritySection } from "@/components/landing/sections" -export default function Home() { +export default function LandingPage() { return ( -
-
- -
+
+ +
+ + + + + +
+ +
) } diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..06087bf --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next" +import { SettingsHeader } from "@/components/settings/settings-header" +import { SettingsForm } from "@/components/settings/settings-form" + +export const metadata: Metadata = { + title: "Settings · FirstWave", +} + +export default function SettingsPage() { + return ( +
+ +
+

Settings

+

Tune how FirstWave listens, speaks and looks.

+ +
+
+ ) +} diff --git a/frontend/src/components/agent-pipeline.tsx b/frontend/src/components/agent-pipeline.tsx new file mode 100644 index 0000000..84d71c4 --- /dev/null +++ b/frontend/src/components/agent-pipeline.tsx @@ -0,0 +1,100 @@ +"use client" + +import { Check, Circle, Loader2 } from "lucide-react" +import { Card, CardAction, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +// Mirrors the backend LangGraph agents (same ids/names as agent-panel.tsx). +// design.md §4.2 wants these in src/lib/agents.ts; kept local until that file exists. +const AGENTS = [ + { id: "extraction", name: "Extraction Agent" }, + { id: "triage", name: "Triage Agent" }, + { id: "next_question", name: "Question Agent" }, + { id: "dispatch_planner", name: "Dispatch Planner" }, + { id: "resource_locator", name: "Resource Locator" }, + { id: "safety_guardrail", name: "Safety Guardrail" }, +] as const + +type RowStatus = "waiting" | "running" | "complete" + +const STATUS_LABEL: Record = { + waiting: "Waiting", + running: "Running", + complete: "Complete", +} + +interface AgentPipelineProps { + /** Agent id of the most recent SSE event, or null. */ + currentAgent: string | null + isProcessing: boolean + /** True once a run has produced a dispatch recommendation (all agents ran). */ + hasCompletedRun: boolean +} + +/** + * Visual pipeline card. Row state is derived from the dashboard's existing + * `currentAgent` / `isProcessing` values: each SSE event marks its agent (and the + * ones before it) complete and the next one running. + */ +export function AgentPipeline({ currentAgent, isProcessing, hasCompletedRun }: AgentPipelineProps) { + const currentIndex = currentAgent ? AGENTS.findIndex((a) => a.id === currentAgent) : -1 + // wait_for_info: the graph stopped after the question agent to ask for more details. + const pausedAfter = currentAgent === "wait_for_info" ? AGENTS.findIndex((a) => a.id === "next_question") : null + + const statusFor = (index: number): RowStatus => { + if (isProcessing && pausedAfter !== null) { + return index <= pausedAfter ? "complete" : "waiting" + } + if (isProcessing) { + if (index <= currentIndex) return "complete" + if (index === currentIndex + 1) return "running" + return "waiting" + } + return hasCompletedRun ? "complete" : "waiting" + } + + const statuses = AGENTS.map((_, i) => statusFor(i)) + const doneCount = statuses.filter((s) => s === "complete").length + + return ( + + + Agent pipeline + + {doneCount}/{AGENTS.length} + + +
    + {AGENTS.map((agent, i) => { + const status = statuses[i] + return ( +
  1. + + {status === "complete" ? ( + + + + ) : status === "running" ? ( + + ) : ( + + )} + + + {agent.name} + + + {STATUS_LABEL[status]} + +
  2. + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/ai-summary.tsx b/frontend/src/components/ai-summary.tsx index ba5da80..bc873fc 100644 --- a/frontend/src/components/ai-summary.tsx +++ b/frontend/src/components/ai-summary.tsx @@ -1,6 +1,8 @@ "use client" -import {MapPin, AlertTriangle, Gauge, FileText, HelpCircle, Loader2} from "lucide-react" +import type { ReactNode } from "react" +import { Loader2 } from "lucide-react" +import { Card, CardAction, CardContent, CardHeader, CardTitle, CardTile } from "@/components/ui/card" export type SummaryData = { location: string | null @@ -15,135 +17,111 @@ interface AISummaryProps { data: SummaryData } -function SummaryField({ - icon: Icon, - label, - value, - isLoading -}: { - icon: React.ElementType - label: string - value: string | null - isLoading?: boolean -}) { +function Analyzing() { return ( -
-
- -
-
-

{label}

- {isLoading ? ( -
- - Analyzing... -
- ) : value ? ( -

{value}

- ) : ( -

?

- )} -
-
+ + + Analyzing… + + ) +} + +function SummaryTile({ label, value, isLoading }: { label: string; value: string | null; isLoading?: boolean }) { + return ( + +

{label}

+ {isLoading ? ( + + ) : value ? ( +

{value}

+ ) : ( +

Unknown

+ )} +
) } -function ListField({ - icon: Icon, - label, +function SummarySection({ + title, items, + isProcessing, emptyText, - isLoading + children, }: { - icon: React.ElementType - label: string + title: string items: string[] + isProcessing: boolean emptyText: string - isLoading?: boolean + children: ReactNode }) { + let body = children + if (isProcessing && items.length === 0) body = + else if (items.length === 0) body =

{emptyText}

+ return ( -
-
- -
-
-

{label}

- {isLoading ? ( -
- - Analyzing... -
- ) : items.length > 0 ? ( -
    - {items.map((item) => ( -
  • - • - {item} -
  • - ))} -
- ) : ( -

{emptyText}

- )} -
-
+
+

{title}

+ {body} +
) } -export function AISummary({data}: AISummaryProps) { - const {location, incident, priority, keyFacts, missingInfo, isProcessing} = data +export function AISummary({ data }: AISummaryProps) { + const { location, incident, keyFacts, missingInfo, isProcessing } = data + const hasData = Boolean(location || incident || keyFacts.length || missingInfo.length) return ( -
- {/* Header */} -
-

Incident Summary

-

Analysis

-
- - {/* Summary Content */} -
- - - - - + + + AI summary + {isProcessing && ( + + + Updating + + )} + -
- + +
+ +
-
- -
-
-
+ +
    + {keyFacts.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ + +
    + {missingInfo.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ + ) } diff --git a/frontend/src/components/chat-panel.tsx b/frontend/src/components/chat-panel.tsx index ab25b50..8ea73c3 100644 --- a/frontend/src/components/chat-panel.tsx +++ b/frontend/src/components/chat-panel.tsx @@ -1,10 +1,10 @@ "use client" import {useEffect, useRef, useState, useCallback} from "react" -import {Mic, MicOff, Send, User, Bot, Keyboard, MessageSquare, AlertCircle, Volume2, VolumeX} from "lucide-react" +import {Mic, MicOff, ArrowRight, AlertCircle, Volume2, VolumeX} from "lucide-react" import {Button} from "@/components/ui/button" -import {Textarea} from "@/components/ui/textarea" -import {Switch} from "@/components/ui/switch" +import {Card, CardAction, CardHeader, CardTitle} from "@/components/ui/card" +import {Input} from "@/components/ui/input" import {cn} from "@/lib/utils" export type ChatMessage = { @@ -53,12 +53,10 @@ export function ChatPanel({ isSpeaking = false, }: ChatPanelProps) { const [speechSupported, setSpeechSupported] = useState(true) - const [useTextInput, setUseTextInput] = useState(false) const [textInput, setTextInput] = useState("") const [speechError, setSpeechError] = useState(null) const recognitionRef = useRef(null) - const messagesEndRef = useRef(null) - const textareaRef = useRef(null) + const logRef = useRef(null) // Use ref to track accumulated transcript to avoid stale closure issues const accumulatedTranscriptRef = useRef("") @@ -190,7 +188,9 @@ export function ChatPanel({ // Scroll to bottom when messages change useEffect(() => { - messagesEndRef.current?.scrollIntoView({behavior: "smooth"}) + // Scroll the transcript itself (scrollIntoView would also scroll the page) + const log = logRef.current + if (log) log.scrollTo({top: log.scrollHeight, behavior: "smooth"}) }, [messages]) const handleMicClick = useCallback(() => { @@ -248,105 +248,61 @@ export function ChatPanel({ } }, [handleTextSubmit]) - const toggleInputMode = useCallback(() => { - // Stop listening if switching to text mode - if (!useTextInput && isListening) { - intentionalStopRef.current = true - - // Clean shutdown of recognition - if (recognitionRef.current) { - recognitionRef.current.onresult = null - recognitionRef.current.onerror = null - recognitionRef.current.onend = null - try { - recognitionRef.current.stop() - } catch { - // Ignore errors - } - recognitionRef.current = null - } - - onStopListening() - onTranscriptChange("") - accumulatedTranscriptRef.current = "" - } - setSpeechError(null) - setUseTextInput(!useTextInput) - }, [useTextInput, isListening, onStopListening, onTranscriptChange]) - const dismissError = useCallback(() => { setSpeechError(null) }, []) + const isEmpty = messages.length === 0 && !currentTranscript + return ( -
- {/* Header */} -
-
-
-

Caller ↔ AI Dispatcher

-

- {useTextInput ? "Text input mode" : "Voice input mode"} -

-
-
- {/* TTS Toggle */} - {onTtsToggle && ( -
- {ttsEnabled ? ( - - ) : ( - - )} - -
- )} - {/* Status indicators */} - {isSpeaking && ( -
- - Speaking -
- )} - {isListening && ( -
- - Recording -
- )} + + + Live transcript + + {isSpeaking && ( + + + Speaking + + )} + {isListening && ( + + + Listening + + )} + {/* Spoken replies toggle. Lives here until Settings → Voice is wired up. */} + {onTtsToggle && ( -
-
-
+ )} + + {/* Speech Error Banner */} {speechError && ( -
-
- +
+
+

{speechError}

@@ -354,124 +310,93 @@ export function ChatPanel({ )} {/* Chat Messages */} -
- {messages.length === 0 && !currentTranscript && ( -
-
- -
-

Chat bubbles appear here

-

Click start to begin recording

+
+ {isEmpty && ( +
+

Waiting for the caller…

+

Type the caller's words or press the mic to listen.

)} {messages.map((message) => (
- {message.role === "ai" && ( -
- -
- )}
+ {message.role === "caller" ? "Caller: " : "FirstWave: "} {message.content}
- {message.role === "caller" && ( -
- -
- )}
))} {/* Current transcript being recorded */} {currentTranscript && ( -
-
+
+
+ Caller (live): {currentTranscript} - -
-
- +
)} -
{/* Input Area */} -
- {useTextInput ? ( - /* Text Input Mode */ -
-