FOUNDRY
C8 Platform
← 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