refactor(ui): move AttentionRow to ui and reuse in Inbox
This commit is contained in:
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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";
|
||||
Reference in new issue
Block a user