Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

voice-to-pr.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

voice-to-pr.tsxBlame1091 lines · 2 contributors
45f3b73Claude1/**
2 * Voice-to-PR — phone-first feature dictation.
3 *
4 * GET /voice → polished hero + tap-to-talk + repo picker
5 * POST /voice/transcribe → JSON, runs interpretVoiceTranscript
6 * POST /voice/ship → ships a `.gluecron/specs/voice-*.md` ready spec
7 * POST /voice/issue → opens an issue via the existing flow
8 *
9 * Hard rules (per the build prompt):
10 * - No shared layout / component changes (one nav link is added in
11 * `layout.tsx`, separately).
12 * - Inline JS lives here, in a single `<script>` tag.
13 * - Every CSS class is prefixed `.voice-*`.
14 * - Web Speech API is feature-detected — if unsupported the UI falls
15 * back to a plain textarea so the demo still works in Firefox / dev.
16 * - Skips gracefully when ANTHROPIC_API_KEY is unset (Claude call
17 * returns a heuristic interpretation instead of a structured one).
18 */
19
20import { Hono } from "hono";
21import { Layout } from "../views/layout";
22import { requireAuth, softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { isAiAvailable } from "../lib/ai-client";
25import {
26 interpretVoiceTranscript,
27 shipAsSpec,
28 createIssueFromVoice,
29 listUserRepos,
30} from "../lib/voice-to-pr";
31import { triggerIssueTriage } from "../lib/issue-triage";
32
33const voice = new Hono<AuthEnv>();
34voice.use("*", softAuth);
35
36// ---------------------------------------------------------------------------
37// CSS — every class prefixed `.voice-*` so it can't leak.
38// ---------------------------------------------------------------------------
39const voiceCss = `
40 .voice-page {
41 max-width: 720px;
42 margin: 0 auto;
43 padding: clamp(20px, 4vw, 40px) clamp(14px, 4vw, 24px) clamp(40px, 8vw, 96px);
44 }
45
46 /* Hero — gradient hairline + orb + eyebrow + display headline */
47 .voice-hero {
48 position: relative;
49 padding: clamp(28px, 5vw, 44px) clamp(20px, 4vw, 36px);
50 background: var(--bg-elevated);
51 border: 1px solid var(--border);
52 border-radius: 20px;
53 overflow: hidden;
54 margin-bottom: clamp(20px, 4vw, 32px);
55 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
56 }
57 .voice-hero::before {
58 content: '';
59 position: absolute;
60 top: 0; left: 0; right: 0;
61 height: 2px;
6fd5915Claude62 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
45f3b73Claude63 opacity: 0.85;
64 pointer-events: none;
65 z-index: 2;
66 }
67 .voice-hero-orb {
68 position: absolute;
69 inset: -40% -20% auto auto;
70 width: 460px; height: 460px;
6fd5915Claude71 background: radial-gradient(circle, rgba(91,110,232,0.26), rgba(95,143,160,0.10) 45%, transparent 70%);
45f3b73Claude72 filter: blur(80px);
73 opacity: 0.75;
74 pointer-events: none;
75 z-index: 0;
76 }
77 .voice-hero-inner { position: relative; z-index: 1; }
78 .voice-eyebrow {
79 display: inline-flex;
80 align-items: center;
81 gap: 8px;
82 font-family: var(--font-mono);
83 font-size: 11.5px;
84 text-transform: uppercase;
85 letter-spacing: 0.16em;
86 color: var(--text-muted);
87 font-weight: 600;
88 margin-bottom: 14px;
89 }
90 .voice-eyebrow-pill {
91 display: inline-flex;
92 align-items: center;
93 justify-content: center;
94 width: 18px; height: 18px;
95 border-radius: 6px;
6fd5915Claude96 background: rgba(91,110,232,0.16);
e589f77ccantynz-alt97 color: var(--accent);
6fd5915Claude98 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
45f3b73Claude99 }
100 .voice-eyebrow-warn {
101 display: inline-flex;
102 align-items: center;
103 gap: 6px;
104 padding: 3px 9px;
105 border-radius: 9999px;
106 background: rgba(251,191,36,0.10);
107 color: #fde68a;
108 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
109 font-size: 10.5px;
110 font-weight: 700;
111 text-transform: uppercase;
112 letter-spacing: 0.10em;
113 margin-left: 6px;
114 }
115 .voice-title {
116 font-family: var(--font-display);
117 font-size: clamp(34px, 9vw, 64px);
118 font-weight: 800;
119 letter-spacing: -0.03em;
120 line-height: 0.98;
121 margin: 0 0 12px;
122 color: var(--text-strong);
123 }
124 .voice-title-grad {
6fd5915Claude125 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
45f3b73Claude126 -webkit-background-clip: text;
127 background-clip: text;
128 -webkit-text-fill-color: transparent;
129 color: transparent;
130 }
131 .voice-sub {
132 font-size: clamp(14px, 3.5vw, 16px);
133 color: var(--text-muted);
134 line-height: 1.55;
135 margin: 0;
136 max-width: 520px;
137 }
138
139 /* Tap-to-talk orb */
140 .voice-mic-wrap {
141 display: flex;
142 flex-direction: column;
143 align-items: center;
144 gap: 14px;
145 margin: clamp(28px, 6vw, 48px) 0;
146 }
147 .voice-mic {
148 appearance: none;
149 border: 0;
150 cursor: pointer;
151 width: clamp(132px, 38vw, 168px);
152 height: clamp(132px, 38vw, 168px);
153 border-radius: 9999px;
6fd5915Claude154 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
45f3b73Claude155 color: #fff;
156 display: inline-flex;
157 align-items: center;
158 justify-content: center;
159 box-shadow:
6fd5915Claude160 0 22px 48px -16px rgba(91,110,232,0.55),
45f3b73Claude161 0 0 0 1px rgba(255,255,255,0.10) inset,
6fd5915Claude162 0 0 64px -8px rgba(95,143,160,0.45);
45f3b73Claude163 transition: transform 160ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 200ms ease;
164 position: relative;
165 font-family: inherit;
166 }
167 .voice-mic:hover { transform: translateY(-2px) scale(1.02); }
168 .voice-mic:active { transform: translateY(0) scale(0.98); }
169 .voice-mic:focus-visible {
170 outline: none;
171 box-shadow:
6fd5915Claude172 0 22px 48px -16px rgba(91,110,232,0.65),
173 0 0 0 4px rgba(91,110,232,0.32),
174 0 0 64px -8px rgba(95,143,160,0.55);
45f3b73Claude175 }
176 .voice-mic[data-recording="1"] {
177 animation: voicePulse 1.6s ease-in-out infinite;
178 }
179 .voice-mic[data-recording="1"]::after {
180 content: '';
181 position: absolute;
182 inset: -8px;
183 border-radius: 9999px;
184 border: 2px solid rgba(248,113,113,0.6);
185 animation: voiceRing 1.6s ease-out infinite;
186 }
187 @keyframes voicePulse {
188 0%, 100% {
189 box-shadow:
6fd5915Claude190 0 22px 48px -16px rgba(91,110,232,0.55),
45f3b73Claude191 0 0 0 1px rgba(255,255,255,0.10) inset,
6fd5915Claude192 0 0 64px -8px rgba(95,143,160,0.45);
45f3b73Claude193 }
194 50% {
195 box-shadow:
196 0 22px 48px -16px rgba(248,113,113,0.55),
197 0 0 0 1px rgba(255,255,255,0.18) inset,
198 0 0 80px -4px rgba(248,113,113,0.55);
199 }
200 }
201 @keyframes voiceRing {
202 0% { opacity: 0.8; transform: scale(0.95); }
203 100% { opacity: 0; transform: scale(1.25); }
204 }
205 .voice-mic-icon { width: 56%; height: 56%; }
206 .voice-mic-label {
207 font-family: var(--font-mono);
208 font-size: 12px;
209 text-transform: uppercase;
210 letter-spacing: 0.14em;
211 color: var(--text-muted);
212 font-weight: 700;
213 }
e589f77ccantynz-alt214 .voice-mic-label[data-recording="1"] { color: var(--red); }
45f3b73Claude215
216 /* Transcript card */
217 .voice-card {
218 margin: clamp(16px, 3vw, 24px) 0;
219 padding: clamp(16px, 3vw, 22px);
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 16px;
223 box-shadow: 0 8px 24px -12px rgba(0,0,0,0.40);
224 }
225 .voice-card-head {
226 display: flex;
227 align-items: center;
228 justify-content: space-between;
229 gap: 10px;
230 margin-bottom: 10px;
231 }
232 .voice-card-title {
233 font-family: var(--font-mono);
234 font-size: 11px;
235 text-transform: uppercase;
236 letter-spacing: 0.14em;
237 color: var(--text-muted);
238 font-weight: 700;
239 display: inline-flex;
240 align-items: center;
241 gap: 8px;
242 }
243 .voice-card-dot {
244 width: 7px; height: 7px; border-radius: 9999px;
6fd5915Claude245 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
246 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
45f3b73Claude247 }
248 .voice-transcript {
249 min-height: 64px;
250 font-size: clamp(15px, 4vw, 18px);
251 line-height: 1.55;
252 color: var(--text);
253 white-space: pre-wrap;
254 word-wrap: break-word;
255 }
256 .voice-transcript[data-empty="1"] {
257 color: var(--text-faint);
258 font-style: italic;
259 }
260 .voice-textarea {
261 width: 100%;
262 box-sizing: border-box;
263 min-height: 120px;
264 padding: 12px 14px;
265 background: var(--bg);
266 color: var(--text);
267 border: 1px solid var(--border);
268 border-radius: 12px;
269 font-family: inherit;
270 font-size: 15px;
271 line-height: 1.55;
272 resize: vertical;
273 }
274 .voice-textarea:focus {
275 outline: none;
6fd5915Claude276 border-color: rgba(91,110,232,0.55);
277 box-shadow: 0 0 0 4px rgba(91,110,232,0.18);
45f3b73Claude278 }
279
280 /* Repo picker */
281 .voice-picker { display: flex; flex-direction: column; gap: 8px; }
282 .voice-picker-label {
283 font-family: var(--font-mono);
284 font-size: 11px;
285 text-transform: uppercase;
286 letter-spacing: 0.14em;
287 color: var(--text-muted);
288 font-weight: 700;
289 }
290 .voice-picker-input {
291 width: 100%;
292 box-sizing: border-box;
293 padding: 12px 14px;
294 background: var(--bg);
295 color: var(--text);
296 border: 1px solid var(--border);
297 border-radius: 12px;
298 font-family: inherit;
299 font-size: 15px;
300 }
301 .voice-picker-input:focus {
302 outline: none;
6fd5915Claude303 border-color: rgba(91,110,232,0.55);
304 box-shadow: 0 0 0 4px rgba(91,110,232,0.18);
45f3b73Claude305 }
306 .voice-picker-list {
307 max-height: 0;
308 overflow: hidden;
309 transition: max-height 200ms ease;
310 border: 1px solid transparent;
311 border-radius: 12px;
312 margin-top: 4px;
313 }
314 .voice-picker-list[data-open="1"] {
315 max-height: 240px;
316 overflow-y: auto;
317 border-color: var(--border);
318 background: var(--bg);
319 }
320 .voice-picker-item {
321 padding: 10px 14px;
322 cursor: pointer;
323 font-size: 14px;
324 border-bottom: 1px solid var(--border);
325 }
326 .voice-picker-item:last-child { border-bottom: 0; }
327 .voice-picker-item:hover,
328 .voice-picker-item[data-active="1"] {
6fd5915Claude329 background: rgba(91,110,232,0.10);
45f3b73Claude330 color: var(--text-strong);
331 }
332
333 /* Interpretation card ("We heard...") */
334 .voice-heard {
335 margin: 12px 0;
336 padding: 16px 18px;
337 background:
6fd5915Claude338 linear-gradient(180deg, rgba(91,110,232,0.06), transparent 70%),
45f3b73Claude339 var(--bg-elevated);
6fd5915Claude340 border: 1px solid rgba(91,110,232,0.32);
45f3b73Claude341 border-radius: 16px;
6fd5915Claude342 box-shadow: 0 12px 28px -16px rgba(91,110,232,0.4);
45f3b73Claude343 }
344 .voice-heard-eyebrow {
345 font-family: var(--font-mono);
346 font-size: 10.5px;
347 text-transform: uppercase;
348 letter-spacing: 0.16em;
349 color: var(--accent);
350 font-weight: 700;
351 margin-bottom: 6px;
352 }
353 .voice-heard-title {
354 font-family: var(--font-display);
355 font-size: clamp(18px, 4.5vw, 22px);
356 font-weight: 700;
357 letter-spacing: -0.02em;
358 color: var(--text-strong);
359 margin-bottom: 6px;
360 }
361 .voice-heard-body {
362 font-size: 14px;
363 line-height: 1.55;
364 color: var(--text);
365 white-space: pre-wrap;
366 word-wrap: break-word;
367 }
368 .voice-heard-kind {
369 display: inline-flex;
370 align-items: center;
371 gap: 6px;
372 padding: 3px 9px;
373 border-radius: 9999px;
374 font-size: 10.5px;
375 font-weight: 700;
376 letter-spacing: 0.08em;
377 text-transform: uppercase;
378 margin-left: 8px;
379 vertical-align: 2px;
380 }
381 .voice-heard-kind[data-kind="spec"] {
6fd5915Claude382 background: rgba(91,110,232,0.14);
e589f77ccantynz-alt383 color: var(--accent);
6fd5915Claude384 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.34);
45f3b73Claude385 }
386 .voice-heard-kind[data-kind="issue"] {
387 background: rgba(251,191,36,0.14);
388 color: #fde68a;
389 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
390 }
391 .voice-heard-kind[data-kind="unclear"] {
392 background: rgba(255,255,255,0.04);
393 color: var(--text-muted);
394 box-shadow: inset 0 0 0 1px var(--border-strong, var(--border));
395 }
396
397 /* Action buttons */
398 .voice-actions {
399 display: grid;
400 grid-template-columns: 1fr;
401 gap: 10px;
402 margin-top: 14px;
403 }
404 @media (min-width: 520px) {
405 .voice-actions { grid-template-columns: 1fr 1fr 1fr; }
406 }
407 .voice-btn {
408 appearance: none;
409 cursor: pointer;
410 padding: 13px 16px;
411 border-radius: 12px;
412 font-family: inherit;
413 font-size: 14px;
414 font-weight: 600;
415 line-height: 1;
416 text-align: center;
417 transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
418 border: 1px solid var(--border);
419 color: var(--text-strong);
420 background: var(--bg-elevated);
421 }
422 .voice-btn:hover { transform: translateY(-1px); background: var(--bg-surface); }
423 .voice-btn:active { transform: translateY(0); }
424 .voice-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
425 .voice-btn-primary {
6fd5915Claude426 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
45f3b73Claude427 color: #fff;
428 border-color: transparent;
6fd5915Claude429 box-shadow: 0 8px 22px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
45f3b73Claude430 }
431 .voice-btn-primary:hover {
6fd5915Claude432 box-shadow: 0 12px 28px -6px rgba(91,110,232,0.65), inset 0 1px 0 rgba(255,255,255,0.20);
45f3b73Claude433 }
434 .voice-btn-warn {
435 background: rgba(251,191,36,0.12);
436 color: #fde68a;
437 border-color: rgba(251,191,36,0.32);
438 }
439
440 /* Status line + toast */
441 .voice-status {
442 margin-top: 10px;
443 font-size: 13px;
444 color: var(--text-muted);
445 min-height: 18px;
446 }
e589f77ccantynz-alt447 .voice-status[data-kind="error"] { color: var(--red); }
45f3b73Claude448 .voice-status[data-kind="ok"] { color: #86efac; }
449
450 /* Mobile spacing tweaks (375px target) */
451 @media (max-width: 420px) {
452 .voice-page { padding-left: 12px; padding-right: 12px; }
453 .voice-hero { padding: 22px 18px; }
454 .voice-title { font-size: 36px; }
455 .voice-card { padding: 14px; }
456 }
457`;
458
459// ---------------------------------------------------------------------------
460// GET /voice — render the page
461// ---------------------------------------------------------------------------
462
463voice.get("/voice", requireAuth, async (c) => {
464 const user = c.get("user")!;
465 const repos = await listUserRepos(user.id);
466 const reposJson = JSON.stringify(repos);
467 const aiOn = isAiAvailable();
468
469 return c.html(
470 <Layout title="Talk. Ship." user={user}>
471 <style dangerouslySetInnerHTML={{ __html: voiceCss }} />
472 <div class="voice-page">
473 <header class="voice-hero">
474 <div class="voice-hero-orb" aria-hidden="true" />
475 <div class="voice-hero-inner">
476 <div class="voice-eyebrow">
477 <span class="voice-eyebrow-pill" aria-hidden="true">
478 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
479 <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
480 <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
481 <line x1="12" y1="19" x2="12" y2="23" />
482 <line x1="8" y1="23" x2="16" y2="23" />
483 </svg>
484 </span>
485 Voice {"·"} phone-first dev workflow
486 {!aiOn && (
487 <span class="voice-eyebrow-warn">AI offline {"—"} heuristic mode</span>
488 )}
489 </div>
490 <h1 class="voice-title">
491 <span class="voice-title-grad">Talk. Ship.</span>
492 </h1>
493 <p class="voice-sub">
494 Tap the orb. Dictate a feature. We'll classify it and open a draft PR
495 or file an issue. Built for the train, the kitchen, the dog walk.
496 </p>
497 </div>
498 </header>
499
500 {/* Tap-to-talk orb */}
501 <div class="voice-mic-wrap">
502 <button
503 type="button"
504 id="voice-mic"
505 class="voice-mic"
506 aria-label="Tap to talk"
507 aria-pressed="false"
508 >
509 <svg class="voice-mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
510 <rect x="9" y="2" width="6" height="13" rx="3" />
511 <path d="M19 11v1a7 7 0 0 1-14 0v-1" />
512 <line x1="12" y1="19" x2="12" y2="23" />
513 <line x1="8" y1="23" x2="16" y2="23" />
514 </svg>
515 </button>
516 <div id="voice-mic-label" class="voice-mic-label">Tap to talk</div>
517 </div>
518
519 {/* Live transcript or textarea fallback */}
520 <section class="voice-card" id="voice-transcript-card">
521 <div class="voice-card-head">
522 <span class="voice-card-title">
523 <span class="voice-card-dot" aria-hidden="true" />
524 <span id="voice-card-title-label">Live transcript</span>
525 </span>
526 <button type="button" id="voice-clear" class="voice-btn" style="padding:6px 10px;font-size:12px">
527 Clear
528 </button>
529 </div>
530 <div
531 id="voice-transcript"
532 class="voice-transcript"
533 data-empty="1"
534 aria-live="polite"
535 >
536 Tap the orb above and start speaking{"…"}
537 </div>
538 {/* Fallback textarea — hidden until the script confirms no Web Speech API */}
539 <textarea
540 id="voice-textarea"
541 class="voice-textarea"
542 style="display:none;margin-top:10px"
543 placeholder="Voice not supported in this browser — type your feature request here."
544 aria-label="Feature request"
545 ></textarea>
546 </section>
547
548 {/* Repo picker */}
549 <section class="voice-card">
550 <div class="voice-picker">
551 <label class="voice-picker-label" for="voice-repo-input">Which repo?</label>
552 <input
553 id="voice-repo-input"
554 class="voice-picker-input"
555 type="text"
556 autocomplete="off"
557 placeholder={
558 repos.length
559 ? "Start typing to search…"
560 : "You don't own any repos yet — create one first"
561 }
562 aria-label="Repository"
563 disabled={!repos.length}
564 />
565 <input type="hidden" id="voice-repo-id" value="" />
566 <div
567 id="voice-repo-list"
568 class="voice-picker-list"
569 role="listbox"
570 aria-label="Repository matches"
571 />
572 </div>
573 </section>
574
575 {/* Interpretation / actions */}
576 <section id="voice-heard" class="voice-heard" style="display:none">
577 <div class="voice-heard-eyebrow">
578 We heard
579 <span id="voice-heard-kind-pill" class="voice-heard-kind" data-kind="unclear">
580 unclear
581 </span>
582 </div>
583 <div id="voice-heard-title" class="voice-heard-title">{""}</div>
584 <div id="voice-heard-body" class="voice-heard-body">{""}</div>
585 </section>
586
587 <div class="voice-actions">
588 <button type="button" id="voice-ship-spec" class="voice-btn voice-btn-primary" disabled>
589 Ship as a spec
590 </button>
591 <button type="button" id="voice-open-issue" class="voice-btn" disabled>
592 Open an issue
593 </button>
594 <button type="button" id="voice-reset" class="voice-btn voice-btn-warn" disabled>
595 Re-record
596 </button>
597 </div>
598
599 <div id="voice-status" class="voice-status" aria-live="polite" />
600 </div>
601
602 {/* Inline JS — feature-detects SpeechRecognition, wires the orb, posts. */}
603 <script
604 // eslint-disable-next-line react/no-danger
605 dangerouslySetInnerHTML={{
606 __html: buildVoiceClientJs(reposJson),
607 }}
608 />
609 </Layout>
610 );
611});
612
613/** Read the CSRF cookie out of document.cookie for fetch calls. */
614function buildVoiceClientJs(reposJson: string): string {
615 return /* js */ `
616(function(){
617 var REPOS = ${reposJson};
618 var state = {
619 transcript: '',
620 interim: '',
621 interpretation: null,
622 recording: false,
623 repoId: '',
624 };
625
626 function $(id){ return document.getElementById(id); }
627
628 function getCsrf(){
629 try {
630 var m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
631 return m ? decodeURIComponent(m[1]) : '';
632 } catch(_) { return ''; }
633 }
634
635 function setStatus(msg, kind){
636 var el = $('voice-status');
637 if (!el) return;
638 el.textContent = msg || '';
639 if (kind) el.setAttribute('data-kind', kind);
640 else el.removeAttribute('data-kind');
641 }
642
643 function setTranscript(text){
644 state.transcript = text || '';
645 var el = $('voice-transcript');
646 if (!el) return;
647 var combined = (state.transcript + ' ' + (state.interim || '')).trim();
648 if (combined) {
649 el.textContent = combined;
650 el.removeAttribute('data-empty');
651 } else {
652 el.textContent = 'Tap the orb above and start speaking…';
653 el.setAttribute('data-empty', '1');
654 }
655 var hasText = !!state.transcript.trim();
656 $('voice-reset').disabled = !hasText;
657 }
658
659 function setInterim(t){
660 state.interim = t || '';
661 setTranscript(state.transcript);
662 }
663
664 function renderInterpretation(it){
665 state.interpretation = it || null;
666 var box = $('voice-heard');
667 if (!it) { box.style.display = 'none'; return; }
668 box.style.display = 'block';
669 $('voice-heard-title').textContent = it.title || '(no title)';
670 $('voice-heard-body').textContent = it.body_markdown || '';
671 var pill = $('voice-heard-kind-pill');
672 pill.textContent = it.kind || 'unclear';
673 pill.setAttribute('data-kind', it.kind || 'unclear');
674 // Suggest the repo when the model returned a hint.
675 if (it.target_repo_id_hint && !state.repoId) {
676 var match = REPOS.find(function(r){ return r.id === it.target_repo_id_hint; });
677 if (match) selectRepo(match);
678 }
679 updateActionState();
680 }
681
682 function updateActionState(){
683 var ready = !!state.transcript.trim() && !!state.repoId;
684 $('voice-ship-spec').disabled = !ready;
685 $('voice-open-issue').disabled = !ready;
686 }
687
688 function selectRepo(r){
689 state.repoId = r.id;
690 $('voice-repo-input').value = r.fullName;
691 $('voice-repo-id').value = r.id;
692 $('voice-repo-list').setAttribute('data-open', '0');
693 $('voice-repo-list').innerHTML = '';
694 updateActionState();
695 }
696
697 // ---- Repo picker (autocomplete) -----------------------------------------
698 var input = $('voice-repo-input');
699 var list = $('voice-repo-list');
700 if (input) {
701 input.addEventListener('input', function(){
702 var q = (input.value || '').trim().toLowerCase();
703 state.repoId = '';
704 $('voice-repo-id').value = '';
705 updateActionState();
706 if (!q) { list.innerHTML = ''; list.setAttribute('data-open', '0'); return; }
707 var matches = REPOS.filter(function(r){
708 return r.fullName.toLowerCase().indexOf(q) !== -1;
709 }).slice(0, 6);
710 if (matches.length === 0) {
711 list.innerHTML = '<div class="voice-picker-item" style="cursor:default;color:var(--text-muted)">No matches</div>';
712 list.setAttribute('data-open', '1');
713 return;
714 }
715 list.innerHTML = matches.map(function(r, i){
716 return '<div class="voice-picker-item" role="option" data-id="' + r.id + '"' +
717 (i === 0 ? ' data-active="1"' : '') + '>' + escapeHtml(r.fullName) + '</div>';
718 }).join('');
719 list.setAttribute('data-open', '1');
720 });
721 list.addEventListener('click', function(e){
722 var t = e.target.closest('.voice-picker-item');
723 if (!t || !t.getAttribute('data-id')) return;
724 var id = t.getAttribute('data-id');
725 var r = REPOS.find(function(r){ return r.id === id; });
726 if (r) selectRepo(r);
727 });
728 document.addEventListener('click', function(e){
729 if (e.target.closest('.voice-picker')) return;
730 list.setAttribute('data-open', '0');
731 });
732 }
733
734 function escapeHtml(s){
735 return String(s).replace(/[&<>"']/g, function(c){
736 return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
737 });
738 }
739
740 // ---- Web Speech API -----------------------------------------------------
741 var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
742 var rec = null;
743 var fallbackArea = $('voice-textarea');
744 var mic = $('voice-mic');
745 var micLabel = $('voice-mic-label');
746 var cardTitleLabel = $('voice-card-title-label');
747
748 if (!SR) {
749 // Fallback path — show the textarea, repurpose the mic button as a
750 // "submit transcript" button.
751 fallbackArea.style.display = 'block';
752 var emptyEl = $('voice-transcript');
753 if (emptyEl) {
754 emptyEl.style.display = 'none';
755 }
756 if (cardTitleLabel) cardTitleLabel.textContent = 'Type your request';
757 if (mic) {
758 mic.setAttribute('aria-label', 'Use typed transcript');
759 micLabel.textContent = 'Type below, then tap';
760 mic.addEventListener('click', function(){
761 var v = (fallbackArea.value || '').trim();
762 if (!v) {
763 setStatus('Type something into the box first.', 'error');
764 fallbackArea.focus();
765 return;
766 }
767 setTranscript(v);
768 submitTranscribe(v);
769 });
770 }
771 setStatus('Voice not supported in this browser — type your feature request below.', 'error');
772 } else if (mic) {
773 rec = new SR();
774 rec.continuous = true;
775 rec.interimResults = true;
776 rec.lang = navigator.language || 'en-US';
777 rec.onresult = function(ev){
778 var finalText = '';
779 var interim = '';
780 for (var i = ev.resultIndex; i < ev.results.length; i++) {
781 var r = ev.results[i];
782 if (r.isFinal) finalText += r[0].transcript;
783 else interim += r[0].transcript;
784 }
785 if (finalText) {
786 var next = (state.transcript ? state.transcript + ' ' : '') + finalText;
787 setTranscript(next.trim());
788 setInterim('');
789 } else {
790 setInterim(interim);
791 }
792 };
793 rec.onerror = function(ev){
794 stopRecording();
795 var msg = (ev && ev.error) ? ('Mic error: ' + ev.error) : 'Mic error';
796 if (ev && ev.error === 'not-allowed') {
797 msg = 'Microphone access denied — enable it in your browser settings.';
798 }
799 setStatus(msg, 'error');
800 };
801 rec.onend = function(){
802 // Browser auto-stops after a pause; reflect that in the UI.
803 if (state.recording) stopRecording(true);
804 };
805 mic.addEventListener('click', function(){
806 if (state.recording) stopRecording();
807 else startRecording();
808 });
809 }
810
811 function startRecording(){
812 if (!rec) return;
813 try { rec.start(); }
814 catch(e){ setStatus('Could not start recording: ' + (e && e.message || e), 'error'); return; }
815 state.recording = true;
816 mic.setAttribute('data-recording', '1');
817 mic.setAttribute('aria-pressed', 'true');
818 micLabel.setAttribute('data-recording', '1');
819 micLabel.textContent = 'Listening… tap to stop';
820 setStatus('');
821 }
822
823 function stopRecording(autoStopped){
824 if (rec) { try { rec.stop(); } catch(_){} }
825 state.recording = false;
826 if (mic) {
827 mic.setAttribute('data-recording', '0');
828 mic.removeAttribute('data-recording');
829 mic.setAttribute('aria-pressed', 'false');
830 }
831 if (micLabel) {
832 micLabel.removeAttribute('data-recording');
833 micLabel.textContent = 'Tap to talk';
834 }
835 setInterim('');
836 var t = state.transcript.trim();
837 if (t) submitTranscribe(t);
838 else if (autoStopped) setStatus('Didn\\'t catch that — tap to try again.');
839 }
840
841 // ---- POSTers ------------------------------------------------------------
842 function submitTranscribe(text){
843 setStatus('Interpreting…');
844 fetch('/voice/transcribe', {
845 method: 'POST',
846 headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
847 credentials: 'same-origin',
848 body: JSON.stringify({ transcript: text }),
849 })
850 .then(function(r){ return r.ok ? r.json() : r.json().then(function(j){ throw new Error(j.error || ('HTTP ' + r.status)); }); })
851 .then(function(j){
852 if (!j || !j.ok) throw new Error(j && j.error || 'Unknown error');
853 renderInterpretation(j.suggestion);
854 setStatus('Heard you. Pick a repo and ship it.', 'ok');
855 })
856 .catch(function(err){
857 setStatus('Failed: ' + (err && err.message || err), 'error');
858 });
859 }
860
861 function shipAction(path, kind){
862 if (!state.repoId) { setStatus('Pick a repo first.', 'error'); return; }
863 if (!state.transcript.trim()) { setStatus('Speak something first.', 'error'); return; }
864 $('voice-ship-spec').disabled = true;
865 $('voice-open-issue').disabled = true;
866 setStatus(kind === 'spec' ? 'Shipping spec to autopilot…' : 'Opening issue…');
867 fetch(path, {
868 method: 'POST',
869 headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
870 credentials: 'same-origin',
871 body: JSON.stringify({
872 repository_id: state.repoId,
873 transcript: state.transcript,
874 }),
875 })
876 .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
877 .then(function(res){
878 if (!res.ok || !res.body || res.body.ok === false) {
879 var msg = (res.body && res.body.error) || ('HTTP error');
880 throw new Error(msg);
881 }
882 if (kind === 'spec') {
883 setStatus('Spec committed — autopilot will open a PR in ~30s. Redirecting…', 'ok');
884 setTimeout(function(){ window.location.href = '/specs'; }, 1200);
885 } else {
886 var b = res.body;
887 if (b.url) {
888 setStatus('Issue opened. Redirecting…', 'ok');
889 setTimeout(function(){ window.location.href = b.url; }, 800);
890 } else {
891 setStatus('Issue opened.', 'ok');
892 }
893 }
894 })
895 .catch(function(err){
896 setStatus('Failed: ' + (err && err.message || err), 'error');
897 updateActionState();
898 });
899 }
900
901 $('voice-ship-spec').addEventListener('click', function(){ shipAction('/voice/ship', 'spec'); });
902 $('voice-open-issue').addEventListener('click', function(){ shipAction('/voice/issue', 'issue'); });
903 $('voice-reset').addEventListener('click', function(){
904 state.transcript = ''; state.interim = ''; state.interpretation = null;
905 setTranscript('');
906 $('voice-heard').style.display = 'none';
907 if (fallbackArea) fallbackArea.value = '';
908 updateActionState();
909 setStatus('Cleared. Tap to talk.');
910 });
911 $('voice-clear').addEventListener('click', function(){
912 state.transcript = ''; state.interim = '';
913 setTranscript('');
914 if (fallbackArea) fallbackArea.value = '';
915 updateActionState();
916 });
917})();
918`;
919}
920
921// ---------------------------------------------------------------------------
922// POST /voice/transcribe — JSON, classify
923// ---------------------------------------------------------------------------
924
925voice.post("/voice/transcribe", requireAuth, async (c) => {
926 const user = c.get("user")!;
927 let body: { transcript?: unknown } = {};
928 try {
929 body = (await c.req.json()) as { transcript?: unknown };
930 } catch {
931 return c.json({ ok: false, error: "invalid JSON body" }, 400);
932 }
933 const transcript = typeof body.transcript === "string" ? body.transcript : "";
934 if (!transcript.trim()) {
935 return c.json({ ok: false, error: "transcript is empty" }, 400);
936 }
937 const repos = await listUserRepos(user.id);
938 const res = await interpretVoiceTranscript({
939 transcript,
940 knownRepos: repos,
941 });
942 if (!res.ok) return c.json(res, 400);
943 return c.json({ ok: true, suggestion: res.suggestion });
944});
945
946// ---------------------------------------------------------------------------
947// POST /voice/ship — commit a ready spec
948// ---------------------------------------------------------------------------
949
950voice.post("/voice/ship", requireAuth, async (c) => {
951 const user = c.get("user")!;
952 let body: { repository_id?: unknown; transcript?: unknown } = {};
953 try {
954 body = (await c.req.json()) as {
955 repository_id?: unknown;
956 transcript?: unknown;
957 };
958 } catch {
959 return c.json({ ok: false, error: "invalid JSON body" }, 400);
960 }
961 const repositoryId =
962 typeof body.repository_id === "string" ? body.repository_id : "";
963 const transcript =
964 typeof body.transcript === "string" ? body.transcript : "";
965 if (!repositoryId) {
966 return c.json({ ok: false, error: "repository_id required" }, 400);
967 }
968 if (!transcript.trim()) {
969 return c.json({ ok: false, error: "transcript is empty" }, 400);
970 }
971
972 // Confirm the user owns this repo. We don't have collaborators here yet —
973 // matches the spec-to-PR convention.
974 const repos = await listUserRepos(user.id);
975 if (!repos.find((r) => r.id === repositoryId)) {
976 return c.json({ ok: false, error: "you can only ship to your own repos" }, 403);
977 }
978
979 // Re-interpret so the committed spec has a polished title + body. Cheap —
980 // the route POSTs once, this Claude call is fire-and-wait but bounded.
981 const interp = await interpretVoiceTranscript({ transcript });
982 const interpretation = interp.ok ? interp.suggestion : undefined;
983
984 const res = await shipAsSpec({
985 repositoryId,
986 transcript,
987 userId: user.id,
988 interpretation,
989 });
990 if (!res.ok) return c.json(res, 400);
991 return c.json({
992 ok: true,
993 spec_path: res.specPath,
994 commit_sha: res.commitSha,
995 branch: res.branch,
996 });
997});
998
999// ---------------------------------------------------------------------------
1000// POST /voice/issue — file an issue
1001// ---------------------------------------------------------------------------
1002
1003voice.post("/voice/issue", requireAuth, async (c) => {
1004 const user = c.get("user")!;
1005 let body: { repository_id?: unknown; transcript?: unknown } = {};
1006 try {
1007 body = (await c.req.json()) as {
1008 repository_id?: unknown;
1009 transcript?: unknown;
1010 };
1011 } catch {
1012 return c.json({ ok: false, error: "invalid JSON body" }, 400);
1013 }
1014 const repositoryId =
1015 typeof body.repository_id === "string" ? body.repository_id : "";
1016 const transcript =
1017 typeof body.transcript === "string" ? body.transcript : "";
1018 if (!repositoryId) {
1019 return c.json({ ok: false, error: "repository_id required" }, 400);
1020 }
1021 if (!transcript.trim()) {
1022 return c.json({ ok: false, error: "transcript is empty" }, 400);
1023 }
1024
1025 // Same ownership guard as /voice/ship.
1026 const repos = await listUserRepos(user.id);
1027 if (!repos.find((r) => r.id === repositoryId)) {
1028 return c.json({ ok: false, error: "you can only file issues on your own repos" }, 403);
1029 }
1030
1031 const interp = await interpretVoiceTranscript({ transcript });
1032 const interpretation = interp.ok ? interp.suggestion : undefined;
1033
1034 const res = await createIssueFromVoice({
1035 repositoryId,
1036 transcript,
1037 userId: user.id,
1038 interpretation,
1039 });
1040 if (!res.ok) return c.json(res, 400);
1041
1042 // Fire-and-forget AI triage so the new issue gets the standard treatment.
1043 // We resolve the repo id back into an issue id via the returning() above,
1044 // but the helper exported from voice-to-pr only surfaces issueNumber. The
1045 // triage trigger expects an issueId — call the lightweight variant only if
1046 // we can resolve it; otherwise skip silently.
1047 try {
1048 const { db } = await import("../db");
1049 const { issues } = await import("../db/schema");
1050 const { eq, and } = await import("drizzle-orm");
1051 const [row] = await db
1052 .select({ id: issues.id })
1053 .from(issues)
1054 .where(
1055 and(
1056 eq(issues.repositoryId, repositoryId),
1057 eq(issues.number, res.issueNumber)
1058 )
1059 )
1060 .limit(1);
1061 if (row) {
1062 triggerIssueTriage({
1063 ownerName: res.ownerName,
1064 repoName: res.repoName,
1065 repositoryId,
1066 issueId: row.id,
1067 issueNumber: res.issueNumber,
1068 authorId: user.id,
1069 title: interpretation?.title || transcript.slice(0, 80),
1070 body: interpretation?.body_markdown || transcript,
1071 }).catch((err) => {
1072 if (process.env.DEBUG_VOICE === "1") {
1073 console.warn(
1074 "[voice] issue triage trigger failed:",
1075 err instanceof Error ? err.message : err
1076 );
1077 }
1078 });
1079 }
1080 } catch {
1081 /* triage is best-effort */
1082 }
1083
1084 return c.json({
1085 ok: true,
1086 issue_number: res.issueNumber,
1087 url: `/${res.ownerName}/${res.repoName}/issues/${res.issueNumber}`,
1088 });
1089});
1090
1091export default voice;