bshepp commited on
Commit ·
06e876f
1
Parent(s): 22718c3
Live progress indicators + feedback widget
Browse files- Backend: yield running state before each step so frontend sees real-time transitions
- AgentPipeline: live elapsed timer, spinning indicator, contextual activity messages
- FeedbackWidget: floating button -> message + optional contact form
- Backend: POST /api/feedback endpoint (JSONL storage)
- Pipeline running view: better messaging about expected duration
src/backend/app/agent/orchestrator.py
CHANGED
|
@@ -137,6 +137,7 @@ class Orchestrator:
|
|
| 137 |
|
| 138 |
try:
|
| 139 |
# ── Step 1: Parse patient data ──
|
|
|
|
| 140 |
step = await self._run_step("parse", self._step_parse, case.patient_text)
|
| 141 |
yield step
|
| 142 |
|
|
@@ -148,6 +149,7 @@ class Orchestrator:
|
|
| 148 |
return
|
| 149 |
|
| 150 |
# ── Step 2: Clinical reasoning ──
|
|
|
|
| 151 |
step = await self._run_step("reason", self._step_reason)
|
| 152 |
yield step
|
| 153 |
|
|
@@ -160,8 +162,10 @@ class Orchestrator:
|
|
| 160 |
# ── Step 3 & 4: Drug check + Guidelines (parallel) ──
|
| 161 |
parallel_tasks = []
|
| 162 |
if case.include_drug_check:
|
|
|
|
| 163 |
parallel_tasks.append(("drugs", self._step_drug_check))
|
| 164 |
if case.include_guidelines:
|
|
|
|
| 165 |
parallel_tasks.append(("guidelines", self._step_guidelines))
|
| 166 |
|
| 167 |
if parallel_tasks:
|
|
@@ -178,9 +182,11 @@ class Orchestrator:
|
|
| 178 |
|
| 179 |
# ── Step 5: Conflict Detection ──
|
| 180 |
if case.include_guidelines:
|
|
|
|
| 181 |
yield await self._run_step("conflicts", self._step_conflict_detection)
|
| 182 |
|
| 183 |
# ── Step 6: Synthesis ──
|
|
|
|
| 184 |
yield await self._run_step("synthesize", self._step_synthesize)
|
| 185 |
|
| 186 |
self._state.completed_at = datetime.utcnow()
|
|
@@ -207,6 +213,12 @@ class Orchestrator:
|
|
| 207 |
skipped.append(step)
|
| 208 |
return skipped
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
async def _run_step(self, step_id: str, fn, *args) -> AgentStep:
|
| 211 |
"""Execute a single step, tracking status and timing."""
|
| 212 |
step = self._get_step(step_id)
|
|
|
|
| 137 |
|
| 138 |
try:
|
| 139 |
# ── Step 1: Parse patient data ──
|
| 140 |
+
yield self._mark_running("parse")
|
| 141 |
step = await self._run_step("parse", self._step_parse, case.patient_text)
|
| 142 |
yield step
|
| 143 |
|
|
|
|
| 149 |
return
|
| 150 |
|
| 151 |
# ── Step 2: Clinical reasoning ──
|
| 152 |
+
yield self._mark_running("reason")
|
| 153 |
step = await self._run_step("reason", self._step_reason)
|
| 154 |
yield step
|
| 155 |
|
|
|
|
| 162 |
# ── Step 3 & 4: Drug check + Guidelines (parallel) ──
|
| 163 |
parallel_tasks = []
|
| 164 |
if case.include_drug_check:
|
| 165 |
+
yield self._mark_running("drugs")
|
| 166 |
parallel_tasks.append(("drugs", self._step_drug_check))
|
| 167 |
if case.include_guidelines:
|
| 168 |
+
yield self._mark_running("guidelines")
|
| 169 |
parallel_tasks.append(("guidelines", self._step_guidelines))
|
| 170 |
|
| 171 |
if parallel_tasks:
|
|
|
|
| 182 |
|
| 183 |
# ── Step 5: Conflict Detection ──
|
| 184 |
if case.include_guidelines:
|
| 185 |
+
yield self._mark_running("conflicts")
|
| 186 |
yield await self._run_step("conflicts", self._step_conflict_detection)
|
| 187 |
|
| 188 |
# ── Step 6: Synthesis ──
|
| 189 |
+
yield self._mark_running("synthesize")
|
| 190 |
yield await self._run_step("synthesize", self._step_synthesize)
|
| 191 |
|
| 192 |
self._state.completed_at = datetime.utcnow()
|
|
|
|
| 213 |
skipped.append(step)
|
| 214 |
return skipped
|
| 215 |
|
| 216 |
+
def _mark_running(self, step_id: str) -> AgentStep:
|
| 217 |
+
"""Mark a step as RUNNING and return it for immediate yielding."""
|
| 218 |
+
step = self._get_step(step_id)
|
| 219 |
+
step.status = AgentStepStatus.RUNNING
|
| 220 |
+
return step
|
| 221 |
+
|
| 222 |
async def _run_step(self, step_id: str, fn, *args) -> AgentStep:
|
| 223 |
"""Execute a single step, tracking status and timing."""
|
| 224 |
step = self._get_step(step_id)
|
src/backend/app/api/feedback.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback endpoint — stores user feedback from the demo.
|
| 3 |
+
Simple JSON Lines file storage. No database needed.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Request
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
FEEDBACK_FILE = Path("/tmp/cds_feedback.jsonl")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FeedbackSubmission(BaseModel):
|
| 22 |
+
message: str = Field(..., max_length=1000)
|
| 23 |
+
contact: str | None = Field(None, max_length=200)
|
| 24 |
+
page_url: str | None = None
|
| 25 |
+
user_agent: str | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/api/feedback")
|
| 29 |
+
async def submit_feedback(feedback: FeedbackSubmission, request: Request):
|
| 30 |
+
"""Save user feedback to a JSONL file."""
|
| 31 |
+
entry = {
|
| 32 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 33 |
+
"message": feedback.message,
|
| 34 |
+
"contact": feedback.contact,
|
| 35 |
+
"page_url": feedback.page_url,
|
| 36 |
+
"user_agent": feedback.user_agent,
|
| 37 |
+
"client_ip": request.client.host if request.client else None,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
with open(FEEDBACK_FILE, "a", encoding="utf-8") as f:
|
| 42 |
+
f.write(json.dumps(entry) + "\n")
|
| 43 |
+
logger.info(f"Feedback saved: {feedback.message[:50]}...")
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error(f"Failed to save feedback: {e}")
|
| 46 |
+
return {"status": "error", "message": "Failed to save feedback"}
|
| 47 |
+
|
| 48 |
+
return {"status": "ok", "message": "Thank you for your feedback!"}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.get("/api/feedback")
|
| 52 |
+
async def list_feedback():
|
| 53 |
+
"""List all feedback (for the developer). No auth — it's a demo."""
|
| 54 |
+
if not FEEDBACK_FILE.exists():
|
| 55 |
+
return {"feedback": [], "count": 0}
|
| 56 |
+
|
| 57 |
+
entries = []
|
| 58 |
+
try:
|
| 59 |
+
with open(FEEDBACK_FILE, "r", encoding="utf-8") as f:
|
| 60 |
+
for line in f:
|
| 61 |
+
line = line.strip()
|
| 62 |
+
if line:
|
| 63 |
+
entries.append(json.loads(line))
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Failed to read feedback: {e}")
|
| 66 |
+
return {"feedback": [], "count": 0, "error": str(e)}
|
| 67 |
+
|
| 68 |
+
return {"feedback": entries, "count": len(entries)}
|
src/backend/app/main.py
CHANGED
|
@@ -6,7 +6,7 @@ import logging
|
|
| 6 |
from fastapi import FastAPI
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
|
| 9 |
-
from app.api import cases, health, ws
|
| 10 |
from app.config import settings
|
| 11 |
|
| 12 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
@@ -30,6 +30,7 @@ app.add_middleware(
|
|
| 30 |
# Routes
|
| 31 |
app.include_router(health.router, tags=["health"])
|
| 32 |
app.include_router(cases.router, prefix="/api/cases", tags=["cases"])
|
|
|
|
| 33 |
app.include_router(ws.router, prefix="/ws", tags=["websocket"])
|
| 34 |
|
| 35 |
|
|
|
|
| 6 |
from fastapi import FastAPI
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
|
| 9 |
+
from app.api import cases, feedback, health, ws
|
| 10 |
from app.config import settings
|
| 11 |
|
| 12 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
|
|
| 30 |
# Routes
|
| 31 |
app.include_router(health.router, tags=["health"])
|
| 32 |
app.include_router(cases.router, prefix="/api/cases", tags=["cases"])
|
| 33 |
+
app.include_router(feedback.router, tags=["feedback"])
|
| 34 |
app.include_router(ws.router, prefix="/ws", tags=["websocket"])
|
| 35 |
|
| 36 |
|
src/frontend/src/app/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useState, useCallback } from "react";
|
|
| 4 |
import { PatientInput } from "@/components/PatientInput";
|
| 5 |
import { AgentPipeline } from "@/components/AgentPipeline";
|
| 6 |
import { CDSReport } from "@/components/CDSReport";
|
|
|
|
| 7 |
import { useAgentWebSocket } from "@/hooks/useAgentWebSocket";
|
| 8 |
import { reportToMarkdown } from "@/lib/reportToMarkdown";
|
| 9 |
|
|
@@ -173,7 +174,13 @@ export default function Home() {
|
|
| 173 |
<div className="flex items-center justify-center h-64 text-gray-400">
|
| 174 |
<div className="text-center">
|
| 175 |
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4" />
|
| 176 |
-
<p>Agent pipeline running...</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
) : error && steps.length === 0 ? (
|
|
@@ -207,6 +214,9 @@ export default function Home() {
|
|
| 207 |
)}
|
| 208 |
</div>
|
| 209 |
|
|
|
|
|
|
|
|
|
|
| 210 |
{/* Disclaimer footer */}
|
| 211 |
<footer className="fixed bottom-0 left-0 right-0 bg-amber-50 border-t border-amber-200 px-6 py-2">
|
| 212 |
<p className="text-center text-xs text-amber-700">
|
|
|
|
| 4 |
import { PatientInput } from "@/components/PatientInput";
|
| 5 |
import { AgentPipeline } from "@/components/AgentPipeline";
|
| 6 |
import { CDSReport } from "@/components/CDSReport";
|
| 7 |
+
import { FeedbackWidget } from "@/components/FeedbackWidget";
|
| 8 |
import { useAgentWebSocket } from "@/hooks/useAgentWebSocket";
|
| 9 |
import { reportToMarkdown } from "@/lib/reportToMarkdown";
|
| 10 |
|
|
|
|
| 174 |
<div className="flex items-center justify-center h-64 text-gray-400">
|
| 175 |
<div className="text-center">
|
| 176 |
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-4" />
|
| 177 |
+
<p className="text-gray-600 font-medium">Agent pipeline running...</p>
|
| 178 |
+
<p className="text-sm text-gray-400 mt-1">
|
| 179 |
+
Watch the steps on the left — the report will appear here when done.
|
| 180 |
+
</p>
|
| 181 |
+
<p className="text-xs text-gray-400 mt-3">
|
| 182 |
+
Full analysis typically takes 2–4 minutes across 6 steps.
|
| 183 |
+
</p>
|
| 184 |
</div>
|
| 185 |
</div>
|
| 186 |
) : error && steps.length === 0 ? (
|
|
|
|
| 214 |
)}
|
| 215 |
</div>
|
| 216 |
|
| 217 |
+
{/* Feedback widget */}
|
| 218 |
+
<FeedbackWidget />
|
| 219 |
+
|
| 220 |
{/* Disclaimer footer */}
|
| 221 |
<footer className="fixed bottom-0 left-0 right-0 bg-amber-50 border-t border-amber-200 px-6 py-2">
|
| 222 |
<p className="text-center text-xs text-amber-700">
|
src/frontend/src/components/AgentPipeline.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
|
|
|
|
|
|
| 3 |
interface Step {
|
| 4 |
step_id: string;
|
| 5 |
step_name: string;
|
|
@@ -48,7 +50,86 @@ const STATUS_CONFIG = {
|
|
| 48 |
},
|
| 49 |
};
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
export function AgentPipeline({ steps, isRunning }: AgentPipelineProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return (
|
| 53 |
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
| 54 |
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
|
@@ -58,6 +139,7 @@ export function AgentPipeline({ steps, isRunning }: AgentPipelineProps) {
|
|
| 58 |
<div className="space-y-1">
|
| 59 |
{steps.map((step, index) => {
|
| 60 |
const config = STATUS_CONFIG[step.status];
|
|
|
|
| 61 |
return (
|
| 62 |
<div key={step.step_id}>
|
| 63 |
{/* Connector line */}
|
|
@@ -70,13 +152,18 @@ export function AgentPipeline({ steps, isRunning }: AgentPipelineProps) {
|
|
| 70 |
className={`flex items-start gap-3 p-3 rounded-lg border ${config.bg} ${config.border} transition-all duration-300`}
|
| 71 |
>
|
| 72 |
{/* Status icon */}
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
{/* Step info */}
|
| 82 |
<div className="flex-1 min-w-0">
|
|
@@ -84,14 +171,25 @@ export function AgentPipeline({ steps, isRunning }: AgentPipelineProps) {
|
|
| 84 |
<span className="text-sm font-medium text-gray-800">
|
| 85 |
{step.step_name}
|
| 86 |
</span>
|
| 87 |
-
{step.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
<span className="text-xs text-gray-400">
|
| 89 |
-
{step.duration_ms}
|
| 90 |
</span>
|
| 91 |
)}
|
| 92 |
</div>
|
| 93 |
|
| 94 |
-
{step.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
<span className="text-xs text-gray-400 font-mono">
|
| 96 |
{step.tool_name}
|
| 97 |
</span>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useRef, useState } from "react";
|
| 4 |
+
|
| 5 |
interface Step {
|
| 6 |
step_id: string;
|
| 7 |
step_name: string;
|
|
|
|
| 50 |
},
|
| 51 |
};
|
| 52 |
|
| 53 |
+
const STEP_MESSAGES: Record<string, string[]> = {
|
| 54 |
+
parse: [
|
| 55 |
+
"Extracting demographics, medications, labs...",
|
| 56 |
+
"Identifying chief complaint and history...",
|
| 57 |
+
"Structuring patient data...",
|
| 58 |
+
],
|
| 59 |
+
reason: [
|
| 60 |
+
"Analyzing symptoms and history...",
|
| 61 |
+
"Generating differential diagnosis...",
|
| 62 |
+
"Evaluating clinical evidence...",
|
| 63 |
+
],
|
| 64 |
+
drugs: [
|
| 65 |
+
"Querying OpenFDA drug database...",
|
| 66 |
+
"Checking RxNorm interactions...",
|
| 67 |
+
"Evaluating medication safety...",
|
| 68 |
+
],
|
| 69 |
+
guidelines: [
|
| 70 |
+
"Searching clinical guideline database...",
|
| 71 |
+
"Retrieving relevant excerpts...",
|
| 72 |
+
"Matching guidelines to diagnosis...",
|
| 73 |
+
],
|
| 74 |
+
conflicts: [
|
| 75 |
+
"Cross-referencing care plan with guidelines...",
|
| 76 |
+
"Detecting care gaps...",
|
| 77 |
+
"Checking for contraindications...",
|
| 78 |
+
],
|
| 79 |
+
synthesize: [
|
| 80 |
+
"Compiling analysis results...",
|
| 81 |
+
"Generating clinical report...",
|
| 82 |
+
"Finalizing recommendations...",
|
| 83 |
+
],
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
function formatElapsed(seconds: number): string {
|
| 87 |
+
if (seconds < 60) return `${seconds}s`;
|
| 88 |
+
const m = Math.floor(seconds / 60);
|
| 89 |
+
const s = seconds % 60;
|
| 90 |
+
return `${m}m ${s}s`;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
export function AgentPipeline({ steps, isRunning }: AgentPipelineProps) {
|
| 94 |
+
const [tick, setTick] = useState(0);
|
| 95 |
+
const startTimes = useRef<Record<string, number>>({});
|
| 96 |
+
|
| 97 |
+
// Track when steps start running
|
| 98 |
+
useEffect(() => {
|
| 99 |
+
const now = Date.now();
|
| 100 |
+
steps.forEach((step) => {
|
| 101 |
+
if (step.status === "running" && !startTimes.current[step.step_id]) {
|
| 102 |
+
startTimes.current[step.step_id] = now;
|
| 103 |
+
}
|
| 104 |
+
if (step.status !== "running" && startTimes.current[step.step_id]) {
|
| 105 |
+
delete startTimes.current[step.step_id];
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
}, [steps]);
|
| 109 |
+
|
| 110 |
+
// Tick every second when any step is running
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
const hasRunning = steps.some((s) => s.status === "running");
|
| 113 |
+
if (!hasRunning) return;
|
| 114 |
+
|
| 115 |
+
const interval = setInterval(() => setTick((t) => t + 1), 1000);
|
| 116 |
+
return () => clearInterval(interval);
|
| 117 |
+
}, [steps.map((s) => s.status).join(",")]);
|
| 118 |
+
|
| 119 |
+
const getElapsed = (stepId: string): number => {
|
| 120 |
+
const start = startTimes.current[stepId];
|
| 121 |
+
if (!start) return 0;
|
| 122 |
+
return Math.floor((Date.now() - start) / 1000);
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const getActivityMessage = (stepId: string, elapsed: number): string => {
|
| 126 |
+
const messages = STEP_MESSAGES[stepId];
|
| 127 |
+
if (!messages) return "Processing...";
|
| 128 |
+
// Cycle through messages every 8 seconds
|
| 129 |
+
const idx = Math.floor(elapsed / 8) % messages.length;
|
| 130 |
+
return messages[idx];
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
return (
|
| 134 |
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
| 135 |
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
|
|
|
| 139 |
<div className="space-y-1">
|
| 140 |
{steps.map((step, index) => {
|
| 141 |
const config = STATUS_CONFIG[step.status];
|
| 142 |
+
const elapsed = step.status === "running" ? getElapsed(step.step_id) : 0;
|
| 143 |
return (
|
| 144 |
<div key={step.step_id}>
|
| 145 |
{/* Connector line */}
|
|
|
|
| 152 |
className={`flex items-start gap-3 p-3 rounded-lg border ${config.bg} ${config.border} transition-all duration-300`}
|
| 153 |
>
|
| 154 |
{/* Status icon */}
|
| 155 |
+
<div className="flex-shrink-0 mt-0.5">
|
| 156 |
+
{step.status === "running" ? (
|
| 157 |
+
<div className="w-5 h-5 relative">
|
| 158 |
+
<div className="absolute inset-0 rounded-full border-2 border-blue-200" />
|
| 159 |
+
<div className="absolute inset-0 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
|
| 160 |
+
</div>
|
| 161 |
+
) : (
|
| 162 |
+
<span className={`text-lg font-bold ${config.color}`}>
|
| 163 |
+
{config.icon}
|
| 164 |
+
</span>
|
| 165 |
+
)}
|
| 166 |
+
</div>
|
| 167 |
|
| 168 |
{/* Step info */}
|
| 169 |
<div className="flex-1 min-w-0">
|
|
|
|
| 171 |
<span className="text-sm font-medium text-gray-800">
|
| 172 |
{step.step_name}
|
| 173 |
</span>
|
| 174 |
+
{step.status === "running" && elapsed > 0 && (
|
| 175 |
+
<span className="text-xs font-mono text-blue-500 tabular-nums">
|
| 176 |
+
{formatElapsed(elapsed)}
|
| 177 |
+
</span>
|
| 178 |
+
)}
|
| 179 |
+
{step.duration_ms != null && step.status !== "running" && (
|
| 180 |
<span className="text-xs text-gray-400">
|
| 181 |
+
{(step.duration_ms / 1000).toFixed(1)}s
|
| 182 |
</span>
|
| 183 |
)}
|
| 184 |
</div>
|
| 185 |
|
| 186 |
+
{step.status === "running" && (
|
| 187 |
+
<p className="text-xs text-blue-500 mt-1 animate-pulse">
|
| 188 |
+
{getActivityMessage(step.step_id, elapsed)}
|
| 189 |
+
</p>
|
| 190 |
+
)}
|
| 191 |
+
|
| 192 |
+
{step.tool_name && step.status !== "running" && (
|
| 193 |
<span className="text-xs text-gray-400 font-mono">
|
| 194 |
{step.tool_name}
|
| 195 |
</span>
|
src/frontend/src/components/FeedbackWidget.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react";
|
| 4 |
+
|
| 5 |
+
export function FeedbackWidget() {
|
| 6 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 7 |
+
const [message, setMessage] = useState("");
|
| 8 |
+
const [contact, setContact] = useState("");
|
| 9 |
+
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
|
| 10 |
+
const panelRef = useRef<HTMLDivElement>(null);
|
| 11 |
+
|
| 12 |
+
// Close on outside click
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
if (!isOpen) return;
|
| 15 |
+
const handleClick = (e: MouseEvent) => {
|
| 16 |
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
| 17 |
+
setIsOpen(false);
|
| 18 |
+
}
|
| 19 |
+
};
|
| 20 |
+
document.addEventListener("mousedown", handleClick);
|
| 21 |
+
return () => document.removeEventListener("mousedown", handleClick);
|
| 22 |
+
}, [isOpen]);
|
| 23 |
+
|
| 24 |
+
const handleSubmit = async () => {
|
| 25 |
+
if (!message.trim()) return;
|
| 26 |
+
setStatus("sending");
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
const res = await fetch("/api/feedback", {
|
| 30 |
+
method: "POST",
|
| 31 |
+
headers: { "Content-Type": "application/json" },
|
| 32 |
+
body: JSON.stringify({
|
| 33 |
+
message: message.trim(),
|
| 34 |
+
contact: contact.trim() || undefined,
|
| 35 |
+
page_url: window.location.href,
|
| 36 |
+
user_agent: navigator.userAgent,
|
| 37 |
+
}),
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
if (res.ok) {
|
| 41 |
+
setStatus("sent");
|
| 42 |
+
setMessage("");
|
| 43 |
+
setContact("");
|
| 44 |
+
setTimeout(() => {
|
| 45 |
+
setIsOpen(false);
|
| 46 |
+
setStatus("idle");
|
| 47 |
+
}, 2000);
|
| 48 |
+
} else {
|
| 49 |
+
setStatus("error");
|
| 50 |
+
}
|
| 51 |
+
} catch {
|
| 52 |
+
setStatus("error");
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<div className="fixed bottom-12 right-6 z-50" ref={panelRef}>
|
| 58 |
+
{/* Feedback panel */}
|
| 59 |
+
{isOpen && (
|
| 60 |
+
<div className="mb-3 w-80 bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden animate-in">
|
| 61 |
+
<div className="bg-blue-600 px-4 py-3">
|
| 62 |
+
<h3 className="text-white font-semibold text-sm">Send Feedback</h3>
|
| 63 |
+
<p className="text-blue-100 text-xs mt-0.5">
|
| 64 |
+
Bugs, impressions, ideas — anything helps!
|
| 65 |
+
</p>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="p-4 space-y-3">
|
| 69 |
+
{status === "sent" ? (
|
| 70 |
+
<div className="text-center py-4">
|
| 71 |
+
<div className="text-3xl mb-2">✓</div>
|
| 72 |
+
<p className="text-green-700 font-medium text-sm">Thanks for the feedback!</p>
|
| 73 |
+
</div>
|
| 74 |
+
) : (
|
| 75 |
+
<>
|
| 76 |
+
<div>
|
| 77 |
+
<textarea
|
| 78 |
+
value={message}
|
| 79 |
+
onChange={(e) => setMessage(e.target.value)}
|
| 80 |
+
placeholder="What's on your mind?"
|
| 81 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
| 82 |
+
rows={3}
|
| 83 |
+
maxLength={1000}
|
| 84 |
+
autoFocus
|
| 85 |
+
/>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div>
|
| 89 |
+
<input
|
| 90 |
+
type="text"
|
| 91 |
+
value={contact}
|
| 92 |
+
onChange={(e) => setContact(e.target.value)}
|
| 93 |
+
placeholder="Email or Twitter (optional)"
|
| 94 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
| 95 |
+
maxLength={200}
|
| 96 |
+
/>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{status === "error" && (
|
| 100 |
+
<p className="text-xs text-red-600">
|
| 101 |
+
Failed to send — try again in a moment.
|
| 102 |
+
</p>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
+
<button
|
| 106 |
+
onClick={handleSubmit}
|
| 107 |
+
disabled={!message.trim() || status === "sending"}
|
| 108 |
+
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
| 109 |
+
>
|
| 110 |
+
{status === "sending" ? "Sending..." : "Send Feedback"}
|
| 111 |
+
</button>
|
| 112 |
+
</>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
)}
|
| 117 |
+
|
| 118 |
+
{/* Floating button */}
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => {
|
| 121 |
+
setIsOpen(!isOpen);
|
| 122 |
+
setStatus("idle");
|
| 123 |
+
}}
|
| 124 |
+
className="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-105 active:scale-95"
|
| 125 |
+
title="Send feedback"
|
| 126 |
+
>
|
| 127 |
+
<svg
|
| 128 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 129 |
+
viewBox="0 0 24 24"
|
| 130 |
+
fill="currentColor"
|
| 131 |
+
className="w-5 h-5"
|
| 132 |
+
>
|
| 133 |
+
{isOpen ? (
|
| 134 |
+
<path
|
| 135 |
+
fillRule="evenodd"
|
| 136 |
+
d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z"
|
| 137 |
+
clipRule="evenodd"
|
| 138 |
+
/>
|
| 139 |
+
) : (
|
| 140 |
+
<path
|
| 141 |
+
fillRule="evenodd"
|
| 142 |
+
d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.29 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.68-3.348 3.97a48.901 48.901 0 01-3.476.383.39.39 0 00-.297.17l-2.755 4.133a.75.75 0 01-1.248 0l-2.755-4.133a.39.39 0 00-.297-.17 48.9 48.9 0 01-3.476-.384c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97zM6.75 8.25a.75.75 0 01.75-.75h9a.75.75 0 010 1.5h-9a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5H12a.75.75 0 000-1.5H7.5z"
|
| 143 |
+
clipRule="evenodd"
|
| 144 |
+
/>
|
| 145 |
+
)}
|
| 146 |
+
</svg>
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
);
|
| 150 |
+
}
|