CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Add autonomous build rules to CLAUDE.md + scaffold new UI features #3949

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/scale-team-capacity-sDcUWmainopened Apr 5, 2026
1 changed file+153−13
Modifiedapp/app/page.tsx+153−13View fileUnifiedSplit
1616 error?: string;
1717 duration?: number;
1818}
19interface HistoryItem { id: string; raw: string; enhanced: string; mode: DocMode; date: string; }
19interface HistoryItem { id: string; raw: string; enhanced: string; mode: DocMode; date: string; audioData?: string; audioMimeType?: string; }
2020interface HotkeyConfig { record: string; enhance: string; copy: string; clear: string; export: string; }
2121
2222// === Mode Configs ===
4444}
4545function saveJSON(key: string, value: any) {
4646 if (typeof window === 'undefined') return;
47 try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
47 try {
48 localStorage.setItem(key, JSON.stringify(value));
49 } catch (e) {
50 // If quota exceeded and saving history, retry without audio data
51 if (key === 'av_history' && Array.isArray(value)) {
52 try {
53 const stripped = value.map((item: any) => ({ ...item, audioData: undefined, audioMimeType: undefined }));
54 localStorage.setItem(key, JSON.stringify(stripped));
55 } catch { /* truly full, nothing we can do */ }
56 }
57 }
4858}
4959
5060// === Main Component ===
102112 const [selectedTemplate, setSelectedTemplate] = useState<DocumentTemplate | null>(null);
103113 const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, string>>({});
104114
115 // Audio playback state
116 const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
117 const [audioProgress, setAudioProgress] = useState(0);
118 const audioElementRef = useRef<HTMLAudioElement | null>(null);
119 const lastRecordedAudioRef = useRef<{ base64: string; mimeType: string } | null>(null);
120
105121 // Refs
106122 const mediaRecorderRef = useRef<MediaRecorder | null>(null);
107123 const chunksRef = useRef<Blob[]>([]);
295311 recorder.onstop = async () => {
296312 stream.getTracks().forEach(t => t.stop());
297313 if (timerRef.current) clearInterval(timerRef.current);
314
315 // Save audio as base64 for playback (cap at 5MB)
316 const fullBlob = new Blob(chunksRef.current, { type: mimeType });
317 if (fullBlob.size > 0 && fullBlob.size <= 5 * 1024 * 1024) {
318 try {
319 const reader = new FileReader();
320 reader.onloadend = () => {
321 if (typeof reader.result === 'string') {
322 lastRecordedAudioRef.current = { base64: reader.result, mimeType };
323 }
324 };
325 reader.readAsDataURL(fullBlob);
326 } catch { /* audio save is best-effort */ }
327 } else {
328 lastRecordedAudioRef.current = null;
329 }
330
298331 // In live mode, skip the final full-blob transcription (text already streamed)
299332 if (transcriptionMode === 'live') return;
300 const blob = new Blob(chunksRef.current, { type: mimeType });
333 const blob = fullBlob;
301334 if (blob.size < 500) { setError('Recording too short'); return; }
302335 await transcribeAudio(blob, mimeType);
303336 };
413446 }
414447 }
415448
416 // Save to history
449 // Save to history (include audio if available and not in privacy mode)
450 const audioSnapshot = lastRecordedAudioRef.current;
451 lastRecordedAudioRef.current = null;
417452 setHistory(prev => {
418453 const item: HistoryItem = {
419454 id: Date.now().toString(),
421456 enhanced: '', // Will be updated below
422457 mode,
423458 date: new Date().toISOString(),
459 ...(audioSnapshot && !privacyMode ? { audioData: audioSnapshot.base64, audioMimeType: audioSnapshot.mimeType } : {}),
424460 };
425461 const updated = [item, ...prev].slice(0, 50);
426 saveJSON('av_history', updated);
462 // Try saving; if localStorage is full, save without audio
463 try {
464 saveJSON('av_history', updated);
465 } catch {
466 const withoutAudio = updated.map(h => ({ ...h, audioData: undefined, audioMimeType: undefined }));
467 saveJSON('av_history', withoutAudio);
468 }
427469 return updated;
428470 });
429471 } catch (err: any) {
642684 const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
643685 const formatDate = (iso: string) => new Date(iso).toLocaleDateString('en-NZ', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
644686
687 // === Audio Playback ===
688 const toggleAudioPlayback = useCallback((itemId: string, audioData: string) => {
689 // If already playing this item, pause it
690 if (playingAudioId === itemId && audioElementRef.current) {
691 audioElementRef.current.pause();
692 setPlayingAudioId(null);
693 setAudioProgress(0);
694 return;
695 }
696
697 // Stop any current playback
698 if (audioElementRef.current) {
699 audioElementRef.current.pause();
700 audioElementRef.current = null;
701 }
702
703 const audio = new Audio(audioData);
704 audioElementRef.current = audio;
705 setPlayingAudioId(itemId);
706 setAudioProgress(0);
707
708 audio.addEventListener('timeupdate', () => {
709 if (audio.duration > 0) {
710 setAudioProgress(audio.currentTime / audio.duration);
711 }
712 });
713
714 audio.addEventListener('ended', () => {
715 setPlayingAudioId(null);
716 setAudioProgress(0);
717 audioElementRef.current = null;
718 });
719
720 audio.addEventListener('error', () => {
721 setPlayingAudioId(null);
722 setAudioProgress(0);
723 audioElementRef.current = null;
724 });
725
726 audio.play().catch(() => {
727 setPlayingAudioId(null);
728 audioElementRef.current = null;
729 });
730 }, [playingAudioId]);
731
732 // Cleanup audio on unmount
733 useEffect(() => {
734 return () => {
735 if (audioElementRef.current) {
736 audioElementRef.current.pause();
737 audioElementRef.current = null;
738 }
739 };
740 }, []);
741
645742 // === Render ===
646743 return (
647744 <div className="h-full flex flex-col">
882979 return filtered.map(item => {
883980 const modeLabel = MODES.find(m => m.value === item.mode)?.label || item.mode;
884981 const preview = item.raw.slice(0, 120);
982 const isPlaying = playingAudioId === item.id;
885983 return (
886 <button
887 key={item.id}
888 onClick={() => loadFromHistory(item)}
889 className="w-full text-left bg-ink-800/50 hover:bg-ink-800 rounded-lg px-3 py-2 transition-colors"
890 >
891 <p className="text-xs text-ink-400 mb-1">{formatDate(item.date)} · {modeLabel}</p>
892 <p className="text-sm text-ink-200 line-clamp-2">{preview}...</p>
893 </button>
984 <div key={item.id} className="relative">
985 <button
986 onClick={() => loadFromHistory(item)}
987 className="w-full text-left bg-ink-800/50 hover:bg-ink-800 rounded-lg px-3 py-2 transition-colors"
988 >
989 <div className="flex items-center justify-between mb-1">
990 <p className="text-xs text-ink-400">{formatDate(item.date)} · {modeLabel}</p>
991 {item.audioData && (
992 <span
993 role="button"
994 tabIndex={0}
995 onClick={(e) => {
996 e.stopPropagation();
997 toggleAudioPlayback(item.id, item.audioData!);
998 }}
999 onKeyDown={(e) => {
1000 if (e.key === 'Enter' || e.key === ' ') {
1001 e.stopPropagation();
1002 e.preventDefault();
1003 toggleAudioPlayback(item.id, item.audioData!);
1004 }
1005 }}
1006 className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-ink-700/50 transition-colors group"
1007 title={isPlaying ? 'Pause audio' : 'Play recording'}
1008 >
1009 {isPlaying ? (
1010 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-[#c4a23a]">
1011 <rect x="2" y="2" width="3" height="8" rx="0.5" fill="currentColor" />
1012 <rect x="7" y="2" width="3" height="8" rx="0.5" fill="currentColor" />
1013 </svg>
1014 ) : (
1015 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="text-[#c4a23a] group-hover:text-[#d4b24a]">
1016 <path d="M3 1.5V10.5L10.5 6L3 1.5Z" fill="currentColor" />
1017 </svg>
1018 )}
1019 </span>
1020 )}
1021 </div>
1022 {/* Audio progress bar */}
1023 {isPlaying && (
1024 <div className="w-full h-0.5 bg-ink-700 rounded-full mb-1.5 overflow-hidden">
1025 <div
1026 className="h-full bg-[#c4a23a] rounded-full transition-all duration-200"
1027 style={{ width: `${audioProgress * 100}%` }}
1028 />
1029 </div>
1030 )}
1031 <p className="text-sm text-ink-200 line-clamp-2">{preview}...</p>
1032 </button>
1033 </div>
8941034 );
8951035 });
8961036 })()}
8971037
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts