Files
AgentCompany/src/renderer/components/communication/MessageItem.tsx
T
ZacharyZhang-NY 3608578ba3 refactor(communication): localize ChannelHeader strings; bump tiny chips from 9px to 10px
Stragglers from the Communication audit that did not fit into C10's
FilterBar scope:

- ChannelHeader carried two hardcoded English strings: the sidebar
  reveal button's aria-label ("Show sidebar") and the "Open task"
  link that appears on task-discussion channels. Both now go through
  i18n — added `comm.showSidebar` ("显示侧边栏" / "Show sidebar") and
  reused the existing `comm.openTask` key. Users running in Chinese
  now get a fully Chinese header.

- MessageBubble had two 9px uppercase chips (department badge and
  priority badge). 9px with tracking is unreadable at standard display
  density; bumped both to 10px and loosened the tracking slightly so
  the letters don't collide. Same typography decision we made for
  the OrgChart connector label in C3.

Covers the remaining Communication audit items. J-C1 (resizable
sidebar) and J-C6 (resizable ThreadPanel) are feature additions and
stay out of scope per the audit's "bug fix only" contract.
2026-04-17 20:16:43 -04:00

222 lines
7.8 KiB
TypeScript

import { memo } from "react"
import { motion } from "framer-motion"
import {
ChatCircle,
At,
Circle,
CheckCircle,
WarningCircle,
ArrowBendUpLeft,
UserCircle,
} from "../ui/icons"
import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } from "@shared/types"
import { getAgentInitials, formatMessageTime } from "../../lib/formatters"
import { safeJson } from "../../lib/safe-json"
import { DEPARTMENT_COLORS } from "@shared/constants"
import { useT, type TranslationFn } from "../../i18n"
const HUMAN_SENDER_ID = "__board__";
function priorityConfig(t: TranslationFn): Record<string, { label: string; className: string }> {
return {
urgent: { label: t("msg.priority.urgent"), className: "bg-destructive-muted text-destructive" },
normal: { label: t("msg.priority.normal"), className: "bg-muted text-muted-foreground" },
low: { label: t("msg.priority.low"), className: "bg-muted text-muted-foreground" },
}
}
function getDepartmentColor(department: DepartmentId | null | undefined): string {
if (!department) return "var(--muted-foreground)"
return DEPARTMENT_COLORS[department] ?? "var(--muted-foreground)"
}
export const AgentAvatar = memo(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-foreground)"
const initials = agent ? getAgentInitials(agent.name) : "?"
return (
<div
className={`flex shrink-0 items-center justify-center rounded-full font-bold ${sizeClass}`}
style={{ backgroundColor: `${color}18`, color }}
>
{initials}
</div>
)
})
export const MessageBubble = memo(function MessageBubble({
message,
agent,
isThread,
replyCount,
onReply,
}: {
message: AgentMessageRecord
agent: AgentRecord | undefined
isThread: boolean
replyCount: number
onReply: () => void
}) {
const t = useT()
const priorityMap = priorityConfig(t)
const isUnread = !message.readAt
const isHuman = message.fromAgentId === HUMAN_SENDER_ID
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
const senderColor = isHuman ? "var(--primary)" : deptColor
const senderName = isHuman ? t("msg.you") : (agent?.name ?? t("msg.unknownAgent"))
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={`group relative flex gap-3 rounded-lg px-4 py-3 transition ${
isThread ? "ml-11" : ""
} ${
isUnread
? "bg-card"
: "hover:bg-muted/40"
}`}
>
{isHuman
? <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={20} weight="fill" /></div>
: <AgentAvatar agent={agent} />}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-semibold" style={{ color: senderColor }}>
{senderName}
</span>
{agent?.title ? (
<span className="truncate text-[11px] text-muted-foreground">{agent.title}</span>
) : null}
{agent?.department ? (
<span
className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-[0.06em] text-muted-foreground"
>
{agent.department.replace("_", " ")}
</span>
) : null}
<span className="whitespace-nowrap shrink-0 text-[11px] text-muted-foreground">
{formatMessageTime(message.createdAt)}
</span>
{message.priority !== "normal" ? (
<span
className={`flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-[0.04em] ${priorityMap[message.priority]?.className ?? ""}`}
>
{message.priority === "urgent" ? <WarningCircle size={10} weight="fill" /> : null}
{priorityMap[message.priority]?.label}
</span>
) : null}
{isUnread ? (
<Circle size={8} weight="fill" className="shrink-0" style={{ color: "var(--primary)" }} aria-label={t("msg.unread")} />
) : (
<CheckCircle
size={10}
weight="fill"
className="shrink-0 text-muted-foreground opacity-0 group-hover:opacity-60 group-focus-within:opacity-60"
/>
)}
</div>
{message.subject ? (
<div className="mt-0.5 break-words text-[13px] font-medium text-foreground">
{message.subject}
</div>
) : null}
<div className="mt-1 break-words whitespace-pre-wrap text-[13px] leading-relaxed text-muted-foreground">
{message.body}
</div>
{message.attachmentsJson && message.attachmentsJson !== "[]" ? (
<AttachmentList json={message.attachmentsJson} />
) : null}
<div className="mt-2 flex items-center gap-3">
{replyCount > 0 && !isThread ? (
<button
type="button"
onClick={onReply}
className="flex items-center gap-1.5 text-[11px] font-medium transition hover:opacity-80"
style={{ color: "var(--primary)" }}
>
<ChatCircle size={12} />
{replyCount} {t("msg.replies")}
</button>
) : null}
<button
type="button"
onClick={onReply}
className="flex items-center gap-1 rounded-lg px-2 py-0.5 text-[11px] font-medium text-muted-foreground opacity-0 transition hover:bg-muted hover:text-foreground group-hover:opacity-100"
>
<ArrowBendUpLeft size={10} />
{t("msg.reply")}
</button>
</div>
</div>
</motion.div>
)
})
export function TaskCommentBubble({
comment,
agent,
}: {
comment: CommentRecord
agent: AgentRecord | undefined
}) {
const isBoard = !comment.authorAgentId
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="group relative flex gap-3 rounded-lg bg-card px-4 py-3"
>
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-semibold" style={{ color: isBoard ? "var(--primary)" : deptColor }}>
{comment.authorName}
</span>
{agent?.title ? <span className="truncate text-[11px] text-muted-foreground">{agent.title}</span> : null}
<span className="whitespace-nowrap shrink-0 text-[11px] text-muted-foreground">{formatMessageTime(comment.createdAt)}</span>
</div>
<div className="mt-1 break-words whitespace-pre-wrap text-[13px] leading-relaxed text-muted-foreground">
{comment.body}
</div>
</div>
</motion.div>
)
}
function AttachmentList({ json }: { json: string }) {
const items = safeJson<Array<{ type: string; id: string; title: string }>>(json, [])
if (!items.length) return null
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{items.map((item, i) => (
<span
key={`${item.id}-${i}`}
className="inline-flex items-center gap-1 rounded-lg bg-muted px-2 py-1 text-[11px] font-medium text-muted-foreground"
>
<At size={10} className="text-muted-foreground" />
{item.title || `${item.type}:${item.id.slice(0, 8)}`}
</span>
))}
</div>
)
}