profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
GeneratePanel.tsx 7,314 bytes
1 import { useEffect, useState } from 'react'
2 import { CATEGORY_IDS, type Coverage, type GenerateResponse } from 'shared/types'
3 import * as api from '../api'
4 import { CATEGORY_LABELS } from '../labels'
5 import { createHandoffMessage, createRecommendationMessage } from '../share'
6
7 interface GeneratePanelProps {
8 sessionId: string
9 projectName: string
10 coverage: Coverage
11 }
12
13 type CopyAction = 'handoff' | 'recommendation'
14
15 const PACK_FILES = [
16 'SPEC.md',
17 'PLAN.md',
18 'TASKS.md',
19 'VERIFICATION.md',
20 'HANDOFF.md',
21 'sources.json',
22 ]
23
24 export function GeneratePanel({ sessionId, projectName, coverage }: GeneratePanelProps) {
25 const [confirming, setConfirming] = useState(false)
26 const [generating, setGenerating] = useState(false)
27 const [packReady, setPackReady] = useState<boolean | null>(null)
28 const [result, setResult] = useState<GenerateResponse | null>(null)
29 const [error, setError] = useState<string | null>(null)
30 const [needsOverwrite, setNeedsOverwrite] = useState(false)
31 const [copiedAction, setCopiedAction] = useState<CopyAction | null>(null)
32 const [copyFallback, setCopyFallback] = useState<string | null>(null)
33
34 const missingCategories = CATEGORY_IDS.filter((category) => coverage[category] === 'missing')
35
36 useEffect(() => {
37 let cancelled = false
38 api
39 .hasSpecPack(sessionId)
40 .then((ready) => {
41 if (!cancelled) setPackReady(ready)
42 })
43 .catch((err: Error) => {
44 if (cancelled) return
45 setPackReady(false)
46 setError(err.message)
47 })
48 return () => {
49 cancelled = true
50 }
51 }, [sessionId])
52
53 async function runGenerate(overwrite: boolean) {
54 setGenerating(true)
55 setError(null)
56 setConfirming(false)
57 try {
58 const response = await api.generateSpecPack(sessionId, { overwrite })
59 setResult(response)
60 setPackReady(true)
61 setNeedsOverwrite(false)
62 } catch (err) {
63 const message = err instanceof Error ? err.message : String(err)
64 setError(message)
65 setNeedsOverwrite(message.includes('already exist'))
66 } finally {
67 setGenerating(false)
68 }
69 }
70
71 function handleGenerateClick() {
72 if (missingCategories.length > 0) {
73 setConfirming(true)
74 return
75 }
76 void runGenerate(false)
77 }
78
79 async function copyText(action: CopyAction, text: string) {
80 setCopyFallback(null)
81 try {
82 await navigator.clipboard.writeText(text)
83 setCopiedAction(action)
84 window.setTimeout(() => setCopiedAction(null), 2000)
85 } catch {
86 setCopiedAction(null)
87 setCopyFallback(text)
88 }
89 }
90
91 if (packReady === null) {
92 return (
93 <div className="generate-panel pack-checking" role="status">
94 <p className="panel-kicker">Interview complete</p>
95 <h2>Checking for your build brief...</h2>
96 </div>
97 )
98 }
99
100 if (packReady) {
101 const files = result?.files.length
102 ? result.files.map((file) => file.replace(/^spec[\\/]/, ''))
103 : PACK_FILES
104 return (
105 <div className="generate-panel pack-ready">
106 <p className="panel-kicker">Ready to send</p>
107 <h2>Your build brief is ready</h2>
108 <p className="panel-lead">
109 Download one zip and send it to a developer, agency, or coding assistant. HANDOFF.md
110 explains where they should start.
111 </p>
112
113 <div className="ready-actions">
114 <a
115 className="primary-download"
116 href={`/api/sessions/${sessionId}/export/spec-pack.zip`}
117 download
118 >
119 Download build brief (.zip)
120 </a>
121 <button
122 type="button"
123 className="secondary-action"
124 onClick={() => void copyText('handoff', createHandoffMessage(projectName))}
125 >
126 {copiedAction === 'handoff' ? 'Handoff message copied' : 'Copy handoff message'}
127 </button>
128 </div>
129
130 {copyFallback && (
131 <div className="copy-fallback" role="alert">
132 <p>Clipboard access was blocked. Select and copy this text:</p>
133 <textarea
134 readOnly
135 value={copyFallback}
136 aria-label="Text to copy"
137 onFocus={(event) => event.currentTarget.select()}
138 />
139 </div>
140 )}
141
142 <details className="pack-details">
143 <summary>See what is included</summary>
144 <ul>
145 {files.map((file) => (
146 <li key={file}>{file}</li>
147 ))}
148 </ul>
149 </details>
150
151 {result && result.warnings.length > 0 && (
152 <div className="generate-warnings">
153 <p>Notes about this brief:</p>
154 <ul>
155 {result.warnings.map((warning) => (
156 <li key={warning}>{warning}</li>
157 ))}
158 </ul>
159 </div>
160 )}
161
162 <button
163 type="button"
164 className="regenerate-button"
165 onClick={() => void runGenerate(true)}
166 disabled={generating}
167 >
168 {generating ? 'Updating build brief...' : 'Update build brief from this interview'}
169 </button>
170
171 <div className="recommendation">
172 <div>
173 <h3>Know someone with an idea?</h3>
174 <p>Copy a short note about VoiceTask. This stays separate from your project files.</p>
175 </div>
176 <button
177 type="button"
178 onClick={() => void copyText('recommendation', createRecommendationMessage())}
179 >
180 {copiedAction === 'recommendation' ? 'Recommendation copied' : 'Copy recommendation'}
181 </button>
182 </div>
183
184 {error && (
185 <p className="error" role="alert">
186 {error}
187 </p>
188 )}
189 </div>
190 )
191 }
192
193 return (
194 <div className="generate-panel">
195 <p className="panel-kicker">Interview complete</p>
196 <h2>Create your build brief</h2>
197 <p className="panel-lead">
198 Turn this interview into the requirements, plan, tasks, checks, and handoff notes someone
199 needs to start building.
200 </p>
201
202 {confirming ? (
203 <div className="generate-confirm">
204 <p>
205 Some topics are still open: {missingCategories.map((c) => CATEGORY_LABELS[c].label).join(', ')}.
206 You can still create the brief now.
207 </p>
208 <button type="button" onClick={() => void runGenerate(false)} disabled={generating}>
209 Create it anyway
210 </button>
211 <button type="button" onClick={() => setConfirming(false)} disabled={generating}>
212 Go back
213 </button>
214 </div>
215 ) : (
216 <button
217 type="button"
218 className="create-pack-button"
219 onClick={handleGenerateClick}
220 disabled={generating}
221 >
222 {generating ? 'Creating build brief...' : 'Create build brief'}
223 </button>
224 )}
225
226 {needsOverwrite && (
227 <div className="generate-overwrite">
228 <p>Build brief files already exist in that folder.</p>
229 <button type="button" onClick={() => void runGenerate(true)} disabled={generating}>
230 Replace existing files
231 </button>
232 </div>
233 )}
234
235 {error && (
236 <p className="error" role="alert">
237 {error}
238 </p>
239 )}
240 </div>
241 )
242 }
243