profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Clarify build brief sharing

commit 34967ef

12 changed files with +634 and −123

Jump to a changed file
  1. client/src/App.css +298 −45
  2. client/src/App.tsx +8 −7
  3. client/src/api.test.ts +30 −0
  4. client/src/api.ts +7 −0
  5. client/src/components/GeneratePanel.tsx +173 −40
  6. client/src/components/TranscriptExport.tsx +23 −9
  7. client/src/share.test.ts +22 −0
  8. client/src/share.ts +17 −0
  9. server/routes/export.test.ts +13 −0
  10. server/routes/export.ts +41 −20
  11. spec/TASKS.md +1 −1
  12. vitest.config.ts +1 −1
modified client/src/App.css +298 −45
@@ -801,11 +801,18 @@
801 801
802 802 .transcript-export {
803 803 display: flex;
804 + flex-direction: column;
804 805 align-items: center;
805 806 gap: 8px;
806 807 }
807 808
808 -.transcript-export button {
809 +.transcript-export-actions {
810 + display: flex;
811 + align-items: center;
812 + gap: 8px;
813 +}
814 +
815 +.transcript-export-actions button {
809 816 padding: 6px 12px;
810 817 border-radius: 999px;
811 818 border: 1.5px solid var(--line);
@@ -816,11 +823,19 @@
816 823 transition: background 0.25s ease, border-color 0.25s ease;
817 824 }
818 825
819 -.transcript-export button:hover {
826 +.transcript-export-actions button:hover {
820 827 background: var(--paper-deep);
821 828 border-color: var(--clay);
822 829 }
823 830
831 +.copy-error {
832 + max-width: 240px;
833 + color: var(--danger);
834 + font-size: 11px;
835 + line-height: 1.35;
836 + text-align: right;
837 +}
838 +
824 839 .download-link {
825 840 padding: 6px 12px;
826 841 border-radius: 999px;
@@ -1349,98 +1364,321 @@
1349 1364 cursor: default;
1350 1365 }
1351 1366
1352 -/* ---------- Finish panels ---------- */
1353 -
1354 -.interview-done {
1355 - font-family: var(--serif);
1356 - font-size: 24px;
1357 - margin: 24px 0 6px;
1358 -}
1359 -
1360 1367 .finish-panels {
1361 1368 display: flex;
1362 1369 flex-direction: column;
1363 1370 gap: 16px;
1364 1371 width: 100%;
1365 - max-width: 460px;
1366 - margin-top: 18px;
1372 + max-width: 640px;
1373 + margin-top: 24px;
1367 1374 text-align: left;
1368 1375 }
1369 1376
1370 1377 .generate-panel,
1371 1378 .blocker-import {
1372 - padding: 20px 22px;
1379 + padding: 26px;
1373 1380 border: 1px solid var(--line);
1374 - border-radius: 16px;
1381 + border-radius: 20px;
1375 1382 background: var(--card);
1376 - box-shadow: 0 12px 30px -24px rgba(23, 25, 45, 0.5);
1383 + box-shadow: 0 18px 40px -34px rgba(23, 25, 45, 0.48);
1377 1384 }
1378 1385
1379 -.generate-panel h2,
1380 -.blocker-import h2 {
1381 - font-size: 17px;
1382 - margin-bottom: 10px;
1386 +.generate-panel.pack-ready {
1387 + border-color: color-mix(in srgb, var(--moss) 36%, var(--line));
1388 + box-shadow: 0 20px 46px -34px color-mix(in srgb, var(--moss) 52%, transparent);
1383 1389 }
1384 1390
1385 -.generate-panel p,
1386 -.blocker-import p {
1387 - font-size: 14px;
1391 +.pack-checking {
1392 + min-height: 112px;
1393 +}
1394 +
1395 +.panel-kicker {
1396 + margin-bottom: 6px !important;
1397 + color: var(--moss) !important;
1398 + font-size: 10px !important;
1399 + font-weight: 700;
1400 + letter-spacing: 0.12em;
1401 + text-transform: uppercase;
1402 +}
1403 +
1404 +.generate-panel h2 {
1405 + margin: 0;
1406 + font-size: 27px;
1407 + letter-spacing: -0.035em;
1408 +}
1409 +
1410 +.generate-panel .panel-lead {
1411 + max-width: 550px;
1412 + margin: 10px 0 20px;
1388 1413 color: var(--ink-soft);
1389 - margin-bottom: 10px;
1414 + font-size: 15px;
1415 + line-height: 1.55;
1390 1416 }
1391 1417
1392 -.generate-panel button,
1393 -.blocker-import button {
1394 - padding: 9px 18px;
1395 - border-radius: 999px;
1418 +.create-pack-button,
1419 +.generate-confirm button:first-of-type,
1420 +.generate-overwrite button {
1421 + min-height: 44px;
1422 + padding: 10px 18px;
1396 1423 border: none;
1424 + border-radius: 12px;
1397 1425 background: var(--clay);
1398 1426 color: var(--card);
1399 1427 font-size: 14px;
1428 + font-weight: 700;
1400 1429 cursor: pointer;
1401 - margin-right: 8px;
1402 - transition: background 0.25s ease;
1430 + transition: background 0.2s ease, transform 0.2s ease;
1403 1431 }
1404 1432
1405 -.generate-panel button:hover:not(:disabled),
1406 -.blocker-import button:hover:not(:disabled) {
1433 +.create-pack-button:hover:not(:disabled),
1434 +.generate-confirm button:first-of-type:hover:not(:disabled),
1435 +.generate-overwrite button:hover:not(:disabled) {
1407 1436 background: var(--clay-deep);
1437 + transform: translateY(-1px);
1408 1438 }
1409 1439
1410 -.generate-panel button:disabled,
1411 -.blocker-import button:disabled {
1440 +.generate-panel button:disabled {
1412 1441 opacity: 0.55;
1413 1442 cursor: default;
1414 1443 }
1415 1444
1416 -.generate-confirm button:last-child,
1417 -.generate-overwrite button,
1418 -.blocker-import button {
1419 - background: transparent;
1420 - color: var(--ink);
1445 +.generate-confirm,
1446 +.generate-overwrite {
1447 + padding: 14px;
1448 + border-radius: 13px;
1449 + background: var(--paper);
1450 +}
1451 +
1452 +.generate-confirm p,
1453 +.generate-overwrite p {
1454 + margin: 0 0 12px;
1455 + color: var(--ink-soft);
1456 + font-size: 14px;
1457 +}
1458 +
1459 +.generate-confirm button {
1460 + margin-right: 8px;
1461 +}
1462 +
1463 +.generate-confirm button:last-child {
1464 + min-height: 44px;
1465 + padding: 9px 16px;
1421 1466 border: 1.5px solid var(--line);
1467 + border-radius: 12px;
1468 + background: var(--card);
1469 + color: var(--ink);
1470 + cursor: pointer;
1422 1471 }
1423 1472
1424 -.generate-confirm button:last-child:hover:not(:disabled),
1425 -.generate-overwrite button:hover:not(:disabled),
1426 -.blocker-import button:hover:not(:disabled) {
1473 +.generate-confirm button:last-child:hover:not(:disabled) {
1427 1474 background: var(--paper-deep);
1428 1475 border-color: var(--clay);
1429 1476 }
1430 1477
1431 -.generate-result ul {
1432 - margin: 8px 0 0;
1433 - padding-left: 20px;
1478 +.ready-actions {
1479 + display: flex;
1480 + align-items: stretch;
1481 + gap: 10px;
1482 +}
1483 +
1484 +.primary-download,
1485 +.secondary-action {
1486 + display: inline-flex;
1487 + align-items: center;
1488 + justify-content: center;
1489 + min-height: 48px;
1490 + padding: 11px 18px;
1491 + border-radius: 12px;
1434 1492 font-size: 14px;
1493 + font-weight: 700;
1494 + text-decoration: none;
1495 +}
1496 +
1497 +.primary-download {
1498 + flex: 1;
1499 + border: 1.5px solid var(--clay);
1500 + background: var(--clay);
1501 + color: var(--card);
1502 + box-shadow: 0 12px 26px -16px color-mix(in srgb, var(--clay) 76%, transparent);
1503 + transition: background 0.2s ease, transform 0.2s ease;
1504 +}
1505 +
1506 +.primary-download:hover {
1507 + background: var(--clay-deep);
1508 + transform: translateY(-1px);
1509 +}
1510 +
1511 +.secondary-action {
1512 + border: 1.5px solid var(--line);
1513 + background: var(--card);
1514 + color: var(--ink);
1515 + cursor: pointer;
1516 + transition: border-color 0.2s ease, background 0.2s ease;
1517 +}
1518 +
1519 +.secondary-action:hover {
1520 + border-color: var(--clay);
1521 + background: var(--clay-soft);
1522 +}
1523 +
1524 +.copy-fallback {
1525 + margin-top: 14px;
1526 + padding: 13px;
1527 + border: 1px solid color-mix(in srgb, var(--amber) 42%, var(--line));
1528 + border-radius: 12px;
1529 + background: color-mix(in srgb, var(--amber) 6%, var(--card));
1530 +}
1531 +
1532 +.copy-fallback p {
1533 + margin: 0 0 8px;
1534 + color: var(--ink);
1535 + font-size: 13px;
1536 +}
1537 +
1538 +.copy-fallback textarea {
1539 + width: 100%;
1540 + min-height: 96px;
1541 + padding: 10px;
1542 + resize: vertical;
1543 + border: 1px solid var(--line);
1544 + border-radius: 9px;
1545 + background: var(--card);
1546 + color: var(--ink);
1547 + font: 13px/1.45 var(--sans);
1548 +}
1549 +
1550 +.pack-details {
1551 + margin-top: 18px;
1552 + padding-top: 15px;
1553 + border-top: 1px solid var(--line);
1554 + color: var(--ink-soft);
1555 + font-size: 13px;
1556 +}
1557 +
1558 +.pack-details summary {
1559 + width: fit-content;
1435 1560 color: var(--ink);
1561 + font-weight: 650;
1562 + cursor: pointer;
1563 +}
1564 +
1565 +.pack-details ul {
1566 + display: grid;
1567 + grid-template-columns: repeat(2, minmax(0, 1fr));
1568 + gap: 5px 18px;
1569 + margin: 12px 0 0;
1570 + padding: 0;
1571 + list-style: none;
1572 + font-family: var(--mono);
1573 + font-size: 12px;
1436 1574 }
1437 1575
1438 1576 .generate-warnings {
1439 - margin-top: 8px;
1577 + margin-top: 14px;
1578 + padding: 12px;
1579 + border-radius: 10px;
1580 + background: color-mix(in srgb, var(--amber) 6%, var(--card));
1440 1581 font-size: 14px;
1441 1582 color: var(--ink-soft);
1442 1583 }
1443 1584
1585 +.generate-warnings p,
1586 +.generate-warnings ul {
1587 + margin: 0;
1588 +}
1589 +
1590 +.generate-warnings ul {
1591 + margin-top: 6px;
1592 + padding-left: 18px;
1593 +}
1594 +
1595 +.regenerate-button {
1596 + margin-top: 15px;
1597 + padding: 0;
1598 + border: none;
1599 + background: transparent;
1600 + color: var(--ink-soft);
1601 + font-size: 12px;
1602 + text-decoration: underline;
1603 + text-decoration-color: var(--line);
1604 + text-underline-offset: 3px;
1605 + cursor: pointer;
1606 +}
1607 +
1608 +.regenerate-button:hover:not(:disabled) {
1609 + color: var(--clay-deep);
1610 + text-decoration-color: var(--clay);
1611 +}
1612 +
1613 +.recommendation {
1614 + display: flex;
1615 + align-items: center;
1616 + justify-content: space-between;
1617 + gap: 20px;
1618 + margin-top: 22px;
1619 + padding: 16px;
1620 + border-radius: 14px;
1621 + background: var(--paper);
1622 +}
1623 +
1624 +.recommendation h3 {
1625 + margin: 0 0 3px;
1626 + font-family: var(--serif);
1627 + font-size: 14px;
1628 +}
1629 +
1630 +.recommendation p {
1631 + margin: 0;
1632 + color: var(--ink-soft);
1633 + font-size: 12px;
1634 +}
1635 +
1636 +.recommendation button {
1637 + flex-shrink: 0;
1638 + min-height: 38px;
1639 + padding: 8px 13px;
1640 + border: 1px solid var(--line);
1641 + border-radius: 10px;
1642 + background: var(--card);
1643 + color: var(--ink);
1644 + font-size: 12px;
1645 + font-weight: 650;
1646 + cursor: pointer;
1647 +}
1648 +
1649 +.recommendation button:hover:not(:disabled) {
1650 + border-color: var(--clay);
1651 + background: var(--clay-soft);
1652 +}
1653 +
1654 +.blocker-import h2 {
1655 + margin: 0 0 8px;
1656 + font-size: 17px;
1657 +}
1658 +
1659 +.blocker-import p {
1660 + margin: 0 0 12px;
1661 + color: var(--ink-soft);
1662 + font-size: 13px;
1663 +}
1664 +
1665 +.blocker-import button {
1666 + min-height: 40px;
1667 + padding: 8px 14px;
1668 + border: 1.5px solid var(--line);
1669 + border-radius: 11px;
1670 + background: var(--card);
1671 + color: var(--ink);
1672 + font-size: 13px;
1673 + font-weight: 650;
1674 + cursor: pointer;
1675 +}
1676 +
1677 +.blocker-import button:hover:not(:disabled) {
1678 + border-color: var(--clay);
1679 + background: var(--clay-soft);
1680 +}
1681 +
1444 1682 .generate-panel .error,
1445 1683 .blocker-import .error {
1446 1684 text-align: left;
@@ -1712,6 +1950,21 @@
1712 1950 .answer-form .btn-primary {
1713 1951 width: 100%;
1714 1952 }
1953 +
1954 + .ready-actions,
1955 + .recommendation {
1956 + flex-direction: column;
1957 + align-items: stretch;
1958 + }
1959 +
1960 + .primary-download,
1961 + .secondary-action {
1962 + width: 100%;
1963 + }
1964 +
1965 + .pack-details ul {
1966 + grid-template-columns: 1fr;
1967 + }
1715 1968 }
1716 1969
1717 1970 @media (prefers-reduced-motion: reduce) {
modified client/src/App.tsx +8 −7
@@ -429,13 +429,14 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
429 429 </button>
430 430 </>
431 431 ) : (
432 - <>
433 - <p className="interview-done">Interview complete.</p>
434 - <div className="finish-panels">
435 - <GeneratePanel sessionId={session.id} coverage={session.coverage} />
436 - <BlockerImport sessionId={session.id} onImported={refresh} />
437 - </div>
438 - </>
432 + <div className="finish-panels">
433 + <GeneratePanel
434 + sessionId={session.id}
435 + projectName={session.name}
436 + coverage={session.coverage}
437 + />
438 + <BlockerImport sessionId={session.id} onImported={refresh} />
439 + </div>
439 440 )}
440 441
441 442 {error && (
added client/src/api.test.ts +30 −0
@@ -0,0 +1,30 @@
1 +import { afterEach, describe, expect, it, vi } from 'vitest'
2 +import { hasSpecPack } from './api'
3 +
4 +describe('hasSpecPack', () => {
5 + afterEach(() => {
6 + vi.unstubAllGlobals()
7 + })
8 +
9 + it('uses HEAD and returns true for an existing pack', async () => {
10 + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }))
11 + vi.stubGlobal('fetch', fetchMock)
12 +
13 + await expect(hasSpecPack('session-1')).resolves.toBe(true)
14 + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/export/spec-pack.zip', {
15 + method: 'HEAD',
16 + })
17 + })
18 +
19 + it('returns false when no pack exists', async () => {
20 + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })))
21 +
22 + await expect(hasSpecPack('session-1')).resolves.toBe(false)
23 + })
24 +
25 + it('surfaces unexpected readiness failures', async () => {
26 + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 })))
27 +
28 + await expect(hasSpecPack('session-1')).rejects.toThrow('status 503')
29 + })
30 +})
modified client/src/api.ts +7 −0
@@ -70,6 +70,13 @@export function generateSpecPack(id: string, req: GenerateRequest = {}): Promise
70 70 })
71 71 }
72 72
73 +export async function hasSpecPack(id: string): Promise<boolean> {
74 + const res = await fetch(`${BASE}/sessions/${id}/export/spec-pack.zip`, { method: 'HEAD' })
75 + if (res.ok) return true
76 + if (res.status === 404) return false
77 + throw new Error(`Could not check the build brief, status ${res.status}`)
78 +}
79 +
73 80 export function importBlockers(id: string): Promise<BlockersResponse> {
74 81 return requestJson<BlockersResponse>(`${BASE}/sessions/${id}/blockers`, {
75 82 method: 'POST',
modified client/src/components/GeneratePanel.tsx +173 −40
@@ -1,29 +1,63 @@
1 -import { useState } from 'react'
1 +import { useEffect, useState } from 'react'
2 2 import { CATEGORY_IDS, type Coverage, type GenerateResponse } from 'shared/types'
3 3 import * as api from '../api'
4 4 import { CATEGORY_LABELS } from '../labels'
5 +import { createHandoffMessage, createRecommendationMessage } from '../share'
5 6
6 7 interface GeneratePanelProps {
7 8 sessionId: string
9 + projectName: string
8 10 coverage: Coverage
9 11 }
10 12
11 -export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) {
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) {
12 25 const [confirming, setConfirming] = useState(false)
13 26 const [generating, setGenerating] = useState(false)
27 + const [packReady, setPackReady] = useState<boolean | null>(null)
14 28 const [result, setResult] = useState<GenerateResponse | null>(null)
15 29 const [error, setError] = useState<string | null>(null)
16 30 const [needsOverwrite, setNeedsOverwrite] = useState(false)
31 + const [copiedAction, setCopiedAction] = useState<CopyAction | null>(null)
32 + const [copyFallback, setCopyFallback] = useState<string | null>(null)
17 33
18 34 const missingCategories = CATEGORY_IDS.filter((category) => coverage[category] === 'missing')
19 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 +
20 53 async function runGenerate(overwrite: boolean) {
21 54 setGenerating(true)
22 55 setError(null)
23 56 setConfirming(false)
24 57 try {
25 58 const response = await api.generateSpecPack(sessionId, { overwrite })
26 59 setResult(response)
60 + setPackReady(true)
27 61 setNeedsOverwrite(false)
28 62 } catch (err) {
29 63 const message = err instanceof Error ? err.message : String(err)
@@ -42,67 +76,166 @@export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) {
42 76 void runGenerate(false)
43 77 }
44 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 +
45 193 return (
46 194 <div className="generate-panel">
47 - <h2>Get your spec</h2>
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>
48 201
49 202 {confirming ? (
50 203 <div className="generate-confirm">
51 204 <p>
52 - A few things are still empty:{' '}
53 - {missingCategories.map((c) => CATEGORY_LABELS[c].label).join(', ')}. You can fill those in
54 - later — continue anyway?
205 + Some topics are still open: {missingCategories.map((c) => CATEGORY_LABELS[c].label).join(', ')}.
206 + You can still create the brief now.
55 207 </p>
56 208 <button type="button" onClick={() => void runGenerate(false)} disabled={generating}>
57 - Continue anyway
209 + Create it anyway
58 210 </button>
59 211 <button type="button" onClick={() => setConfirming(false)} disabled={generating}>
60 - Cancel
212 + Go back
61 213 </button>
62 214 </div>
63 215 ) : (
64 - <button type="button" onClick={handleGenerateClick} disabled={generating}>
65 - {generating ? 'Creating your spec…' : 'Create my spec'}
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'}
66 223 </button>
67 224 )}
68 225
69 226 {needsOverwrite && (
70 227 <div className="generate-overwrite">
71 - <p>There's already a spec in that folder.</p>
228 + <p>Build brief files already exist in that folder.</p>
72 229 <button type="button" onClick={() => void runGenerate(true)} disabled={generating}>
73 - Replace it
230 + Replace existing files
74 231 </button>
75 232 </div>
76 233 )}
77 234
78 - {error && <p className="error">{error}</p>}
79 -
80 - {result && (
81 - <div className="generate-result">
82 - <p>Done! Here's what was created:</p>
83 - <ul>
84 - {result.files.map((file) => (
85 - <li key={file}>{file}</li>
86 - ))}
87 - </ul>
88 - <a
89 - className="download-link"
90 - href={`/api/sessions/${sessionId}/export/spec-pack.zip`}
91 - download
92 - >
93 - ⬇ Download spec pack (.zip)
94 - </a>
95 - {result.warnings.length > 0 && (
96 - <div className="generate-warnings">
97 - <p>A couple of notes:</p>
98 - <ul>
99 - {result.warnings.map((warning) => (
100 - <li key={warning}>{warning}</li>
101 - ))}
102 - </ul>
103 - </div>
104 - )}
105 - </div>
235 + {error && (
236 + <p className="error" role="alert">
237 + {error}
238 + </p>
106 239 )}
107 240 </div>
108 241 )
modified client/src/components/TranscriptExport.tsx +23 −9
@@ -10,21 +10,35 @@interface TranscriptExportProps {
10 10
11 11 export function TranscriptExport({ sessionId, projectName, segments }: TranscriptExportProps) {
12 12 const [copied, setCopied] = useState(false)
13 + const [copyError, setCopyError] = useState<string | null>(null)
13 14
14 15 async function handleCopy() {
15 - await navigator.clipboard.writeText(formatTranscript(segments, projectName))
16 - setCopied(true)
17 - setTimeout(() => setCopied(false), 2000)
16 + setCopyError(null)
17 + try {
18 + await navigator.clipboard.writeText(formatTranscript(segments, projectName))
19 + setCopied(true)
20 + window.setTimeout(() => setCopied(false), 2000)
21 + } catch {
22 + setCopied(false)
23 + setCopyError('Copy was blocked. Use Download transcript instead.')
24 + }
18 25 }
19 26
20 27 return (
21 28 <div className="transcript-export">
22 - <button type="button" onClick={() => void handleCopy()}>
23 - {copied ? 'Copied!' : 'Copy transcript'}
24 - </button>
25 - <a className="download-link" href={`/api/sessions/${sessionId}/export/transcript.md`} download>
26 - ⬇ Download transcript
27 - </a>
29 + <div className="transcript-export-actions">
30 + <button type="button" onClick={() => void handleCopy()}>
31 + {copied ? 'Transcript copied' : 'Copy transcript'}
32 + </button>
33 + <a className="download-link" href={`/api/sessions/${sessionId}/export/transcript.md`} download>
34 + Download transcript
35 + </a>
36 + </div>
37 + {copyError && (
38 + <span className="copy-error" role="alert">
39 + {copyError}
40 + </span>
41 + )}
28 42 </div>
29 43 )
30 44 }
added client/src/share.test.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { describe, expect, it } from 'vitest'
2 +import {
3 + createHandoffMessage,
4 + createRecommendationMessage,
5 + VOICETASK_URL,
6 +} from './share'
7 +
8 +describe('sharing copy', () => {
9 + it('creates a project-specific handoff message', () => {
10 + const message = createHandoffMessage(' Family Recipes ')
11 +
12 + expect(message).toContain('Family Recipes')
13 + expect(message).toContain('HANDOFF.md')
14 + expect(message).not.toContain(VOICETASK_URL)
15 + })
16 +
17 + it('keeps the VoiceTask recommendation separate', () => {
18 + expect(createRecommendationMessage()).toBe(
19 + `I used VoiceTask to turn a conversation about my idea into a build brief: ${VOICETASK_URL}`,
20 + )
21 + })
22 +})
added client/src/share.ts +17 −0
@@ -0,0 +1,17 @@
1 +export const VOICETASK_URL = 'https://github.com/rasmusjy/voicetask'
2 +
3 +function cleanProjectName(projectName: string): string {
4 + return projectName.trim().replace(/\s+/g, ' ') || 'your project'
5 +}
6 +
7 +export function createHandoffMessage(projectName: string): string {
8 + return [
9 + `Here is the build brief for ${cleanProjectName(projectName)}.`,
10 + 'It includes the requirements, implementation plan, task list, checks, and notes from my interview.',
11 + 'Start with HANDOFF.md, then use the other files in the package.',
12 + ].join(' ')
13 +}
14 +
15 +export function createRecommendationMessage(): string {
16 + return `I used VoiceTask to turn a conversation about my idea into a build brief: ${VOICETASK_URL}`
17 +}
modified server/routes/export.test.ts +13 −0
@@ -43,6 +43,19 @@describe('export routes', () => {
43 43 expect(res.statusCode).toBe(404)
44 44 })
45 45
46 + it('reports spec pack readiness through HEAD', async () => {
47 + const session = await createSessionWithAnAnswer()
48 + const url = `/api/sessions/${session.id}/export/spec-pack.zip`
49 +
50 + const before = await app.inject({ method: 'HEAD', url })
51 + expect(before.statusCode).toBe(404)
52 +
53 + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
54 +
55 + const after = await app.inject({ method: 'HEAD', url })
56 + expect(after.statusCode).toBe(200)
57 + })
58 +
46 59 it('downloads a valid zip of the spec pack after generating', async () => {
47 60 const session = await createSessionWithAnAnswer()
48 61 await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
modified server/routes/export.ts +41 −20
@@ -7,6 +7,8 @@import { SPEC_PACK_FILES } from '../providers/types'
7 7 import type { SessionStore } from '../store/sessionStore'
8 8
9 9 const SOURCES_FILE = 'sources.json'
10 +const SPEC_PACK_EXPORT_PATH = '/api/sessions/:id/export/spec-pack.zip'
11 +const SPEC_PACK_EXPORT_FILES = [...SPEC_PACK_FILES, SOURCES_FILE]
10 12
11 13 export interface ExportRouteDeps {
12 14 store: SessionStore
@@ -20,35 +22,54 @@function slugify(name: string): string {
20 22 return slug.length > 0 ? slug : 'voicetask'
21 23 }
22 24
25 +async function hasCompleteSpecPack(targetDir: string): Promise<boolean> {
26 + const specDir = path.join(targetDir, 'spec')
27 + const checks = await Promise.all(
28 + SPEC_PACK_EXPORT_FILES.map((file) =>
29 + stat(path.join(specDir, file))
30 + .then((fileStat) => fileStat.isFile())
31 + .catch(() => false),
32 + ),
33 + )
34 + return checks.every(Boolean)
35 +}
36 +
23 37 export function registerExportRoutes(app: FastifyInstance, deps: ExportRouteDeps): void {
24 38 const { store } = deps
25 39
26 - app.get<{ Params: { id: string } }>('/api/sessions/:id/export/spec-pack.zip', async (request, reply) => {
40 + app.head<{ Params: { id: string } }>(SPEC_PACK_EXPORT_PATH, async (request, reply) => {
27 41 const session = await store.getSession(request.params.id)
28 42 if (!session) return reply.code(404).send({ error: 'session not found' })
29 43
30 - const specDir = path.join(session.targetDir, 'spec')
31 - const specDirExists = await stat(specDir)
32 - .then((s) => s.isDirectory())
33 - .catch(() => false)
34 - if (!specDirExists) {
35 - return reply.code(404).send({ error: 'no spec pack found yet — generate it first' })
44 + if (!(await hasCompleteSpecPack(session.targetDir))) {
45 + return reply.code(404).send({ error: 'no complete spec pack found yet, generate it first' })
36 46 }
47 + return reply.code(200).send()
48 + })
37 49
38 - const entries: ZipEntryInput[] = []
39 - for (const file of [...SPEC_PACK_FILES, SOURCES_FILE]) {
40 - const content = await readFile(path.join(specDir, file)).catch(() => null)
41 - if (content) entries.push({ name: file, content })
42 - }
43 - if (entries.length === 0) {
44 - return reply.code(404).send({ error: 'no spec pack found yet — generate it first' })
45 - }
50 + app.get<{ Params: { id: string } }>(
51 + SPEC_PACK_EXPORT_PATH,
52 + { exposeHeadRoute: false },
53 + async (request, reply) => {
54 + const session = await store.getSession(request.params.id)
55 + if (!session) return reply.code(404).send({ error: 'session not found' })
46 56
47 - const zipBuffer = createZip(entries)
48 - reply.header('content-type', 'application/zip')
49 - reply.header('content-disposition', `attachment; filename="${slugify(session.name)}-spec-pack.zip"`)
50 - return reply.send(zipBuffer)
51 - })
57 + if (!(await hasCompleteSpecPack(session.targetDir))) {
58 + return reply.code(404).send({ error: 'no complete spec pack found yet, generate it first' })
59 + }
60 +
61 + const specDir = path.join(session.targetDir, 'spec')
62 + const entries: ZipEntryInput[] = []
63 + for (const file of SPEC_PACK_EXPORT_FILES) {
64 + entries.push({ name: file, content: await readFile(path.join(specDir, file)) })
65 + }
66 +
67 + const zipBuffer = createZip(entries)
68 + reply.header('content-type', 'application/zip')
69 + reply.header('content-disposition', `attachment; filename="${slugify(session.name)}-spec-pack.zip"`)
70 + return reply.send(zipBuffer)
71 + },
72 + )
52 73
53 74 app.get<{ Params: { id: string } }>('/api/sessions/:id/export/transcript.md', async (request, reply) => {
54 75 const session = await store.getSession(request.params.id)
modified spec/TASKS.md +1 −1
@@ -92,7 +92,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
92 92 - Depends: T17
93 93 - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: mouse click toggles one recording, Space still records only while held, and coverage statuses are understandable without color.
94 94
95 -- [ ] T19 Clear completion and user-driven sharing
95 +- [x] T19 Clear completion and user-driven sharing
96 96 - Add a HEAD readiness check for the existing spec-pack export. Redesign the completed state around the primary zip download, recipient guidance, a copyable project handoff message, and a secondary generated-file disclosure. Add a separate optional VoiceTask recommendation action using the canonical repository link. Clipboard failures must be visible.
97 97 - Depends: T18
98 98 - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Tests cover the readiness check plus deterministic handoff and recommendation copy. Manual: refresh a completed session with an existing pack and confirm the download remains visible.
modified vitest.config.ts +1 −1
@@ -5,6 +5,6 @@process.env.MOCK_PROVIDERS = '1'
5 5 export default defineConfig({
6 6 test: {
7 7 environment: 'node',
8 - include: ['server/**/*.test.ts', 'shared/**/*.test.ts'],
8 + include: ['server/**/*.test.ts', 'shared/**/*.test.ts', 'client/src/**/*.test.ts'],
9 9 },
10 10 })