profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
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&hellip;</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