This commit is contained in:
ZacharyZhang-NY committed 2026-03-23 16:27:55 -04:00
1 parent b15f1c0e5e
commit b56aaa1498
71 files changed
+866 -659

No files matched your search

@@ -1,17 +1,7 @@
import type { AgentMessageRecord, CommentRecord, TaskRecord, MessageChannel } from "@shared/types"
import { ChatCircle, Hash, CaretDown } from "@phosphor-icons/react"
import { CaretDown } from "@phosphor-icons/react"
import { useT } from "../../i18n"
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType; color: string }> = {
direct: { label: "Direct Messages", icon: ChatCircle, color: "var(--accent)" },
department: { label: "Department", icon: ChatCircle, color: "#6366f1" },
company: { label: "Company-wide", icon: ChatCircle, color: "#d97745" },
project: { label: "Project", icon: Hash, color: "#10b981" },
incident: { label: "Incident", icon: ChatCircle, color: "var(--danger)" },
}
const TERRACOTTA = "#d97745"
export interface ChannelDescriptor {
key: string
kind: "message"
@@ -70,51 +60,19 @@ export function ChannelHeader({
) : null}
{activeChannel ? (
<>
{activeChannel.kind === "message" ? (() => {
const Icon = channelConfig[activeChannel.channel].icon
return (
<Icon
size={16}
weight="fill"
style={{
color: channelConfig[activeChannel.channel].color,
}}
/>
)
})() : (
<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)]">
{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>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-[color:var(--text)]">{activeChannel.label}</div>
{activeChannel.kind === "task" && activeTask ? (
<button
type="button"
onClick={() => onNavigateToTask(activeTask.id)}
className="rounded-[6px] border border-[color:var(--line)] px-2.5 py-1 text-[11px] font-medium text-[color:var(--muted-strong)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
>
<button type="button" onClick={() => onNavigateToTask(activeTask.id)} className="mt-0.5 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
Open task
</button>
) : null}
</>
</div>
) : (
<span className="text-[13px] text-[color:var(--muted)]">
{t("comm.selectChannel")}
</span>
<span className="text-[13px] text-[color:var(--muted)]">{t("comm.selectChannel")}</span>
)}
<div className="ml-auto w-[320px]">
<div className="ml-auto w-full max-w-[320px]">
{filterBar}
</div>
</div>
@@ -1,18 +1,16 @@
import { useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { EnvelopeSimple, Hash, Buildings, Megaphone, Fire, CaretDown, MagnifyingGlass, Funnel } from "@phosphor-icons/react"
import { EnvelopeSimple, Hash, Buildings, Megaphone, Fire, MagnifyingGlass, Funnel } from "@phosphor-icons/react"
import type { AgentRecord, MessageChannel } from "@shared/types"
import { AgentAvatar } from "./MessageItem"
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
import { useT } from "../../i18n"
const TERRACOTTA = "#d97745"
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType; color: string }> = {
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" },
department: { label: "Department", icon: Buildings, color: "var(--muted-strong)" },
company: { label: "Company-wide", icon: Megaphone, color: "var(--accent)" },
project: { label: "Project", icon: Hash, color: "var(--success)" },
incident: { label: "Incident", icon: Fire, color: "var(--danger)" },
}
@@ -40,8 +38,8 @@ function ChannelItem({
type="button"
onClick={onClick}
aria-current={active ? "page" : undefined}
className={`group flex w-full items-center gap-2.5 rounded-[8px] px-3 py-2 text-left transition duration-150 ${
active ? "bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]/50"
className={`group relative flex w-full items-center gap-2.5 rounded-[8px] px-3 py-2 text-left transition duration-150 ${
active ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]/50"
}`}
>
{descriptor.channel === "direct" && agent ? (
@@ -56,7 +54,7 @@ function ChannelItem({
)}
<span
className={`min-w-0 flex-1 truncate text-[12px] ${
className={`min-w-0 flex-1 truncate text-[13px] ${
active
? "font-semibold text-[color:var(--text)]"
: unreadCount > 0
@@ -71,8 +69,8 @@ function ChannelItem({
<span className="h-2 w-2 shrink-0 rounded-full bg-[color:var(--danger)]" />
) : unreadCount > 0 ? (
<span
className="flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full px-1 text-[9px] font-bold text-white"
style={{ backgroundColor: TERRACOTTA }}
className="flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full px-1 text-[9px] font-bold text-[color:var(--text-on-accent)]"
style={{ backgroundColor: "var(--accent)" }}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
@@ -113,24 +111,10 @@ export function ChannelSidebar({
className="flex h-full shrink-0 flex-col overflow-hidden border-r border-[color:var(--line)] bg-[color:var(--panel)]"
style={{ width: 260 }}
>
<div className="flex items-center justify-between border-b border-[color:var(--line)] px-4 py-3">
<span className="text-[13px] font-semibold text-[color:var(--text)]">
{t("comm.channels")}
</span>
<button
type="button"
onClick={onHideSidebar}
className="rounded-[4px] p-1 text-[color:var(--muted)] transition hover:text-[color:var(--text)]"
aria-label="Hide sidebar"
>
<CaretDown size={12} className="-rotate-90" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
<div className="flex-1 overflow-y-auto px-2 py-3">
{channelGroups.map((group) => (
<div key={group.type} className="mb-3">
<div className="mb-1 px-2 text-[9px] font-bold uppercase tracking-[0.1em] text-[color:var(--muted)]">
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
{group.label}
</div>
{group.items.map((ch) => {
@@ -152,7 +136,7 @@ export function ChannelSidebar({
{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)]">
<div className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">
{t("comm.taskDiscussions")}
</div>
{taskDiscussionChannels.map((discussion) => (
@@ -164,10 +148,10 @@ export function ChannelSidebar({
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 className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px]" style={{ backgroundColor: "var(--accent-soft)" }}>
<Hash size={14} weight={selectedChannelKey === discussion.key ? "fill" : "regular"} style={{ color: "var(--accent)" }} />
</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)]"}`}>
<span className={`min-w-0 flex-1 truncate text-[13px] ${selectedChannelKey === discussion.key ? "font-semibold text-[color:var(--text)]" : "font-medium text-[color:var(--muted-strong)]"}`}>
{discussion.label}
</span>
</button>
@@ -183,7 +167,7 @@ export function ChannelSidebar({
</div>
<div className="border-t border-[color:var(--line)] px-4 py-2">
<div className="flex items-center justify-between text-[10px] text-[color:var(--muted)]">
<div className="flex items-center justify-between text-[11px] text-[color:var(--muted)]">
<span>{totalChannels} channels</span>
<span>{agents.length} agents</span>
</div>
@@ -225,21 +209,21 @@ export function FilterBar({
onKeyDown={onSearchKeyDown}
placeholder={t("comm.searchMessages")}
className="w-full rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] py-1.5 pl-8 pr-3 text-[11px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:outline-none focus:ring-1"
style={{ focusRingColor: TERRACOTTA } as React.CSSProperties}
style={{ focusRingColor: "var(--accent)" } as React.CSSProperties}
/>
</div>
{scopeToggle}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={`flex items-center gap-1.5 rounded-[6px] border px-2.5 py-1.5 text-[10px] font-medium transition ${
className={`flex items-center gap-1.5 rounded-[6px] border px-2.5 py-1.5 text-[11px] font-medium transition ${
expanded || filters.channelType !== "all" || filters.priority !== "all" || filters.unreadOnly
? "border-transparent text-white"
? "border-transparent text-[color:var(--text-on-accent)]"
: "border-[color:var(--line)] text-[color:var(--muted-strong)] hover:border-[color:var(--line-strong)]"
}`}
style={
expanded || filters.channelType !== "all" || filters.priority !== "all" || filters.unreadOnly
? { backgroundColor: TERRACOTTA }
? { backgroundColor: "var(--accent)" }
: undefined
}
>
@@ -259,7 +243,7 @@ export function FilterBar({
<select
value={filters.channelType}
onChange={(e) => onChangeFilters({ ...filters, channelType: e.target.value as MessageChannel | "all" })}
className="rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] px-2 py-1 text-[10px] font-medium text-[color:var(--text)]"
className="rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] px-2 py-1 text-[11px] font-medium text-[color:var(--text)]"
>
<option value="all">{t("comm.allChannels")}</option>
<option value="direct">{t("comm.direct")}</option>
@@ -271,7 +255,7 @@ export function FilterBar({
<select
value={filters.priority}
onChange={(e) => onChangeFilters({ ...filters, priority: e.target.value })}
className="rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] px-2 py-1 text-[10px] font-medium text-[color:var(--text)]"
className="rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] px-2 py-1 text-[11px] font-medium text-[color:var(--text)]"
>
<option value="all">{t("comm.allPriorities")}</option>
<option value="urgent">{t("comm.urgent")}</option>
@@ -281,10 +265,10 @@ export function FilterBar({
<button
type="button"
onClick={() => onChangeFilters({ ...filters, unreadOnly: !filters.unreadOnly })}
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold transition ${
filters.unreadOnly ? "text-white" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
className={`rounded-full px-2.5 py-1.5 text-[11px] font-semibold transition ${
filters.unreadOnly ? "text-[color:var(--text-on-accent)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
}`}
style={filters.unreadOnly ? { backgroundColor: TERRACOTTA } : undefined}
style={filters.unreadOnly ? { backgroundColor: "var(--accent)" } : undefined}
>
{t("comm.unreadOnly")}
</button>
@@ -292,7 +276,7 @@ export function FilterBar({
<button
type="button"
onClick={() => onChangeFilters({ search: filters.search, channelType: "all", priority: "all", unreadOnly: false })}
className="text-[10px] font-medium text-[color:var(--muted)] underline transition hover:text-[color:var(--text)]"
className="text-[11px] font-medium text-[color:var(--muted)] underline transition hover:text-[color:var(--text)]"
>
{t("comm.clearFilters")}
</button>
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ChatCircle, Plus } from "@phosphor-icons/react"
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
import { useAppStore } from "../../state/store"
import { unwrap } from "../../lib/desktop"
@@ -9,12 +8,10 @@ import { ChannelSidebar, FilterBar, type FilterState } from "./ChannelSidebar"
import type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./ChannelHeader"
import { ChannelHeader } from "./ChannelHeader"
import { MessageList } from "./MessageList"
import { MessageComposer, TaskDiscussionComposer, NoChannelComposer, ComposeModal } from "./MessageComposer"
import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./MessageComposer"
import { SearchResults } from "./SearchResults"
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
const TERRACOTTA = "#d97745"
const TERRACOTTA_SOFT = "#d9774518"
interface CommunicationHubProps {
messages: AgentMessageRecord[]; comments: CommentRecord[]; tasks: TaskRecord[]; agents: AgentRecord[]
@@ -35,9 +32,9 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
aria-checked={scope === "global"}
onClick={() => onChangeScope("global")}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onChangeScope("global") } }}
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold transition-colors duration-150 ${
className={`rounded-full px-2.5 py-1.5 text-[11px] font-semibold transition-colors duration-150 ${
scope === "global"
? "bg-[color:var(--accent)] text-white"
? "bg-[color:var(--accent)] text-[color:var(--text-on-accent)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
}`}
>
@@ -49,9 +46,9 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
aria-checked={scope === "channel"}
onClick={() => onChangeScope("channel")}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onChangeScope("channel") } }}
className={`rounded-full px-2.5 py-1 text-[10px] font-semibold transition-colors duration-150 ${
className={`rounded-full px-2.5 py-1.5 text-[11px] font-semibold transition-colors duration-150 ${
scope === "channel"
? "bg-[color:var(--accent)] text-white"
? "bg-[color:var(--accent)] text-[color:var(--text-on-accent)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
}`}
>
@@ -65,7 +62,6 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
const t = useT()
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [showSidebar, setShowSidebar] = useState(true)
const [showCompose, setShowCompose] = useState(false)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [optimisticReadIds, setOptimisticReadIds] = useState<Set<string>>(new Set())
const pendingReadIdsRef = useRef<Set<string>>(new Set())
@@ -218,7 +214,6 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
return counts
}, [channels, effectiveMessages])
const totalUnread = useMemo(() => effectiveMessages.filter((m) => !m.readAt).length, [effectiveMessages])
const channelGroups = useMemo(() => {
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
@@ -235,64 +230,47 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
) : null
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]" style={{ backgroundColor: TERRACOTTA_SOFT }}>
<ChatCircle size={20} weight="fill" style={{ color: TERRACOTTA }} />
</div>
<div>
<h2 className="text-[16px] font-bold text-[color:var(--text)]">{t("comm.communicationHub")}</h2>
<p className="text-[12px] text-[color:var(--muted)]">{messages.length} message{messages.length !== 1 ? "s" : ""}{totalUnread > 0 ? <span style={{ color: TERRACOTTA }}> ({totalUnread} unread)</span> : null}</p>
</div>
<div className="flex h-full min-h-0 overflow-hidden">
<AnimatePresence initial={false}>
{showSidebar ? <ChannelSidebar channelGroups={channelGroups} taskDiscussionChannels={taskDiscussionChannels} selectedChannelKey={selectedChannelKey} onSelectChannel={setSelectedChannelKey} unreadByChannel={unreadByChannel} agents={agents} totalChannels={channels.length} onHideSidebar={() => setShowSidebar(false)} /> : null}
</AnimatePresence>
<div className="flex min-w-0 flex-1 flex-col">
<ChannelHeader activeChannel={activeChannel} showSidebar={showSidebar} onShowSidebar={() => setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={<FilterBar filters={filters} onChangeFilters={setFilters} scopeToggle={scopeToggleElement} onSearchKeyDown={handleSearchKeyDown} />} />
<div className="flex-1 overflow-y-auto px-2 py-3">
<AnimatePresence mode="wait">
{isSearchActive ? (
<motion.div
key="search-results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="h-full"
>
<SearchResults
results={searchResults}
query={searchQuery}
loading={searchLoading}
agents={agents}
onNavigateToResult={handleNavigateToResult}
/>
</motion.div>
) : (
<motion.div
key="message-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="h-full"
>
<MessageList activeChannel={activeChannel} filteredMessages={filteredMessages} taskDiscussionComments={taskComments} activeTask={activeTask} agentMap={agentMap} filters={filters} onReply={setReplyTo} messageCount={effectiveMessages.length} selectedChannelKey={selectedChannelKey} />
</motion.div>
)}
</AnimatePresence>
</div>
<button type="button" onClick={() => setShowCompose(true)} 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" />{t("comm.newMessage")}
</button>
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
</div>
<div className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]" style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}>
<AnimatePresence initial={false}>
{showSidebar ? <ChannelSidebar channelGroups={channelGroups} taskDiscussionChannels={taskDiscussionChannels} selectedChannelKey={selectedChannelKey} onSelectChannel={setSelectedChannelKey} unreadByChannel={unreadByChannel} agents={agents} totalChannels={channels.length} onHideSidebar={() => setShowSidebar(false)} /> : null}
</AnimatePresence>
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
<ChannelHeader activeChannel={activeChannel} showSidebar={showSidebar} onShowSidebar={() => setShowSidebar(true)} channelMessages={channelMessages} taskDiscussionComments={taskComments} activeTask={activeTask} unreadByChannel={unreadByChannel} onNavigateToTask={onNavigateToTask} filterBar={<FilterBar filters={filters} onChangeFilters={setFilters} scopeToggle={scopeToggleElement} onSearchKeyDown={handleSearchKeyDown} />} />
<div className="flex-1 overflow-y-auto px-2 py-3">
<AnimatePresence mode="wait">
{isSearchActive ? (
<motion.div
key="search-results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="h-full"
>
<SearchResults
results={searchResults}
query={searchQuery}
loading={searchLoading}
agents={agents}
onNavigateToResult={handleNavigateToResult}
/>
</motion.div>
) : (
<motion.div
key="message-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="h-full"
>
<MessageList activeChannel={activeChannel} filteredMessages={filteredMessages} taskDiscussionComments={taskComments} activeTask={activeTask} agentMap={agentMap} filters={filters} onReply={setReplyTo} messageCount={effectiveMessages.length} selectedChannelKey={selectedChannelKey} />
</motion.div>
)}
</AnimatePresence>
</div>
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
</div>
</div>
<ComposeModal open={showCompose} onClose={() => setShowCompose(false)} agents={agents} projects={projects} companyId={companyId} onSent={onRefresh} />
</div>
)
}
@@ -7,11 +7,9 @@ import { InlineNotice, Modal, Select, Input, TextArea } from "../ui"
import { AgentAvatar } from "./MessageItem"
import { useT } from "../../i18n"
const TERRACOTTA = "#d97745"
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
return (
<button type="button" onClick={onClick} disabled={disabled || 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={label}>
<button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-[8px] bg-[color:var(--accent)] text-[color:var(--text-on-accent)] transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}>
{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>
)
@@ -57,7 +55,7 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
<AnimatePresence>
{replyTo ? (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="mb-2 flex items-center gap-2 overflow-hidden rounded-[6px] bg-[color:var(--panel-soft)] px-3 py-2">
<ArrowBendUpLeft size={12} style={{ color: TERRACOTTA }} />
<ArrowBendUpLeft size={12} className="text-[color:var(--accent)]" />
<span className="min-w-0 flex-1 truncate text-[11px] text-[color:var(--muted-strong)]">Replying to <span className="font-semibold">{agents.find((a) => a.id === replyTo.fromAgentId)?.name ?? "agent"}</span>{replyTo.subject ? `: ${replyTo.subject}` : ""}</span>
<button type="button" onClick={onCancelReply} className="shrink-0 rounded-[4px] p-0.5 text-[color:var(--muted)] transition hover:text-[color:var(--text)]"><X size={12} /></button>
</motion.div>
@@ -71,10 +69,10 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" className="min-w-0 flex-1 rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] px-2.5 py-1 text-[11px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]" /> : null}
</div>
<div className="flex items-end gap-2">
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." 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} />
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." 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-[13px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--accent)" } as React.CSSProperties} />
<SendButton sending={sending} disabled={!body.trim() || !fromAgentId || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
</div>
<div className="mt-1 text-[9px] text-[color:var(--muted)]">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div>
</div>
)
}
@@ -89,7 +87,7 @@ export function TaskDiscussionComposer({ companyId, taskId, onSent }: { companyI
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
<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} />
<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-[13px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--accent)" } as React.CSSProperties} />
<SendButton sending={sending} disabled={!body.trim()} onClick={() => void handleSend()} label="Send task comment" />
</div>
</div>
@@ -132,8 +130,8 @@ export function ComposeModal({ open, onClose, agents, projects, companyId, onSen
<TextArea label={t("comm.message")} value={body} onChange={setBody} placeholder={t("comm.writeMessage")} rows={5} />
<Select label="Priority" value={priority} onChange={(v) => setPriority(v as MessagePriority)} options={[{ value: "low", label: "Low" }, { value: "normal", label: "Normal" }, { value: "urgent", label: "Urgent" }]} />
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]">{t("common.cancel")}</button>
<button type="button" onClick={() => void handleSend()} disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || ((channel === "department" || channel === "project") && !channelTargetId) || sending} className="inline-flex items-center gap-2 rounded-[8px] px-4 py-2 text-[12px] font-semibold text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" style={{ backgroundColor: TERRACOTTA }}>
<button type="button" onClick={onClose} className="rounded-[8px] px-3 py-2 text-[13px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]">{t("common.cancel")}</button>
<button type="button" onClick={() => void handleSend()} disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || ((channel === "department" || channel === "project") && !channelTargetId) || sending} className="inline-flex items-center gap-2 rounded-[8px] px-3 py-2 text-[13px] font-semibold bg-[color:var(--accent)] text-[color:var(--text-on-accent)] transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none">
{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={14} weight="fill" />}{t("comm.sendMessage")}
</button>
</div>
@@ -1,3 +1,4 @@
import { memo } from "react"
import { motion } from "framer-motion"
import {
ChatCircle,
@@ -11,7 +12,6 @@ import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } fro
import { getAgentInitials, formatMessageTime } from "../../lib/formatters"
import { DEPARTMENT_COLORS } from "@shared/constants"
const TERRACOTTA = "#d97745"
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
urgent: { label: "Urgent", color: "var(--danger)", bg: "var(--danger-soft)" },
@@ -24,7 +24,7 @@ function getDepartmentColor(department: DepartmentId | null | undefined): string
return DEPARTMENT_COLORS[department] ?? "var(--muted)"
}
export function AgentAvatar({
export const AgentAvatar = memo(function AgentAvatar({
agent,
size = "md",
}: {
@@ -43,9 +43,9 @@ export function AgentAvatar({
{initials}
</div>
)
}
})
export function MessageBubble({
export const MessageBubble = memo(function MessageBubble({
message,
agent,
isThread,
@@ -67,7 +67,7 @@ export function MessageBubble({
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={`group relative flex gap-3 rounded-[10px] px-4 py-3 transition ${
className={`group relative flex gap-3 rounded-[8px] px-4 py-3 transition ${
isThread ? "ml-11" : ""
} ${
isUnread
@@ -82,21 +82,21 @@ export function MessageBubble({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold" style={{ color: deptColor }}>
<span className="truncate text-[13px] font-semibold" style={{ color: deptColor }}>
{agent?.name ?? "Unknown Agent"}
</span>
{agent?.title ? (
<span className="text-[10px] text-[color:var(--muted)]">{agent.title}</span>
<span className="truncate text-[11px] text-[color:var(--muted)]">{agent.title}</span>
) : null}
{agent?.department ? (
<span
className="rounded-full px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-[0.08em]"
className="shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.08em]"
style={{ backgroundColor: `${deptColor}15`, color: deptColor }}
>
{agent.department.replace("_", " ")}
</span>
) : null}
<span className="text-[10px] text-[color:var(--muted)]">
<span className="whitespace-nowrap shrink-0 text-[11px] text-[color:var(--muted)]">
{formatMessageTime(message.createdAt)}
</span>
{message.priority !== "normal" ? (
@@ -112,23 +112,23 @@ export function MessageBubble({
</span>
) : null}
{isUnread ? (
<Circle size={8} weight="fill" className="shrink-0" style={{ color: TERRACOTTA }} />
<Circle size={8} weight="fill" className="shrink-0" style={{ color: "var(--accent)" }} aria-label="Unread" />
) : (
<CheckCircle
size={10}
weight="fill"
className="shrink-0 text-[color:var(--muted)] opacity-0 group-hover:opacity-60"
className="shrink-0 text-[color:var(--muted)] opacity-0 group-hover:opacity-60 group-focus-within:opacity-60"
/>
)}
</div>
{message.subject ? (
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
<div className="mt-0.5 break-words text-[13px] font-medium text-[color:var(--text)]">
{message.subject}
</div>
) : null}
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
<div className="mt-1 break-words whitespace-pre-wrap text-[13px] leading-relaxed text-[color:var(--muted-strong)]">
{message.body}
</div>
@@ -142,7 +142,7 @@ export function MessageBubble({
type="button"
onClick={onReply}
className="flex items-center gap-1.5 text-[11px] font-medium transition hover:opacity-80"
style={{ color: TERRACOTTA }}
style={{ color: "var(--accent)" }}
>
<ChatCircle size={12} />
{replyCount} {replyCount === 1 ? "reply" : "replies"}
@@ -151,7 +151,7 @@ export function MessageBubble({
<button
type="button"
onClick={onReply}
className="flex items-center gap-1 rounded-[5px] px-2 py-0.5 text-[10px] font-medium text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] group-hover:opacity-100"
className="flex items-center gap-1 rounded-[6px] px-2 py-0.5 text-[11px] font-medium text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] group-hover:opacity-100"
>
<ArrowBendUpLeft size={10} />
Reply
@@ -160,7 +160,7 @@ export function MessageBubble({
</div>
</motion.div>
)
}
})
export function TaskCommentBubble({
comment,
@@ -177,19 +177,19 @@ export function TaskCommentBubble({
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 }}
className="group relative flex gap-3 rounded-[8px] border-l-[3px] bg-[color:var(--panel)] px-4 py-3"
style={{ borderLeftColor: isBoard ? "var(--accent)" : 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 }}>
<span className="truncate text-[13px] font-semibold" style={{ color: isBoard ? "var(--accent)" : 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>
{agent?.title ? <span className="truncate text-[11px] text-[color:var(--muted)]">{agent.title}</span> : null}
<span className="whitespace-nowrap shrink-0 text-[11px] 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)]">
<div className="mt-1 break-words whitespace-pre-wrap text-[13px] leading-relaxed text-[color:var(--muted-strong)]">
{comment.body}
</div>
</div>
@@ -211,7 +211,7 @@ function AttachmentList({ json }: { json: string }) {
{items.map((item, i) => (
<span
key={`${item.id}-${i}`}
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)]"
className="inline-flex items-center gap-1 rounded-[6px] bg-[color:var(--panel-soft)] px-2 py-1 text-[11px] font-medium text-[color:var(--muted-strong)]"
>
<At size={10} className="text-[color:var(--muted)]" />
{item.title || `${item.type}:${item.id.slice(0, 8)}`}
@@ -121,7 +121,7 @@ export function MessageList({
: t("comm.noMessagesChannel")}
</div>
{(filters.search || filters.unreadOnly || filters.priority !== "all") ? (
<div className="mt-1 text-[12px] text-[color:var(--muted)]">{t("comm.adjustFilters")}</div>
<div className="mt-1 text-[13px] text-[color:var(--muted)]">{t("comm.adjustFilters")}</div>
) : null}
</div>
)
@@ -10,9 +10,9 @@ import { useRovingTabIndex } from "../../lib/keyboard"
const channelConfig: Record<MessageChannel, { label: string; icon: React.ElementType; color: string }> = {
direct: { label: "Direct", icon: EnvelopeSimple, color: "var(--accent)" },
department: { label: "Department", icon: Buildings, color: "#6366f1" },
company: { label: "Company", icon: Megaphone, color: "#d97745" },
project: { label: "Project", icon: Hash, color: "#10b981" },
department: { label: "Department", icon: Buildings, color: "var(--muted-strong)" },
company: { label: "Company", icon: Megaphone, color: "var(--accent)" },
project: { label: "Project", icon: Hash, color: "var(--success)" },
incident: { label: "Incident", icon: Fire, color: "var(--danger)" },
}
@@ -152,28 +152,28 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes
<div className="min-w-0 flex-1">
{/* Row 1: Agent name, channel badge, timestamp */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-[color:var(--text)]">
<span className="truncate text-[11px] font-semibold text-[color:var(--text)]">
{agent?.name ?? "Unknown Agent"}
</span>
<span
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium"
style={{ backgroundColor: `${config.color}18`, color: config.color }}
>
<ChannelIcon size={10} weight="bold" />
{config.label}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-[color:var(--muted)]">
<span className="ml-auto whitespace-nowrap shrink-0 font-mono text-[11px] text-[color:var(--muted)]">
{formatMessageTime(message.createdAt)}
</span>
</div>
{/* Row 2: Subject */}
<div className="mt-0.5 truncate text-[12px] font-semibold text-[color:var(--text)]">
<div className="mt-0.5 truncate text-[13px] font-semibold text-[color:var(--text)]">
{highlightMatch(message.subject, query)}
</div>
{/* Row 3: Body snippet */}
<div className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-[color:var(--muted)]">
<div className="mt-0.5 line-clamp-2 break-words text-[13px] leading-relaxed text-[color:var(--muted)]">
{highlightMatch(message.body, query)}
</div>
</div>