diff --git a/prototype/.gitignore b/prototype/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/prototype/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/prototype/README.md b/prototype/README.md
new file mode 100644
index 00000000..06c7b81b
--- /dev/null
+++ b/prototype/README.md
@@ -0,0 +1,25 @@
+# AI Transcription Tab Prototype
+
+A standalone UI prototype for the AI transcription/insights feature.
+
+## Setup
+
+```bash
+cd prototype
+npm install
+```
+
+## Running
+
+```bash
+npm run dev
+```
+
+Then open http://localhost:5173 in your browser.
+
+## Notes
+
+- This is a self-contained Vite + React app with Tailwind CSS
+- The prototype is in `src/ai-transcription-tab.jsx`
+- All data is mocked - no backend required
+- Node.js 20.19+ or 22.12+ is recommended (though it works with 21.x with warnings)
diff --git a/prototype/eslint.config.js b/prototype/eslint.config.js
new file mode 100644
index 00000000..4fa125da
--- /dev/null
+++ b/prototype/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/prototype/index.html b/prototype/index.html
new file mode 100644
index 00000000..5424e736
--- /dev/null
+++ b/prototype/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
{ e.stopPropagation(); setIsOpen(!isOpen); }} className="px-2 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded hover:bg-indigo-100 transition-colors flex items-center gap-1">
+ + Add to session
+
+
+ {isOpen && (
+ <>
+
{ e.stopPropagation(); setIsOpen(false); }}
+ />
+
+
Add to upcoming session
+ {sessions.map((session) => (
+
{ e.stopPropagation(); onAdd(session); setIsOpen(false); }} className="w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex items-center justify-between">
+ {session.date}
{session.time}
+ {session.label && {session.label} }
+
+ ))}
+
+ >
+ )}
+
+ );
+};
+
+const TimeSpentWithChart = ({ timeSpent, itemName, allTopics, totalDuration }) => {
+ const [isHovered, setIsHovered] = useState(false);
+
+ // Calculate percentages for pie chart
+ const percentage = Math.round((timeSpent / totalDuration) * 100);
+ const otherTopics = allTopics.filter(t => t.name !== itemName);
+
+ // Build pie chart segments
+ const segments = [];
+ let currentAngle = 0;
+
+ // Add current topic first (highlighted)
+ const currentTopicAngle = (timeSpent / totalDuration) * 360;
+ segments.push({
+ name: itemName,
+ angle: currentTopicAngle,
+ startAngle: currentAngle,
+ percentage: percentage,
+ isHighlighted: true
+ });
+ currentAngle += currentTopicAngle;
+
+ // Add other topics
+ otherTopics.forEach(topic => {
+ const angle = (topic.timeSpent / totalDuration) * 360;
+ segments.push({
+ name: topic.name,
+ angle: angle,
+ startAngle: currentAngle,
+ percentage: Math.round((topic.timeSpent / totalDuration) * 100),
+ isHighlighted: false
+ });
+ currentAngle += angle;
+ });
+
+ // SVG pie chart helper
+ const describeArc = (startAngle, endAngle, radius = 40) => {
+ const start = polarToCartesian(50, 50, radius, endAngle);
+ const end = polarToCartesian(50, 50, radius, startAngle);
+ const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
+ return `M 50 50 L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`;
+ };
+
+ const polarToCartesian = (cx, cy, radius, angleInDegrees) => {
+ const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
+ return {
+ x: cx + (radius * Math.cos(angleInRadians)),
+ y: cy + (radius * Math.sin(angleInRadians))
+ };
+ };
+
+ const colors = [
+ '#6366f1', // indigo - highlighted
+ '#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569', '#334155', '#1e293b'
+ ];
+
+ return (
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+
+
{timeSpent}m
+
+ {isHovered && (
+
+
+
+
+ {segments.map((seg, i) => (
+
+ ))}
+
+ {percentage}%
+
+
+
{itemName}
+
{timeSpent}m of {totalDuration}m total
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+// Card Components
+const TopicCard = ({ topic, rank, onClick }) => (
+
+
{rank}
+
+
{topic.name}
+
{topic.frequency} mentions · View conversation →
+
+
+
+);
+
+const QuestionCard = ({ question, onClick }) => (
+
+
{question.timestamp}
+
"{question.text}"
+
View conversation →
+
+);
+
+const InsightCard = ({ insight }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const hasQuestions = insight.suggestedQuestions?.length > 0;
+ return (
+
hasQuestions && setIsExpanded(!isExpanded)}
+ >
+
+
{insight.speaker} • {insight.timestamp}
+
+
+ {hasQuestions && (
+
+ )}
+
+
+
{insight.text}
+ {isExpanded && hasQuestions && (
+
+
+
+ Deepening question:
+ AI Coach
+
+
+ {insight.suggestedQuestions.map((question, i) => (
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const DetectedActionCard = ({ action, onAdd, isAdded }) => (
+
+
+
+
{action.text}
+
{action.owner} • {action.dueDate} • {action.timestamp}
+
+ {isAdded ? (
+
Added
+ ) : (
+
+ Add to Actions
+ )}
+
+
+);
+
+const DetectedAgreementCard = ({ agreement, onAdd, isAdded }) => (
+
+
+
{agreement.text}
{agreement.timestamp}
+ {isAdded ? (
+
Added
+ ) : (
+
+ Add to Agreements
+ )}
+
+
+);
+
+const DetectedFrustrationCard = ({ frustration }) => (
+
+
+
+
+
"{frustration.text}"
+
{frustration.speaker} • {frustration.timestamp}
+
+
+
+);
+
+const DetectedBlockerCard = ({ blocker }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const hasExpandableContent = blocker.unblockingNeeds?.length > 0 || blocker.suggestedQuestions?.length > 0;
+ return (
+
hasExpandableContent && setIsExpanded(!isExpanded)}>
+
+
+
+
{blocker.text}
+
+
{blocker.speaker} • {blocker.timestamp}
+ {hasExpandableContent && (
+
+
{isExpanded ? 'Hide details' : 'View details'}
+
+
+ )}
+
+
+
+ {isExpanded && (
+
+ {blocker.unblockingNeeds?.length > 0 && (
+
+
+
+ What {blocker.speaker} said they'd need to get unblocked:
+
+
+ {blocker.unblockingNeeds.map((need, i) => (
+
+
+
"{need.text}"
{need.timestamp}
+
+ ))}
+
+
+ )}
+ {blocker.suggestedQuestions?.length > 0 && (
+
+
+
+ Suggested followup question:
+ AI Coach
+
+
+ {blocker.suggestedQuestions.map((question, i) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+};
+
+// Drawer Components
+const ThreadDrawer = ({ question, onClose, isJim }) => {
+ const [selectedFork, setSelectedFork] = useState(null);
+
+ if (!question) return null;
+
+ const getConclusionStyle = (type) => {
+ switch (type) {
+ case 'breakthrough': return { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', icon: '✨' };
+ case 'action_item': return { bg: 'bg-emerald-50', border: 'border-emerald-200', text: 'text-emerald-700', icon: '✓' };
+ case 'resolved': return { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', icon: '●' };
+ case 'unresolved': return { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', icon: '◐' };
+ case 'pivoted': return { bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-600', icon: '↪' };
+ default: return { bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-600', icon: '○' };
+ }
+ };
+
+ // If viewing a specific fork's thread
+ if (selectedFork) {
+ const fork = question.forks.find(f => f.id === selectedFork);
+ return (
+
+
+
+
+
+
setSelectedFork(null)} className="p-1 hover:bg-slate-100 rounded transition-colors">
+
+
+
+
+ {fork.emoji} {fork.label}
+
+
{fork.exchangeCount} exchanges
+
+
+
+
+
+
+ {fork.thread.map((msg, i) => (
+
+
+
+ {msg.speaker === 'jim' &&
J
}
+
{msg.name} • {msg.timestamp}
+ {msg.speaker !== 'jim' &&
M
}
+
+
+ {msg.confidence === 'medium' && (
+
May be tangentially related
+ )}
+
+
+ ))}
+
+
+ {/* Conclusion */}
+
+
+
{getConclusionStyle(fork.conclusion.type).icon}
+
+
+ {fork.conclusion.type === 'breakthrough' && 'Breakthrough moment'}
+ {fork.conclusion.type === 'action_item' && 'Led to action item'}
+ {fork.conclusion.type === 'resolved' && 'Thread resolved'}
+ {fork.conclusion.type === 'unresolved' && 'Thread unresolved'}
+
+
{fork.conclusion.text}
+ {fork.conclusion.type === 'unresolved' && (
+
+ + Add to next session agenda
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ // Thread map view (for questions with forks)
+ if (question.hasForks) {
+ return (
+
+
+
+
+
Thread Map {question.timestamp}
+
+
+
+ {/* Anchor question */}
+
+
+
{isJim ? 'J' : 'M'}
+
{isJim ? 'Jim Hodapp' : 'Mark Richardson'}
+
+
+
"{question.text}"
+
+
+ {/* Thread map visualization */}
+
+
This question led to {question.forks.length} threads
+
+ {/* Visual tree */}
+
+ {/* Vertical connector line */}
+
+
+ {question.forks.map((fork, i) => (
+
+ {/* Horizontal connector */}
+
+
+ {/* Fork card */}
+
setSelectedFork(fork.id)}
+ className="w-full text-left p-4 bg-white rounded-xl border border-slate-200 hover:border-indigo-300 hover:shadow-md transition-all group"
+ >
+
+
+ {fork.emoji}
+
+
+
+
{fork.label}
+ {fork.exchangeCount} exchanges
+
+
{fork.summary}
+
+ {/* Conclusion badge */}
+
+ {getConclusionStyle(fork.conclusion.type).icon}
+ {fork.conclusion.type === 'breakthrough' && 'Breakthrough'}
+ {fork.conclusion.type === 'action_item' && 'Action item'}
+ {fork.conclusion.type === 'resolved' && 'Resolved'}
+ {fork.conclusion.type === 'unresolved' && 'Unresolved'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Timeline visualization */}
+
+
Timeline
+
+ {question.forks.map((fork, i) => {
+ const colors = ['bg-indigo-400', 'bg-emerald-400', 'bg-amber-400'];
+ const widths = [35, 25, 20]; // Approximate percentages
+ const lefts = [0, 38, 66];
+ return (
+
setSelectedFork(fork.id)}
+ />
+ );
+ })}
+
+
+ 18:22
+ 23:00
+
+
+ {question.forks.map((fork, i) => {
+ const colors = ['bg-indigo-400', 'bg-emerald-400', 'bg-amber-400'];
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+ }
+
+ // Default single-thread view (with interruptions, conclusions, confidence)
+ return (
+
+
+
+
+
Question Thread {question.timestamp}
+
+
+
+
+
{isJim ? 'J' : 'M'}
+
{isJim ? 'Jim Hodapp' : 'Mark Richardson'}
+
+
+
"{question.text}"
+
+
+ {question.thread?.map((msg, i) => {
+ const prevMsg = i > 0 ? question.thread[i - 1] : null;
+ const showGapStart = msg.isInterruption || (msg.isGap && !prevMsg?.isGap && !prevMsg?.isInterruption);
+ const showReturnMarker = msg.isReturn;
+ const isInGap = msg.isGap || msg.isInterruption;
+
+ return (
+
+ {/* Gap start divider */}
+ {showGapStart && (
+
+
+
+
+ Thread interrupted
+
+
+
+ )}
+
+ {/* Return marker */}
+ {showReturnMarker && (
+
+
+
+
+ Thread resumed
+
+
+
+ )}
+
+
+
+
+ {msg.speaker === 'jim' &&
J
}
+
{msg.name} • {msg.timestamp}
+ {msg.speaker !== 'jim' &&
M
}
+
+
+ {isInGap && (
+
Off-topic — scheduling
+ )}
+ {msg.confidence === 'medium' && !isInGap && (
+
May be tangentially related
+ )}
+
+
+
+ );
+ })}
+
+
+ {/* Thread conclusion panel */}
+ {question.conclusion && (
+
+
+
{getConclusionStyle(question.conclusion.type).icon}
+
+
+
+ {question.conclusion.type === 'breakthrough' && 'Breakthrough moment'}
+ {question.conclusion.type === 'action_item' && 'Led to action item'}
+ {question.conclusion.type === 'resolved' && 'Thread resolved'}
+ {question.conclusion.type === 'unresolved' && 'Thread unresolved'}
+ {question.conclusion.type === 'pivoted' && 'Thread pivoted'}
+
+ {/* Confidence indicator */}
+ {question.conclusion.confidence && (
+
+ {question.conclusion.confidence === 'high' ? '● Clear ending' : '◐ Fuzzy ending'}
+
+ )}
+
+
{question.conclusion.text}
+ {question.conclusion.timestamp && (
+
at {question.conclusion.timestamp}
+ )}
+ {question.conclusion.type === 'unresolved' && (
+
+ + Add to next session agenda
+
+ )}
+ {question.conclusion.type === 'pivoted' && (
+
+
+ + Add to next session agenda
+
+ Revisit this thread?
+
+ )}
+
+
+
+ )}
+
+
+ );
+};
+
+const TopicThreadDrawer = ({ topic, onClose }) => {
+ if (!topic) return null;
+ const getResonanceLabel = (level) => level >= 80 ? 'Resonates' : level >= 60 ? 'Neutral' : "Doesn't resonate";
+ const getResonanceColor = (level) => level >= 80 ? 'bg-emerald-100 text-emerald-700' : level >= 60 ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-600';
+ return (
+
+
+
+
+
Topic Thread {topic.frequency} mentions in session
+
+
+
+
{topic.name} {getResonanceLabel(topic.enthusiasm)}
+
Sample conversation from this topic
+
+
+ {topic.thread?.map((msg, i) => (
+
+
+
+ {msg.speaker === 'jim' &&
J
}
+
{msg.name} • {msg.timestamp}
+ {msg.speaker !== 'jim' &&
M
}
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+const TranscriptMessage = ({ message, isCoach, onContinue, isContinued, sessions, addedToSession }) => (
+
+
+
+ {isCoach &&
{message.name[0]}
}
+
{message.name} • {message.timestamp}
+ {!isCoach &&
{message.name[0]}
}
+
+
+
+
+);
+
+// Main Component
+export default function InsightsTab() {
+ const [activeTab, setActiveTab] = useState('overview');
+ const [mainTab, setMainTab] = useState('debrief');
+ const [selectedQuestion, setSelectedQuestion] = useState(null);
+ const [isQuestionFromJim, setIsQuestionFromJim] = useState(true);
+ const [selectedTopic, setSelectedTopic] = useState(null);
+ const [addedActions, setAddedActions] = useState({});
+ const [addedAgreements, setAddedAgreements] = useState({});
+ const [addedToSession, setAddedToSession] = useState({});
+ const [continuedTranscriptMessages, setContinuedTranscriptMessages] = useState({});
+ const [transcriptSearch, setTranscriptSearch] = useState('');
+ const insights = mockInsights;
+
+ const handleQuestionClick = (question, isJim) => { setSelectedQuestion(question); setIsQuestionFromJim(isJim); };
+ const closeDrawer = () => setSelectedQuestion(null);
+ const handleTopicClick = (topic) => setSelectedTopic(topic);
+ const closeTopicDrawer = () => setSelectedTopic(null);
+ const handleAddAction = (index) => setAddedActions(prev => ({ ...prev, [index]: true }));
+ const handleAddAgreement = (index) => setAddedAgreements(prev => ({ ...prev, [index]: true }));
+ const handleAddToSession = (key, session) => setAddedToSession(prev => ({ ...prev, [key]: session.date }));
+ const handleContinueTranscript = (index, session) => setContinuedTranscriptMessages(prev => ({ ...prev, [index]: session.date }));
+
+ return (
+
+ {/* Header */}
+
+
+
+
/
+
+
Feb 1, 2026 • 9:30 AM
+
+
+ Goal:
+ Articulate a clear new client relationship development strategy
+
+
+
+ {/* Main Tabs */}
+
+
+ {['Notes', 'Agreements', 'Actions', 'Debrief'].map(tab => (
+ setMainTab(tab.toLowerCase())} className={`px-3 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${mainTab === tab.toLowerCase() ? 'border-slate-900 text-slate-900' : 'border-transparent text-slate-500 hover:text-slate-700'}`}>{tab}
+ ))}
+
+
+
+ {mainTab === 'debrief' && (
+
+ {/* Sub Tabs */}
+
+ {['overview', 'questions', 'insights', 'transcript'].map(section => (
+ setActiveTab(section)} className={`px-3 py-1.5 text-xs font-medium rounded-md capitalize whitespace-nowrap ${activeTab === section ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'}`}>{section}
+ ))}
+
+
+ {activeTab === 'overview' && (
+
+ {/* Session Summary */}
+
+
+
+
+
Session Summary {insights.sessionDate}
+
+ {insights.sessionDuration.total}m total
+ {insights.sessionDuration.focused}m focused
+ {insights.sessionDuration.unfocused}m off-topic
+
+
+
Session Goal
"{insights.sessionGoal}"
+
+
+
+
+ {/* Stats Grid Row 1 */}
+
+
{insights.topics.length}
Topics Discussed
+
{insights.keyQuestions.jim.length + insights.keyQuestions.mark.length}
Questions Asked
+
{insights.keyInsights.length}
Insights Surfaced
+
{insights.detectedAgreements.length}
Agreements Made
+
+ {/* Stats Grid Row 2 */}
+
+
{insights.detectedActions.length}
Actions Identified
+
{insights.detectedBlockers.length}
Blockers Detected
+
+
+
+
+ {/* Topics Covered */}
+
+
Topics Covered
+
Breakdown of session content by category
+
+ {/* Combine all topics for pie chart calculation */}
+ {(() => {
+ const allTopicsForChart = [
+ ...insights.goalsDiscussed.filter(g => g.discussed).map(g => ({ name: g.name, timeSpent: g.timeSpent })),
+ ...insights.agendaItems.filter(a => a.discussed).map(a => ({ name: a.name, timeSpent: a.timeSpent })),
+ ...insights.generalThemes.map(t => ({ name: t.name, timeSpent: t.timeSpent })),
+ ];
+ const totalDiscussedTime = allTopicsForChart.reduce((sum, t) => sum + t.timeSpent, 0);
+
+ return (
+ <>
+ {/* Goals Discussed */}
+
+
+
+
Goals Discussed
+
Progress on stated objectives
+
+
+ {insights.goalsDiscussed.map((item, i) => (
+
+
+ {item.discussed ? (
+
+
+
handleAddToSession(`goal-${i}`, session)} isAdded={!!addedToSession[`goal-${i}`]} addedToSession={addedToSession[`goal-${i}`]} />
+
+ ) : (
+
+
Not discussed
+
handleAddToSession(`goal-${i}`, session)} isAdded={!!addedToSession[`goal-${i}`]} addedToSession={addedToSession[`goal-${i}`]} />
+
+ )}
+
+ ))}
+
+
+
+ {/* Planned Agenda Items */}
+
+
+
+
Planned Agenda Items
+
Pre-set topics for this session
+
+
+ {insights.agendaItems.map((item, i) => (
+
+
+ {item.discussed ? (
+
+
+
handleAddToSession(`agenda-${i}`, session)} isAdded={!!addedToSession[`agenda-${i}`]} addedToSession={addedToSession[`agenda-${i}`]} />
+
+ ) : (
+
+
Not discussed
+
handleAddToSession(`agenda-${i}`, session)} isAdded={!!addedToSession[`agenda-${i}`]} addedToSession={addedToSession[`agenda-${i}`]} />
+
+ )}
+
+ ))}
+
+
+
+ {/* General Themes */}
+
+
+
+
General Themes
+
Organic topics that emerged
+
+
+ {insights.generalThemes.map((item, i) => (
+
+
+
+
+
handleAddToSession(`theme-${i}`, session)} isAdded={!!addedToSession[`theme-${i}`]} addedToSession={addedToSession[`theme-${i}`]} />
+
+
+ ))}
+
+
+ >
+ );
+ })()}
+
+
+ {insights.agendaItems.filter(a => a.discussed).length + insights.goalsDiscussed.filter(g => g.discussed).length} topics covered
+ {insights.agendaItems.filter(a => !a.discussed).length + insights.goalsDiscussed.filter(g => !g.discussed).length} items remaining
+
+
+
+ )}
+
+ {activeTab === 'insights' && (
+
+
+
Discussion Topics
+
Topics ranked by discussion frequency
+
{insights.topics.map((t, i) => handleTopicClick(t)} />)}
+
+
+
✨ Breakthrough Moments
+
Key realizations and insights from the session
+
{insights.keyInsights.map((ins, i) => )}
+
+
+
📋 Actions Detected
+
Action items mentioned during the session
+
{insights.detectedActions.map((action, i) => handleAddAction(i)} isAdded={addedActions[i]} />)}
+
+
+
🤝 Agreements Detected
+
Agreements made during the session
+
{insights.detectedAgreements.map((agreement, i) => handleAddAgreement(i)} isAdded={addedAgreements[i]} />)}
+
+
+
🚧 Blockers Detected
+
Obstacles preventing progress — click to see unblocking needs
+
{insights.detectedBlockers.map((b, i) => )}
+
+
+ )}
+
+ {activeTab === 'questions' && (
+
+
+
+
{insights.keyQuestions.jim.map((q, i) => handleQuestionClick(q, true)} />)}
+
+
+
+
{insights.keyQuestions.mark.map((q, i) => handleQuestionClick(q, false)} />)}
+
+
+ )}
+
+ {activeTab === 'transcript' && (
+
+
+
Session Transcript 84:00 duration
+
Export
+
+
+
+
+
+
setTranscriptSearch(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
+ />
+ {transcriptSearch && (
+
setTranscriptSearch('')}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+
+
+
+
+ )}
+
+
+ {insights.transcriptExcerpts
+ .filter(msg => !transcriptSearch || msg.text.toLowerCase().includes(transcriptSearch.toLowerCase()) || msg.name.toLowerCase().includes(transcriptSearch.toLowerCase()))
+ .map((msg, i) => (
+
handleContinueTranscript(i, session)} isContinued={!!continuedTranscriptMessages[i]} addedToSession={continuedTranscriptMessages[i]} />
+ ))}
+ {transcriptSearch && insights.transcriptExcerpts.filter(msg => msg.text.toLowerCase().includes(transcriptSearch.toLowerCase()) || msg.name.toLowerCase().includes(transcriptSearch.toLowerCase())).length === 0 && (
+ No messages found matching "{transcriptSearch}"
+ )}
+
+
+
Continue this conversation
+
Use AI to explore topics from this session further
+
+
+ )}
+
+ )}
+
+ {mainTab !== 'debrief' && (
+
Switch to Debrief tab to see session analysis
+ )}
+
+
+
+
+ );
+}
diff --git a/prototype/src/assets/react.svg b/prototype/src/assets/react.svg
new file mode 100644
index 00000000..6c87de9b
--- /dev/null
+++ b/prototype/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/prototype/src/index.css b/prototype/src/index.css
new file mode 100644
index 00000000..b5c61c95
--- /dev/null
+++ b/prototype/src/index.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/prototype/src/main.jsx b/prototype/src/main.jsx
new file mode 100644
index 00000000..b9a1a6de
--- /dev/null
+++ b/prototype/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/prototype/tailwind.config.js b/prototype/tailwind.config.js
new file mode 100644
index 00000000..d37737fc
--- /dev/null
+++ b/prototype/tailwind.config.js
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
+
diff --git a/prototype/vite.config.js b/prototype/vite.config.js
new file mode 100644
index 00000000..8b0f57b9
--- /dev/null
+++ b/prototype/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/src/app/coaching-sessions/[id]/page.tsx b/src/app/coaching-sessions/[id]/page.tsx
index bc77b5d8..3691b2e4 100644
--- a/src/app/coaching-sessions/[id]/page.tsx
+++ b/src/app/coaching-sessions/[id]/page.tsx
@@ -11,11 +11,11 @@ import { OverarchingGoalContainer } from "@/components/ui/coaching-sessions/over
import { CoachingTabsContainer } from "@/components/ui/coaching-sessions/coaching-tabs-container";
import { EditorCacheProvider } from "@/components/ui/coaching-sessions/editor-cache-context";
-import CoachingSessionSelector from "@/components/ui/coaching-session-selector";
import { useRouter, useParams, useSearchParams } from "next/navigation";
import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship";
import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session";
import ShareSessionLink from "@/components/ui/share-session-link";
+import { MeetingControls } from "@/components/ui/coaching-sessions/meeting-controls";
import { toast } from "sonner";
import { ForbiddenError } from "@/components/ui/errors/forbidden-error";
import { EntityApiError } from "@/types/general";
@@ -87,11 +87,6 @@ export default function CoachingSessionsPage() {
);
}
- const handleCoachingSessionSelect = (coachingSessionId: string) => {
- console.debug("coachingSessionId selected: " + coachingSessionId);
- router.push(`/coaching-sessions/${coachingSessionId}`);
- };
-
const handleShareError = (error: Error) => {
toast.error("Failed to copy session link.");
};
@@ -122,16 +117,12 @@ export default function CoachingSessionsPage() {
locale={siteConfig.locale}
style={siteConfig.titleStyle}
/>
-
diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx
new file mode 100644
index 00000000..fe21652c
--- /dev/null
+++ b/src/app/settings/layout.tsx
@@ -0,0 +1,32 @@
+import type { Metadata } from "next";
+import "@/styles/globals.css";
+import { siteConfig } from "@/site.config.ts";
+
+import { SiteHeader } from "@/components/ui/site-header";
+import { AppSidebar } from "@/components/ui/app-sidebar";
+import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import { Toaster } from "@/components/ui/sonner";
+
+export const metadata: Metadata = {
+ title: `Settings | ${siteConfig.name}`,
+ description: "Manage your account and integration settings",
+};
+
+export default function SettingsLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
new file mode 100644
index 00000000..5cf8c83e
--- /dev/null
+++ b/src/app/settings/page.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { PageContainer } from "@/components/ui/page-container";
+import { SettingsContainer } from "@/components/ui/settings/settings-container";
+
+export default function SettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/coaching-session-selector.tsx b/src/components/ui/coaching-session-selector.tsx
index acbdf657..4660bcd6 100644
--- a/src/components/ui/coaching-session-selector.tsx
+++ b/src/components/ui/coaching-session-selector.tsx
@@ -168,7 +168,7 @@ export default function CoachingSessionSelector({
onValueChange={handleSetCoachingSession}
>
diff --git a/src/components/ui/coaching-sessions/actions-list.tsx b/src/components/ui/coaching-sessions/actions-list.tsx
index 7e2dff88..9cd90dc4 100644
--- a/src/components/ui/coaching-sessions/actions-list.tsx
+++ b/src/components/ui/coaching-sessions/actions-list.tsx
@@ -308,8 +308,10 @@ const ActionsList: React.FC<{
{action.due_by
- .setLocale(siteConfig.locale)
- .toLocaleString(DateTime.DATE_MED)}
+ ? action.due_by
+ .setLocale(siteConfig.locale)
+ .toLocaleString(DateTime.DATE_MED)
+ : "—"}
{action.created_at
@@ -330,7 +332,7 @@ const ActionsList: React.FC<{
setEditingActionId(action.id);
setNewBody(action.body ?? "");
setNewStatus(action.status);
- setNewDueBy(action.due_by);
+ setNewDueBy(action.due_by ?? DateTime.now());
}}
>
Edit
diff --git a/src/components/ui/coaching-sessions/ai-suggestion-card.tsx b/src/components/ui/coaching-sessions/ai-suggestion-card.tsx
new file mode 100644
index 00000000..ca27b04b
--- /dev/null
+++ b/src/components/ui/coaching-sessions/ai-suggestion-card.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { useState } from "react";
+import { Check, X, Loader2, Target, Handshake, Users } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/components/lib/utils";
+import { AiSuggestedItem, AiSuggestionType } from "@/types/meeting-recording";
+import { useAiSuggestionMutation } from "@/lib/api/ai-suggestions";
+import { toast } from "sonner";
+import { Id } from "@/types/general";
+
+interface AiSuggestionCardProps {
+ suggestion: AiSuggestedItem;
+ onAction?: () => void;
+ className?: string;
+ /** Coach user ID for displaying assignee labels */
+ coachId?: Id;
+ /** Coachee user ID for displaying assignee labels */
+ coacheeId?: Id;
+}
+
+/**
+ * Gets a display label for a user ID (Coach, Coachee, or unknown).
+ */
+function getUserLabel(userId: Id | null, coachId?: Id, coacheeId?: Id): string | null {
+ if (!userId) return null;
+ if (coachId && userId === coachId) return "Coach";
+ if (coacheeId && userId === coacheeId) return "Coachee";
+ return null;
+}
+
+/**
+ * Renders a single AI suggestion with accept/dismiss actions.
+ * When accepted, creates the corresponding Action or Agreement.
+ */
+export function AiSuggestionCard({
+ suggestion,
+ onAction,
+ className,
+ coachId,
+ coacheeId,
+}: AiSuggestionCardProps) {
+ const [isAccepting, setIsAccepting] = useState(false);
+ const [isDismissing, setIsDismissing] = useState(false);
+ const { accept, dismiss } = useAiSuggestionMutation();
+
+ const isAction = suggestion.item_type === AiSuggestionType.Action;
+ const Icon = isAction ? Target : Handshake;
+ const typeLabel = isAction ? "Action" : "Agreement";
+
+ // Get display labels for stated_by and assigned_to users
+ const statedByLabel = getUserLabel(suggestion.stated_by_user_id, coachId, coacheeId);
+ const assignedToLabel = getUserLabel(suggestion.assigned_to_user_id, coachId, coacheeId);
+
+ const handleAccept = async () => {
+ setIsAccepting(true);
+ try {
+ const result = await accept(suggestion.id);
+ toast.success(`${typeLabel} added successfully`, {
+ description: `Created new ${result.entity_type} from AI suggestion.`,
+ });
+ onAction?.();
+ } catch (error) {
+ toast.error(`Failed to add ${typeLabel.toLowerCase()}`, {
+ description: error instanceof Error ? error.message : "Please try again.",
+ });
+ } finally {
+ setIsAccepting(false);
+ }
+ };
+
+ const handleDismiss = async () => {
+ setIsDismissing(true);
+ try {
+ await dismiss(suggestion.id);
+ toast.info("Suggestion dismissed");
+ onAction?.();
+ } catch (error) {
+ toast.error("Failed to dismiss suggestion", {
+ description: error instanceof Error ? error.message : "Please try again.",
+ });
+ } finally {
+ setIsDismissing(false);
+ }
+ };
+
+ const isLoading = isAccepting || isDismissing;
+
+ return (
+
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Content */}
+
+
+
+ {typeLabel}
+
+
+ {/* Assignee badges for actions */}
+ {isAction && assignedToLabel && (
+
+
+
+
+ → {assignedToLabel}
+
+
+
+ Assigned to {assignedToLabel}
+
+
+
+ )}
+
+ {/* Mutual commitment badge for agreements */}
+ {!isAction && (
+
+
+
+
+
+ Mutual
+
+
+
+ Mutual commitment between coach and coachee
+
+
+
+ )}
+
+ {/* Stated by badge (if known) */}
+ {statedByLabel && (
+
+ Stated by {statedByLabel}
+
+ )}
+
+ {suggestion.confidence && (
+
+ {Math.round(suggestion.confidence * 100)}% confident
+
+ )}
+
+
{suggestion.content}
+ {suggestion.source_text && (
+
+ “{suggestion.source_text}”
+
+ )}
+
+
+ {/* Actions */}
+
+
+ {isAccepting ? (
+
+ ) : (
+
+ )}
+ Add
+
+
+ {isDismissing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/coaching-sessions/ai-suggestions-panel.tsx b/src/components/ui/coaching-sessions/ai-suggestions-panel.tsx
new file mode 100644
index 00000000..7c366640
--- /dev/null
+++ b/src/components/ui/coaching-sessions/ai-suggestions-panel.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { Bot } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { AiSuggestedItem, AiSuggestionType } from "@/types/meeting-recording";
+import { AiSuggestionCard } from "./ai-suggestion-card";
+
+interface AiSuggestionsPanelProps {
+ suggestions: AiSuggestedItem[];
+ onSuggestionAction?: () => void;
+}
+
+/**
+ * Panel displaying AI-detected actions and agreements.
+ * Groups suggestions by type and provides accept/dismiss actions.
+ */
+export function AiSuggestionsPanel({
+ suggestions,
+ onSuggestionAction,
+}: AiSuggestionsPanelProps) {
+ // Group suggestions by type
+ const actions = suggestions.filter((s) => s.item_type === AiSuggestionType.Action);
+ const agreements = suggestions.filter((s) => s.item_type === AiSuggestionType.Agreement);
+
+ if (suggestions.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ AI-Detected Items
+
+ ({suggestions.length} suggestion{suggestions.length !== 1 ? "s" : ""})
+
+
+
+
+ {/* Action Items */}
+ {actions.length > 0 && (
+
+
+ Action Items ({actions.length})
+
+
+ {actions.map((suggestion) => (
+
+ ))}
+
+
+ )}
+
+ {/* Agreements */}
+ {agreements.length > 0 && (
+
+
+ Agreements ({agreements.length})
+
+
+ {agreements.map((suggestion) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/coaching-sessions/coaching-tabs-container.tsx b/src/components/ui/coaching-sessions/coaching-tabs-container.tsx
index 2795e8cd..afdf44ed 100644
--- a/src/components/ui/coaching-sessions/coaching-tabs-container.tsx
+++ b/src/components/ui/coaching-sessions/coaching-tabs-container.tsx
@@ -5,8 +5,12 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CoachingNotes } from "@/components/ui/coaching-sessions/coaching-notes";
import { AgreementsList } from "@/components/ui/coaching-sessions/agreements-list";
import { ActionsList } from "@/components/ui/coaching-sessions/actions-list";
+import { SessionSummary } from "@/components/ui/coaching-sessions/session-summary";
+import { SessionTranscript } from "@/components/ui/coaching-sessions/session-transcript";
import { useAgreementMutation } from "@/lib/api/agreements";
import { useActionMutation } from "@/lib/api/actions";
+import { useTranscript } from "@/lib/api/meeting-recordings";
+import { TranscriptionStatus } from "@/types/meeting-recording";
import { ItemStatus, Id } from "@/types/general";
import { Action, defaultAction } from "@/types/action";
import { Agreement, defaultAgreement } from "@/types/agreement";
@@ -28,6 +32,10 @@ const CoachingTabsContainer: React.FC<{
// Get coaching session ID from URL
const { currentCoachingSessionId } = useCurrentCoachingSession();
+ // Get transcript to check if one exists
+ const { transcript } = useTranscript(currentCoachingSessionId || "");
+ const hasTranscript = transcript && transcript.status === TranscriptionStatus.Completed;
+
// Agreement and Action mutation hooks
const {
create: createAgreement,
@@ -110,10 +118,17 @@ const CoachingTabsContainer: React.FC<{
-
+
Notes
Agreements
Actions
+ Summary
+
+ Transcript
+ {hasTranscript && (
+
+ )}
+
@@ -147,6 +162,14 @@ const CoachingTabsContainer: React.FC<{
onActionDeleted={handleActionDeleted}
/>
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/coaching-sessions/meeting-controls.tsx b/src/components/ui/coaching-sessions/meeting-controls.tsx
new file mode 100644
index 00000000..21b25839
--- /dev/null
+++ b/src/components/ui/coaching-sessions/meeting-controls.tsx
@@ -0,0 +1,415 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import {
+ Video,
+ VideoOff,
+ Circle,
+ Square,
+ ExternalLink,
+ Lock,
+ Settings,
+ Loader2,
+ ChevronDown,
+ ListTodo,
+ Handshake,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/components/lib/utils";
+import { Id } from "@/types/general";
+import { AiPrivacyLevel } from "@/types/coaching-relationship";
+import { RecordingStatus, formatDuration } from "@/types/meeting-recording";
+import { useMeetingRecording, useMeetingRecordingMutation, useTranscript, MeetingRecordingApi } from "@/lib/api/meeting-recordings";
+import { TranscriptionStatus } from "@/types/meeting-recording";
+import { useCurrentCoachingRelationship } from "@/lib/hooks/use-current-coaching-relationship";
+import { useCurrentRelationshipRole } from "@/lib/hooks/use-current-relationship-role";
+import { toast } from "sonner";
+import Link from "next/link";
+
+interface MeetingControlsProps {
+ sessionId: Id;
+ className?: string;
+}
+
+/**
+ * Compact meeting controls dropdown for joining Google Meet and managing recording.
+ * Uses a dropdown menu to save header space while providing full functionality.
+ */
+export function MeetingControls({ sessionId, className }: MeetingControlsProps) {
+ const [isStarting, setIsStarting] = useState(false);
+ const [isStopping, setIsStopping] = useState(false);
+ const [isExtractingActions, setIsExtractingActions] = useState(false);
+ const [isExtractingAgreements, setIsExtractingAgreements] = useState(false);
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+
+ const { recording, isLoading: recordingLoading } = useMeetingRecording(sessionId);
+ const { transcript, isLoading: transcriptLoading } = useTranscript(sessionId);
+ const { startRecording, stopRecording } = useMeetingRecordingMutation(sessionId);
+ const { currentCoachingRelationship } = useCurrentCoachingRelationship();
+ const { isCoachInCurrentRelationship } = useCurrentRelationshipRole();
+
+ const meetingUrl = currentCoachingRelationship?.meeting_url;
+ // Use effective privacy level which is the minimum of coach and coachee consent
+ const privacyLevel = currentCoachingRelationship?.effective_ai_privacy_level ?? AiPrivacyLevel.Full;
+
+ // Timer for recording duration
+ useEffect(() => {
+ let interval: NodeJS.Timeout;
+
+ if (recording?.status === RecordingStatus.Recording && recording.started_at) {
+ const startTime = new Date(recording.started_at).getTime();
+
+ const updateElapsed = () => {
+ const now = Date.now();
+ setElapsedSeconds(Math.floor((now - startTime) / 1000));
+ };
+
+ updateElapsed();
+ interval = setInterval(updateElapsed, 1000);
+ } else {
+ setElapsedSeconds(0);
+ }
+
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [recording?.status, recording?.started_at]);
+
+ const handleStartRecording = async () => {
+ setIsStarting(true);
+ setIsOpen(false);
+ try {
+ await startRecording();
+ toast.success("Recording started", {
+ description: "The meeting bot is joining your call.",
+ });
+ } catch (error) {
+ toast.error("Failed to start recording", {
+ description: error instanceof Error ? error.message : "Please try again.",
+ });
+ } finally {
+ setIsStarting(false);
+ }
+ };
+
+ const handleStopRecording = async () => {
+ setIsStopping(true);
+ setIsOpen(false);
+ try {
+ await stopRecording();
+ toast.success("Recording stopped", {
+ description: "Your transcript will be available shortly.",
+ });
+ } catch (error) {
+ toast.error("Failed to stop recording", {
+ description: error instanceof Error ? error.message : "Please try again.",
+ });
+ } finally {
+ setIsStopping(false);
+ }
+ };
+
+ const handleExtractActions = async () => {
+ setIsExtractingActions(true);
+ setIsOpen(false);
+ try {
+ const result = await MeetingRecordingApi.extractActions(sessionId);
+ toast.success(`Extracted ${result.actions.length} actions`, {
+ description: `Created ${result.created_count} new action items.`,
+ });
+ } catch (error) {
+ toast.error("Failed to extract actions", {
+ description: error instanceof Error ? error.message : "Please try again.",
+ });
+ } finally {
+ setIsExtractingActions(false);
+ }
+ };
+
+ const handleExtractAgreements = async () => {
+ setIsExtractingAgreements(true);
+ setIsOpen(false);
+ try {
+ const result = await MeetingRecordingApi.extractAgreements(sessionId);
+ toast.success(`Extracted ${result.agreements.length} agreements`, {
+ description: `Created ${result.created_count} new agreements.`,
+ });
+ } catch (error) {
+ toast.error("Failed to extract agreements", {
+ description: error instanceof Error ? error.message : "Please try again.",
+ });
+ } finally {
+ setIsExtractingAgreements(false);
+ }
+ };
+
+ const isRecordingActive = recording?.status === RecordingStatus.Recording;
+ const isJoining = recording?.status === RecordingStatus.Joining;
+ const isProcessing = recording?.status === RecordingStatus.Processing;
+ const isCompleted = recording?.status === RecordingStatus.Completed;
+ const isFailed = recording?.status === RecordingStatus.Failed;
+ const aiDisabled = privacyLevel === AiPrivacyLevel.None;
+
+ // Check if we have a completed transcript for extraction
+ // LeMUR requires the AssemblyAI transcript ID to analyze the transcript
+ const hasCompletedTranscript = transcript?.status === TranscriptionStatus.Completed &&
+ transcript?.assemblyai_transcript_id != null;
+
+ // Debug logging for transcript detection
+ console.log("[MeetingControls] transcript:", {
+ exists: !!transcript,
+ status: transcript?.status,
+ assemblyai_transcript_id: transcript?.assemblyai_transcript_id,
+ hasCompletedTranscript,
+ });
+
+ // Determine the button appearance based on state
+ const getButtonContent = () => {
+ if (recordingLoading || isStarting || isStopping) {
+ return (
+ <>
+