From ccfdbe73a372ede6a0a57c2b844eccf30867219e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 01:50:15 -0400 Subject: [PATCH] refactor(01-02): decompose communication.tsx into 6 focused sub-components - CommunicationHub.tsx: main layout shell with sidebar + message area - ChannelSidebar.tsx: channel list with unread badges and filter bar - MessageList.tsx: message display with threading and empty states - MessageItem.tsx: message bubble, task comment bubble, agent avatar - MessageComposer.tsx: message input, task discussion composer, compose modal - ChannelHeader.tsx: channel info bar with stats - channel-utils.ts: shared channel derivation and message filtering logic - Original communication.tsx replaced with backward-compatible re-exports - All sub-components under 300 lines, TypeScript compilation passes --- src/renderer/components/communication.tsx | 1779 +---------------- .../communication/ChannelHeader.tsx | 122 ++ .../communication/ChannelSidebar.tsx | 299 +++ .../communication/CommunicationHub.tsx | 135 ++ .../communication/MessageComposer.tsx | 143 ++ .../components/communication/MessageItem.tsx | 222 ++ .../components/communication/MessageList.tsx | 151 ++ .../components/communication/channel-utils.ts | 70 + 8 files changed, 1151 insertions(+), 1770 deletions(-) create mode 100644 src/renderer/components/communication/ChannelHeader.tsx create mode 100644 src/renderer/components/communication/ChannelSidebar.tsx create mode 100644 src/renderer/components/communication/CommunicationHub.tsx create mode 100644 src/renderer/components/communication/MessageComposer.tsx create mode 100644 src/renderer/components/communication/MessageItem.tsx create mode 100644 src/renderer/components/communication/MessageList.tsx create mode 100644 src/renderer/components/communication/channel-utils.ts diff --git a/src/renderer/components/communication.tsx b/src/renderer/components/communication.tsx index 561b06b0..944e0c74 100644 --- a/src/renderer/components/communication.tsx +++ b/src/renderer/components/communication.tsx @@ -1,1770 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { - ChatCircle, - EnvelopeSimple, - At, - Hash, - PaperPlaneTilt, - MagnifyingGlass, - Funnel, - Circle, - CheckCircle, - WarningCircle, - Megaphone, - Buildings, - Fire, - ArrowBendUpLeft, - Plus, - X, - CaretDown, - Tray, -} from "@phosphor-icons/react" -import type { - AgentMessageRecord, - AgentRecord, - CommentRecord, - DepartmentId, - MessageChannel, - MessagePriority, - ProjectRecord, - TaskRecord, -} from "@shared/types" -import { getAgentInitials, formatMessageTime } from "../lib/formatters" -import { DEPARTMENT_COLORS } from "@shared/constants" -import { unwrap } from "../lib/desktop" -import { InlineNotice, Modal, Select, Input, TextArea } from "./ui" -import { useT } from "../i18n" - -const TERRACOTTA = "#d97745" -const TERRACOTTA_SOFT = "#d9774518" - -const channelConfig: Record = { - direct: { label: "Direct Messages", icon: EnvelopeSimple, color: "var(--accent)" }, - department: { label: "Department", icon: Buildings, color: "#6366f1" }, - company: { label: "Company-wide", icon: Megaphone, color: TERRACOTTA }, - project: { label: "Project", icon: Hash, color: "#10b981" }, - incident: { label: "Incident", icon: Fire, color: "var(--danger)" }, -} - -const priorityConfig: Record = { - urgent: { label: "Urgent", color: "var(--danger)", bg: "var(--danger-soft)" }, - normal: { label: "Normal", color: "var(--muted-strong)", bg: "var(--panel-soft)" }, - low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" }, -} - -function getDepartmentColor(department: DepartmentId | null | undefined): string { - if (!department) return "var(--muted)" - return DEPARTMENT_COLORS[department] ?? "var(--muted)" -} - -interface ChannelDescriptor { - key: string - kind: "message" - channel: MessageChannel - label: string - targetId: string | null - /** Agent ID for DM channels */ - agentId: string | null - /** Participant IDs for DM channels */ - participantIds: string[] -} - -interface TaskDiscussionDescriptor { - key: string - kind: "task" - taskId: string - label: string -} - -function deriveChannels( - messages: AgentMessageRecord[], - agents: AgentRecord[], - projects?: ProjectRecord[], -): ChannelDescriptor[] { - const channelMap = new Map() - const agentMap = new Map(agents.map((a) => [a.id, a])) - const projectMap = new Map((projects ?? []).map((project) => [project.id, project])) - - // Always create company-wide channel - channelMap.set("company-all", { - key: "company-all", - kind: "message", - channel: "company", - label: "#company-wide", - targetId: null, - agentId: null, - participantIds: [], - }) - - // Create permanent department channels for all departments that have agents - const activeDepts = new Set() - for (const agent of agents) { - if (agent.department && agent.status !== "terminated") { - activeDepts.add(agent.department) - } - } - for (const dept of activeDepts) { - const key = `department-${dept}` - const deptLabel = dept.replace(/_/g, "-") - channelMap.set(key, { - key, - kind: "message", - channel: "department", - label: `#${deptLabel}`, - targetId: dept, - agentId: null, - participantIds: [], - }) - } - - // Add channels derived from existing messages (DMs, projects, incidents) - for (const msg of messages) { - let key: string - let label: string - let agentId: string | null = null - - switch (msg.channel) { - case "direct": { - const otherId = msg.toAgentId - if (!otherId) { - key = `direct-broadcast` - label = "Broadcast DMs" - agentId = null - } else { - const ids = [msg.fromAgentId, otherId].sort() - key = `direct-${ids.join("-")}` - const first = agentMap.get(ids[0] ?? "") - const second = agentMap.get(ids[1] ?? "") - label = `${first?.name ?? (ids[0] ? ids[0].slice(0, 8) : "Unknown")} ↔ ${second?.name ?? (ids[1] ? ids[1].slice(0, 8) : "Unknown")}` - agentId = ids[1] ?? null - } - break - } - case "department": { - const dept = msg.channelTargetId ?? "general" - key = `department-${dept}` - label = `#${dept.replace(/_/g, "-")}` - break - } - case "company": { - key = "company-all" - label = "#company-wide" - break - } - case "project": { - const projId = msg.channelTargetId ?? "general" - key = `project-${projId}` - label = projectMap.get(projId)?.name ?? `#project-${projId.slice(0, 8)}` - break - } - case "incident": { - const incId = msg.channelTargetId ?? "room" - key = `incident-${incId}` - label = incId === "room" ? "#incident-room" : `#incident-${incId.slice(0, 8)}` - break - } - } - - if (!channelMap.has(key)) { - channelMap.set(key, { - key, - kind: "message", - channel: msg.channel, - label, - targetId: msg.channelTargetId, - agentId, - participantIds: msg.channel === "direct" && msg.toAgentId - ? [msg.fromAgentId, msg.toAgentId].sort() - : [], - }) - } - } - - const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"] - return Array.from(channelMap.values()).sort((a, b) => { - const ai = order.indexOf(a.channel) - const bi = order.indexOf(b.channel) - if (ai !== bi) return ai - bi - return a.label.localeCompare(b.label) - }) -} - -function deriveTaskDiscussionChannels( - comments: CommentRecord[], - tasks: TaskRecord[], - forcedTaskId?: string | null, -): TaskDiscussionDescriptor[] { - const taskMap = new Map(tasks.map((task) => [task.id, task])) - const activeTaskIds = tasks - .filter((task) => ["todo", "in_progress", "in_review", "blocked"].includes(task.status)) - .map((task) => task.id) - const discussionTaskIds = new Set([ - ...comments.map((comment) => comment.taskId), - ...activeTaskIds, - ...(forcedTaskId ? [forcedTaskId] : []), - ]) - - return Array.from(discussionTaskIds) - .map((taskId) => { - const task = taskMap.get(taskId) - return { - key: `task-${taskId}`, - kind: "task" as const, - taskId, - label: task - ? `${task.title}${task.status === "blocked" ? " · blocked" : task.status === "in_review" ? " · review" : ""}` - : `Task ${taskId.slice(0, 8)}`, - } - }) - .sort((left, right) => { - const leftTask = taskMap.get(left.taskId) - const rightTask = taskMap.get(right.taskId) - const priorityWeight: Record = { critical: 4, high: 3, medium: 2, low: 1 } - const leftWeight = leftTask ? priorityWeight[leftTask.priority] ?? 0 : 0 - const rightWeight = rightTask ? priorityWeight[rightTask.priority] ?? 0 : 0 - if (leftWeight !== rightWeight) return rightWeight - leftWeight - return left.label.localeCompare(right.label) - }) -} - -function getChannelMessages( - messages: AgentMessageRecord[], - channel: ChannelDescriptor, -): AgentMessageRecord[] { - return messages.filter((msg) => { - if (channel.channel === "company" && msg.channel === "company") return true - if (channel.channel === "department" && msg.channel === "department") { - return msg.channelTargetId === channel.targetId - } - if (channel.channel === "project" && msg.channel === "project") { - return msg.channelTargetId === channel.targetId - } - if (channel.channel === "incident" && msg.channel === "incident") { - return msg.channelTargetId === channel.targetId - } - if (channel.channel === "direct" && msg.channel === "direct") { - if (channel.key === "direct-broadcast") return !msg.toAgentId - if (!msg.toAgentId || channel.participantIds.length < 2) return false - const ids = [msg.fromAgentId, msg.toAgentId].sort() - return ids[0] === channel.participantIds[0] && ids[1] === channel.participantIds[1] - } - return false - }) -} - -function AgentAvatar({ - agent, - size = "md", -}: { - agent: AgentRecord | undefined - size?: "sm" | "md" | "lg" -}) { - const sizeClass = size === "sm" ? "h-7 w-7 text-[10px]" : size === "lg" ? "h-11 w-11 text-[14px]" : "h-9 w-9 text-[11px]" - const color = agent ? getDepartmentColor(agent.department) : "var(--muted)" - const initials = agent ? getAgentInitials(agent.name) : "?" - - return ( -
- {initials} -
- ) -} - -function ChannelItem({ - descriptor, - active, - unreadCount, - hasUrgent, - agents, - onClick, -}: { - descriptor: ChannelDescriptor - active: boolean - unreadCount: number - hasUrgent: boolean - agents: AgentRecord[] - onClick: () => void -}) { - const config = channelConfig[descriptor.channel] - const Icon = config.icon - const agent = descriptor.agentId - ? agents.find((a) => a.id === descriptor.agentId) - : undefined - - return ( - - ) -} - -function MessageBubble({ - message, - agent, - isThread, - replyCount, - onReply, -}: { - message: AgentMessageRecord - agent: AgentRecord | undefined - isThread: boolean - replyCount: number - onReply: () => void -}) { - const isUnread = !message.readAt - const isUrgent = message.priority === "urgent" - const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)" - - return ( - - - -
- {/* Header row */} -
- - {agent?.name ?? "Unknown Agent"} - - {agent?.title ? ( - - {agent.title} - - ) : null} - {agent?.department ? ( - - {agent.department.replace("_", " ")} - - ) : null} - - {formatMessageTime(message.createdAt)} - - - {/* Priority badge */} - {message.priority !== "normal" ? ( - - {message.priority === "urgent" ? ( - - ) : null} - {priorityConfig[message.priority].label} - - ) : null} - - {/* Read indicator */} - {isUnread ? ( - - ) : ( - - )} -
- - {message.subject ? ( -
- {message.subject} -
- ) : null} - -
- {message.body} -
- - {/* Attachments */} - {message.attachmentsJson && message.attachmentsJson !== "[]" ? ( - - ) : null} - - {/* Thread / Reply */} -
- {replyCount > 0 && !isThread ? ( - - ) : null} - -
-
-
- ) -} - -function TaskCommentBubble({ - comment, - agent, -}: { - comment: CommentRecord - agent: AgentRecord | undefined -}) { - const isBoard = !comment.authorAgentId - const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)" - - return ( - - -
-
- - {comment.authorName} - - {agent?.title ? {agent.title} : null} - {formatMessageTime(comment.createdAt)} -
-
- {comment.body} -
-
-
- ) -} - -function TaskDiscussionComposer({ - companyId, - taskId, - onSent, -}: { - companyId: string - taskId: string - onSent: () => void -}) { - const [body, setBody] = useState("") - const [sending, setSending] = useState(false) - const [sendError, setSendError] = useState(null) - - const handleSend = useCallback(async () => { - if (!body.trim()) return - setSending(true) - setSendError(null) - try { - unwrap(await window.agentCompany.addComment({ - companyId, - taskId, - authorName: "Board", - body: body.trim(), - })) - setBody("") - onSent() - } catch (error) { - setSendError(error instanceof Error ? error.message : "Failed to send task discussion reply.") - } finally { - setSending(false) - } - }, [body, companyId, taskId, onSent]) - - return ( -
- {sendError ?
: null} -
-