fix(ui): fix crashes, dark theme tokens, Invalid Date bug

- FocusGroupSession: add missing DiscussionGuideViewer import (was crashing)
- PersonaProfile: wrap TabsTrigger in TabsList (RovingFocusGroup crash)
- Dashboard: fix Invalid Date bug — tx.ts vs tx.created_at field mismatch
- ParticipantPanel, UserCard, SyntheticUsers: replace hardcoded light-mode
  colors (bg-blue-50, text-slate-*, bg-white) with dark theme tokens
- AutonomousDashboard, NotesPanel, QuickNoteModal: same dark theme sweep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-26 15:24:28 +01:00
parent 4c70bc8aa6
commit 0f7b8a5a9e
15 changed files with 1328 additions and 628 deletions

View file

@ -139,7 +139,7 @@ export default function UserCard({
{/* AI-Synthesized Bio */}
<div className="mt-2">
{currentPersona.aiSynthesizedBio ? (
<p className="text-xs text-slate-700 line-clamp-3 leading-relaxed">
<p className="text-xs text-foreground/75 line-clamp-3 leading-relaxed">
{currentPersona.aiSynthesizedBio}
{currentPersona.aiSynthesizedBio.length > 150 && '...'}
</p>
@ -157,7 +157,7 @@ export default function UserCard({
{currentPersona.qualitativeAttributes.slice(0, 3).map((attribute, index) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full"
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary text-xs rounded-full"
>
<Tag className="h-3 w-3" />
{attribute}
@ -177,7 +177,7 @@ export default function UserCard({
return (
<span
key={folderId}
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full"
className="inline-flex items-center gap-1 px-2 py-1 bg-secondary text-muted-foreground text-xs rounded-full"
title={`In folder: ${folder.name}`}
>
<Folder className="h-3 w-3" />
@ -186,7 +186,7 @@ export default function UserCard({
);
})}
{currentPersona.folder_ids.length > 2 && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-secondary text-muted-foreground text-xs rounded-full">
<Plus className="h-3 w-3" />
{currentPersona.folder_ids.length - 2} more
</span>

View file

@ -27,7 +27,8 @@ interface Transaction {
type: string;
amount: number;
balance_after: number;
created_at: string;
ts?: string;
created_at?: string;
description?: string;
}
@ -71,8 +72,10 @@ const TX_TYPE_COLORS: Record<string, string> = {
refund: 'text-blue-400',
};
function formatDate(iso: string) {
function formatDate(iso: string | undefined) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
@ -362,7 +365,7 @@ const Dashboard = () => {
<p className="text-sm font-medium text-foreground">
{TX_TYPE_LABELS[tx.type] || tx.type}
</p>
<p className="text-xs text-muted-foreground">{formatDate(tx.created_at)}</p>
<p className="text-xs text-muted-foreground">{formatDate(tx.ts ?? tx.created_at)}</p>
</div>
<div className="text-right flex-shrink-0 ml-4">
<p className={`text-sm font-semibold ${TX_TYPE_COLORS[tx.type] || 'text-foreground'}`}>

View file

@ -202,30 +202,30 @@ const AutonomousDashboard = ({
switch (sentiment) {
case 'positive': return 'text-green-600';
case 'negative': return 'text-red-600';
default: return 'text-gray-600';
default: return 'text-muted-foreground';
}
};
const getHealthColor = (health: string) => {
switch (health) {
case 'excellent': return 'text-green-600';
case 'good': return 'text-blue-600';
case 'good': return 'text-primary';
case 'fair': return 'text-amber-600';
case 'poor': return 'text-red-600';
default: return 'text-gray-600';
default: return 'text-muted-foreground';
}
};
if (!isVisible) return null;
return (
<div className="fixed right-4 top-4 bottom-4 w-80 bg-white rounded-lg shadow-lg border border-gray-200 flex flex-col overflow-hidden z-50">
<div className="fixed right-4 top-4 bottom-4 w-80 bg-card rounded-lg shadow-lg border border-border flex flex-col overflow-hidden z-50">
{/* Header */}
<div className="p-4 border-b border-gray-200 bg-gray-50">
<div className="p-4 border-b border-border bg-secondary/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-900">AI Dashboard</h3>
<Bot className="h-5 w-5 text-primary" />
<h3 className="font-semibold text-foreground">AI Dashboard</h3>
</div>
<div className="flex items-center gap-2">
<Button
@ -249,7 +249,7 @@ const AutonomousDashboard = ({
</div>
{lastUpdated && (
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-muted-foreground mt-1">
Last updated: {lastUpdated.toLocaleTimeString()}
</p>
)}
@ -317,7 +317,7 @@ const AutonomousDashboard = ({
<Progress value={conversationState.conversation_health.score} className="h-2" />
</div>
<div className="space-y-1">
<span className="text-xs text-gray-600">Indicators:</span>
<span className="text-xs text-muted-foreground">Indicators:</span>
<div className="flex flex-wrap gap-1">
{conversationState.conversation_health.indicators.map((indicator, index) => (
<Badge key={index} variant="outline" className="text-xs">
@ -344,16 +344,16 @@ const AutonomousDashboard = ({
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
<div className="text-lg font-semibold text-primary">
{analytics.overview.active_participants}
</div>
<div className="text-xs text-gray-600">Active</div>
<div className="text-xs text-muted-foreground">Active</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{analytics.overview.participant_messages}
</div>
<div className="text-xs text-gray-600">Messages</div>
<div className="text-xs text-muted-foreground">Messages</div>
</div>
</div>
@ -372,7 +372,7 @@ const AutonomousDashboard = ({
)}
{analytics.participation.quiet_participants.length > 0 && (
<div className="text-xs text-blue-600">
<div className="text-xs text-primary">
Quiet: {analytics.participation.quiet_participants.length} participant(s)
</div>
)}
@ -482,8 +482,8 @@ const AutonomousDashboard = ({
</div>
{insights.next_suggested_action && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 mt-2">
<div className="text-xs text-blue-800">
<div className="bg-primary/10 border border-primary/30 rounded-lg p-2 mt-2">
<div className="text-xs text-primary">
<strong>Suggestion:</strong> {insights.next_suggested_action}
</div>
</div>
@ -505,7 +505,7 @@ const AutonomousDashboard = ({
<CardContent className="pt-0">
<div className="space-y-2">
{analytics.recommendations.map((rec, index) => (
<div key={index} className="bg-amber-50 border border-amber-200 rounded-lg p-2">
<div key={index} className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-2">
<div className="text-xs text-amber-800">{rec}</div>
</div>
))}

View file

@ -84,28 +84,28 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
const hasStructuredGuide = discussionGuide && typeof discussionGuide === 'object' && discussionGuide.sections;
return (
<div className={cn("w-full border-b bg-white shadow-sm", className)}>
<div className={cn("w-full border-b bg-card border-border", className)}>
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<div className={cn(
"w-full px-4 py-3 flex items-center justify-between transition-all cursor-pointer",
isOpen
? "bg-amber-50 border-l-4 border-l-amber-400"
: "bg-slate-50 hover:bg-amber-50 border-l-4 border-l-transparent hover:border-l-amber-300"
? "bg-primary/10 border-b border-primary/40"
: "bg-secondary/30 hover:bg-primary/8 border-b border-border hover:border-primary/30"
)}>
<div className="flex items-center gap-3">
<ClipboardList className={cn("h-5 w-5", isOpen ? "text-amber-600" : "text-slate-600")} />
<ClipboardList className={cn("h-5 w-5", isOpen ? "text-primary" : "text-muted-foreground")} />
<div>
<div className="flex items-center gap-2">
<h2 className="font-semibold text-slate-900">Discussion Guide</h2>
<h2 className="font-semibold text-foreground">Discussion Guide</h2>
{!isOpen && (
<Badge variant="secondary" className="text-xs bg-amber-100 text-amber-700">
<Badge variant="secondary" className="text-xs bg-primary/15 text-primary border-primary/20">
Click to Edit
</Badge>
)}
</div>
{hasStructuredGuide && (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
{discussionGuide.title} {discussionGuide.total_duration} minutes
</p>
)}
@ -130,16 +130,16 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
)}
</Button>
{isOpen ? (
<ChevronUp className="h-4 w-4 text-amber-600" />
<ChevronUp className="h-4 w-4 text-primary" />
) : (
<ChevronDown className="h-4 w-4 text-slate-500" />
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t bg-slate-50">
<div className="border-t border-border bg-card">
<Card className="mx-4 mb-4 mt-2">
<CardContent className="p-4">
<div className="max-h-[70vh] overflow-y-auto">

View file

@ -254,8 +254,8 @@ const DroppableContainer: React.FC<DroppableContainerProps> = ({
ref={setNodeRef}
className={cn(
className,
isOver && allowed && "ring-2 ring-blue-500 bg-blue-50/50",
isOver && !allowed && "ring-2 ring-red-400 opacity-50"
isOver && allowed && "ring-2 ring-primary bg-primary/10",
isOver && !allowed && "ring-2 ring-red-500/50 opacity-50"
)}
>
{children}
@ -1035,16 +1035,16 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
return (
<SortableItem key={`edit-item-${item.id}`} id={uid} disabled={!containerId} className="mb-2" data={itemData}>
{({ setActivatorNodeRef, attributes, listeners, isDragging }) => (
<div className="flex items-start gap-3 p-3 rounded-lg border bg-white border-blue-200">
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card border-primary/30">
{/* Drag handle replaces the arrow buttons */}
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="flex-shrink-0 h-8 w-8 inline-flex items-center justify-center rounded-md hover:bg-gray-50 cursor-grab active:cursor-grabbing"
className="flex-shrink-0 h-8 w-8 inline-flex items-center justify-center rounded-md hover:bg-secondary/50 cursor-grab active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-gray-500" />
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<div className="flex-1 min-w-0 space-y-3">
@ -1064,7 +1064,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
</Badge>
{item.time_limit && (
<div className="flex items-center gap-1 text-xs text-slate-500">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<Input
type="number"
@ -1087,7 +1087,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{itemType === 'question' && (
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
<label className="text-sm font-medium text-foreground/80 mb-1 block">
Probe Questions (one per line)
</label>
<Textarea
@ -1105,26 +1105,26 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(item.metadata?.image_url || item.metadata?.image_id || imageFilename) && (
<div className="mt-3">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground/80">Visual Aid</span>
</div>
{item.metadata?.image_url ? (
<img
src={item.metadata.image_url}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : item.metadata?.image_id && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, item.metadata.image_id)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : imageFilename && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, imageFilename)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : null}
</div>
@ -1158,10 +1158,10 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
return (
<div
key={item.id}
className="flex items-start gap-3 p-3 rounded-lg border bg-white border-blue-300 ring-2 ring-blue-200"
className="flex items-start gap-3 p-3 rounded-lg border bg-card border-primary/30 ring-2 ring-primary/30"
>
<div className="flex-shrink-0 mt-1">
<Edit3 className="h-4 w-4 text-blue-600" />
<Edit3 className="h-4 w-4 text-primary" />
</div>
<div className="flex-1 min-w-0 space-y-3">
@ -1181,7 +1181,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
)}
</Badge>
<div className="flex items-center gap-1 text-xs text-slate-500">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<Input
type="number"
@ -1216,7 +1216,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{/* Probes for questions */}
{itemType === 'question' && (
<div>
<label className="text-xs font-medium text-slate-700 mb-1 block">
<label className="text-xs font-medium text-foreground/80 mb-1 block">
Probe Questions (one per line)
</label>
<Textarea
@ -1262,7 +1262,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
<X className="h-3 w-3 mr-1" />
Cancel
</Button>
<span className="text-xs text-slate-400 ml-auto">
<span className="text-xs text-muted-foreground/60 ml-auto">
Esc to cancel, {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to save
</span>
</div>
@ -1277,20 +1277,20 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
key={item.id}
className={cn(
"group flex items-start gap-3 p-3 rounded-lg border transition-colors",
isCurrent && "bg-blue-50 border-blue-200",
isCompleted && "bg-green-50 border-green-200",
!isCurrent && !isCompleted && "bg-slate-50 border-slate-200",
onSectionSelect && "cursor-pointer hover:bg-slate-100"
isCurrent && "bg-primary/10 border-primary/30",
isCompleted && "bg-green-500/10 border-green-500/30",
!isCurrent && !isCompleted && "bg-secondary/30 border-border",
onSectionSelect && "cursor-pointer hover:bg-secondary/50"
)}
onClick={() => onSectionSelect?.(structuredGuide!.sections[sectionIndex].id, item.id)}
>
<div className="flex-shrink-0 mt-1">
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-green-600" />
<CheckCircle className="h-4 w-4 text-green-400" />
) : isCurrent ? (
<PlayCircle className="h-4 w-4 text-blue-600" />
<PlayCircle className="h-4 w-4 text-primary" />
) : (
<Circle className="h-4 w-4 text-slate-400" />
<Circle className="h-4 w-4 text-muted-foreground/60" />
)}
</div>
@ -1311,7 +1311,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
</Badge>
{item.time_limit && (
<div className="flex items-center gap-1 text-xs text-slate-500 whitespace-nowrap">
<div className="flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
<Clock className="h-3 w-3" />
{item.time_limit} min
</div>
@ -1359,14 +1359,14 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
)}
</div>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{item.content}</p>
<p className="text-sm text-foreground/80 whitespace-pre-wrap">{item.content}</p>
{item.probes && item.probes.length > 0 && (
<div className="mt-2 pl-4 border-l-2 border-slate-200">
<p className="text-xs font-medium text-slate-600 mb-1">Probe Questions:</p>
<div className="mt-2 pl-4 border-l-2 border-border/60">
<p className="text-xs font-medium text-muted-foreground mb-1">Probe Questions:</p>
<ul className="space-y-1">
{item.probes.map((probe, idx) => (
<li key={idx} className="text-xs text-slate-600"> {probe}</li>
<li key={idx} className="text-xs text-muted-foreground"> {probe}</li>
))}
</ul>
</div>
@ -1375,26 +1375,26 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(item.metadata?.image_url || item.metadata?.image_id || imageFilename) && (
<div className="mt-3">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground/80">Visual Aid</span>
</div>
{item.metadata?.image_url ? (
<img
<img
src={item.metadata.image_url}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : item.metadata?.image_id && focusGroupId ? (
<img
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, item.metadata.image_id)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : imageFilename && focusGroupId ? (
<img
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, imageFilename)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : null}
</div>
@ -1416,22 +1416,22 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
key={section.id}
className={cn(
"border rounded-lg overflow-hidden transition-colors",
isCurrentSection && "border-blue-500 shadow-md",
!isCurrentSection && "border-slate-200"
isCurrentSection && "border-primary shadow-primary/20",
!isCurrentSection && "border-border"
)}
>
<div
className={cn(
"px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-slate-50 transition-colors",
isCurrentSection && "bg-blue-50"
"px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-secondary/30 transition-colors",
isCurrentSection && "bg-primary/10"
)}
onClick={() => !isEditing && toggleSection(section.id)}
>
<div className="flex items-center gap-3">
<div className="transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)' }}>
<ChevronRight className="h-5 w-5 text-slate-500" />
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
<h3 className="font-semibold text-slate-800">
<h3 className="font-semibold text-foreground/80">
{isEditing ? (
<Input
value={displaySection.title}
@ -1497,7 +1497,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
</div>
{isOpen && (
<div className="px-4 py-3 border-t border-slate-200 space-y-4">
<div className="px-4 py-3 border-t border-border space-y-4">
{displaySection.content && (
<div className="prose prose-sm max-w-none">
{isEditing ? (
@ -1508,7 +1508,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
className="min-h-[80px] w-full"
/>
) : (
<p className="text-slate-700">{displaySection.content}</p>
<p className="text-foreground/80">{displaySection.content}</p>
)}
</div>
)}
@ -1516,7 +1516,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(displaySection.activities && displaySection.activities.length > 0) || isEditing ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-slate-700 flex items-center gap-2">
<h5 className="font-medium text-foreground/80 flex items-center gap-2">
<Activity className="h-4 w-4" />
Activities
</h5>
@ -1556,7 +1556,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(displaySection.questions && displaySection.questions.length > 0) || isEditing ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-slate-700 flex items-center gap-2">
<h5 className="font-medium text-foreground/80 flex items-center gap-2">
<MessageCircle className="h-4 w-4" />
Questions
</h5>
@ -1596,7 +1596,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{isEditing && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-slate-700 flex items-center gap-2">
<h5 className="font-medium text-foreground/80 flex items-center gap-2">
<Target className="h-4 w-4" />
Subsections
</h5>
@ -1616,7 +1616,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{displaySection.subsections && displaySection.subsections.length > 0 && (
<div className="space-y-3 ml-4">
{displaySection.subsections.map((subsection, idx) => (
<div key={subsection.id} className="border-l-2 border-slate-200 pl-4">
<div key={subsection.id} className="border-l-2 border-primary/30 pl-4">
<div className="flex items-center gap-2 mb-2">
{isEditing && (
<div className="flex flex-col gap-1">
@ -1666,10 +1666,10 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
</div>
) : (
<div className="flex items-center gap-2 flex-1">
<h5
<h5
className={cn(
"font-medium text-slate-700",
isEditing && "cursor-pointer hover:text-blue-600"
"font-medium text-foreground/80",
isEditing && "cursor-pointer hover:text-primary"
)}
onClick={() => isEditing && startEditingSubsectionTitle(subsection.id, subsection.title)}
>
@ -1703,7 +1703,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(subsection.questions && subsection.questions.length > 0) || isEditing ? (
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between">
<h6 className="text-sm font-medium text-slate-600 flex items-center gap-1">
<h6 className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
Questions
</h6>
@ -1743,7 +1743,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(subsection.activities && subsection.activities.length > 0) || isEditing ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h6 className="text-sm font-medium text-slate-600 flex items-center gap-1">
<h6 className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Activity className="h-3 w-3" />
Activities
</h6>
@ -1787,20 +1787,20 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
{(section.metadata?.image_url || section.metadata?.image_id) && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground/80">Visual Aid</span>
</div>
{section.metadata.image_url ? (
<img
<img
src={section.metadata.image_url}
alt="Visual aid for section"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : section.metadata.image_id && focusGroupId ? (
<img
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, section.metadata.image_id)}
alt="Visual aid for section"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-border"
/>
) : null}
</div>
@ -1817,22 +1817,22 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
<div className={cn("space-y-4", className)}>
{showProgress && moderatorStatus && (
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-slate-600 mb-2">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>Progress</span>
<span>{Math.round(moderatorStatus.progress)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${moderatorStatus.progress}%` }}
/>
</div>
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-slate-800">Discussion Guide</h2>
<h2 className="text-xl font-semibold text-foreground">Discussion Guide</h2>
{onDownload && (
<Button
size="sm"
@ -1851,17 +1851,17 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
</div>
<div className="prose prose-sm max-w-none">
<pre className="whitespace-pre-wrap text-sm text-slate-700 font-sans">
<pre className="whitespace-pre-wrap text-sm text-foreground/80 font-sans">
{discussionGuide as string}
</pre>
</div>
{moderatorStatus && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<h3 className="font-medium text-blue-900 mb-2">Current Position</h3>
<p className="text-sm text-blue-800">{moderatorStatus.current_section}</p>
<div className="mt-6 p-4 bg-primary/10 rounded-lg border border-primary/30">
<h3 className="font-medium text-primary mb-2">Current Position</h3>
<p className="text-sm text-primary">{moderatorStatus.current_section}</p>
{moderatorStatus.current_item && (
<p className="text-sm text-blue-700 mt-1">{moderatorStatus.current_item}</p>
<p className="text-sm text-primary/80 mt-1">{moderatorStatus.current_item}</p>
)}
</div>
)}
@ -1873,8 +1873,8 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
// Render structured format
if (!structuredGuide) {
return (
<div className={cn("bg-slate-50 rounded-lg p-8 text-center", className)}>
<p className="text-slate-600">No discussion guide available</p>
<div className={cn("bg-secondary/30 rounded-lg p-8 text-center", className)}>
<p className="text-muted-foreground">No discussion guide available</p>
</div>
);
}
@ -1893,18 +1893,18 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
<div className="space-y-4">
{showProgress && moderatorStatus && (
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-slate-600 mb-2">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>Overall Progress</span>
<span>{Math.round(moderatorStatus.progress)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${moderatorStatus.progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-slate-500 mt-2">
<div className="flex items-center justify-between text-xs text-muted-foreground mt-2">
<span>Section {moderatorStatus.moderator_position.section_index + 1} of {moderatorStatus.total_sections}</span>
<span>{Math.round(moderatorStatus.section_progress)}% of current section</span>
</div>
@ -1927,10 +1927,10 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
return (
<Collapsible defaultOpen={defaultExpanded} className={className}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-slate-200 cursor-pointer hover:bg-slate-50 transition-colors">
<div className="flex items-center justify-between p-4 bg-card rounded-lg border border-border cursor-pointer hover:bg-secondary/30 transition-colors">
<div className="flex items-center gap-3">
<ChevronRight className="h-5 w-5 text-slate-500 transition-transform data-[state=open]:rotate-90" />
<h2 className="text-lg font-semibold text-slate-800">
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform data-[state=open]:rotate-90" />
<h2 className="text-lg font-semibold text-foreground">
{structuredGuide.title || 'Discussion Guide'}
</h2>
<Badge variant="outline" className="text-xs">

View file

@ -1164,12 +1164,12 @@ const DiscussionPanel = ({
return (
<div className="glass-panel rounded-xl p-4 flex flex-col h-full">
{/* Messages area - takes remaining space after bottom panels */}
<div className="flex-1 min-h-0 mb-4">
<div className="flex-1 min-h-0 mb-4 relative">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{getTimelineItems().map((item) => (
item.type === 'message' ? (
<ChatMessage
<ChatMessage
key={item.data.id}
message={item.data as Message}
persona={(item.data as Message).senderId !== 'moderator' && (item.data as Message).senderId !== 'facilitator' ? getPersona((item.data as Message).senderId) : null}
@ -1178,7 +1178,7 @@ const DiscussionPanel = ({
focusGroupId={focusGroupId}
/>
) : (
<ModeSwitchMarker
<ModeSwitchMarker
key={item.data.id}
modeEvent={item.data as ModeEvent}
/>
@ -1203,21 +1203,21 @@ const DiscussionPanel = ({
{/* Place at the end of the last message to provide better scroll reference */}
<div ref={messagesEndRef} className="h-1" />
</div>
{/* Manual scroll button that only appears when not at the bottom */}
{!autoScroll && filteredMessages.length > 6 && (
<div className="sticky bottom-5 ml-auto mr-5 z-10 w-fit">
<Button
size="sm"
className="rounded-full shadow-md h-10 w-10 p-0"
onClick={scrollToBottom}
title="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</ScrollArea>
{/* Manual scroll button — absolute inside relative wrapper, outside ScrollArea to avoid bleeding */}
{!autoScroll && filteredMessages.length > 6 && (
<div className="absolute bottom-4 right-6 z-20 pointer-events-none">
<Button
size="sm"
className="rounded-full shadow-lg h-9 w-9 p-0 pointer-events-auto bg-card/90 border border-border hover:border-primary/50 text-muted-foreground hover:text-primary backdrop-blur-sm"
onClick={scrollToBottom}
title="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* AI Reasoning Panel - pinned above controls */}

View file

@ -185,7 +185,7 @@ const NotesPanel = ({
<StickyNote className="h-5 w-5 text-primary mr-2" />
<h2 className="font-sf text-xl font-semibold">Notes</h2>
{notes.length > 0 && (
<span className="ml-2 text-sm text-slate-500">({notes.length})</span>
<span className="ml-2 text-sm text-muted-foreground">({notes.length})</span>
)}
</div>
@ -203,10 +203,10 @@ const NotesPanel = ({
{/* Notes list */}
<ScrollArea className="flex-1">
{notes.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center bg-slate-50 rounded-lg">
<StickyNote className="h-8 w-8 text-slate-400 mb-3" />
<p className="text-slate-600">No notes yet.</p>
<p className="text-sm text-slate-500 mt-2">
<div className="flex flex-col items-center justify-center p-8 text-center bg-secondary/30 rounded-lg">
<StickyNote className="h-8 w-8 text-muted-foreground/60 mb-3" />
<p className="text-muted-foreground">No notes yet.</p>
<p className="text-sm text-muted-foreground mt-2">
Click the "Note" button during the session to add contextual notes.
</p>
</div>
@ -221,11 +221,11 @@ const NotesPanel = ({
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-sm font-medium text-muted-foreground">
{formatTimestamp(note.createdAt)}
</CardTitle>
{note.sectionInfo?.sectionTitle && (
<div className="text-xs text-slate-500 mt-1">
<div className="text-xs text-muted-foreground mt-1">
<span>{note.sectionInfo.sectionTitle}</span>
</div>
)}
@ -265,7 +265,7 @@ const NotesPanel = ({
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-slate-700 whitespace-pre-wrap">
<p className="text-sm text-foreground/80 whitespace-pre-wrap">
{note.content}
</p>
</CardContent>

View file

@ -94,7 +94,7 @@ const ParticipantPanel = ({
<Bot className="h-8 w-8 text-primary mr-3" />
<div>
<p className="font-medium text-primary">AI Moderator</p>
<p className="text-xs text-slate-500">Session facilitator</p>
<p className="text-xs text-muted-foreground">Session facilitator</p>
</div>
</div>
@ -107,7 +107,7 @@ const ParticipantPanel = ({
<div
key={participantId}
className={`flex items-center p-2 rounded-lg transition-colors ${
isSelected && !isEditable ? 'bg-blue-50 border border-blue-200' : 'hover:bg-slate-100'
isSelected && !isEditable ? 'bg-primary/10 border border-primary/30' : 'hover:bg-secondary/50'
}`}
>
<div
@ -124,23 +124,23 @@ const ParticipantPanel = ({
<div className="flex-1 min-w-0">
<div className="flex items-center">
<p
className={`font-medium truncate ${isEditable ? '' : 'cursor-pointer hover:text-blue-600 transition-colors'}`}
className={`font-medium truncate ${isEditable ? '' : 'cursor-pointer hover:text-primary transition-colors'}`}
onClick={() => handleNameClick(participant)}
title={isEditable ? undefined : `Filter to show only ${participant.name}'s messages`}
>
{participant.name}
</p>
{isSelected && !isEditable && (
<Check className="h-4 w-4 text-blue-600 ml-2 shrink-0" />
<Check className="h-4 w-4 text-primary ml-2 shrink-0" />
)}
</div>
<p className="text-xs text-slate-500 truncate">{participant.occupation}</p>
<p className="text-xs text-muted-foreground truncate">{participant.occupation}</p>
</div>
{isEditable && (
<button
onClick={() => handleRemove(participant)}
disabled={isRemoving}
className="ml-2 shrink-0 text-slate-400 hover:text-red-500 transition-colors disabled:opacity-40"
className="ml-2 shrink-0 text-muted-foreground/60 hover:text-destructive transition-colors disabled:opacity-40"
title={`Remove ${participant.name}`}
>
<X className="h-4 w-4" />
@ -170,7 +170,7 @@ const ParticipantPanel = ({
<DialogTitle>Add Participant</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/60" />
<Input
placeholder="Search personas…"
value={search}
@ -191,7 +191,7 @@ const ParticipantPanel = ({
return (
<div
key={pid}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-100 cursor-pointer"
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 cursor-pointer"
onClick={() => !isAdding && handleAdd(persona)}
>
<img
@ -204,7 +204,7 @@ const ParticipantPanel = ({
<p className="text-xs text-slate-500 truncate">{persona.occupation}</p>
</div>
{isAdding ? (
<span className="text-xs text-slate-400">Adding</span>
<span className="text-xs text-muted-foreground/60">Adding</span>
) : (
<UserPlus className="h-4 w-4 text-slate-400 shrink-0" />
)}

View file

@ -108,7 +108,7 @@ const QuickNoteModal = ({
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-slate-600">
<div className="text-sm text-muted-foreground">
<div>
<strong>Section:</strong> {sectionInfo?.sectionTitle || 'Unknown section'}
</div>

View file

@ -0,0 +1,669 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three';
import { Persona } from '@/types/persona';
import { Message } from './types';
interface RoundTable3DProps {
personas: Persona[];
activeSpeakerId: string | null;
messages: Message[];
}
interface AvatarObject {
mesh: THREE.Mesh;
ring: THREE.Mesh;
haloLight: THREE.PointLight;
inactiveLight: THREE.PointLight;
spotLight: THREE.SpotLight;
targetScale: number;
currentScale: number;
}
interface SpeechBubble {
personaId: string;
text: string;
screenX: number;
screenY: number;
}
interface NameTag {
personaId: string;
name: string;
screenX: number;
screenY: number;
}
function nameToColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 60%, 35%)`;
}
function nameToHex(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
const hue = Math.abs(hash) % 360;
const h = hue / 360;
const s = 0.6;
const l = 0.35;
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hue2rgb = (p2: number, q2: number, t: number) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p2 + (q2 - p2) * 6 * tt;
if (tt < 1 / 2) return q2;
if (tt < 2 / 3) return p2 + (q2 - p2) * (2 / 3 - tt) * 6;
return p2;
};
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
return (r << 16) | (g << 8) | b;
}
function makeAvatarTexture(initial: string, color: string): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d')!;
// Background circle with gradient
const gradient = ctx.createRadialGradient(128, 100, 0, 128, 128, 128);
gradient.addColorStop(0, lightenColor(color, 20));
gradient.addColorStop(1, darkenColor(color, 20));
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(128, 128, 128, 0, Math.PI * 2);
ctx.fill();
// Rim highlight
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(128, 128, 122, Math.PI * 1.1, Math.PI * 1.9);
ctx.stroke();
// Letter
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.font = 'bold 110px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initial, 128, 136);
return new THREE.CanvasTexture(canvas);
}
function lightenColor(hsl: string, amount: number): string {
return hsl.replace(/(\d+)%\)$/, (_, l) => `${Math.min(100, parseInt(l) + amount)}%)`);
}
function darkenColor(hsl: string, amount: number): string {
return hsl.replace(/(\d+)%\)$/, (_, l) => `${Math.max(0, parseInt(l) - amount)}%)`);
}
function makeTableTexture(): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d')!;
// Dark wood base
ctx.fillStyle = '#120d08';
ctx.fillRect(0, 0, 512, 512);
// Wood grain lines
for (let i = 0; i < 40; i++) {
const y = Math.random() * 512;
ctx.strokeStyle = `rgba(${60 + Math.random() * 20}, ${35 + Math.random() * 10}, ${15 + Math.random() * 8}, ${0.3 + Math.random() * 0.4})`;
ctx.lineWidth = 0.5 + Math.random() * 1.5;
ctx.beginPath();
ctx.moveTo(0, y + Math.sin(0) * 8);
for (let x = 0; x < 512; x += 10) {
ctx.lineTo(x, y + Math.sin(x * 0.02) * 12 + Math.random() * 3);
}
ctx.stroke();
}
// Subtle amber center glow
const radialGrad = ctx.createRadialGradient(256, 256, 0, 256, 256, 200);
radialGrad.addColorStop(0, 'rgba(251, 191, 36, 0.08)');
radialGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = radialGrad;
ctx.fillRect(0, 0, 512, 512);
return new THREE.CanvasTexture(canvas);
}
function makeEmblemTexture(): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, 128, 128);
// Outer circle
ctx.strokeStyle = 'rgba(251, 191, 36, 0.7)';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(64, 64, 56, 0, Math.PI * 2);
ctx.stroke();
// Inner glow
const glow = ctx.createRadialGradient(64, 64, 0, 64, 64, 50);
glow.addColorStop(0, 'rgba(251, 191, 36, 0.18)');
glow.addColorStop(1, 'rgba(251, 191, 36, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(64, 64, 50, 0, Math.PI * 2);
ctx.fill();
// Letter C
ctx.strokeStyle = 'rgba(251, 191, 36, 0.9)';
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(64, 64, 28, Math.PI * 0.25, Math.PI * 1.75);
ctx.stroke();
return new THREE.CanvasTexture(canvas);
}
function makeFloorTexture(): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#0a0806';
ctx.fillRect(0, 0, 512, 512);
// Very subtle grid
ctx.strokeStyle = 'rgba(251, 191, 36, 0.04)';
ctx.lineWidth = 0.5;
const step = 512 / 20;
for (let i = 0; i <= 20; i++) {
ctx.beginPath();
ctx.moveTo(i * step, 0);
ctx.lineTo(i * step, 512);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * step);
ctx.lineTo(512, i * step);
ctx.stroke();
}
return new THREE.CanvasTexture(canvas);
}
export default function RoundTable3D({ personas, activeSpeakerId, messages }: RoundTable3DProps) {
const mountRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const avatarsRef = useRef<Map<string, AvatarObject>>(new Map());
const animFrameRef = useRef<number>(0);
const cameraAngleRef = useRef<number>(0);
const disposablesRef = useRef<Array<THREE.BufferGeometry | THREE.Material | THREE.Texture>>([]);
const reducedMotionRef = useRef<boolean>(false);
const CANVAS_HEIGHT = 440;
const [speechBubbles, setSpeechBubbles] = useState<SpeechBubble[]>([]);
const [nameTags, setNameTags] = useState<NameTag[]>([]);
// Track reduced-motion preference
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
reducedMotionRef.current = mq.matches;
const handler = (e: MediaQueryListEvent) => {
reducedMotionRef.current = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const track = useCallback(<T extends THREE.BufferGeometry | THREE.Material | THREE.Texture>(obj: T): T => {
disposablesRef.current.push(obj);
return obj;
}, []);
const projectToScreen = useCallback((position: THREE.Vector3): { x: number; y: number } | null => {
const camera = cameraRef.current;
if (!camera) return null;
const vec = position.clone();
vec.project(camera);
return {
x: (vec.x + 1) / 2,
y: (-vec.y + 1) / 2,
};
}, []);
const updateOverlays = useCallback(() => {
// Speech bubbles
const bubbles: SpeechBubble[] = [];
const seen = new Set<string>();
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (!msg.senderId || msg.senderId === 'moderator' || msg.senderId === 'facilitator') continue;
if (seen.has(msg.senderId)) continue;
seen.add(msg.senderId);
const avatarObj = avatarsRef.current.get(msg.senderId);
if (!avatarObj) continue;
const worldPos = new THREE.Vector3();
avatarObj.mesh.getWorldPosition(worldPos);
worldPos.y += 0.8;
const screen = projectToScreen(worldPos);
if (!screen) continue;
bubbles.push({
personaId: msg.senderId,
text: msg.text.length > 80 ? msg.text.slice(0, 77) + '…' : msg.text,
screenX: screen.x * 100,
screenY: screen.y * 100,
});
}
setSpeechBubbles(bubbles);
// Name tags — always visible, offset below avatar
const tags: NameTag[] = [];
avatarsRef.current.forEach((avatarObj, id) => {
const persona = personas.find((p) => (p._id || p.id) === id);
if (!persona) return;
const worldPos = new THREE.Vector3();
avatarObj.mesh.getWorldPosition(worldPos);
worldPos.y -= 0.6; // below avatar
const screen = projectToScreen(worldPos);
if (!screen) return;
tags.push({
personaId: id,
name: persona.name.split(' ')[0],
screenX: screen.x * 100,
screenY: screen.y * 100,
});
});
setNameTags(tags);
}, [messages, personas, projectToScreen]);
// Main Three.js setup
useEffect(() => {
const container = mountRef.current;
if (!container || personas.length === 0) return;
const width = container.clientWidth;
const height = CANVAS_HEIGHT;
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080605);
scene.fog = new THREE.FogExp2(0x0a0705, 0.04);
sceneRef.current = scene;
// Camera — higher, more dramatic angle
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 7, 6);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// ── Lighting ──────────────────────────────────────────────────────────────
const ambientLight = new THREE.AmbientLight(0x1a1208, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xfff5e4, 1.8);
dirLight.position.set(3, 8, 5);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 30;
scene.add(dirLight);
// Amber center glow
const centerLight = new THREE.PointLight(0xfbbf24, 0.8, 6);
centerLight.position.set(0, 2, 0);
scene.add(centerLight);
// Hemisphere — warm sky / dark ground
const hemiLight = new THREE.HemisphereLight(0xfbbf24, 0x0c0a09, 0.3);
scene.add(hemiLight);
// ── Table surface ─────────────────────────────────────────────────────────
const tableTexture = track(makeTableTexture());
tableTexture.wrapS = THREE.RepeatWrapping;
tableTexture.wrapT = THREE.RepeatWrapping;
const tableGeo = track(new THREE.CylinderGeometry(2.4, 2.2, 0.12, 64));
const tableMat = track(new THREE.MeshStandardMaterial({
map: tableTexture,
roughness: 0.7,
metalness: 0.15,
}));
const table = new THREE.Mesh(tableGeo, tableMat);
table.receiveShadow = true;
table.position.y = 0;
scene.add(table);
// ── Amber rim ─────────────────────────────────────────────────────────────
const rimGeo = track(new THREE.TorusGeometry(2.4, 0.06, 16, 64));
const rimMat = track(new THREE.MeshStandardMaterial({
color: 0xfbbf24,
emissive: 0xfbbf24,
emissiveIntensity: 0.8,
roughness: 0.2,
metalness: 0.4,
}));
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.x = Math.PI / 2;
rim.position.y = 0.062;
scene.add(rim);
// Thin inner rim
const innerRimGeo = track(new THREE.TorusGeometry(2.35, 0.025, 8, 64));
const innerRimMat = track(new THREE.MeshStandardMaterial({
color: 0xfbbf24,
emissive: 0xfbbf24,
emissiveIntensity: 0.3,
roughness: 0.4,
}));
const innerRim = new THREE.Mesh(innerRimGeo, innerRimMat);
innerRim.rotation.x = Math.PI / 2;
innerRim.position.y = 0.058;
scene.add(innerRim);
// ── Center emblem ─────────────────────────────────────────────────────────
const emblemTexture = track(makeEmblemTexture());
const emblemGeo = track(new THREE.CylinderGeometry(0.25, 0.25, 0.001, 32));
const emblemMat = track(new THREE.MeshStandardMaterial({
map: emblemTexture,
emissive: new THREE.Color(0xfbbf24),
emissiveIntensity: 0.6,
transparent: true,
roughness: 0.3,
metalness: 0.2,
}));
const emblem = new THREE.Mesh(emblemGeo, emblemMat);
emblem.position.y = 0.062;
scene.add(emblem);
// ── Floor ─────────────────────────────────────────────────────────────────
const floorTexture = track(makeFloorTexture());
floorTexture.wrapS = THREE.RepeatWrapping;
floorTexture.wrapT = THREE.RepeatWrapping;
floorTexture.repeat.set(3, 3);
const floorGeo = track(new THREE.PlaneGeometry(20, 20));
const floorMat = track(new THREE.MeshStandardMaterial({
map: floorTexture,
color: 0x0a0806,
roughness: 0.9,
metalness: 0.1,
}));
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.1;
floor.receiveShadow = true;
scene.add(floor);
// ── Avatars ───────────────────────────────────────────────────────────────
const count = personas.length;
const orbitRadius = 2.85;
const newAvatars = new Map<string, AvatarObject>();
personas.forEach((persona, index) => {
const angle = (2 * Math.PI / count) * index - Math.PI / 2;
const x = orbitRadius * Math.cos(angle);
const z = orbitRadius * Math.sin(angle);
const color = nameToColor(persona.name);
const hexColor = nameToHex(persona.name);
const initial = persona.name.charAt(0).toUpperCase();
// Avatar sphere — larger, higher-res texture
const avatarGeo = track(new THREE.SphereGeometry(0.42, 32, 32));
const texture = track(makeAvatarTexture(initial, color));
const avatarMat = track(new THREE.MeshStandardMaterial({
map: texture,
roughness: 0.5,
metalness: 0.15,
}));
const avatarMesh = new THREE.Mesh(avatarGeo, avatarMat);
avatarMesh.position.set(x, 0.5, z);
avatarMesh.castShadow = true;
scene.add(avatarMesh);
// Amber halo ring (active speaker only)
const ringGeo = track(new THREE.TorusGeometry(0.52, 0.04, 8, 32));
const ringMat = track(new THREE.MeshStandardMaterial({
color: 0xfbbf24,
emissive: 0xfbbf24,
emissiveIntensity: 0.9,
transparent: true,
opacity: 0,
}));
const ringMesh = new THREE.Mesh(ringGeo, ringMat);
ringMesh.position.set(x, 0.5, z);
scene.add(ringMesh);
// Active speaker amber point light
const haloLight = new THREE.PointLight(0xfbbf24, 0, 4);
haloLight.position.set(x, 1.5, z);
scene.add(haloLight);
// Inactive persona soft colored light
const inactiveLight = new THREE.PointLight(hexColor, 0.2, 2.5);
inactiveLight.position.set(x, 1.0, z);
scene.add(inactiveLight);
// SpotLight from above for each avatar
const spotLight = new THREE.SpotLight(0xfff5e4, 0.6, 5, Math.PI / 8, 0.4, 1);
spotLight.position.set(x, 3, z);
spotLight.target.position.set(x, 0, z);
scene.add(spotLight);
scene.add(spotLight.target);
const personaId = persona._id || persona.id;
newAvatars.set(personaId, {
mesh: avatarMesh,
ring: ringMesh,
haloLight,
inactiveLight,
spotLight,
targetScale: 1,
currentScale: 1,
});
});
avatarsRef.current = newAvatars;
// ── Animation loop ────────────────────────────────────────────────────────
const animate = () => {
animFrameRef.current = requestAnimationFrame(animate);
if (!reducedMotionRef.current) {
cameraAngleRef.current += 0.0005;
}
const r = 8.5;
const camAngle = cameraAngleRef.current;
camera.position.x = r * Math.sin(camAngle);
camera.position.z = r * Math.cos(camAngle) * 0.8 + 1.5;
camera.position.y = 7;
camera.lookAt(0, 0, 0);
// Update avatars
avatarsRef.current.forEach((avatarObj) => {
avatarObj.currentScale += (avatarObj.targetScale - avatarObj.currentScale) * 0.07;
avatarObj.mesh.scale.setScalar(avatarObj.currentScale);
if (avatarObj.targetScale > 1.1) {
avatarObj.ring.rotation.z += 0.04;
}
});
renderer.render(scene, camera);
};
animate();
// ── ResizeObserver ────────────────────────────────────────────────────────
const resizeObserver = new ResizeObserver(() => {
if (!container || !rendererRef.current || !cameraRef.current) return;
const w = container.clientWidth;
rendererRef.current.setSize(w, CANVAS_HEIGHT);
cameraRef.current.aspect = w / CANVAS_HEIGHT;
cameraRef.current.updateProjectionMatrix();
});
resizeObserver.observe(container);
// ── Cleanup ───────────────────────────────────────────────────────────────
return () => {
cancelAnimationFrame(animFrameRef.current);
resizeObserver.disconnect();
disposablesRef.current.forEach((obj) => obj.dispose());
disposablesRef.current = [];
avatarsRef.current.clear();
renderer.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
sceneRef.current = null;
cameraRef.current = null;
rendererRef.current = null;
};
// personas rebuild → recreate scene
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [personas]);
// ── Active speaker glow ────────────────────────────────────────────────────
useEffect(() => {
avatarsRef.current.forEach((avatarObj, id) => {
const isSpeaking = id === activeSpeakerId;
avatarObj.targetScale = isSpeaking ? 1.25 : 1.0;
avatarObj.haloLight.intensity = isSpeaking ? 3 : 0;
avatarObj.inactiveLight.intensity = isSpeaking ? 0 : 0.2;
const ringMat = avatarObj.ring.material as THREE.MeshStandardMaterial;
ringMat.opacity = isSpeaking ? 0.9 : 0;
ringMat.needsUpdate = true;
});
}, [activeSpeakerId]);
// ── Overlay tick ──────────────────────────────────────────────────────────
const [tick, setTick] = useState(0);
useEffect(() => {
if (personas.length === 0) return;
const id = setInterval(() => setTick((t) => t + 1), 200);
return () => clearInterval(id);
}, [personas.length]);
useEffect(() => {
updateOverlays();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tick, messages]);
return (
<div style={{ position: 'relative', width: '100%', height: CANVAS_HEIGHT }}>
{/* Three.js canvas mount */}
<div ref={mountRef} style={{ width: '100%', height: CANVAS_HEIGHT }} />
{/* Speech bubble overlay */}
{speechBubbles.map((bubble) => (
<div
key={bubble.personaId}
style={{
position: 'absolute',
left: `${bubble.screenX}%`,
top: `${bubble.screenY}%`,
transform: 'translate(-50%, -110%)',
pointerEvents: 'none',
zIndex: 10,
maxWidth: 200,
animation: 'roundtable-fadein 0.3s ease',
}}
>
<div
style={{
background: 'rgba(20, 16, 12, 0.95)',
border: '1px solid rgba(251, 191, 36, 0.6)',
borderRadius: 8,
padding: '8px 12px',
fontFamily: "'Figtree', system-ui, sans-serif",
fontSize: 12,
color: '#fef3c7',
lineHeight: 1.45,
backdropFilter: 'blur(6px)',
boxShadow: '0 0 16px rgba(251, 191, 36, 0.15), 0 4px 12px rgba(0,0,0,0.5)',
}}
>
{bubble.text}
</div>
{/* Tail */}
<div
style={{
width: 0,
height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: '6px solid rgba(251, 191, 36, 0.6)',
margin: '0 auto',
}}
/>
</div>
))}
{/* Floating name tags */}
{nameTags.map((tag) => (
<div
key={`tag-${tag.personaId}`}
style={{
position: 'absolute',
left: `${tag.screenX}%`,
top: `${tag.screenY}%`,
transform: 'translate(-50%, 0)',
pointerEvents: 'none',
zIndex: 9,
fontFamily: "'JetBrains Mono', monospace",
fontSize: 9,
color: 'rgba(184, 178, 172, 0.8)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
whiteSpace: 'nowrap',
}}
>
{tag.name}
</div>
))}
<style>{`
@keyframes roundtable-fadein {
from { opacity: 0; transform: translate(-50%, -120%); }
to { opacity: 1; transform: translate(-50%, -110%); }
}
`}</style>
</div>
);
}

View file

@ -1,122 +1,161 @@
import { Target, Zap, Activity, Brain, Heart } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Persona } from '@/types/persona';
interface PersonaAttitudinalProfileProps {
persona: Persona;
}
export function PersonaAttitudinalProfile({ persona }: PersonaAttitudinalProfileProps) {
function SectionHeader({ icon: Icon, label, accent }: { icon: React.ElementType; label: string; accent?: string }) {
return (
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center mb-4">
<Target className="h-5 w-5 text-primary mr-2" />
<h3 className="font-sf text-lg font-medium">Goals</h3>
</div>
<ul className="space-y-2">
{persona.goals?.map((goal, index) => (
<li key={index} className="flex items-start">
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5 mr-3">
<span className="text-xs text-primary font-medium">{index + 1}</span>
</div>
<p className="text-sm">{goal}</p>
</li>
))}
</ul>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center mb-4">
<Zap className="h-5 w-5 text-amber-500 mr-2" />
<h3 className="font-sf text-lg font-medium">Frustrations</h3>
</div>
<ul className="space-y-2">
{persona.frustrations?.map((item, index) => (
<li key={index} className="text-sm flex items-start">
<span className="text-amber-500 mr-2"></span>
<span>{item}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center mb-4">
<Activity className="h-5 w-5 text-green-500 mr-2" />
<h3 className="font-sf text-lg font-medium">Motivations</h3>
</div>
<ul className="space-y-2">
{persona.motivations?.map((item, index) => (
<li key={index} className="text-sm flex items-start">
<span className="text-green-500 mr-2"></span>
<span>{item}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<h3
className="text-sm font-semibold text-foreground uppercase tracking-widest"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{label}
</h3>
</div>
);
}
export function PersonaAttitudinalProfile({ persona }: PersonaAttitudinalProfileProps) {
return (
<div className="space-y-4">
{/* Goals */}
<div className="rounded-xl border border-border bg-card p-6">
<SectionHeader icon={Target} label="Goals" />
<ul className="space-y-3">
{persona.goals?.map((goal, i) => (
<li key={i} className="flex items-start gap-3">
<div
className="w-5 h-5 rounded-full bg-primary/15 border border-primary/30 flex items-center justify-center flex-shrink-0 mt-0.5"
>
<span
className="text-[10px] font-bold text-primary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{i + 1}
</span>
</div>
<p className="text-sm text-foreground/85 leading-relaxed">{goal}</p>
</li>
))}
</ul>
</div>
{/* Frustrations + Motivations */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-xl border border-border bg-card p-6">
<SectionHeader icon={Zap} label="Frustrations" />
<ul className="space-y-2.5">
{persona.frustrations?.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80 leading-relaxed">
<span className="text-primary/60 mt-0.5 flex-shrink-0"></span>
<span>{item}</span>
</li>
))}
</ul>
</div>
<div className="rounded-xl border border-border bg-card p-6">
<SectionHeader icon={Activity} label="Motivations" />
<ul className="space-y-2.5">
{persona.motivations?.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground/80 leading-relaxed">
<span className="text-primary/60 mt-0.5 flex-shrink-0">+</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
{/* Think, Feel, Do */}
<div className="rounded-xl border border-border bg-card p-6">
<div className="flex items-center gap-2.5 mb-5">
<div className="h-px flex-1 bg-border/60" />
<span
className="text-xs font-medium text-muted-foreground uppercase tracking-widest px-3"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
Think · Feel · Do
</span>
<div className="h-px flex-1 bg-border/60" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Thinks */}
<div>
<div className="flex items-center gap-2 mb-3">
<Brain className="h-4 w-4 text-primary/70" />
<span
className="text-[11px] font-medium text-primary/70 uppercase tracking-widest"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
Thinks
</span>
</div>
<ul className="space-y-2">
{persona.thinkFeelDo?.thinks?.map((item, i) => (
<li
key={i}
className="text-sm text-foreground/75 bg-secondary/40 rounded-lg px-3 py-2.5 leading-relaxed border border-border/40"
>
<span className="text-primary/50 mr-1">"</span>{item}<span className="text-primary/50">"</span>
</li>
))}
</ul>
</div>
{/* Feels */}
<div>
<div className="flex items-center gap-2 mb-3">
<Heart className="h-4 w-4 text-primary/70" />
<span
className="text-[11px] font-medium text-primary/70 uppercase tracking-widest"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
Feels
</span>
</div>
<ul className="space-y-2">
{persona.thinkFeelDo?.feels?.map((item, i) => (
<li
key={i}
className="text-sm text-foreground/75 bg-secondary/40 rounded-lg px-3 py-2.5 leading-relaxed border border-border/40"
>
<span className="text-primary/50 mr-1">"</span>{item}<span className="text-primary/50">"</span>
</li>
))}
</ul>
</div>
{/* Does */}
<div>
<div className="flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-primary/70" />
<span
className="text-[11px] font-medium text-primary/70 uppercase tracking-widest"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
Does
</span>
</div>
<ul className="space-y-2">
{persona.thinkFeelDo?.does?.map((item, i) => (
<li
key={i}
className="text-sm text-foreground/75 bg-secondary/40 rounded-lg px-3 py-2.5 leading-relaxed border border-border/40"
>
<span className="text-primary/50 mr-1">"</span>{item}<span className="text-primary/50">"</span>
</li>
))}
</ul>
</div>
</div>
</div>
<Card>
<CardContent className="p-6">
<h3 className="font-sf text-lg font-medium mb-4">Think, Feel, Do</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="flex items-center mb-3">
<Brain className="h-5 w-5 text-blue-500 mr-2" />
<h4 className="font-medium text-sm">Thinks</h4>
</div>
<ul className="space-y-2">
{persona.thinkFeelDo?.thinks?.map((item, index) => (
<li key={index} className="text-sm bg-blue-500/10 p-2 rounded-md">
"{item}"
</li>
))}
</ul>
</div>
<div>
<div className="flex items-center mb-3">
<Heart className="h-5 w-5 text-red-500 mr-2" />
<h4 className="font-medium text-sm">Feels</h4>
</div>
<ul className="space-y-2">
{persona.thinkFeelDo?.feels?.map((item, index) => (
<li key={index} className="text-sm bg-red-500/10 p-2 rounded-md">
"{item}"
</li>
))}
</ul>
</div>
<div>
<div className="flex items-center mb-3">
<Activity className="h-5 w-5 text-green-500 mr-2" />
<h4 className="font-medium text-sm">Does</h4>
</div>
<ul className="space-y-2">
{persona.thinkFeelDo?.does?.map((item, index) => (
<li key={index} className="text-sm bg-green-500/10 p-2 rounded-md">
"{item}"
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -10,7 +10,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { ArrowLeft, Edit, Home, Users, User, Download, Bot } from 'lucide-react';
import { ArrowLeft, Edit, Home, Users, User, Download, Bot, Target, Sparkles, BookOpen, SlidersHorizontal } from 'lucide-react';
import { useNavigation } from '@/contexts/NavigationContext';
import { focusGroupsApi, personasApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
@ -256,46 +256,56 @@ export default function PersonaProfile() {
{/* Review Mode Banner */}
{isReviewMode && (
<div className="mb-6 p-4 bg-amber-500/10 border-l-4 border-amber-400 rounded-r-lg">
<div className="flex items-center">
<div className="ml-3">
<p className="text-sm text-amber-400">
📝 <strong>Reviewing proposed changes to {displayPersona?.name}</strong> - Save or cancel to continue
</p>
</div>
</div>
<div className="mb-6 p-4 bg-primary/8 border border-primary/40 rounded-xl">
<p className="text-sm text-primary/90">
<strong>Reviewing proposed changes to {displayPersona?.name}</strong> save or cancel to continue.
</p>
</div>
)}
<div className="flex items-center mb-6 relative">
<Button
variant="ghost"
onClick={() => {
if (isReviewMode) {
if (confirm("You have unsaved changes. Your modifications will be lost if you leave. Do you want to continue?")) {
exitReviewMode();
<div className="flex items-center justify-between mb-6 gap-4">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (isReviewMode) {
if (confirm("You have unsaved changes. Your modifications will be lost if you leave. Do you want to continue?")) {
exitReviewMode();
handleGoBack();
}
} else {
handleGoBack();
}
} else {
handleGoBack();
}
}}
className="absolute left-0 top-0 flex items-center"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-sf text-3xl font-bold text-foreground ml-16">Persona Profile</h1>
<div className="absolute right-0 top-0 flex items-center gap-3">
}}
className="h-8 w-8 p-0 rounded-lg border border-border/60 hover:border-primary/40"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<p
className="text-[10px] font-medium text-muted-foreground uppercase tracking-widest"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
Persona Profile
</p>
<h1
className="text-2xl font-bold text-foreground leading-tight"
style={{ fontFamily: "'Montserrat', sans-serif" }}
>
{isReviewMode ? displayPersona?.name : currentPersona?.name}
</h1>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isReviewMode ? (
<>
<Button
variant="outline"
onClick={exitReviewMode}
className="hover-transition"
>
<Button variant="outline" size="sm" onClick={exitReviewMode} className="hover-transition">
Cancel Revision
</Button>
<Button
<Button
size="sm"
onClick={saveReviewedPersona}
className="bg-green-600 hover:bg-green-700 text-white"
>
@ -304,25 +314,27 @@ export default function PersonaProfile() {
</>
) : (
<>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={handleExportProfile}
disabled={isExporting}
className="hover-transition"
className="hover-transition gap-1.5"
>
<Download className="h-4 w-4 mr-2" />
{isExporting ? 'Generating...' : 'Download Profile'}
<Download className="h-3.5 w-3.5" />
{isExporting ? 'Generating' : 'Download Profile'}
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => setIsModificationModalOpen(true)}
className="hover-transition"
className="hover-transition gap-1.5"
>
<Bot className="h-4 w-4 mr-2" />
<Bot className="h-3.5 w-3.5" />
Modify with AI
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit className="h-4 w-4 mr-2" />
<Button size="sm" onClick={() => setIsEditing(true)} className="gap-1.5">
<Edit className="h-3.5 w-3.5" />
Edit Persona
</Button>
</>
@ -330,33 +342,46 @@ export default function PersonaProfile() {
</div>
</div>
<div className={`grid grid-cols-1 lg:grid-cols-3 gap-6 mt-10 ${isReviewMode ? 'border-2 border-amber-400 rounded-lg p-4 bg-amber-500/5' : ''}`}>
<div className="lg:col-span-1">
<div className={`grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6 ${isReviewMode ? 'ring-1 ring-primary/40 rounded-2xl p-4 bg-primary/5' : ''}`}>
<div>
<PersonaSidebar persona={displayPersona} />
</div>
<div className="lg:col-span-2">
<Tabs defaultValue="attitudinal-profile">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="attitudinal-profile">Attitudinal Profile</TabsTrigger>
<TabsTrigger value="personality">Personality</TabsTrigger>
<TabsTrigger value="scenarios">Scenarios</TabsTrigger>
<TabsTrigger value="generation-prompts">Persona Inputs</TabsTrigger>
<div>
<Tabs defaultValue="attitudinal-profile" className="w-full">
{/* Custom icon-tab nav */}
<TabsList className="flex items-center gap-1 p-1 bg-secondary/40 rounded-xl border border-border mb-5 w-fit h-auto">
{[
{ value: 'attitudinal-profile', icon: Target, label: 'Profile' },
{ value: 'personality', icon: Sparkles, label: 'Personality' },
{ value: 'scenarios', icon: BookOpen, label: 'Scenarios' },
{ value: 'generation-prompts', icon: SlidersHorizontal, label: 'Inputs' },
].map(({ value, icon: Icon, label }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium text-muted-foreground transition-all data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-primary/25 hover:text-foreground"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="uppercase tracking-wider">{label}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="attitudinal-profile" className="mt-6">
<TabsContent value="attitudinal-profile" className="mt-0">
<PersonaAttitudinalProfile persona={displayPersona} />
</TabsContent>
<TabsContent value="personality" className="mt-6">
<TabsContent value="personality" className="mt-0">
<PersonaPersonality persona={displayPersona} />
</TabsContent>
<TabsContent value="scenarios" className="mt-6">
<TabsContent value="scenarios" className="mt-0">
<PersonaScenarios persona={displayPersona} />
</TabsContent>
<TabsContent value="generation-prompts" className="mt-6">
<TabsContent value="generation-prompts" className="mt-0">
<PersonaGenerationPrompts persona={displayPersona} />
</TabsContent>
</Tabs>

View file

@ -1,6 +1,4 @@
import { User, Users, MapPin, BookOpen, Heart, Monitor, ShoppingBag, Info } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Users, MapPin, Heart, BookOpen, Monitor, ShoppingBag, Info } from 'lucide-react';
import { Persona } from '@/types/persona';
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
@ -8,185 +6,155 @@ interface PersonaSidebarProps {
persona: Persona;
}
const BAR_ITEMS = [
{ key: 'techSavviness', label: 'Tech Savviness' },
{ key: 'brandLoyalty', label: 'Brand Loyalty' },
{ key: 'priceConsciousness', label: 'Price Sensitivity' },
{ key: 'environmentalConcern', label: 'Env. Concern' },
] as const;
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p
className="text-[10px] font-medium text-primary/70 uppercase tracking-widest mb-2"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{children}
</p>
);
}
export function PersonaSidebar({ persona }: PersonaSidebarProps) {
return (
<Card className="p-6">
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<img
src={getPersonaAvatarSrc(persona)}
alt={persona.name}
className="h-16 w-16 rounded-full object-cover"
/>
</div>
<div>
<h2 className="font-sf text-xl font-semibold">{persona.name}</h2>
<p className="text-muted-foreground">{persona.occupation}</p>
<div className="rounded-2xl border border-border bg-card overflow-hidden">
{/* Avatar + identity block */}
<div className="p-6 border-b border-border/60">
<div className="flex items-center gap-4">
<div className="relative flex-shrink-0">
<div className="w-16 h-16 rounded-full ring-2 ring-primary/40 ring-offset-2 ring-offset-card overflow-hidden bg-secondary">
<img
src={getPersonaAvatarSrc(persona)}
alt={persona.name}
className="w-full h-full object-cover"
/>
</div>
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500/80 border-2 border-card" />
</div>
<div className="min-w-0">
<h2
className="text-lg font-bold text-foreground leading-tight truncate"
style={{ fontFamily: "'Montserrat', sans-serif" }}
>
{persona.name}
</h2>
<p className="text-sm text-muted-foreground truncate">{persona.occupation}</p>
{persona.location && (
<div className="flex items-center gap-1 mt-1">
<MapPin className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
<span className="text-xs text-muted-foreground/70 truncate">{persona.location}</span>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 space-y-4">
<div className="sidebar-section">
<Users className="sidebar-icon" />
<div>
<h3 className="font-medium text-sm">Demographics</h3>
<p className="text-sm text-muted-foreground mt-1">
{persona.age} {persona.gender ? <> {persona.gender}</> : null}
{persona.ethnicity ? <> {persona.ethnicity}</> : null}
</p>
{persona.education && (
<p className="sidebar-sub-item">{persona.education}</p>
)}
{persona.socialGrade && (
<p className="sidebar-sub-item">Social Grade: {persona.socialGrade}</p>
)}
{persona.householdIncome && (
<p className="sidebar-sub-item">Household Income: {persona.householdIncome}</p>
)}
{persona.householdComposition && (
<p className="sidebar-sub-item">Household: {persona.householdComposition}</p>
)}
</div>
</div>
<div className="sidebar-section">
<MapPin className="sidebar-icon" />
<div>
<h3 className="font-medium text-sm">Location</h3>
<p className="text-sm text-muted-foreground mt-1">{persona.location}</p>
{persona.livingSituation && (
<p className="sidebar-sub-item">{persona.livingSituation}</p>
<div className="p-6 space-y-5">
{/* Demographics */}
<div>
<SectionLabel>Demographics</SectionLabel>
<div className="space-y-1 text-sm text-muted-foreground">
{(persona.age || persona.gender) && (
<p>
{[persona.age, persona.gender, persona.ethnicity].filter(Boolean).join(' · ')}
</p>
)}
{persona.education && <p>{persona.education}</p>}
{persona.socialGrade && <p>Social Grade: {persona.socialGrade}</p>}
{persona.householdIncome && <p>Income: {persona.householdIncome}</p>}
{persona.householdComposition && <p>{persona.householdComposition}</p>}
{persona.livingSituation && <p>{persona.livingSituation}</p>}
</div>
</div>
{/* Interests */}
{persona.interests && (
<div className="sidebar-section">
<Heart className="sidebar-icon" />
<div>
<h3 className="font-medium text-sm">Interests</h3>
<p className="text-sm text-muted-foreground mt-1">{persona.interests}</p>
</div>
<div>
<SectionLabel>Interests</SectionLabel>
<p className="text-sm text-muted-foreground leading-relaxed">{persona.interests}</p>
</div>
)}
{/* Media */}
{persona.mediaConsumption && (
<div className="sidebar-section">
<BookOpen className="sidebar-icon" />
<div>
<h3 className="font-medium text-sm">Media</h3>
<p className="text-sm text-muted-foreground mt-1">{persona.mediaConsumption}</p>
</div>
<div>
<SectionLabel>Media</SectionLabel>
<p className="text-sm text-muted-foreground leading-relaxed">{persona.mediaConsumption}</p>
</div>
)}
<div className="pt-4 border-t">
<h3 className="font-medium text-sm mb-3">Digital Behavior</h3>
{/* Digital Behavior — all bars in amber */}
<div className="pt-1 border-t border-border/60">
<SectionLabel>Digital Behavior</SectionLabel>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>Tech Savviness</span>
<span>{persona.techSavviness}%</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${persona.techSavviness}%` }}></div>
</div>
</div>
{persona.brandLoyalty !== undefined && (
<div>
<div className="flex justify-between text-xs mb-1">
<span>Brand Loyalty</span>
<span>{persona.brandLoyalty}%</span>
{BAR_ITEMS.map(({ key, label }) => {
const val = persona[key] as number | undefined;
if (val === undefined) return null;
return (
<div key={key}>
<div className="flex justify-between items-center mb-1.5">
<span className="text-[11px] text-muted-foreground">{label}</span>
<span
className="text-[11px] font-medium text-primary/80"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{val}%
</span>
</div>
<div className="h-1.5 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${val}%` }}
/>
</div>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${persona.brandLoyalty}%` }}></div>
</div>
</div>
)}
{persona.priceConsciousness !== undefined && (
<div>
<div className="flex justify-between text-xs mb-1">
<span>Price Sensitivity</span>
<span>{persona.priceConsciousness}%</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div className="h-full bg-amber-500 rounded-full" style={{ width: `${persona.priceConsciousness}%` }}></div>
</div>
</div>
)}
{persona.environmentalConcern !== undefined && (
<div>
<div className="flex justify-between text-xs mb-1">
<span>Environmental Concern</span>
<span>{persona.environmentalConcern}%</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: `${persona.environmentalConcern}%` }}></div>
</div>
</div>
)}
);
})}
{persona.deviceUsage && (
<div>
<h4 className="text-xs font-medium mt-3">Device Usage</h4>
<p className="sidebar-sub-item text-xs">{persona.deviceUsage}</p>
<div className="pt-1">
<p className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-widest mb-1" style={{ fontFamily: "'JetBrains Mono', monospace" }}>Device</p>
<p className="text-xs text-muted-foreground">{persona.deviceUsage}</p>
</div>
)}
{persona.shoppingHabits && (
<div>
<h4 className="text-xs font-medium mt-3">Shopping Habits</h4>
<p className="sidebar-sub-item text-xs">{persona.shoppingHabits}</p>
<p className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-widest mb-1" style={{ fontFamily: "'JetBrains Mono', monospace" }}>Shopping</p>
<p className="text-xs text-muted-foreground">{persona.shoppingHabits}</p>
</div>
)}
</div>
</div>
<div className="pt-4 border-t">
<h3 className="font-medium text-sm mb-3">Additional Information</h3>
<div className="space-y-2">
{persona.brandPreferences && (
<div className="sidebar-section">
<Heart className="sidebar-icon" />
<span className="text-muted-foreground text-sm">{persona.brandPreferences}</span>
</div>
)}
{persona.communicationPreferences && (
<div className="sidebar-section">
<User className="sidebar-icon" />
<span className="text-muted-foreground text-sm">Prefers: {persona.communicationPreferences}</span>
</div>
)}
{persona.deviceUsage && (
<div className="sidebar-section">
<Monitor className="sidebar-icon" />
<span className="text-muted-foreground text-sm">{persona.deviceUsage}</span>
</div>
)}
{persona.shoppingHabits && (
<div className="sidebar-section">
<ShoppingBag className="sidebar-icon" />
<span className="text-muted-foreground text-sm">{persona.shoppingHabits}</span>
</div>
)}
{persona.additionalInformation && typeof persona.additionalInformation === 'string' && (
<div className="sidebar-section">
<Info className="sidebar-icon" />
<div className="sidebar-sub-item">
{persona.additionalInformation.split('\n').map((line, index) => (
<div key={index} className="mb-1">
{line.trim().startsWith('•') || line.trim().startsWith('-') ? (
line.trim().substring(1).trim()
) : (
line.trim()
)}
{/* Additional Info */}
{(persona.brandPreferences || persona.communicationPreferences || persona.additionalInformation) && (
<div className="pt-1 border-t border-border/60">
<SectionLabel>Additional</SectionLabel>
<div className="space-y-2 text-sm text-muted-foreground">
{persona.brandPreferences && <p>{persona.brandPreferences}</p>}
{persona.communicationPreferences && <p>Prefers: {persona.communicationPreferences}</p>}
{persona.additionalInformation && typeof persona.additionalInformation === 'string' && (
<div>
{persona.additionalInformation.split('\n').map((line, i) => (
<div key={i} className="mb-0.5">
{line.trim().replace(/^[•\-]\s*/, '')}
</div>
))}
</div>
</div>
)}
)}
</div>
</div>
</div>
)}
</div>
</Card>
</div>
);
}

View file

@ -28,6 +28,7 @@ import ThemesPanel from '@/components/focus-group-session/ThemesPanel';
import AnalyticsPanel from '@/components/focus-group-session/AnalyticsPanel';
import AutonomousDashboard from '@/components/focus-group-session/AutonomousDashboard';
import CollapsibleDiscussionGuide from '@/components/focus-group-session/CollapsibleDiscussionGuide';
import DiscussionGuideViewer from '@/components/focus-group-session/DiscussionGuideViewer';
import NotesPanel from '@/components/focus-group-session/NotesPanel';
import QuickNoteModal from '@/components/focus-group-session/QuickNoteModal';
import { FocusGroup, Message, Theme, Note, QuoteData, ModeEvent } from '@/components/focus-group-session/types';
@ -1811,9 +1812,9 @@ const FocusGroupSession = () => {
}
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen bg-background flex flex-col">
{/* WebSocket Connection Status Bar */}
{useWebSocketEnabled && isStatusBarVisible && (
<div className={`w-full transition-all duration-300 ${
@ -1890,72 +1891,62 @@ const FocusGroupSession = () => {
</div>
)}
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<div className="flex items-center">
<Button
variant="ghost"
onClick={() => navigate('/focus-groups')}
className="mr-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-sf text-2xl font-bold text-foreground">{focusGroup.name}</h1>
<p className="text-muted-foreground">{new Date(focusGroup.date).toLocaleString()}</p>
<div className="flex items-center mt-1">
<Bot className="h-3 w-3 text-muted-foreground mr-1" />
<Badge variant="secondary" className="text-xs">
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
</div>
{user?.role === 'admin' && fgCostTotal > 0 && (
<div className="flex items-center gap-1 mt-1">
<Badge variant="outline" className="text-xs font-mono">
${fgCostTotal.toFixed(4)} &bull; {(fgTokensTotal / 1000).toFixed(1)}k tok
</Badge>
</div>
)}
</div>
<main className="flex flex-col overflow-hidden" style={{ height: 'calc(100vh - 4rem)' }}>
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/30 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/focus-groups')}
className="shrink-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<p className="text-sm text-muted-foreground shrink-0">{new Date(focusGroup.date).toLocaleString()}</p>
<Bot className="h-3 w-3 text-muted-foreground shrink-0" />
<Badge variant="secondary" className="text-xs shrink-0">
{focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
</Badge>
{user?.role === 'admin' && fgCostTotal > 0 && (
<Badge variant="outline" className="text-xs font-mono shrink-0">
${fgCostTotal.toFixed(4)} &bull; {(fgTokensTotal / 1000).toFixed(1)}k tok
</Badge>
)}
</div>
<div className="flex items-center space-x-4 mt-4 sm:mt-0">
<div className="flex items-center gap-1 shrink-0">
<Button
variant="outline"
onClick={() => setIsDiscussionGuideOpen(true)}
className={isDiscussionGuideOpen ? 'bg-amber-50 text-amber-700 border-amber-200' : ''}
>
<ClipboardList className="mr-2 h-4 w-4" />
{isDiscussionGuideOpen ? 'Guide Open' : 'Edit Discussion Guide'}
</Button>
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={() => setShowAutonomousDashboard(!showAutonomousDashboard)}
className={showAutonomousDashboard ? 'bg-blue-50 text-blue-600' : ''}
className={showAutonomousDashboard ? 'text-blue-400' : 'text-muted-foreground'}
title="AI Dashboard"
>
<BarChart className="mr-2 h-4 w-4" />
AI Dashboard
<BarChart className="h-4 w-4" />
</Button>
<Button
variant="outline"
<Button
variant="ghost"
size="sm"
onClick={() => setShowModelSettings(true)}
className="text-muted-foreground"
title="AI Model Settings"
>
<Settings className="mr-2 h-4 w-4" />
AI Model
<Settings className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={downloadTranscript}>
<Download className="mr-2 h-4 w-4" />
Download Transcript
<Button
variant="ghost"
size="sm"
onClick={downloadTranscript}
className="text-muted-foreground"
title="Download Transcript"
>
<Download className="h-4 w-4" />
</Button>
</div>
</div>
{/* Quota warning banner */}
{quotaWarning && (
<div className="mx-4 mt-2 p-3 bg-amber-50 border border-amber-200 rounded-md flex items-center justify-between">
<div className="px-4 py-2 bg-amber-50 border-b border-amber-200 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3 flex-1">
<span className="text-sm text-amber-700">
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
@ -1969,7 +1960,7 @@ const FocusGroupSession = () => {
{/* Quota exceeded banner */}
{quotaExceeded && (
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">
<div className="px-4 py-2 bg-red-50 border-b border-red-200 flex items-center justify-between shrink-0">
<span className="text-sm text-red-700">
Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used.
</span>
@ -1992,86 +1983,105 @@ const FocusGroupSession = () => {
onComplete={handleThemeProgressComplete}
/>
{/* Collapsible Discussion Guide Panel */}
<CollapsibleDiscussionGuide
discussionGuide={focusGroup.discussionGuide}
moderatorStatus={moderatorStatus}
onSectionSelect={handleSectionSelect}
onSetPosition={handleSetPosition}
onSave={handleDiscussionGuideSave}
focusGroupId={id || ''}
isOpen={isDiscussionGuideOpen}
onToggle={handleToggleDiscussionGuide}
onEditingChange={handleGuideEditingStateChange}
/>
<div className="flex flex-col lg:flex-row gap-6 lg:h-[calc(100vh-12rem)]">
<ParticipantPanel
participants={participants}
selectedParticipantIds={selectedParticipantIds}
onToggleParticipantFilter={toggleParticipantFilter}
isEditable={focusGroup?.status === 'new'}
allPersonas={allPersonas}
onAddParticipant={handleAddParticipant}
onRemoveParticipant={handleRemoveParticipant}
/>
<div className="flex-1 flex flex-col">
<Tabs defaultValue="chat" value={activeTab} onValueChange={setActiveTab} className="w-full h-full flex flex-col">
<TabsList className="grid grid-cols-4 mb-4">
<TabsTrigger value="chat" className="flex items-center gap-1 sm:gap-2">
<MessageCircle className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Discussion</span>
{/* Session header strip */}
<div className="flex items-center gap-4 px-6 py-3 border-b border-border/50 bg-card/80 backdrop-blur shrink-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="text-xs font-mono uppercase tracking-widest text-muted-foreground shrink-0">
Session
</span>
<h1 className="font-semibold text-foreground truncate max-w-[300px]">{focusGroup.name}</h1>
{isAiModeActive && (
<span className="flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-widest text-green-400 border border-green-500/30 bg-green-500/10 rounded-full px-2 py-0.5 shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
Live
</span>
)}
</div>
</div>
{/* 3-column Director's Studio grid */}
<div className="flex-1 grid grid-cols-[220px_1fr_300px] overflow-hidden">
{/* LEFT: Participants */}
<div className="bg-background border-r border-border/50 overflow-y-auto">
<ParticipantPanel
participants={participants}
selectedParticipantIds={selectedParticipantIds}
onToggleParticipantFilter={toggleParticipantFilter}
isEditable={focusGroup?.status === 'new'}
allPersonas={allPersonas}
onAddParticipant={handleAddParticipant}
onRemoveParticipant={handleRemoveParticipant}
/>
</div>
{/* CENTER: Discussion */}
<div className="bg-background flex flex-col overflow-hidden">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<p className="text-lg text-muted-foreground">
No messages yet. Start the session to begin the discussion.
</p>
<Button onClick={startSession} size="lg" className="flex items-center gap-2">
<PlayCircle className="h-5 w-5" />
Start Session
</Button>
</div>
) : (
<DiscussionPanel
messages={messages}
modeEvents={modeEvents}
personas={participants}
isSpeaking={false}
focusGroupId={id || ''}
isAiModeActive={isAiModeActive}
selectedParticipantIds={selectedParticipantIds}
onToggleHighlight={toggleHighlight}
onAdvanceDiscussion={() => null}
onNewMessage={handleNewMessage}
onStatusChange={reloadFocusGroup}
isEditingDiscussionGuide={isEditingDiscussionGuide}
/>
)}
</div>
{/* RIGHT: Guide + Themes + Notes + Analytics */}
<div className="bg-card/30 border-l border-border/50 flex flex-col overflow-hidden">
<Tabs defaultValue="guide" className="flex flex-col h-full">
<TabsList className="grid grid-cols-4 m-3 h-8 shrink-0">
<TabsTrigger value="guide" className="text-[10px]">
Guide
</TabsTrigger>
<TabsTrigger value="themes" className="flex items-center gap-1 sm:gap-2">
<Lightbulb className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Key Themes</span>
<TabsTrigger value="themes" className="text-[10px]">
Themes
</TabsTrigger>
<TabsTrigger value="notes" className="flex items-center gap-1 sm:gap-2">
<StickyNote className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Notes</span>
<TabsTrigger value="notes" className="text-[10px]">
Notes
</TabsTrigger>
<TabsTrigger value="analytics" className="flex items-center gap-1 sm:gap-2">
<BarChart className="h-4 w-4 flex-shrink-0" />
<span className="hidden sm:inline">Analytics</span>
<TabsTrigger value="analytics" className="text-[10px]">
Stats
</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="m-0 flex-1 flex flex-col h-0">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<p className="text-lg text-muted-foreground">No messages yet. Start the session to begin the discussion.</p>
<Button
onClick={startSession}
size="lg"
className="flex items-center gap-2"
>
<PlayCircle className="h-5 w-5" />
Start Session
</Button>
</div>
) : (
<DiscussionPanel
messages={messages}
modeEvents={modeEvents}
personas={participants}
isSpeaking={false}
focusGroupId={id || ''}
isAiModeActive={isAiModeActive}
selectedParticipantIds={selectedParticipantIds}
onToggleHighlight={toggleHighlight}
onAdvanceDiscussion={() => null}
onNewMessage={handleNewMessage}
onStatusChange={reloadFocusGroup}
isEditingDiscussionGuide={isEditingDiscussionGuide}
/>
)}
<TabsContent value="guide" className="flex-1 overflow-y-auto px-3 pb-3 m-0">
<DiscussionGuideViewer
discussionGuide={focusGroup.discussionGuide}
moderatorStatus={moderatorStatus}
onSectionSelect={handleSectionSelect}
onSetPosition={handleSetPosition}
onSave={handleDiscussionGuideSave}
focusGroupId={id || ''}
collapsible={false}
defaultExpanded={true}
showProgress={true}
onEditingChange={handleGuideEditingStateChange}
/>
</TabsContent>
<TabsContent value="themes" className="m-0">
<ThemesPanel
themes={themes}
messages={messages}
<TabsContent value="themes" className="flex-1 overflow-y-auto m-0">
<ThemesPanel
themes={themes}
messages={messages}
personas={participants}
focusGroupId={id || ''}
onThemesGenerated={handleThemesGenerated}
@ -2080,19 +2090,17 @@ const FocusGroupSession = () => {
onGenerateKeyThemes={generateKeyThemes}
/>
</TabsContent>
<TabsContent value="notes" className="m-0" style={{ height: 'calc(100% - 3.5rem)' }}>
<div className="h-full">
<NotesPanel
focusGroupId={id || ''}
focusGroupName={focusGroup?.name}
onNoteClick={handleNoteClick}
/>
</div>
<TabsContent value="notes" className="flex-1 overflow-y-auto m-0">
<NotesPanel
focusGroupId={id || ''}
focusGroupName={focusGroup?.name}
onNoteClick={handleNoteClick}
/>
</TabsContent>
<TabsContent value="analytics" className="m-0">
<AnalyticsPanel
<TabsContent value="analytics" className="flex-1 overflow-y-auto m-0">
<AnalyticsPanel
messages={messages}
themes={themes}
personas={participants}
@ -2100,22 +2108,10 @@ const FocusGroupSession = () => {
</TabsContent>
</Tabs>
</div>
</div>
</main>
{/* Floating Note Button */}
{messages.length > 0 && (
<div className="fixed bottom-6 right-6 z-40">
<Button
onClick={handleOpenNoteModal}
className="rounded-full h-12 w-12 p-0 shadow-lg"
title="Take a quick note"
>
<StickyNote className="h-5 w-5" />
</Button>
</div>
)}
{/* Quick Note Modal */}
<QuickNoteModal
isOpen={isNoteModalOpen}

View file

@ -1222,7 +1222,7 @@ const SyntheticUsers = () => {
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('synthetic_users.search_placeholder')}
className="pl-10 bg-white"
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
@ -1556,7 +1556,7 @@ const SyntheticUsers = () => {
const renderFolderOption = (folder: Folder, isChild = false) => (
<div key={folder._id}>
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-slate-200 pl-4' : ''}`}>
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-border pl-4' : ''}`}>
<Checkbox
id={`folder-${folder._id}`}
checked={targetFolders.has(folder._id)}
@ -1564,7 +1564,7 @@ const SyntheticUsers = () => {
/>
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2 cursor-pointer">
<Folder className={`h-4 w-4 ${isChild ? 'opacity-75' : ''}`} />
<span className={isChild ? 'text-slate-600' : ''}>{folder.name}</span>
<span className={isChild ? 'text-muted-foreground' : ''}>{folder.name}</span>
</Label>
</div>
{/* Render children if any */}