profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

T10: generate route and UI

POST /api/sessions/:id/generate wraps generateSpecPack, mapping
GenerateFilesExistError to 409 and other failures (e.g. missing target
directory) to 400. GeneratePanel shows a missing-category confirmation
dialog per FR-008 before generating, an overwrite prompt when a pack
already exists, and the resulting file list plus provenance warnings.

Manually verified in a headless browser: ending an interview
immediately (all categories missing) shows the confirmation dialog
listing them, generating writes the six files, and regenerating without
confirming overwrite first surfaces the overwrite prompt and then
creates a spec/backup-<timestamp>/ dir.
commit 533e25d

8 changed files with +298 and −2

Jump to a changed file
  1. client/src/App.css +50 −0
  2. client/src/App.tsx +5 −1
  3. client/src/api.ts +9 −0
  4. client/src/components/GeneratePanel.tsx +112 −0
  5. server/app.ts +2 −0
  6. server/routes/generate.test.ts +85 −0
  7. server/routes/generate.ts +34 −0
  8. spec/TASKS.md +1 −1
modified client/src/App.css +50 −0
@@ -263,6 +263,56 @@
263 263 cursor: default;
264 264 }
265 265
266 +.generate-panel {
267 + margin-top: 20px;
268 + padding: 16px 20px;
269 + border: 1px solid var(--border);
270 + border-radius: 10px;
271 +}
272 +
273 +.generate-panel h2 {
274 + font-size: 16px;
275 + margin-bottom: 12px;
276 +}
277 +
278 +.generate-panel button {
279 + padding: 8px 14px;
280 + border-radius: 6px;
281 + border: none;
282 + background: var(--accent);
283 + color: white;
284 + cursor: pointer;
285 + margin-right: 8px;
286 +}
287 +
288 +.generate-panel button:disabled {
289 + opacity: 0.6;
290 + cursor: default;
291 +}
292 +
293 +.generate-confirm,
294 +.generate-overwrite {
295 + margin-bottom: 12px;
296 +}
297 +
298 +.generate-confirm button:last-child,
299 +.generate-overwrite button {
300 + background: var(--bg-alt);
301 + color: var(--text-h);
302 + border: 1px solid var(--border);
303 +}
304 +
305 +.generate-result ul {
306 + margin: 8px 0 0;
307 + padding-left: 20px;
308 +}
309 +
310 +.generate-warnings {
311 + margin-top: 8px;
312 + color: var(--text);
313 + opacity: 0.85;
314 +}
315 +
266 316 .interview-done {
267 317 padding: 12px 16px;
268 318 border-radius: 8px;
modified client/src/App.tsx +5 −1
@@ -3,6 +3,7 @@import type { Session, SessionSummary } from 'shared/types'
3 3 import * as api from './api'
4 4 import './App.css'
5 5 import { CoveragePanel } from './components/CoveragePanel'
6 +import { GeneratePanel } from './components/GeneratePanel'
6 7 import { PushToTalkButton } from './components/PushToTalkButton'
7 8 import { QuestionCard } from './components/QuestionCard'
8 9 import { Transcript } from './components/Transcript'
@@ -202,7 +203,10 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
202 203 </form>
203 204 </>
204 205 ) : (
205 - <p className="interview-done">Interview complete.</p>
206 + <>
207 + <p className="interview-done">Interview complete.</p>
208 + <GeneratePanel sessionId={session.id} coverage={session.coverage} />
209 + </>
206 210 )}
207 211
208 212 {error && <p className="error">{error}</p>}
modified client/src/api.ts +9 −0
@@ -2,6 +2,8 @@import type {
2 2 AnswerRequest,
3 3 AnswerResponse,
4 4 CreateSessionRequest,
5 + GenerateRequest,
6 + GenerateResponse,
5 7 Session,
6 8 SessionSummary,
7 9 } from 'shared/types'
@@ -49,3 +51,10 @@export async function uploadAudio(id: string, audio: Blob): Promise<AnswerRespon
49 51 const res = await fetch(`${BASE}/sessions/${id}/audio`, { method: 'POST', body: form })
50 52 return readJsonOrThrow<AnswerResponse>(res)
51 53 }
54 +
55 +export function generateSpecPack(id: string, req: GenerateRequest = {}): Promise<GenerateResponse> {
56 + return requestJson<GenerateResponse>(`${BASE}/sessions/${id}/generate`, {
57 + method: 'POST',
58 + body: JSON.stringify(req),
59 + })
60 +}
added client/src/components/GeneratePanel.tsx +112 −0
@@ -0,0 +1,112 @@
1 +import { useState } from 'react'
2 +import { CATEGORY_IDS, type CategoryId, type Coverage, type GenerateResponse } from 'shared/types'
3 +import * as api from '../api'
4 +
5 +interface GeneratePanelProps {
6 + sessionId: string
7 + coverage: Coverage
8 +}
9 +
10 +const LABELS: Record<CategoryId, string> = {
11 + goal: 'Goal',
12 + users: 'Users',
13 + 'core-flow': 'Core flow',
14 + data: 'Data',
15 + integrations: 'Integrations',
16 + 'edge-cases': 'Edge cases',
17 + constraints: 'Constraints',
18 + 'non-goals': 'Non-goals',
19 + verification: 'Verification',
20 +}
21 +
22 +export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) {
23 + const [confirming, setConfirming] = useState(false)
24 + const [generating, setGenerating] = useState(false)
25 + const [result, setResult] = useState<GenerateResponse | null>(null)
26 + const [error, setError] = useState<string | null>(null)
27 + const [needsOverwrite, setNeedsOverwrite] = useState(false)
28 +
29 + const missingCategories = CATEGORY_IDS.filter((category) => coverage[category] === 'missing')
30 +
31 + async function runGenerate(overwrite: boolean) {
32 + setGenerating(true)
33 + setError(null)
34 + setConfirming(false)
35 + try {
36 + const response = await api.generateSpecPack(sessionId, { overwrite })
37 + setResult(response)
38 + setNeedsOverwrite(false)
39 + } catch (err) {
40 + const message = err instanceof Error ? err.message : String(err)
41 + setError(message)
42 + setNeedsOverwrite(message.includes('already exist'))
43 + } finally {
44 + setGenerating(false)
45 + }
46 + }
47 +
48 + function handleGenerateClick() {
49 + if (missingCategories.length > 0) {
50 + setConfirming(true)
51 + return
52 + }
53 + void runGenerate(false)
54 + }
55 +
56 + return (
57 + <div className="generate-panel">
58 + <h2>Generate spec pack</h2>
59 +
60 + {confirming ? (
61 + <div className="generate-confirm">
62 + <p>
63 + These categories are still missing: {missingCategories.map((c) => LABELS[c]).join(', ')}. Generate
64 + anyway?
65 + </p>
66 + <button type="button" onClick={() => void runGenerate(false)} disabled={generating}>
67 + Generate anyway
68 + </button>
69 + <button type="button" onClick={() => setConfirming(false)} disabled={generating}>
70 + Cancel
71 + </button>
72 + </div>
73 + ) : (
74 + <button type="button" onClick={handleGenerateClick} disabled={generating}>
75 + {generating ? 'Generating…' : 'Generate spec pack'}
76 + </button>
77 + )}
78 +
79 + {needsOverwrite && (
80 + <div className="generate-overwrite">
81 + <p>A spec pack already exists in the target directory.</p>
82 + <button type="button" onClick={() => void runGenerate(true)} disabled={generating}>
83 + Overwrite and regenerate
84 + </button>
85 + </div>
86 + )}
87 +
88 + {error && <p className="error">{error}</p>}
89 +
90 + {result && (
91 + <div className="generate-result">
92 + <p>Wrote {result.files.length} files:</p>
93 + <ul>
94 + {result.files.map((file) => (
95 + <li key={file}>{file}</li>
96 + ))}
97 + </ul>
98 + {result.warnings.length > 0 && (
99 + <div className="generate-warnings">
100 + <p>Warnings:</p>
101 + <ul>
102 + {result.warnings.map((warning) => (
103 + <li key={warning}>{warning}</li>
104 + ))}
105 + </ul>
106 + </div>
107 + )}
108 + </div>
109 + )}
110 + </div>
111 + )
112 +}
modified server/app.ts +2 −0
@@ -4,6 +4,7 @@import Fastify, { type FastifyInstance } from 'fastify'
4 4 import { createInterviewLlm, createSttProvider } from './providers/factory'
5 5 import type { InterviewLlm, SttProvider } from './providers/types'
6 6 import { registerAudioRoutes } from './routes/audio'
7 +import { registerGenerateRoutes } from './routes/generate'
7 8 import { registerSessionRoutes } from './routes/sessions'
8 9 import { createDefaultSessionStore, SessionStore } from './store/sessionStore'
9 10
@@ -27,6 +28,7 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
27 28 app.get('/api/health', async () => ({ ok: true }))
28 29 registerSessionRoutes(app, resolved)
29 30 registerAudioRoutes(app, resolved)
31 + registerGenerateRoutes(app, resolved)
30 32
31 33 return app
32 34 }
added server/routes/generate.test.ts +85 −0
@@ -0,0 +1,85 @@
1 +import { mkdtemp, readdir, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import type { GenerateResponse, Session } from '../../shared/types'
7 +import { buildApp } from '../app'
8 +import { createLlmMock } from '../providers/llmMock'
9 +import { SessionStore } from '../store/sessionStore'
10 +
11 +describe('generate route', () => {
12 + let storeDir: string
13 + let targetDir: string
14 + let app: FastifyInstance
15 +
16 + beforeEach(async () => {
17 + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
18 + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-'))
19 + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() })
20 + })
21 +
22 + afterEach(async () => {
23 + await app.close()
24 + await rm(storeDir, { recursive: true, force: true })
25 + await rm(targetDir, { recursive: true, force: true })
26 + })
27 +
28 + async function createSessionWithAnAnswer(): Promise<Session> {
29 + const created = await app
30 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir } })
31 + .then((r) => r.json<Session>())
32 + await app.inject({
33 + method: 'POST',
34 + url: `/api/sessions/${created.id}/answer`,
35 + payload: { text: 'we are building a todo app' },
36 + })
37 + return app.inject({ method: 'GET', url: `/api/sessions/${created.id}` }).then((r) => r.json<Session>())
38 + }
39 +
40 + it('generates the six spec pack files', async () => {
41 + const session = await createSessionWithAnAnswer()
42 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
43 + expect(res.statusCode).toBe(200)
44 + const body = res.json<GenerateResponse>()
45 + expect(body.files).toHaveLength(6)
46 + expect(body.files).toContain('spec/SPEC.md')
47 + })
48 +
49 + it('returns 409 on a second generate without overwrite', async () => {
50 + const session = await createSessionWithAnAnswer()
51 + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
52 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
53 + expect(res.statusCode).toBe(409)
54 + })
55 +
56 + it('regenerates and creates a backup dir when overwrite is set', async () => {
57 + const session = await createSessionWithAnAnswer()
58 + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
59 + const res = await app.inject({
60 + method: 'POST',
61 + url: `/api/sessions/${session.id}/generate`,
62 + payload: { overwrite: true },
63 + })
64 + expect(res.statusCode).toBe(200)
65 + const entries = await readdir(path.join(targetDir, 'spec'))
66 + expect(entries.some((e) => e.startsWith('backup-'))).toBe(true)
67 + })
68 +
69 + it('returns 404 for an unknown session', async () => {
70 + const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/generate', payload: {} })
71 + expect(res.statusCode).toBe(404)
72 + })
73 +
74 + it('returns 400 when the target directory does not exist', async () => {
75 + const created = await app
76 + .inject({
77 + method: 'POST',
78 + url: '/api/sessions',
79 + payload: { name: 'p', targetDir: path.join(targetDir, 'does-not-exist') },
80 + })
81 + .then((r) => r.json<Session>())
82 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${created.id}/generate`, payload: {} })
83 + expect(res.statusCode).toBe(400)
84 + })
85 +})
added server/routes/generate.ts +34 −0
@@ -0,0 +1,34 @@
1 +import type { FastifyInstance } from 'fastify'
2 +import { GenerateRequestSchema } from '../../shared/types'
3 +import { GenerateFilesExistError, generateSpecPack } from '../generator/generate'
4 +import type { InterviewLlm } from '../providers/types'
5 +import type { SessionStore } from '../store/sessionStore'
6 +
7 +export interface GenerateRouteDeps {
8 + store: SessionStore
9 + llm: InterviewLlm
10 +}
11 +
12 +export function registerGenerateRoutes(app: FastifyInstance, deps: GenerateRouteDeps): void {
13 + const { store, llm } = deps
14 +
15 + app.post<{ Params: { id: string } }>('/api/sessions/:id/generate', async (request, reply) => {
16 + const parsed = GenerateRequestSchema.safeParse(request.body ?? {})
17 + if (!parsed.success) {
18 + return reply.code(400).send({ error: parsed.error.message })
19 + }
20 +
21 + const session = await store.getSession(request.params.id)
22 + if (!session) return reply.code(404).send({ error: 'session not found' })
23 +
24 + try {
25 + const result = await generateSpecPack(llm, session, { overwrite: parsed.data.overwrite })
26 + return reply.send(result)
27 + } catch (err) {
28 + if (err instanceof GenerateFilesExistError) {
29 + return reply.code(409).send({ error: err.message })
30 + }
31 + return reply.code(400).send({ error: err instanceof Error ? err.message : 'generation failed' })
32 + }
33 + })
34 +}
modified spec/TASKS.md +1 −1
@@ -47,7 +47,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
47 47 - Depends: T5
48 48 - Verify: `npm test` (with llmMock into a temp dir: all six files written; bogus `[S999]` marker removed and `[unverified]` appended; second run without overwrite fails; with overwrite creates backup dir).
49 49
50 -- [ ] T10 Generate route and UI
50 +- [x] T10 Generate route and UI
51 51 - `POST /api/sessions/:id/generate`, GeneratePanel with missing-category confirmation dialog per FR-008, result/warnings display.
52 52 - Depends: T7, T9
53 53 - Verify: `npm test` (route test) and `npm run typecheck`.