refactor(ui): move AttentionRow to ui and reuse in Inbox

This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 23:29:58 -04:00
1 parent 279af3b034
commit c6935ff983
29 files changed
+615 -442

No files matched your search

@@ -106,6 +106,45 @@ function AttachmentPicker({ documents, attachments, onAdd, onRemove }: {
// ─── @Mention Autocomplete ──────────────────────────────────────────────────
/**
* Compute pixel coordinates of the caret inside a textarea relative to the
* textarea's own top-left corner. Uses a hidden mirror div that replicates
* the textarea's type/font metrics so the span we drop at the caret offset
* lays out in the same position the real caret does.
*/
function getTextareaCaretCoords(textarea: HTMLTextAreaElement, caretIndex: number): { top: number; left: number } {
const style = window.getComputedStyle(textarea)
const mirror = document.createElement("div")
const mirrorStyle = mirror.style
const copyProps = [
"boxSizing", "width", "height", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
"fontFamily", "fontSize", "fontWeight", "fontStyle", "fontVariant", "lineHeight",
"letterSpacing", "wordSpacing", "textTransform", "textIndent", "whiteSpace", "wordWrap",
] as const
for (const prop of copyProps) {
mirrorStyle[prop as never] = style[prop as never]
}
mirrorStyle.position = "absolute"
mirrorStyle.visibility = "hidden"
mirrorStyle.overflow = "hidden"
mirrorStyle.whiteSpace = "pre-wrap"
mirrorStyle.wordWrap = "break-word"
mirrorStyle.top = "0"
mirrorStyle.left = "-9999px"
mirror.textContent = textarea.value.slice(0, caretIndex)
const marker = document.createElement("span")
marker.textContent = "\u200b"
mirror.appendChild(marker)
document.body.appendChild(mirror)
const coords = { top: marker.offsetTop - textarea.scrollTop, left: marker.offsetLeft - textarea.scrollLeft }
document.body.removeChild(mirror)
return coords
}
function MentionDropdown({ agents, query, onSelect, position }: {
agents: AgentRecord[];
query: string;
@@ -142,7 +181,7 @@ function MentionDropdown({ agents, query, onSelect, position }: {
return (
<div
className="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-xl"
style={{ bottom: position.top, left: position.left }}
style={{ top: position.top, left: position.left }}
>
{filtered.map((agent, i) => (
<button
@@ -182,7 +221,7 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
const [body, setBody] = useState(""); const [subject, setSubject] = useState("")
const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null)
const [attachments, setAttachments] = useState<AttachmentItem[]>([])
const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number }>({ active: false, query: "", startIndex: -1 })
const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number; caretTop: number; caretLeft: number }>({ active: false, query: "", startIndex: -1, caretTop: 0, caretLeft: 0 })
const textareaRef = useRef<HTMLTextAreaElement>(null)
const addToast = useAppStore((s) => s.addToast)
@@ -205,13 +244,13 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
}, [])
const handleMentionSelect = useCallback((agent: AgentRecord | null) => {
if (!agent) { setMentionState({ active: false, query: "", startIndex: -1 }); return }
if (!agent) { setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 })); return }
setBody((prev) => {
const before = prev.slice(0, mentionState.startIndex)
const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1)
return `${before}@${agent.name} ${after}`
})
setMentionState({ active: false, query: "", startIndex: -1 })
setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 }))
textareaRef.current?.focus()
}, [mentionState])
@@ -219,20 +258,25 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
setBody(value)
const cursorPos = e.target.selectionStart
const textBeforeCursor = value.slice(0, cursorPos)
const atIndex = textBeforeCursor.lastIndexOf("@")
if (atIndex >= 0) {
const charBefore = atIndex > 0 ? textBeforeCursor[atIndex - 1] : " "
if (charBefore === " " || charBefore === "\n" || atIndex === 0) {
const query = textBeforeCursor.slice(atIndex + 1)
if (!query.includes(" ") && !query.includes("\n") && query.length <= 30) {
setMentionState({ active: true, query, startIndex: atIndex })
return
}
}
const windowStart = Math.max(0, cursorPos - 50)
const windowText = value.slice(windowStart, cursorPos)
const match = windowText.match(/(?:^|\s)@(\w*)$/)
if (match) {
const query = match[1]
const atIndex = windowStart + match.index! + (match[0].startsWith("@") ? 0 : 1)
const coords = getTextareaCaretCoords(e.target, atIndex)
const style = window.getComputedStyle(e.target)
const lineHeightPx = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.3
setMentionState({
active: true,
query,
startIndex: atIndex,
caretTop: coords.top + lineHeightPx,
caretLeft: coords.left,
})
return
}
setMentionState({ active: false, query: "", startIndex: -1 })
setMentionState((s) => ({ ...s, active: false, query: "", startIndex: -1 }))
}, [])
const handleSend = useCallback(async () => {
@@ -323,14 +367,17 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
textareaAriaLabel="Message body"
/>
{mentionState.active && (
{mentionState.active && textareaRef.current ? (
<MentionDropdown
agents={agents}
query={mentionState.query}
onSelect={handleMentionSelect}
position={{ top: 60, left: 48 }}
position={{
top: textareaRef.current.offsetTop + mentionState.caretTop,
left: textareaRef.current.offsetLeft + mentionState.caretLeft,
}}
/>
)}
) : null}
</div>
)
}
@@ -1,5 +1,4 @@
import { useMemo, type ElementType, type ReactNode } from "react";
import { motion } from "framer-motion";
import { useMemo } from "react";
import {
ArrowRight,
Briefcase,
@@ -28,46 +27,7 @@ import { ActivityFeed } from "./ActivityFeed";
import { RecoveryBanner } from "./RecoveryBanner";
import { BudgetAlertBanner } from "./BudgetAlertBanner";
import { ChartSection } from "./ChartSection";
const ATTENTION_STAGGER_MAX = 8;
const ATTENTION_STAGGER_STEP = 0.04;
function AttentionRow({
icon: Icon,
tone,
title,
subtitle,
onClick,
index,
}: {
icon: ElementType;
tone: "warn" | "danger";
title: ReactNode;
subtitle: ReactNode;
onClick: () => void;
index: number;
}) {
const iconClass = tone === "danger" ? "text-destructive" : "text-warning";
const bgClass = tone === "danger" ? "bg-destructive-muted" : "bg-warning-muted";
const delay = Math.min(index, ATTENTION_STAGGER_MAX) * ATTENTION_STAGGER_STEP;
return (
<motion.button
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay, ease: [0.16, 1, 0.3, 1] }}
type="button"
onClick={onClick}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 ${bgClass} px-4 py-3 text-left transition hover:border-opacity-40`}
>
<Icon size={18} className={`shrink-0 ${iconClass}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{title}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{subtitle}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</motion.button>
);
}
import { AttentionRow } from "../ui";
export interface DashboardProps {
companyName?: string;
+39 -42
View File
@@ -1,8 +1,7 @@
import { useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AnimatePresence } from "framer-motion";
import {
Bell,
ArrowRight,
Robot,
Tray,
} from "@phosphor-icons/react";
@@ -10,6 +9,7 @@ import type { InboxItem, InboxItemType, SectionId } from "@shared/types";
import { timeAgo } from "../../lib/formatters";
import { useT } from "../../i18n";
import { typeConfig, severityBorderClass } from "./inbox-helpers";
import { AttentionRow } from "../ui";
interface InboxProps {
items: InboxItem[];
@@ -106,50 +106,47 @@ export function Inbox({ items, onNavigate }: InboxProps) {
) : (
<div className="space-y-1.5">
<AnimatePresence>
{filteredItems.map((item) => {
{filteredItems.map((item, i) => {
const config = typeConfig[item.type];
const Icon = config.icon;
const title = (
<span className="flex items-center gap-2">
<span className="truncate">{item.title}</span>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
style={{ backgroundColor: config.bg, color: config.color }}
>
{config.label}
</span>
</span>
);
const subtitle = (
<span className="flex items-center gap-2">
{item.agentName ? (
<span className="flex items-center gap-1">
<Robot size={10} />
{item.agentName}
</span>
) : null}
<span className="truncate">{item.subtitle.slice(0, 80)}</span>
<span className="shrink-0 tabular-nums">{timeAgo(item.createdAt)}</span>
</span>
);
return (
<motion.div
<AttentionRow
key={item.id}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 20 }}
className={`group flex items-center gap-3 rounded-lg border border-l-[3px] border-border bg-card px-4 py-3 transition duration-150 hover:border-border hover:shadow-md hover:-translate-y-px ${severityBorderClass(item.severity)}`}
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: config.bg }}
>
<Icon size={18} weight="fill" style={{ color: config.color }} />
</div>
<button
type="button"
onClick={() => handleItemClick(item)}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{item.title}</span>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
style={{ backgroundColor: config.bg, color: config.color }}
>
{config.label}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
{item.agentName ? (
<span className="flex items-center gap-1">
<Robot size={10} />
{item.agentName}
</span>
) : null}
<span className="truncate">{item.subtitle.slice(0, 80)}</span>
<span className="shrink-0 tabular-nums">{timeAgo(item.createdAt)}</span>
</div>
</button>
<ArrowRight size={14} className="shrink-0 text-muted-foreground opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100" />
</motion.div>
index={i}
icon={Icon}
iconColor={config.color}
iconTileBg={config.bg}
iconWeight="fill"
rowBgClass="bg-card"
borderAccentClass={`border-l-[3px] border-border hover:shadow-md hover:-translate-y-px ${severityBorderClass(item.severity)}`}
title={title}
subtitle={subtitle}
onClick={() => handleItemClick(item)}
showArrow={false}
/>
);
})}
</AnimatePresence>
+110
View File
@@ -0,0 +1,110 @@
import type { ElementType, ReactNode } from "react";
import { motion } from "framer-motion";
import { ArrowRight } from "@phosphor-icons/react";
const ATTENTION_STAGGER_MAX = 8;
const ATTENTION_STAGGER_STEP = 0.04;
export type AttentionTone = "warn" | "danger" | "info" | "neutral";
export interface AttentionRowProps {
icon: ElementType;
tone?: AttentionTone;
title: ReactNode;
subtitle?: ReactNode;
onClick?: () => void;
index?: number;
rightMeta?: ReactNode;
iconColor?: string;
iconTileBg?: string;
rowBgClass?: string;
borderAccentClass?: string;
showArrow?: boolean;
iconWeight?: "fill" | "bold" | "regular";
}
const toneIconClass: Record<AttentionTone, string> = {
warn: "text-warning",
danger: "text-destructive",
info: "text-primary",
neutral: "text-muted-foreground",
};
const toneBgClass: Record<AttentionTone, string> = {
warn: "bg-warning-muted",
danger: "bg-destructive-muted",
info: "bg-accent",
neutral: "bg-muted",
};
export function AttentionRow({
icon: Icon,
tone = "neutral",
title,
subtitle,
onClick,
index = 0,
rightMeta,
iconColor,
iconTileBg,
rowBgClass,
borderAccentClass,
showArrow = true,
iconWeight,
}: AttentionRowProps) {
const iconClass = iconColor ? "" : toneIconClass[tone];
const bgClass = rowBgClass ?? toneBgClass[tone];
const delay = Math.min(index, ATTENTION_STAGGER_MAX) * ATTENTION_STAGGER_STEP;
const borderClass = borderAccentClass ?? "border-[color:var(--destructive)] border-opacity-20 hover:border-opacity-40";
const isButton = typeof onClick === "function";
const commonClass = `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border ${borderClass} ${bgClass} px-4 py-3 text-left transition`;
const iconElement = iconTileBg ? (
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: iconTileBg }}
>
<Icon size={18} weight={iconWeight} className={iconClass} style={iconColor ? { color: iconColor } : undefined} />
</div>
) : (
<Icon size={18} weight={iconWeight} className={`shrink-0 ${iconClass}`} style={iconColor ? { color: iconColor } : undefined} />
);
const inner = (
<>
{iconElement}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{title}</div>
{subtitle ? <div className="mt-0.5 text-[11px] text-muted-foreground">{subtitle}</div> : null}
</div>
{rightMeta}
{showArrow ? <ArrowRight size={14} className="shrink-0 text-muted-foreground" /> : null}
</>
);
if (isButton) {
return (
<motion.button
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay, ease: [0.16, 1, 0.3, 1] }}
type="button"
onClick={onClick}
className={commonClass}
>
{inner}
</motion.button>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay, ease: [0.16, 1, 0.3, 1] }}
className={commonClass}
>
{inner}
</motion.div>
);
}
+2
View File
@@ -2,3 +2,5 @@ export { Badge, SectionButton, Panel, Field, Input, TextArea, Select, ActionButt
export { StatusPill, InlineNotice, ConfirmDialog, EmptyState } from "./feedback";
export { Modal, ListFrame, ListRow } from "./layout";
export { useFocusTrap, useReducedMotion, ErrorBoundary } from "./hooks";
export { AttentionRow } from "./AttentionRow";
export type { AttentionRowProps, AttentionTone } from "./AttentionRow";