App.tsx
17,278 bytes
| 1 | import { useCallback, useEffect, useRef, useState } from 'react' |
|---|---|
| 2 | import type { ConfigStatus, Session, SessionSummary } from 'shared/types' |
| 3 | import * as api from './api' |
| 4 | import './App.css' |
| 5 | import { BlockerImport } from './components/BlockerImport' |
| 6 | import { CoveragePanel } from './components/CoveragePanel' |
| 7 | import { FolderBrowser } from './components/FolderBrowser' |
| 8 | import { GeneratePanel } from './components/GeneratePanel' |
| 9 | import { OutcomePreview } from './components/OutcomePreview' |
| 10 | import { ProviderSetup } from './components/ProviderSetup' |
| 11 | import { PushToTalkButton } from './components/PushToTalkButton' |
| 12 | import { QuestionCard } from './components/QuestionCard' |
| 13 | import { Transcript } from './components/Transcript' |
| 14 | import { TranscriptExport } from './components/TranscriptExport' |
| 15 | import { VoiceOrb, type VoiceState } from './components/VoiceOrb' |
| 16 | import { Waveform, type WaveformMode } from './components/Waveform' |
| 17 | import { connectionLabel } from './labels' |
| 18 | import { speak, stopSpeaking } from './tts' |
| 19 | |
| 20 | function latestQuestion(session: Session): string | null { |
| 21 | for (let i = session.segments.length - 1; i >= 0; i--) { |
| 22 | const segment = session.segments[i] |
| 23 | if (segment && segment.speaker === 'interviewer') return segment.text |
| 24 | } |
| 25 | return null |
| 26 | } |
| 27 | |
| 28 | function statusLabel(status: Session['status']): string { |
| 29 | return status === 'done' ? 'Ready' : 'In progress' |
| 30 | } |
| 31 | |
| 32 | const CAPTIONS: Record<VoiceState, string> = { |
| 33 | idle: 'Ready for your answer.', |
| 34 | listening: 'Listening. Send the recording when you are finished.', |
| 35 | thinking: 'Turning your answer into the next question.', |
| 36 | speaking: 'Reading the question aloud. Press Escape to stop.', |
| 37 | } |
| 38 | |
| 39 | function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) { |
| 40 | const [sessions, setSessions] = useState<SessionSummary[]>([]) |
| 41 | const [name, setName] = useState('') |
| 42 | const [targetDir, setTargetDir] = useState('') |
| 43 | const [error, setError] = useState<string | null>(null) |
| 44 | const [creating, setCreating] = useState(false) |
| 45 | const [browsingFolder, setBrowsingFolder] = useState(false) |
| 46 | const [config, setConfig] = useState<ConfigStatus | null>(null) |
| 47 | const [editingConnection, setEditingConnection] = useState(false) |
| 48 | |
| 49 | useEffect(() => { |
| 50 | api.listSessions().then(setSessions).catch((err: Error) => setError(err.message)) |
| 51 | api.getConfig().then(setConfig).catch((err: Error) => setError(err.message)) |
| 52 | }, []) |
| 53 | |
| 54 | async function handleDelete(id: string) { |
| 55 | if (!window.confirm('Delete this interview? Its saved transcript will be removed from VoiceTask.')) return |
| 56 | setError(null) |
| 57 | try { |
| 58 | await api.deleteSession(id) |
| 59 | setSessions(await api.listSessions()) |
| 60 | } catch (err) { |
| 61 | setError(err instanceof Error ? err.message : String(err)) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | async function handleCreate(event: React.FormEvent) { |
| 66 | event.preventDefault() |
| 67 | setError(null) |
| 68 | setCreating(true) |
| 69 | try { |
| 70 | const session = await api.createSession({ name, targetDir }) |
| 71 | onOpen(session.id) |
| 72 | } catch (err) { |
| 73 | setError(err instanceof Error ? err.message : String(err)) |
| 74 | } finally { |
| 75 | setCreating(false) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | const showSetup = config !== null && (config.mode === 'unconfigured' || editingConnection) |
| 80 | |
| 81 | return ( |
| 82 | <div className="screen home-screen"> |
| 83 | <header className="home-nav"> |
| 84 | <span className="wordmark"> |
| 85 | voicetask<span className="wordmark-dot">.</span> |
| 86 | </span> |
| 87 | <div className="home-nav-status"> |
| 88 | <span className="local-badge"> |
| 89 | <span aria-hidden="true" /> |
| 90 | Saved on this computer |
| 91 | </span> |
| 92 | {config && config.mode === 'unconfigured' && ( |
| 93 | <span className="connection-chip mode-unconfigured">{connectionLabel(config)}</span> |
| 94 | )} |
| 95 | {config && config.mode !== 'unconfigured' && ( |
| 96 | <button |
| 97 | type="button" |
| 98 | className={`connection-chip mode-${config.mode}`} |
| 99 | aria-expanded={editingConnection} |
| 100 | onClick={() => setEditingConnection((open) => !open)} |
| 101 | > |
| 102 | {connectionLabel(config)} |
| 103 | </button> |
| 104 | )} |
| 105 | </div> |
| 106 | </header> |
| 107 | |
| 108 | <main className="home-main"> |
| 109 | <section className="home-intro"> |
| 110 | <p className="eyebrow">Voice-first build planning</p> |
| 111 | <h1>Turn a conversation into a plan someone can build.</h1> |
| 112 | <p className="hero-copy"> |
| 113 | VoiceTask asks one useful question at a time, keeps track of the details, and turns |
| 114 | your answers into a build brief you can send on. |
| 115 | </p> |
| 116 | <div className="privacy-note"> |
| 117 | <span className="privacy-icon" aria-hidden="true"> |
| 118 | <span /> |
| 119 | </span> |
| 120 | <p> |
| 121 | <strong>No account needed.</strong> Your interview is saved locally, and the finished |
| 122 | files go to the folder you choose. |
| 123 | </p> |
| 124 | </div> |
| 125 | <OutcomePreview /> |
| 126 | </section> |
| 127 | |
| 128 | {showSetup && config ? ( |
| 129 | <section className="start-card"> |
| 130 | <ProviderSetup |
| 131 | status={config} |
| 132 | onSaved={setConfig} |
| 133 | onClose={config.mode === 'unconfigured' ? undefined : () => setEditingConnection(false)} |
| 134 | /> |
| 135 | </section> |
| 136 | ) : ( |
| 137 | <section className="start-card" aria-labelledby="start-heading"> |
| 138 | <p className="card-kicker">Start here</p> |
| 139 | <h2 id="start-heading">What are you working on?</h2> |
| 140 | <p className="card-intro">A rough idea is enough. The interview will help fill in the rest.</p> |
| 141 | |
| 142 | <form className="create-form" onSubmit={handleCreate}> |
| 143 | <div className="form-field"> |
| 144 | <label htmlFor="project-name">Name your idea</label> |
| 145 | <p id="project-name-help">Use a short name so you can find this interview later.</p> |
| 146 | <input |
| 147 | id="project-name" |
| 148 | value={name} |
| 149 | onChange={(event) => setName(event.target.value)} |
| 150 | placeholder="Family recipe app" |
| 151 | aria-describedby="project-name-help" |
| 152 | required |
| 153 | /> |
| 154 | </div> |
| 155 | |
| 156 | <div className="form-field"> |
| 157 | <span className="field-label" id="project-folder-label"> |
| 158 | Choose where to save the build brief |
| 159 | </span> |
| 160 | <p id="project-folder-help"> |
| 161 | VoiceTask writes the finished files here. You can also download them as a zip. |
| 162 | </p> |
| 163 | <button |
| 164 | type="button" |
| 165 | className="choose-folder-button" |
| 166 | onClick={() => setBrowsingFolder(true)} |
| 167 | aria-describedby="project-folder-help" |
| 168 | > |
| 169 | {targetDir ? 'Choose a different folder' : 'Choose a folder'} |
| 170 | </button> |
| 171 | {targetDir && ( |
| 172 | <p className="selected-folder" title={targetDir}> |
| 173 | <span>Selected</span> |
| 174 | {targetDir} |
| 175 | </p> |
| 176 | )} |
| 177 | <label className="path-label" htmlFor="project-folder"> |
| 178 | Or enter a folder path |
| 179 | </label> |
| 180 | <div className="folder-field"> |
| 181 | <input |
| 182 | id="project-folder" |
| 183 | value={targetDir} |
| 184 | onChange={(event) => setTargetDir(event.target.value)} |
| 185 | placeholder="Paste an absolute folder path" |
| 186 | aria-labelledby="project-folder-label" |
| 187 | aria-describedby="project-folder-help" |
| 188 | required |
| 189 | /> |
| 190 | </div> |
| 191 | </div> |
| 192 | |
| 193 | <button type="submit" className="btn-primary start-button" disabled={creating}> |
| 194 | {creating ? 'Starting...' : 'Start my interview'} |
| 195 | </button> |
| 196 | <p className="form-footnote">Answer by voice or typing. Stop whenever you have said enough.</p> |
| 197 | </form> |
| 198 | |
| 199 | {error && ( |
| 200 | <p className="error" role="alert"> |
| 201 | {error} |
| 202 | </p> |
| 203 | )} |
| 204 | </section> |
| 205 | )} |
| 206 | </main> |
| 207 | |
| 208 | <section className="process-section" aria-labelledby="process-heading"> |
| 209 | <div className="process-heading"> |
| 210 | <p className="eyebrow">How it works</p> |
| 211 | <h2 id="process-heading">From rough idea to clear handoff</h2> |
| 212 | </div> |
| 213 | <ol className="process-steps"> |
| 214 | <li> |
| 215 | <span className="step-number">1</span> |
| 216 | <div> |
| 217 | <h3>Talk naturally</h3> |
| 218 | <p>Describe the thing you want to build by voice or typing.</p> |
| 219 | </div> |
| 220 | </li> |
| 221 | <li> |
| 222 | <span className="step-number">2</span> |
| 223 | <div> |
| 224 | <h3>Fill in the gaps</h3> |
| 225 | <p>Answer one focused question at a time while VoiceTask tracks what is covered.</p> |
| 226 | </div> |
| 227 | </li> |
| 228 | <li> |
| 229 | <span className="step-number">3</span> |
| 230 | <div> |
| 231 | <h3>Send the brief</h3> |
| 232 | <p>Download the requirements, plan, tasks, checks, and handoff notes in one zip.</p> |
| 233 | </div> |
| 234 | </li> |
| 235 | </ol> |
| 236 | </section> |
| 237 | |
| 238 | {browsingFolder && ( |
| 239 | <FolderBrowser |
| 240 | onSelect={(path) => { |
| 241 | setTargetDir(path) |
| 242 | setBrowsingFolder(false) |
| 243 | }} |
| 244 | onClose={() => setBrowsingFolder(false)} |
| 245 | /> |
| 246 | )} |
| 247 | |
| 248 | {sessions.length > 0 && ( |
| 249 | <section className="session-list" aria-labelledby="sessions-heading"> |
| 250 | <div className="session-list-heading"> |
| 251 | <div> |
| 252 | <p className="eyebrow">Pick up where you left off</p> |
| 253 | <h2 id="sessions-heading">Earlier interviews</h2> |
| 254 | </div> |
| 255 | <span>{sessions.length}</span> |
| 256 | </div> |
| 257 | <ul> |
| 258 | {sessions.map((session) => ( |
| 259 | <li key={session.id} className="session-row"> |
| 260 | <button type="button" className="session-item" onClick={() => onOpen(session.id)}> |
| 261 | <span className="session-name">{session.name}</span> |
| 262 | <span className="session-meta"> |
| 263 | <span className="session-date"> |
| 264 | {new Date(session.createdAt).toLocaleDateString()} |
| 265 | </span> |
| 266 | <span className={`session-status status-${session.status}`}> |
| 267 | {statusLabel(session.status)} |
| 268 | </span> |
| 269 | </span> |
| 270 | </button> |
| 271 | <button |
| 272 | type="button" |
| 273 | className="session-delete" |
| 274 | aria-label={`Delete ${session.name}`} |
| 275 | onClick={() => void handleDelete(session.id)} |
| 276 | > |
| 277 | Delete |
| 278 | </button> |
| 279 | </li> |
| 280 | ))} |
| 281 | </ul> |
| 282 | </section> |
| 283 | )} |
| 284 | </div> |
| 285 | ) |
| 286 | } |
| 287 | |
| 288 | function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () => void }) { |
| 289 | const [session, setSession] = useState<Session | null>(null) |
| 290 | const [answerText, setAnswerText] = useState('') |
| 291 | const [error, setError] = useState<string | null>(null) |
| 292 | const [submitting, setSubmitting] = useState(false) |
| 293 | const [recording, setRecording] = useState(false) |
| 294 | const [speaking, setSpeaking] = useState(false) |
| 295 | const [ttsEnabled, setTtsEnabled] = useState(true) |
| 296 | const lastSpokenQuestion = useRef<string | null>(null) |
| 297 | const levelRef = useRef(0) |
| 298 | |
| 299 | function refresh() { |
| 300 | return api.getSession(sessionId).then(setSession) |
| 301 | } |
| 302 | |
| 303 | useEffect(() => { |
| 304 | api.getSession(sessionId).then(setSession).catch((err: Error) => setError(err.message)) |
| 305 | }, [sessionId]) |
| 306 | |
| 307 | useEffect(() => { |
| 308 | if (!ttsEnabled) { |
| 309 | stopSpeaking() |
| 310 | setSpeaking(false) |
| 311 | return |
| 312 | } |
| 313 | if (!session) return |
| 314 | const question = latestQuestion(session) |
| 315 | if (question && question !== lastSpokenQuestion.current) { |
| 316 | lastSpokenQuestion.current = question |
| 317 | speak(question, { |
| 318 | onStart: () => setSpeaking(true), |
| 319 | onEnd: () => setSpeaking(false), |
| 320 | }) |
| 321 | } |
| 322 | }, [ttsEnabled, session]) |
| 323 | |
| 324 | useEffect(() => { |
| 325 | return () => stopSpeaking() |
| 326 | }, []) |
| 327 | |
| 328 | useEffect(() => { |
| 329 | function handleKeyDown(event: KeyboardEvent) { |
| 330 | if (event.key !== 'Escape') return |
| 331 | stopSpeaking() |
| 332 | setSpeaking(false) |
| 333 | } |
| 334 | window.addEventListener('keydown', handleKeyDown) |
| 335 | return () => window.removeEventListener('keydown', handleKeyDown) |
| 336 | }, []) |
| 337 | |
| 338 | const handleRecordingChange = useCallback((isRecording: boolean) => { |
| 339 | setRecording(isRecording) |
| 340 | if (isRecording) { |
| 341 | stopSpeaking() |
| 342 | setSpeaking(false) |
| 343 | } |
| 344 | }, []) |
| 345 | |
| 346 | const handleLevel = useCallback((level: number) => { |
| 347 | levelRef.current = level |
| 348 | }, []) |
| 349 | |
| 350 | async function submit(text: string) { |
| 351 | if (!text.trim()) return |
| 352 | setError(null) |
| 353 | setSubmitting(true) |
| 354 | try { |
| 355 | await api.submitAnswer(sessionId, { text }) |
| 356 | setAnswerText('') |
| 357 | await refresh() |
| 358 | } catch (err) { |
| 359 | setError(err instanceof Error ? err.message : String(err)) |
| 360 | } finally { |
| 361 | setSubmitting(false) |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | async function submitAudio(blob: Blob) { |
| 366 | setError(null) |
| 367 | setSubmitting(true) |
| 368 | try { |
| 369 | await api.uploadAudio(sessionId, blob) |
| 370 | await refresh() |
| 371 | } catch (err) { |
| 372 | setError(err instanceof Error ? err.message : String(err)) |
| 373 | } finally { |
| 374 | setSubmitting(false) |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | async function handleSubmit(event: React.FormEvent) { |
| 379 | event.preventDefault() |
| 380 | await submit(answerText) |
| 381 | } |
| 382 | |
| 383 | if (!session) { |
| 384 | return ( |
| 385 | <div className="screen interview-screen"> |
| 386 | <header className="topbar"> |
| 387 | <button type="button" className="back-link" onClick={onBack}> |
| 388 | All interviews |
| 389 | </button> |
| 390 | </header> |
| 391 | {error ? <p className="error">{error}</p> : <p className="muted">Loading…</p>} |
| 392 | </div> |
| 393 | ) |
| 394 | } |
| 395 | |
| 396 | const interviewing = session.status === 'interviewing' |
| 397 | const voiceState: VoiceState = recording |
| 398 | ? 'listening' |
| 399 | : submitting |
| 400 | ? 'thinking' |
| 401 | : speaking |
| 402 | ? 'speaking' |
| 403 | : 'idle' |
| 404 | const waveformMode: WaveformMode = |
| 405 | voiceState === 'listening' ? 'live' : voiceState === 'speaking' ? 'simulated' : 'idle' |
| 406 | |
| 407 | return ( |
| 408 | <div className="screen interview-screen"> |
| 409 | <header className="topbar"> |
| 410 | <button type="button" className="back-link" onClick={onBack}> |
| 411 | All interviews |
| 412 | </button> |
| 413 | <span className="topbar-title">{session.name}</span> |
| 414 | <div className="topbar-actions"> |
| 415 | <TranscriptExport sessionId={session.id} projectName={session.name} segments={session.segments} /> |
| 416 | <label className="tts-toggle"> |
| 417 | <input |
| 418 | type="checkbox" |
| 419 | checked={ttsEnabled} |
| 420 | onChange={(e) => setTtsEnabled(e.target.checked)} |
| 421 | /> |
| 422 | <span className="tts-track" aria-hidden="true" /> |
| 423 | read aloud |
| 424 | </label> |
| 425 | </div> |
| 426 | </header> |
| 427 | |
| 428 | <CoveragePanel coverage={session.coverage} /> |
| 429 | |
| 430 | <main className="stage"> |
| 431 | <VoiceOrb state={voiceState} levelRef={levelRef} /> |
| 432 | |
| 433 | {interviewing ? ( |
| 434 | <> |
| 435 | <QuestionCard question={latestQuestion(session)} /> |
| 436 | <Waveform mode={waveformMode} levelRef={levelRef} /> |
| 437 | <p className="status-caption" role="status" aria-live="polite"> |
| 438 | {CAPTIONS[voiceState]} |
| 439 | </p> |
| 440 | |
| 441 | <PushToTalkButton |
| 442 | disabled={submitting} |
| 443 | onRecorded={(blob) => void submitAudio(blob)} |
| 444 | onRecordingChange={handleRecordingChange} |
| 445 | onLevel={handleLevel} |
| 446 | /> |
| 447 | |
| 448 | <form className="answer-form" onSubmit={handleSubmit}> |
| 449 | <input |
| 450 | value={answerText} |
| 451 | onChange={(e) => setAnswerText(e.target.value)} |
| 452 | placeholder="Type your answer instead" |
| 453 | disabled={submitting} |
| 454 | /> |
| 455 | <button type="submit" className="btn-primary" disabled={submitting || !answerText.trim()}> |
| 456 | Send answer |
| 457 | </button> |
| 458 | </form> |
| 459 | <button type="button" className="done-button" disabled={submitting} onClick={() => submit('done')}> |
| 460 | I'm done, wrap it up |
| 461 | </button> |
| 462 | </> |
| 463 | ) : ( |
| 464 | <div className="finish-panels"> |
| 465 | <GeneratePanel |
| 466 | sessionId={session.id} |
| 467 | projectName={session.name} |
| 468 | coverage={session.coverage} |
| 469 | /> |
| 470 | <BlockerImport sessionId={session.id} onImported={refresh} /> |
| 471 | </div> |
| 472 | )} |
| 473 | |
| 474 | {error && ( |
| 475 | <p className="error" role="alert"> |
| 476 | {error} |
| 477 | </p> |
| 478 | )} |
| 479 | </main> |
| 480 | |
| 481 | <Transcript segments={session.segments} /> |
| 482 | </div> |
| 483 | ) |
| 484 | } |
| 485 | |
| 486 | function App() { |
| 487 | const [activeSessionId, setActiveSessionId] = useState<string | null>(null) |
| 488 | |
| 489 | if (activeSessionId) { |
| 490 | return <InterviewScreen sessionId={activeSessionId} onBack={() => setActiveSessionId(null)} /> |
| 491 | } |
| 492 | |
| 493 | return <SessionListScreen onOpen={setActiveSessionId} /> |
| 494 | } |
| 495 | |
| 496 | export default App |
| 497 | |