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 | |