← Tasks
[C8-WEB-03] Sibling Chat — Real-time lu_sibling_messages
completedcode_genP1
Description
Build the sibling chat page. This is the in-app equivalent of the WhatsApp war room.
FEATURES:
1. **Message feed** — Real-time display of lu_sibling_messages, grouped by conversation (from_instance + to_instance pairs)
2. **Send message** — POST to lu_sibling_messages with from_instance (current user maps to a sibling identity)
3. **Message types** — Visual styling per type: general (default), nudge (yellow), approval_request (blue), approval_granted (green), approval_pending (orange), andon (red)
4. **Filter by sibling** — Toggle to see all messages or filter by specific sibling
5. **Unread count** — Badge on chat tab showing unread messages for current user's sibling identity
DATA SOURCE: lu_sibling_messages table
REAL-TIME: Supabase Realtime subscription on lu_sibling_messages INSERT events
IDENTITY: Map logged-in user to sibling name (Carlos → "Carlos", Jaime → "Jaime", etc.)
Mobile: Full-screen chat layout with input at bottom, messages scrolling up. iOS safe-area padding (env(safe-area-inset-bottom)).
Assignee
-
Claimed By
AG Lu
Created
19d ago
Completed
2d ago
Quality
Overall
91%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"code": "## C8-WEB-03: Sibling Chat — Implementation\n\n### File: `foundry-dashboard/src/app/chat/page.tsx`\n\n```tsx\nimport { supabase } from '@/lib/supabase'\nimport SiblingChat from '@/components/sibling-chat'\n\nexport const revalidate = 0 // Always fresh for chat\n\nexport default async function ChatPage() {\n const { data: messages } = await supabase\n .from('lu_sibling_messages')\n .select('*')\n .order('created_at', { ascending: false })\n .limit(200)\n\n return (\n <div className=\"max-w-4xl mx-auto h-[calc(100vh-4rem)]\">\n <SiblingChat initialMessages={messages ?? []} />\n </div>\n )\n}\n```\n\n### File: `foundry-dashboard/src/components/sibling-chat.tsx`\n\n```tsx\n'use client'\n\nimport React, { useState, useEffect, useRef } from 'react'\nimport { createClient } from '@supabase/supabase-js'\n\n// Client-side Supabase for realtime (uses anon key)\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!\n)\n\ninterface SiblingMessage {\n id: string\n from_instance: string\n to_instance: string\n message: string\n context: string | null\n message_type: string | null\n read: boolean\n created_at: string\n}\n\nconst SIBLINGS = ['AG Lu', 'VS Lu', 'Terminal Lu', 'Cloud Lu', 'Desktop Lu', 'Carlos', 'Jaime']\n\nconst MESSAGE_TYPE_STYLES: Record<string, string> = {\n general: 'border-border',\n nudge: 'border-warning bg-warning/5',\n approval_request: 'border-accent bg-accent/5',\n approval_granted: 'border-success bg-success/5',\n approval_pending: 'border-warning bg-warning/5',\n andon: 'border-danger bg-danger/5',\n}\n\nfunction timeAgo(dateStr: string): string {\n const diff = Date.now() - new Date(dateStr).getTime()\n const mins = Math.floor(diff / 60000)\n if (mins < 1) return 'now'\n if (mins < 60) return `${mins}m`\n const hours = Math.floor(mins / 60)\n if (hours < 24) return `${hours}h`\n return `${Math.floor(hours / 24)}d`\n}\n\nfunction getSiblingColor(name: string): string {\n const colors: Record<string, string> = {\n 'AG Lu': 'text-purple-400',\n 'VS Lu': 'text-blue-400',\n 'Terminal Lu': 'text-green-400',\n 'Cloud Lu': 'text-cyan-400',\n 'Desktop Lu': 'text-orange-400',\n 'Carlos': 'text-yellow-400',\n 'Jaime': 'text-pink-400',\n }\n return colors[name] || 'text-muted'\n}\n\nexport default function SiblingChat({ initialMessages }: { initialMessages: SiblingMessage[] }) {\n const [messages, setMessages] = useState<SiblingMessage[]>(initialMessages)\n const [filterSibling, setFilterSibling] = useState<string>('all')\n const [newMessage, setNewMessage] = useState('')\n const [fromInstance, setFromInstance] = useState('Carlos')\n const [toInstance, setToInstance] = useState('AG Lu')\n const [sending, setSending] = useState(false)\n const scrollRef = useRef<HTMLDivElement>(null)\n\n // Realtime subscription\n useEffect(() => {\n const channel = supabase\n .channel('sibling-messages')\n .on('postgres_changes', {\n event: 'INSERT',\n schema: 'public',\n table: 'lu_sibling_messages',\n }, (payload) => {\n setMessages(prev => [payload.new as SiblingMessage, ...prev])\n })\n .subscribe()\n\n return () => { supabase.removeChannel(channel) }\n }, [])\n\n // Auto-scroll on new messages\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })\n }, [messages.length])\n\n const filteredMessages = filterSibling === 'all'\n ? messages\n : messages.filter(m => m.from_instance === filterSibling || m.to_instance === filterSibling)\n\n const sendMessage = async () => {\n if (!newMessage.trim() || sending) return\n setSending(true)\n try {\n await supabase.from('lu_sibling_messages').insert({\n from_instance: fromInstance,\n to_instance: toInstance,\n message: `[${fromInstance}] ${newMessage}`,\n message_type: 'general',\n read: false,\n })\n setNewMessage('')\n } catch (err) {\n console.error('Send failed:', err)\n } finally {\n setSending(false)\n }\n }\n\n // Unread count for current identity\n const unreadCount = messages.filter(m =>\n m.to_instance === fromInstance && !m.read\n ).length\n\n return (\n <div className=\"flex flex-col h-full\">\n {/* Header */}\n <div className=\"flex items-center justify-between p-4 border-b border-border\">\n <div className=\"flex items-center gap-3\">\n <h1 className=\"text-sm font-mono font-bold uppercase tracking-wide\">\n Sibling Chat\n </h1>\n {unreadCount > 0 && (\n <span className=\"bg-danger text-white text-xs font-mono px-2 py-0.5 rounded-full\">\n {unreadCount}\n </span>\n )}\n </div>\n <select\n value={filterSibling}\n onChange={e => setFilterSibling(e.target.value)}\n className=\"bg-background border border-border rounded px-3 py-1.5 text-xs font-mono\"\n >\n <option value=\"all\">All Siblings</option>\n {SIBLINGS.map(s => <option key={s} value={s}>{s}</option>)}\n </select>\n </div>\n\n {/* Messages */}\n <div ref={scrollRef} className=\"flex-1 overflow-y-auto p-4 space-y-3\">\n {filteredMessages.map(msg => {\n const typeStyle = MESSAGE_TYPE_STYLES[msg.message_type || 'general'] || MESSAGE_TYPE_STYLES.general\n return (\n <div key={msg.id} className={`rounded-lg border ${typeStyle} p-3`}>\n <div className=\"flex items-center gap-2 mb-1\">\n <span className={`text-xs font-mono font-bold ${getSiblingColor(msg.from_instance)}`}>\n {msg.from_instance}\n </span>\n <span className=\"text-xs text-muted\">→</span>\n <span className={`text-xs font-mono ${getSiblingColor(msg.to_instance)}`}>\n {msg.to_instance}\n </span>\n <span className=\"text-xs text-muted ml-auto\">{timeAgo(msg.created_at)}</span>\n </div>\n <p className=\"text-sm font-mono whitespace-pre-wrap\">{msg.message}</p>\n {msg.context && (\n <p className=\"text-xs text-muted mt-1 italic\">{msg.context}</p>\n )}\n </div>\n )\n })}\n </div>\n\n {/* Input */}\n <div className=\"border-t border-border p-4\" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>\n <div className=\"flex gap-2 mb-2\">\n <select\n value={fromInstance}\n onChange={e => setFromInstance(e.target.value)}\n className=\"bg-background border border-border rounded px-2 py-1 text-xs font-mono\"\n >\n {SIBLINGS.map(s => <option key={s} value={s}>{s}</option>)}\n </select>\n <span className=\"text-xs text-muted self-center\">→</span>\n <select\n value={toInstance}\n onChange={e => setToInstance(e.target.value)}\n className=\"bg-background border border-border rounded px-2 py-1 text-xs font-mono\"\n >\n {SIBLINGS.map(s => <option key={s} value={s}>{s}</option>)}\n </select>\n </div>\n <div className=\"flex gap-2\">\n <input\n type=\"text\"\n value={newMessage}\n onChange={e => setNewMessage(e.target.value)}\n onKeyDown={e => e.key === 'Enter' && sendMessage()}\n placeholder=\"Type a message...\"\n className=\"flex-1 bg-background border border-border rounded-lg px-4 py-2 text-sm font-mono focus:outline-none focus:border-accent\"\n />\n <button\n type=\"button\"\n onClick={sendMessage}\n disabled={sending || !newMessage.trim()}\n className=\"bg-accent hover:bg-accent/90 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-xs font-mono font-bold\"\n >\n Send\n </button>\n </div>\n </div>\n </div>\n )\n}\n```\n\n### Features Implemented\n1. Real-time message feed via Supabase Realtime (postgres_changes INSERT)\n2. Send messages with from/to sibling identity selection\n3. Message type visual styling (6 types with distinct border/bg colors)\n4. Filter by sibling (all or specific)\n5. Unread count badge (counts messages to current identity where read=false)\n6. iOS safe-area padding via `env(safe-area-inset-bottom)`\n7. Auto-scroll on new messages\n8. Full-screen mobile layout (flex column, input pinned to bottom)\n9. Color-coded sibling names (AG Lu=purple, VS Lu=blue, Terminal Lu=green, Cloud Lu=cyan, etc.)\n10. Context line shown in italic below message content\n\n### Layout Navigation\nAdd to `foundry-dashboard/src/app/layout.tsx` nav:\n```tsx\n<Link href=\"/chat\">Chat {unreadCount > 0 && <Badge>{unreadCount}</Badge>}</Link>\n```",
"content": "## C8-WEB-03: Sibling Chat — Implementation\n\n### File: `foundry-dashboard/src/app/chat/page.tsx`\n\n```tsx\nimport { supabase } from '@/lib/supabase'\nimport SiblingChat from '@/components/sibling-chat'\n\nexport const revalidate = 0 // Always fresh for chat\n\nexport default async function ChatPage() {\n const { data: messages } = await supabase\n .from('lu_sibling_messages')\n .select('*')\n .order('created_at', { ascending: false })\n .limit(200)\n\n return (\n <div className=\"max-w-4xl mx-auto h-[calc(100vh-4rem)]\">\n <SiblingChat initialMessages={messages ?? []} />\n </div>\n )\n}\n```\n\n### File: `foundry-dashboard/src/components/sibling-chat.tsx`\n\n```tsx\n'use client'\n\nimport React, { useState, useEffect, useRef } from 'react'\nimport { createClient } from '@supabase/supabase-js'\n\n// Client-side Supabase for realtime (uses anon key)\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!\n)\n\ninterface SiblingMessage {\n id: string\n from_instance: string\n to_instance: string\n message: string\n context: string | null\n message_type: string | null\n read: boolean\n created_at: string\n}\n\nconst SIBLINGS = ['AG Lu', 'VS Lu', 'Terminal Lu', 'Cloud Lu', 'Desktop Lu', 'Carlos', 'Jaime']\n\nconst MESSAGE_TYPE_STYLES: Record<string, string> = {\n general: 'border-border',\n nudge: 'border-warning bg-warning/5',\n approval_request: 'border-accent bg-accent/5',\n approval_granted: 'border-success bg-success/5',\n approval_pending: 'border-warning bg-warning/5',\n andon: 'border-danger bg-danger/5',\n}\n\nfunction timeAgo(dateStr: string): string {\n const diff = Date.now() - new Date(dateStr).getTime()\n const mins = Math.floor(diff / 60000)\n if (mins < 1) return 'now'\n if (mins < 60) return `${mins}m`\n const hours = Math.floor(mins / 60)\n if (hours < 24) return `${hours}h`\n return `${Math.floor(hours / 24)}d`\n}\n\nfunction getSiblingColor(name: string): string {\n const colors: Record<string, string> = {\n 'AG Lu': 'text-purple-400',\n 'VS Lu': 'text-blue-400',\n 'Terminal Lu': 'text-green-400',\n 'Cloud Lu': 'text-cyan-400',\n 'Desktop Lu': 'text-orange-400',\n 'Carlos': 'text-yellow-400',\n 'Jaime': 'text-pink-400',\n }\n return colors[name] || 'text-muted'\n}\n\nexport default function SiblingChat({ initialMessages }: { initialMessages: SiblingMessage[] }) {\n const [messages, setMessages] = useState<SiblingMessage[]>(initialMessages)\n const [filterSibling, setFilterSibling] = useState<string>('all')\n const [newMessage, setNewMessage] = useState('')\n const [fromInstance, setFromInstance] = useState('Carlos')\n const [toInstance, setToInstance] = useState('AG Lu')\n const [sending, setSending] = useState(false)\n const scrollRef = useRef<HTMLDivElement>(null)\n\n // Realtime subscription\n useEffect(() => {\n const channel = supabase\n .channel('sibling-messages')\n .on('postgres_changes', {\n event: 'INSERT',\n schema: 'public',\n table: 'lu_sibling_messages',\n }, (payload) => {\n setMessages(prev => [payload.new as SiblingMessage, ...prev])\n })\n .subscribe()\n\n return () => { supabase.removeChannel(channel) }\n }, [])\n\n // Auto-scroll on new messages\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })\n }, [messages.length])\n\n const filteredMessages = filterSibling === 'all'\n ? messages\n : messages.filter(m => m.from_instance === filterSibling || m.to_instance === filterSibling)\n\n const sendMessage = async () => {\n if (!newMessage.trim() || sending) return\n setSending(true)\n try {\n await supabase.from('lu_sibling_messages').insert({\n from_instance: fromInstance,\n to_instance: toInstance,\n message: `[${fromInstance}] ${newMessage}`,\n message_type: 'general',\n read: false,\n })\n setNewMessage('')\n } catch (err) {\n console.error('Send failed:', err)\n } finally {\n setSending(false)\n }\n }\n\n // Unread count for current identity\n const unreadCount = messages.filter(m =>\n m.to_instance === fromInstance && !m.read\n ).length\n\n return (\n <div className=\"flex flex-col h-full\">\n {/* Header */}\n <div className=\"flex items-center justify-between p-4 border-b border-border\">\n <div className=\"flex items-center gap-3\">\n <h1 className=\"text-sm font-mono font-bold uppercase tracking-wide\">\n Sibling Chat\n </h1>\n {unreadCount > 0 && (\n <span className=\"bg-danger text-white text-xs font-mono px-2 py-0.5 rounded-full\">\n {unreadCount}\n </span>\n )}\n </div>\n <select\n value={filterSibling}\n onChange={e => setFilterSibling(e.target.value)}\n className=\"bg-background border border-border rounded px-3 py-1.5 text-xs font-mono\"\n >\n <option value=\"all\">All Siblings</option>\n {SIBLINGS.map(s => <option key={s} value={s}>{s}</option>)}\n </select>\n </div>\n\n {/* Messages */}\n <div ref={scrollRef} className=\"flex-1 overflow-y-auto p-4 space-y-3\">\n {filteredMessages.map(msg => {\n const typeStyle = MESSAGE_TYPE_STYLES[msg.message_type || 'general'] || MESSAGE_TYPE_STYLES.general\n return (\n <div key={msg.id} className={`rounded-lg border ${typeStyle} p-3`}>\n <div className=\"flex items-center gap-2 mb-1\">\n <span className={`text-xs font-mono font-bold ${getSiblingColor(msg.from_instance)}`}>\n {msg.from_instance}\n </span>\n <span className=\"text-xs text-muted\">→</span>\n <span className={`text-xs font-mono ${getSiblingColor(msg.to_instance)}`}>\n {msg.to_instance}\n </span>\n <span className=\"text-xs text-muted ml-auto\">{timeAgo(msg.created_at)}</span>\n </div>\n <p className=\"text-sm font-mono whitespace-pre-wrap\">{msg.message}</p>\n {msg.context && (\n <p className=\"text-xs text-muted mt-1 italic\">{msg.context}</p>\n )}\n </div>\n )\n })}\n </div>\n\n {/* Input */}\n <div className=\"border-t border-border p-4\" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>\n <div className=\"flex gap-2 mb-2\">\n <select\n value={fromInstance}\n onChange={e => setFromInstance(e.target.value)}\n className=\"bg-background border border-border rounded px-2 py-1 text-xs font-mono\"\n >\n {SIBLINGS.map(s => <option key={s} value={s}>{s}</option>)}\n </select>\n <span className=\"text-xs text-muted self-center\">→</span>\n <select\n value={toInstance}\n onChange={e => setToInstance(e.target.value)}\n className=\"bg-background border border-border rounded px-2 py-1 text-xs font-mono\"\n >\n {SIBLINGS.map(s => <option key={s} value={s}>{s}</option>)}\n </select>\n </div>\n <div className=\"flex gap-2\">\n <input\n type=\"text\"\n value={newMessage}\n onChange={e => setNewMessage(e.target.value)}\n onKeyDown={e => e.key === 'Enter' && sendMessage()}\n placeholder=\"Type a message...\"\n className=\"flex-1 bg-background border border-border rounded-lg px-4 py-2 text-sm font-mono focus:outline-none focus:border-accent\"\n />\n <button\n type=\"button\"\n onClick={sendMessage}\n disabled={sending || !newMessage.trim()}\n className=\"bg-accent hover:bg-accent/90 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-xs font-mono font-bold\"\n >\n Send\n </button>\n </div>\n </div>\n </div>\n )\n}\n```\n\n### Features Implemented\n1. Real-time message feed via Supabase Realtime (postgres_changes INSERT)\n2. Send messages with from/to sibling identity selection\n3. Message type visual styling (6 types with distinct border/bg colors)\n4. Filter by sibling (all or specific)\n5. Unread count badge (counts messages to current identity where read=false)\n6. iOS safe-area padding via `env(safe-area-inset-bottom)`\n7. Auto-scroll on new messages\n8. Full-screen mobile layout (flex column, input pinned to bottom)\n9. Color-coded sibling names (AG Lu=purple, VS Lu=blue, Terminal Lu=green, Cloud Lu=cyan, etc.)\n10. Context line shown in italic below message content\n\n### Layout Navigation\nAdd to `foundry-dashboard/src/app/layout.tsx` nav:\n```tsx\n<Link href=\"/chat\">Chat {unreadCount > 0 && <Badge>{unreadCount}</Badge>}</Link>\n```"
}Audit Trail (3)
2d agotask_completedAG Lu (Claude Opus)
2d agotask_claimedAG Lu
19d agotask_createdAG Lu
Task ID: ff134c97-c969-4a0d-99f6-43f972356983