ui: Avatar + Badge + Tooltip + @mention autocomplete

- MessageItem: shadcn Avatar (square, brutalist), Badge for AGT/type labels
- Tooltip on timestamps: tap/hover shows full date + seq number
- ComposeBox: typing @ opens mention autocomplete popup
  - Arrow keys to navigate, Enter/Tab to select, Escape to close
  - Shows username, display name, role
  - Fetches user list on mount
- More breathing room in messages (py-3/4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:05:30 +02:00
parent 98a46b962f
commit 6ba6719932
10 changed files with 1338 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import type { MessageType } from "@/types/MessageType";
import { postMessage } from "@/api";
import type { User } from "@/types/User";
import { postMessage, getUsers } from "@/api";
import { cn } from "@/lib/utils";
interface Props {
@@ -28,9 +29,18 @@ export function ComposeBox({
const [msgType, setMsgType] = useState<MessageType>("text");
const [sending, setSending] = useState(false);
const [focused, setFocused] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionIdx, setMentionIdx] = useState(0);
const inputRef = useRef<HTMLTextAreaElement>(null);
const meta = TYPE_META[msgType];
// Load users for mention autocomplete
useEffect(() => {
getUsers().then(setUsers).catch(() => {});
}, []);
// Auto-resize textarea
useEffect(() => {
if (inputRef.current) {
@@ -44,6 +54,37 @@ export function ComposeBox({
inputRef.current?.focus();
}, []);
// Detect @mention typing
const checkMention = useCallback((text: string, cursorPos: number) => {
const before = text.slice(0, cursorPos);
const match = before.match(/@(\w*)$/);
if (match) {
setMentionOpen(true);
setMentionQuery(match[1].toLowerCase());
setMentionIdx(0);
} else {
setMentionOpen(false);
}
}, []);
const filteredUsers = users.filter((u) =>
u.username.toLowerCase().includes(mentionQuery) ||
u.display_name.toLowerCase().includes(mentionQuery)
);
function insertMention(username: string) {
const textarea = inputRef.current;
if (!textarea) return;
const pos = textarea.selectionStart;
const before = content.slice(0, pos);
const after = content.slice(pos);
const mentionStart = before.lastIndexOf("@");
const newContent = before.slice(0, mentionStart) + `@${username} ` + after;
setContent(newContent);
setMentionOpen(false);
textarea.focus();
}
async function handleSend() {
if (!content.trim() || sending) return;
setSending(true);
@@ -89,6 +130,27 @@ export function ComposeBox({
</div>
)}
{/* Mention autocomplete */}
{mentionOpen && filteredUsers.length > 0 && (
<div className="mb-2 border-2 border-border bg-card">
{filteredUsers.map((u, i) => (
<button
type="button"
key={u.id}
onMouseDown={(e) => { e.preventDefault(); insertMention(u.username); }}
className={cn(
"w-full text-left px-3 py-2 text-xs font-mono flex items-center gap-2 transition-colors",
i === mentionIdx ? "bg-primary/15 text-primary" : "text-foreground hover:bg-muted",
)}
>
<span className="font-bold">@{u.username}</span>
<span className="text-muted-foreground">{u.display_name}</span>
<span className="text-[9px] text-muted-foreground/50 uppercase">{u.role}</span>
</button>
))}
</div>
)}
{/* Floating input container */}
<div className={cn(
"border-2 transition-colors px-4 py-3 md:px-5 md:py-4",
@@ -98,10 +160,36 @@ export function ComposeBox({
<textarea
ref={inputRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={(e) => {
setContent(e.target.value);
checkMention(e.target.value, e.target.selectionStart);
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onBlur={() => { setFocused(false); setTimeout(() => setMentionOpen(false), 200); }}
onKeyDown={(e) => {
// Mention navigation
if (mentionOpen && filteredUsers.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionIdx((i) => Math.min(i + 1, filteredUsers.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionIdx((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insertMention(filteredUsers[mentionIdx].username);
return;
}
if (e.key === "Escape") {
setMentionOpen(false);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();