update
This commit is contained in:
1 parent
54bcc77ef7
commit
7a3db27ffa
33 files changed
+5906
-3089
No files matched your search
@@ -5,7 +5,6 @@ import {
|
||||
EnvelopeSimple,
|
||||
At,
|
||||
Hash,
|
||||
UserCircle,
|
||||
PaperPlaneTilt,
|
||||
MagnifyingGlass,
|
||||
Funnel,
|
||||
@@ -19,21 +18,20 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
CaretDown,
|
||||
Robot,
|
||||
Tray,
|
||||
} from "@phosphor-icons/react"
|
||||
import type {
|
||||
AgentMessageRecord,
|
||||
AgentRecord,
|
||||
CommentRecord,
|
||||
DepartmentId,
|
||||
MessageChannel,
|
||||
MessagePriority,
|
||||
TaskRecord,
|
||||
} from "@shared/types"
|
||||
import { ActionButton, Modal, Select, Input, TextArea } from "./ui"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants & Config */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
|
||||
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
import { Modal, Select, Input, TextArea } from "./ui"
|
||||
|
||||
const TERRACOTTA = "#d97745"
|
||||
const TERRACOTTA_SOFT = "#d9774518"
|
||||
@@ -52,83 +50,14 @@ const priorityConfig: Record<MessagePriority, { label: string; color: string; bg
|
||||
low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" },
|
||||
}
|
||||
|
||||
const departmentColors: Record<string, string> = {
|
||||
executive: "#8b5cf6",
|
||||
engineering: "#3b82f6",
|
||||
product: "#f59e0b",
|
||||
marketing: "#ec4899",
|
||||
sales: "#10b981",
|
||||
hr: "#6366f1",
|
||||
finance: "#14b8a6",
|
||||
legal: "#a78bfa",
|
||||
operations: "#f97316",
|
||||
customer_support: "#06b6d4",
|
||||
research: "#8b5cf6",
|
||||
design: "#f43f5e",
|
||||
}
|
||||
|
||||
function getDepartmentColor(department: DepartmentId | null | undefined): string {
|
||||
if (!department) return "var(--muted)"
|
||||
return departmentColors[department] ?? "var(--muted)"
|
||||
return DEPARTMENT_COLORS[department] ?? "var(--muted)"
|
||||
}
|
||||
|
||||
function getAgentInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
const diff = Math.max(0, now - then)
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
function formatMessageTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const isToday =
|
||||
d.getDate() === now.getDate() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getFullYear() === now.getFullYear()
|
||||
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const isYesterday =
|
||||
d.getDate() === yesterday.getDate() &&
|
||||
d.getMonth() === yesterday.getMonth() &&
|
||||
d.getFullYear() === yesterday.getFullYear()
|
||||
|
||||
if (isYesterday) {
|
||||
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
|
||||
}
|
||||
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Channel Derivation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ChannelDescriptor {
|
||||
key: string
|
||||
kind: "message"
|
||||
channel: MessageChannel
|
||||
label: string
|
||||
targetId: string | null
|
||||
@@ -136,6 +65,13 @@ interface ChannelDescriptor {
|
||||
agentId: string | null
|
||||
}
|
||||
|
||||
interface TaskDiscussionDescriptor {
|
||||
key: string
|
||||
kind: "task"
|
||||
taskId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function deriveChannels(
|
||||
messages: AgentMessageRecord[],
|
||||
agents: AgentRecord[],
|
||||
@@ -143,6 +79,37 @@ function deriveChannels(
|
||||
const channelMap = new Map<string, ChannelDescriptor>()
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
||||
|
||||
// Always create company-wide channel
|
||||
channelMap.set("company-all", {
|
||||
key: "company-all",
|
||||
kind: "message",
|
||||
channel: "company",
|
||||
label: "#company-wide",
|
||||
targetId: null,
|
||||
agentId: null,
|
||||
})
|
||||
|
||||
// Create permanent department channels for all departments that have agents
|
||||
const activeDepts = new Set<string>()
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Add channels derived from existing messages (DMs, projects, incidents)
|
||||
for (const msg of messages) {
|
||||
let key: string
|
||||
let label: string
|
||||
@@ -166,7 +133,7 @@ function deriveChannels(
|
||||
case "department": {
|
||||
const dept = msg.channelTargetId ?? "general"
|
||||
key = `department-${dept}`
|
||||
label = `#${dept.replace("_", "-")}`
|
||||
label = `#${dept.replace(/_/g, "-")}`
|
||||
break
|
||||
}
|
||||
case "company": {
|
||||
@@ -191,6 +158,7 @@ function deriveChannels(
|
||||
if (!channelMap.has(key)) {
|
||||
channelMap.set(key, {
|
||||
key,
|
||||
kind: "message",
|
||||
channel: msg.channel,
|
||||
label,
|
||||
targetId: msg.channelTargetId,
|
||||
@@ -199,17 +167,6 @@ function deriveChannels(
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one company-wide channel exists
|
||||
if (!channelMap.has("company-all")) {
|
||||
channelMap.set("company-all", {
|
||||
key: "company-all",
|
||||
channel: "company",
|
||||
label: "#company-wide",
|
||||
targetId: null,
|
||||
agentId: null,
|
||||
})
|
||||
}
|
||||
|
||||
const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
|
||||
return Array.from(channelMap.values()).sort((a, b) => {
|
||||
const ai = order.indexOf(a.channel)
|
||||
@@ -219,6 +176,25 @@ function deriveChannels(
|
||||
})
|
||||
}
|
||||
|
||||
function deriveTaskDiscussionChannels(
|
||||
comments: CommentRecord[],
|
||||
tasks: TaskRecord[],
|
||||
): TaskDiscussionDescriptor[] {
|
||||
const taskMap = new Map(tasks.map((task) => [task.id, task]))
|
||||
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
|
||||
return Array.from(discussedTaskIds)
|
||||
.map((taskId) => {
|
||||
const task = taskMap.get(taskId)
|
||||
return {
|
||||
key: `task-${taskId}`,
|
||||
kind: "task" as const,
|
||||
taskId,
|
||||
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
function getChannelMessages(
|
||||
messages: AgentMessageRecord[],
|
||||
channel: ChannelDescriptor,
|
||||
@@ -244,10 +220,6 @@ function getChannelMessages(
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Agent Avatar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function AgentAvatar({
|
||||
agent,
|
||||
size = "md",
|
||||
@@ -269,10 +241,6 @@ function AgentAvatar({
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Channel Sidebar Item */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ChannelItem({
|
||||
descriptor,
|
||||
active,
|
||||
@@ -346,36 +314,23 @@ function ChannelItem({
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Message Bubble */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
agent,
|
||||
isThread,
|
||||
replyCount,
|
||||
onReply,
|
||||
onMarkRead,
|
||||
}: {
|
||||
message: AgentMessageRecord
|
||||
agent: AgentRecord | undefined
|
||||
isThread: boolean
|
||||
replyCount: number
|
||||
onReply: () => void
|
||||
onMarkRead: () => void
|
||||
}) {
|
||||
const isUnread = !message.readAt
|
||||
const isUrgent = message.priority === "urgent"
|
||||
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnread) {
|
||||
const timer = setTimeout(onMarkRead, 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isUnread, onMarkRead])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
@@ -466,14 +421,12 @@ function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
{message.subject ? (
|
||||
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
|
||||
{message.subject}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Body */}
|
||||
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
{message.body}
|
||||
</div>
|
||||
@@ -510,9 +463,102 @@ function MessageBubble({
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Attachment List */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function TaskCommentBubble({
|
||||
comment,
|
||||
agent,
|
||||
}: {
|
||||
comment: CommentRecord
|
||||
agent: AgentRecord | undefined
|
||||
}) {
|
||||
const isBoard = !comment.authorAgentId
|
||||
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="group relative flex gap-3 rounded-[10px] border-l-[3px] bg-[color:var(--panel)] px-4 py-3"
|
||||
style={{ borderLeftColor: isBoard ? TERRACOTTA : deptColor }}
|
||||
>
|
||||
<AgentAvatar agent={agent} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold" style={{ color: isBoard ? TERRACOTTA : deptColor }}>
|
||||
{comment.authorName}
|
||||
</span>
|
||||
{agent?.title ? <span className="text-[10px] text-[color:var(--muted)]">{agent.title}</span> : null}
|
||||
<span className="text-[10px] text-[color:var(--muted)]">{formatMessageTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
|
||||
{comment.body}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskDiscussionComposer({
|
||||
companyId,
|
||||
taskId,
|
||||
onSent,
|
||||
}: {
|
||||
companyId: string
|
||||
taskId: string
|
||||
onSent: () => void
|
||||
}) {
|
||||
const [body, setBody] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim()) return
|
||||
setSending(true)
|
||||
try {
|
||||
await window.agentCompany.addComment({
|
||||
companyId,
|
||||
taskId,
|
||||
authorName: "Board",
|
||||
body: body.trim(),
|
||||
})
|
||||
setBody("")
|
||||
onSent()
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [body, companyId, taskId, onSent])
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Reply in task discussion..."
|
||||
rows={2}
|
||||
className="min-h-[40px] min-w-0 flex-1 resize-none rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3 py-2 text-[12px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:border-transparent focus:outline-none focus:ring-2"
|
||||
style={{ focusRingColor: TERRACOTTA } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!body.trim() || sending}
|
||||
className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-[8px] text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ backgroundColor: TERRACOTTA }}
|
||||
aria-label="Send task comment"
|
||||
>
|
||||
{sending ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
|
||||
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<PaperPlaneTilt size={18} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentList({ json }: { json: string }) {
|
||||
let items: Array<{ type: string; id: string; title: string }> = []
|
||||
@@ -538,10 +584,6 @@ function AttachmentList({ json }: { json: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Message Composer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface MessageComposerProps {
|
||||
agents: AgentRecord[]
|
||||
companyId: string
|
||||
@@ -595,7 +637,7 @@ function MessageComposer({
|
||||
setSubject("")
|
||||
onSent()
|
||||
} catch (err) {
|
||||
console.error("[MessageComposer] send failed:", err)
|
||||
void err
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -615,7 +657,6 @@ function MessageComposer({
|
||||
|
||||
return (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
|
||||
{/* Reply indicator */}
|
||||
<AnimatePresence>
|
||||
{replyTo ? (
|
||||
<motion.div
|
||||
@@ -640,7 +681,6 @@ function MessageComposer({
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sender selector row */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AgentAvatar agent={fromAgent} size="sm" />
|
||||
<select
|
||||
@@ -665,7 +705,6 @@ function MessageComposer({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Body input */}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -702,10 +741,6 @@ function MessageComposer({
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compose New Message Modal */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ComposeModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -748,14 +783,13 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
parentMessageId: null,
|
||||
attachmentsJson: "[]",
|
||||
})
|
||||
// Reset form
|
||||
setSubject("")
|
||||
setBody("")
|
||||
setPriority("normal")
|
||||
onSent()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error("[ComposeModal] send failed:", err)
|
||||
void err
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -764,7 +798,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
return (
|
||||
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
|
||||
<div className="space-y-4">
|
||||
{/* From */}
|
||||
<Select
|
||||
label="From Agent"
|
||||
value={fromAgentId}
|
||||
@@ -775,7 +808,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Channel type */}
|
||||
<Select
|
||||
label="Channel"
|
||||
value={channel}
|
||||
@@ -789,7 +821,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Channel target */}
|
||||
{channel === "direct" ? (
|
||||
<Select
|
||||
label="To Agent"
|
||||
@@ -827,7 +858,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Subject */}
|
||||
<Input
|
||||
label="Subject"
|
||||
value={subject}
|
||||
@@ -835,7 +865,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
placeholder="Message subject"
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<TextArea
|
||||
label="Message"
|
||||
value={body}
|
||||
@@ -844,7 +873,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<Select
|
||||
label="Priority"
|
||||
value={priority}
|
||||
@@ -856,7 +884,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -888,10 +915,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Search & Filter Bar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface FilterState {
|
||||
search: string
|
||||
channelType: MessageChannel | "all"
|
||||
@@ -1030,27 +1053,29 @@ function FilterBar({
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main: CommunicationHub */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface CommunicationHubProps {
|
||||
messages: AgentMessageRecord[]
|
||||
comments: CommentRecord[]
|
||||
tasks: TaskRecord[]
|
||||
agents: AgentRecord[]
|
||||
companyId: string
|
||||
selectedEntityId?: string | null
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function CommunicationHub({
|
||||
messages,
|
||||
comments,
|
||||
tasks,
|
||||
agents,
|
||||
companyId,
|
||||
selectedEntityId,
|
||||
onRefresh,
|
||||
}: CommunicationHubProps) {
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
const [showCompose, setShowCompose] = useState(false)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [showCompose, setShowCompose] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: "",
|
||||
channelType: "all",
|
||||
@@ -1062,7 +1087,9 @@ export function CommunicationHub({
|
||||
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
|
||||
|
||||
// Derive channels from messages
|
||||
const channels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
||||
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
|
||||
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
|
||||
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
|
||||
|
||||
// Auto-select first channel
|
||||
useEffect(() => {
|
||||
@@ -1071,6 +1098,27 @@ export function CommunicationHub({
|
||||
}
|
||||
}, [channels, selectedChannelKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntityId) return
|
||||
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
|
||||
if (matchingMessage) {
|
||||
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
|
||||
if (matchingChannel) {
|
||||
setSelectedChannelKey(matchingChannel.key)
|
||||
}
|
||||
return
|
||||
}
|
||||
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
|
||||
if (matchingComment) {
|
||||
setSelectedChannelKey(`task-${matchingComment.taskId}`)
|
||||
return
|
||||
}
|
||||
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
|
||||
if (matchingTaskDiscussion) {
|
||||
setSelectedChannelKey(matchingTaskDiscussion.key)
|
||||
}
|
||||
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
|
||||
|
||||
const activeChannel = useMemo(
|
||||
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
|
||||
[channels, selectedChannelKey],
|
||||
@@ -1078,16 +1126,32 @@ export function CommunicationHub({
|
||||
|
||||
// Messages for the selected channel
|
||||
const channelMessages = useMemo(() => {
|
||||
if (!activeChannel) return []
|
||||
if (!activeChannel || activeChannel.kind !== "message") return []
|
||||
return getChannelMessages(messages, activeChannel).sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)
|
||||
}, [messages, activeChannel])
|
||||
|
||||
const taskDiscussionComments = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "task") return []
|
||||
return comments
|
||||
.filter((comment) => comment.taskId === activeChannel.taskId)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
}, [comments, activeChannel])
|
||||
|
||||
const activeTask = useMemo(() => {
|
||||
if (!activeChannel || activeChannel.kind !== "task") return null
|
||||
return tasks.find((task) => task.id === activeChannel.taskId) ?? null
|
||||
}, [tasks, activeChannel])
|
||||
|
||||
// Apply filters to visible messages
|
||||
const filteredMessages = useMemo(() => {
|
||||
let result = channelMessages
|
||||
|
||||
if (filters.channelType !== "all") {
|
||||
result = result.filter((m) => m.channel === filters.channelType)
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase()
|
||||
result = result.filter(
|
||||
@@ -1127,6 +1191,10 @@ export function CommunicationHub({
|
||||
const unreadByChannel = useMemo(() => {
|
||||
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
|
||||
for (const ch of channels) {
|
||||
if (ch.kind !== "message") {
|
||||
counts.set(ch.key, { unread: 0, hasUrgent: false })
|
||||
continue
|
||||
}
|
||||
const msgs = getChannelMessages(messages, ch)
|
||||
const unread = msgs.filter((m) => !m.readAt).length
|
||||
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
|
||||
@@ -1139,22 +1207,6 @@ export function CommunicationHub({
|
||||
return messages.filter((m) => !m.readAt).length
|
||||
}, [messages])
|
||||
|
||||
// Mark message read
|
||||
const handleMarkRead = useCallback(
|
||||
(messageId: string) => {
|
||||
void window.agentCompany.markMessageRead({ messageId, companyId })
|
||||
},
|
||||
[companyId],
|
||||
)
|
||||
|
||||
// After sending, refresh and scroll
|
||||
const handleSent = useCallback(() => {
|
||||
onRefresh()
|
||||
setTimeout(() => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, 200)
|
||||
}, [onRefresh])
|
||||
|
||||
// Scroll to bottom when channel changes
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
|
||||
@@ -1165,7 +1217,7 @@ export function CommunicationHub({
|
||||
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
|
||||
const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
|
||||
for (const type of typeOrder) {
|
||||
const items = channels.filter((c) => c.channel === type)
|
||||
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
|
||||
if (items.length > 0) {
|
||||
groups.push({
|
||||
type,
|
||||
@@ -1200,12 +1252,15 @@ export function CommunicationHub({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label="Compose"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompose(true)}
|
||||
icon={Plus}
|
||||
tone="accent"
|
||||
/>
|
||||
className="inline-flex items-center gap-2 rounded-[8px] px-3 py-2 text-[11px] font-semibold text-white transition hover:brightness-90"
|
||||
style={{ backgroundColor: TERRACOTTA }}
|
||||
>
|
||||
<Plus size={13} weight="bold" />
|
||||
New message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main layout: sidebar + messages */}
|
||||
@@ -1213,7 +1268,6 @@ export function CommunicationHub({
|
||||
className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]"
|
||||
style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}
|
||||
>
|
||||
{/* ── Sidebar ────────────────────────────────────────── */}
|
||||
<AnimatePresence initial={false}>
|
||||
{showSidebar ? (
|
||||
<motion.div
|
||||
@@ -1258,7 +1312,6 @@ export function CommunicationHub({
|
||||
agents={agents}
|
||||
onClick={() => {
|
||||
setSelectedChannelKey(ch.key)
|
||||
setReplyTo(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -1266,9 +1319,34 @@ export function CommunicationHub({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{taskDiscussionChannels.length > 0 ? (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 px-2 text-[9px] font-bold uppercase tracking-[0.1em] text-[color:var(--muted)]">
|
||||
Task Discussions
|
||||
</div>
|
||||
{taskDiscussionChannels.map((discussion) => (
|
||||
<button
|
||||
key={discussion.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedChannelKey(discussion.key)}
|
||||
className={`group flex w-full items-center gap-2.5 rounded-[8px] px-3 py-2 text-left transition duration-150 ${
|
||||
selectedChannelKey === discussion.key ? "bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px]" style={{ backgroundColor: `${TERRACOTTA}18` }}>
|
||||
<Hash size={14} weight={selectedChannelKey === discussion.key ? "fill" : "regular"} style={{ color: TERRACOTTA }} />
|
||||
</div>
|
||||
<span className={`min-w-0 flex-1 truncate text-[12px] ${selectedChannelKey === discussion.key ? "font-semibold text-[color:var(--text)]" : "font-medium text-[color:var(--muted-strong)]"}`}>
|
||||
{discussion.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{channels.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-[11px] text-[color:var(--muted)]">
|
||||
No channels yet. Send a message to create one.
|
||||
No active departments yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1284,7 +1362,6 @@ export function CommunicationHub({
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Main content ─────────────────────────────────── */}
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
|
||||
{/* Channel header */}
|
||||
<div className="flex items-center gap-3 border-b border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
|
||||
@@ -1301,7 +1378,7 @@ export function CommunicationHub({
|
||||
|
||||
{activeChannel ? (
|
||||
<>
|
||||
{(() => {
|
||||
{activeChannel.kind === "message" ? (() => {
|
||||
const Icon = channelConfig[activeChannel.channel].icon
|
||||
return (
|
||||
<Icon
|
||||
@@ -1312,19 +1389,20 @@ export function CommunicationHub({
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
})() : (
|
||||
<Hash size={16} weight="fill" style={{ color: TERRACOTTA }} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-semibold text-[color:var(--text)]">
|
||||
{activeChannel.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[color:var(--muted)]">
|
||||
{channelMessages.length} message{channelMessages.length !== 1 ? "s" : ""}
|
||||
{(() => {
|
||||
const stats = unreadByChannel.get(activeChannel.key)
|
||||
return stats?.unread
|
||||
? ` (${stats.unread} unread)`
|
||||
: ""
|
||||
})()}
|
||||
{activeChannel.kind === "message"
|
||||
? `${channelMessages.length} message${channelMessages.length !== 1 ? "s" : ""}${(() => {
|
||||
const stats = unreadByChannel.get(activeChannel.key)
|
||||
return stats?.unread ? ` (${stats.unread} unread)` : ""
|
||||
})()}`
|
||||
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1352,9 +1430,32 @@ export function CommunicationHub({
|
||||
Select a channel to view messages
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||
Or compose a new message to get started
|
||||
Monitor internal agent communications across departments
|
||||
</div>
|
||||
</div>
|
||||
) : activeChannel.kind === "task" ? (
|
||||
taskDiscussionComments.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
|
||||
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">
|
||||
No discussion yet
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||
Comments for this task will appear here as agents and the board coordinate.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{taskDiscussionComments.map((comment) => (
|
||||
<TaskCommentBubble
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
agent={comment.authorAgentId ? (agentMap.get(comment.authorAgentId) ?? undefined) : undefined}
|
||||
/>
|
||||
))}
|
||||
<div ref={messageEndRef} />
|
||||
</div>
|
||||
)
|
||||
) : filteredMessages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
|
||||
@@ -1366,7 +1467,7 @@ export function CommunicationHub({
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
|
||||
{filters.search || filters.unreadOnly || filters.priority !== "all"
|
||||
? "Try adjusting your search or filters"
|
||||
: "Be the first to send a message"}
|
||||
: "Messages will appear here as agents work and communicate"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1381,7 +1482,6 @@ export function CommunicationHub({
|
||||
isThread={false}
|
||||
replyCount={replies.length}
|
||||
onReply={() => setReplyTo(msg)}
|
||||
onMarkRead={() => handleMarkRead(msg.id)}
|
||||
/>
|
||||
{/* Thread replies */}
|
||||
{replies.length > 0 ? (
|
||||
@@ -1394,7 +1494,6 @@ export function CommunicationHub({
|
||||
isThread={true}
|
||||
replyCount={0}
|
||||
onReply={() => setReplyTo(reply)}
|
||||
onMarkRead={() => handleMarkRead(reply.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1407,31 +1506,44 @@ export function CommunicationHub({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
{activeChannel ? (
|
||||
<MessageComposer
|
||||
agents={agents}
|
||||
companyId={companyId}
|
||||
channel={activeChannel.channel}
|
||||
channelTargetId={activeChannel.targetId}
|
||||
replyTo={replyTo}
|
||||
onSent={handleSent}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
) : null}
|
||||
activeChannel.kind === "message" ? (
|
||||
<MessageComposer
|
||||
agents={agents}
|
||||
companyId={companyId}
|
||||
channel={activeChannel.channel}
|
||||
channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId}
|
||||
replyTo={replyTo}
|
||||
onSent={() => {
|
||||
setReplyTo(null)
|
||||
onRefresh()
|
||||
}}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
/>
|
||||
) : (
|
||||
<TaskDiscussionComposer
|
||||
companyId={companyId}
|
||||
taskId={activeChannel.taskId}
|
||||
onSent={onRefresh}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<ChatCircle size={14} />
|
||||
<span>Select a channel to read and send messages.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compose modal */}
|
||||
<ComposeModal
|
||||
open={showCompose}
|
||||
onClose={() => setShowCompose(false)}
|
||||
agents={agents}
|
||||
companyId={companyId}
|
||||
onSent={() => {
|
||||
onRefresh()
|
||||
setShowCompose(false)
|
||||
}}
|
||||
onSent={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user