// # team chat (agent teams, OPE-99): a minimal Slack-shaped exception channel over // the session area — author-grouped messages, @mention highlighting, composer that // posts as [User] (which wakes every member; agent posts wake mentions only). // No derived board-event clusters (owner call, eighth pass): status lives on the // board rail one click away — this surface is pure messages. import { useEffect, useRef, useState } from "react"; import { getTeamChat, postTeamChat, type TeamChat } from "../api"; import { Icon } from "./Icon"; function mentionify(text: string, members: Set) { // Split on @word tokens; wrap known handles in a highlight span. const parts = text.split(/(@[\w.-]+)/g); return parts.map((part, i) => part.startsWith("@") && members.has(part.slice(1)) ? ( {part} ) : ( {part} ), ); } function clock(ts: string): string { const d = new Date(ts); return isNaN(d.getTime()) ? "" : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } export function TeamChatView({ teamId, onClose }: { teamId: string; onClose: () => void }) { const [chat, setChat] = useState(null); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const bottom = useRef(null); const load = () => getTeamChat(teamId).then(setChat).catch(() => {}); useEffect(() => { load(); const t = setInterval(load, 3000); return () => clearInterval(t); }, [teamId]); useEffect(() => { bottom.current?.scrollIntoView({ block: "end" }); }, [chat?.messages.length]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const send = async () => { const text = draft.trim(); if (!text || busy) return; setBusy(true); try { await postTeamChat(teamId, text); setDraft(""); await load(); } finally { setBusy(false); } }; const handles = new Set((chat?.members || []).map((m) => m.name)); const messages = chat?.messages || []; return ( // Replaces the session view IN PLACE (absolute inside .main, not a modal): // the sidebar stays interactive; back or Esc returns to the session.
# team chat questions & consensus — status lives on the board
{messages.length === 0 && (
No messages yet. Agents post here only when something needs a reply — @mention a coworker to reach it.
)} {messages.map((m, i) => { const grouped = i > 0 && messages[i - 1].author === m.author; const label = m.author_role === "user" ? "You" : m.author; return (
{!grouped && (
{label.slice(0, 1).toUpperCase()} {label} {m.author_role === "user" ? "" : m.author_role} {clock(m.ts)}
)}
{mentionify(m.text, handles)}
); })}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") send(); }} />
); } // Note: artifact links in chat (agents referencing reports, click → artifact // viewer) are planned — see the Linear follow-up on chat evolution.