Commit
Clarify build brief sharing
commit
34967ef
12 changed files with +634 and −123
Jump to a changed file
- client/src/App.css +298 −45
- client/src/App.tsx +8 −7
- client/src/api.test.ts +30 −0
- client/src/api.ts +7 −0
- client/src/components/GeneratePanel.tsx +173 −40
- client/src/components/TranscriptExport.tsx +23 −9
- client/src/share.test.ts +22 −0
- client/src/share.ts +17 −0
- server/routes/export.test.ts +13 −0
- server/routes/export.ts +41 −20
- spec/TASKS.md +1 −1
- 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 | }) |