mobile-first UI redesign — touch targets, responsive layout, safe areas
- MessageItem: 44px touch targets, relative time on mobile, tap-expand metadata - ComposeBox: safe-area-inset-bottom, compact type labels (T/C/R/E/P) - ChannelSidebar: wider on mobile, 44px channel buttons - All components: mobile-first with md: breakpoint for desktop - viewport: cover, no-scale, apple-mobile-web-app-capable - Pure Tailwind, no custom CSS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { Message } from "@/types/Message";
|
||||
|
||||
interface Props {
|
||||
@@ -6,67 +7,65 @@ interface Props {
|
||||
onReply: (id: string) => void;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<
|
||||
string,
|
||||
{ border: string; label: string; labelColor: string }
|
||||
> = {
|
||||
text: { border: "", label: "", labelColor: "" },
|
||||
code: {
|
||||
border: "border-l-2 border-[var(--color-msg-code)]",
|
||||
label: "CODE",
|
||||
labelColor: "text-[var(--color-msg-code)]",
|
||||
},
|
||||
result: {
|
||||
border: "border-l-2 border-[var(--color-msg-result)]",
|
||||
label: "RESULT",
|
||||
labelColor: "text-[var(--color-msg-result)]",
|
||||
},
|
||||
error: {
|
||||
border: "border-l-2 border-[var(--color-msg-error)]",
|
||||
label: "ERROR",
|
||||
labelColor: "text-[var(--color-msg-error)]",
|
||||
},
|
||||
plan: {
|
||||
border: "border-l-2 border-[var(--color-msg-plan)]",
|
||||
label: "PLAN",
|
||||
labelColor: "text-[var(--color-msg-plan)]",
|
||||
},
|
||||
const TYPE_BORDER: Record<string, string> = {
|
||||
text: "",
|
||||
code: "border-l-2 border-amber-500/70",
|
||||
result: "border-l-2 border-emerald-500/70",
|
||||
error: "border-l-2 border-red-500/70",
|
||||
plan: "border-l-2 border-blue-500/70",
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, { text: string; color: string }> = {
|
||||
code: { text: "CODE", color: "text-amber-500" },
|
||||
result: { text: "RES", color: "text-emerald-500" },
|
||||
error: { text: "ERR", color: "text-red-500" },
|
||||
plan: { text: "PLAN", color: "text-blue-500" },
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "now";
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h`;
|
||||
return `${Math.floor(hrs / 24)}d`;
|
||||
}
|
||||
|
||||
export function MessageItem({ message, replyTarget, onReply }: Props) {
|
||||
const [metaOpen, setMetaOpen] = useState(false);
|
||||
const isAgent = message.user.role === "agent";
|
||||
const isDeleted = !!message.deleted_at;
|
||||
const cfg = TYPE_CONFIG[message.type] || TYPE_CONFIG.text;
|
||||
const border = TYPE_BORDER[message.type] || "";
|
||||
const label = TYPE_LABEL[message.type];
|
||||
const meta = message.metadata as Record<string, string> | null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-4 py-2 transition-colors hover:bg-[oklch(0.15_0.005_260)] ${
|
||||
isAgent ? "bg-[oklch(0.13_0.008_260)]" : ""
|
||||
className={`group relative px-3 py-2 md:px-4 transition-colors hover:bg-white/[0.03] ${
|
||||
isAgent ? "bg-white/[0.02]" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Agent glow line */}
|
||||
{/* Agent glow — left edge */}
|
||||
{isAgent && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-[var(--color-agent-glow)]" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-500/30" />
|
||||
)}
|
||||
|
||||
{/* Reply context */}
|
||||
{replyTarget && (
|
||||
<div className="ml-6 mb-1 text-[11px] text-muted-foreground flex items-center gap-1 opacity-70">
|
||||
<span className="select-none">^</span>
|
||||
<div className="mb-1 text-[10px] md:text-[11px] text-muted-foreground flex items-center gap-1 opacity-60 pl-7 md:pl-9">
|
||||
<span>^</span>
|
||||
<span className="font-medium">{replyTarget.user.display_name}</span>
|
||||
<span className="truncate max-w-80 opacity-60">
|
||||
{replyTarget.content}
|
||||
</span>
|
||||
<span className="truncate max-w-40 md:max-w-80">{replyTarget.content}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex items-start gap-3 ${cfg.border} ${cfg.border ? "pl-3" : ""}`}>
|
||||
{/* Avatar */}
|
||||
<div className={`flex items-start gap-2 md:gap-3 ${border} ${border ? "pl-2 md:pl-3" : ""}`}>
|
||||
{/* Avatar — small on mobile */}
|
||||
<div
|
||||
className={`w-6 h-6 flex-shrink-0 flex items-center justify-center text-[10px] font-bold rounded-sm ${
|
||||
className={`w-5 h-5 md:w-6 md:h-6 flex-shrink-0 flex items-center justify-center text-[9px] md:text-[10px] font-bold rounded-sm ${
|
||||
isAgent
|
||||
? "bg-[var(--color-agent-glow)] text-foreground"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: "bg-secondary text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
@@ -74,68 +73,81 @@ export function MessageItem({ message, replyTarget, onReply }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header line */}
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className={`font-bold ${isAgent ? "text-[oklch(0.75_0.12_250)]" : "text-foreground"}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 text-[10px] md:text-[11px] flex-wrap">
|
||||
<span className={`font-bold ${isAgent ? "text-blue-400" : "text-foreground"}`}>
|
||||
{message.user.display_name}
|
||||
</span>
|
||||
{isAgent && (
|
||||
<span className="text-[10px] px-1 py-0 bg-[var(--color-agent-glow)] rounded-sm text-[oklch(0.8_0.1_250)]">
|
||||
AGENT
|
||||
<span className="text-[9px] px-1 bg-blue-500/20 rounded-sm text-blue-400">
|
||||
AGT
|
||||
</span>
|
||||
)}
|
||||
{cfg.label && (
|
||||
<span className={`text-[10px] font-bold ${cfg.labelColor}`}>
|
||||
{cfg.label}
|
||||
{label && (
|
||||
<span className={`text-[9px] md:text-[10px] font-bold ${label.color}`}>
|
||||
{label.text}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{/* Mobile: relative time. Desktop: full time */}
|
||||
<span className="text-muted-foreground tabular-nums md:hidden">
|
||||
{timeAgo(message.created_at)}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums hidden md:inline">
|
||||
{new Date(message.created_at).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground opacity-40 tabular-nums">
|
||||
<span className="text-muted-foreground/40 tabular-nums hidden md:inline">
|
||||
#{Number(message.seq)}
|
||||
</span>
|
||||
{/* Reply — always visible on mobile (no hover), hover on desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReply(message.id)}
|
||||
className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity ml-auto"
|
||||
className="text-[9px] md:text-[10px] text-muted-foreground md:opacity-0 md:group-hover:opacity-60 hover:!opacity-100 transition-opacity ml-auto min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-end"
|
||||
>
|
||||
REPLY
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`mt-0.5 text-[13px] leading-relaxed ${
|
||||
message.type === "code" ? "font-mono bg-[oklch(0.1_0.005_260)] px-2 py-1 rounded-sm whitespace-pre-wrap" : ""
|
||||
} ${
|
||||
message.type === "error" ? "text-[var(--color-msg-error)]" : ""
|
||||
}`}>
|
||||
<div className={`mt-0.5 text-[12px] md:text-[13px] leading-relaxed break-words ${
|
||||
message.type === "code" ? "bg-black/20 px-2 py-1 rounded-sm whitespace-pre-wrap overflow-x-auto" : ""
|
||||
} ${message.type === "error" ? "text-red-400" : ""}`}>
|
||||
{isDeleted ? (
|
||||
<span className="italic text-muted-foreground opacity-40">
|
||||
[deleted]
|
||||
</span>
|
||||
<span className="italic text-muted-foreground/40">[deleted]</span>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent metadata strip */}
|
||||
{/* Agent metadata — tap to expand on mobile, always compact on desktop */}
|
||||
{meta && isAgent && (
|
||||
<div className="mt-1 flex gap-3 text-[10px] text-muted-foreground opacity-50">
|
||||
{meta.model && <span>{meta.model}</span>}
|
||||
{meta.hostname && <span>{meta.hostname}</span>}
|
||||
{meta.cwd && <span>{meta.cwd}</span>}
|
||||
{meta.skill && (
|
||||
<span className="text-[var(--color-msg-plan)]">
|
||||
{meta.skill}
|
||||
</span>
|
||||
<>
|
||||
{/* Desktop: inline strip */}
|
||||
<div className="hidden md:flex mt-1 gap-3 text-[10px] text-muted-foreground/50">
|
||||
{meta.model && <span>{meta.model}</span>}
|
||||
{meta.hostname && <span>{meta.hostname}</span>}
|
||||
{meta.cwd && <span>{meta.cwd}</span>}
|
||||
{meta.skill && <span className="text-blue-400/50">{meta.skill}</span>}
|
||||
</div>
|
||||
{/* Mobile: tap to expand */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMetaOpen(!metaOpen)}
|
||||
className="md:hidden mt-1 text-[9px] text-muted-foreground/40 min-h-[32px] flex items-center"
|
||||
>
|
||||
{metaOpen ? "hide meta" : `${meta.model || "agent"} ...`}
|
||||
</button>
|
||||
{metaOpen && (
|
||||
<div className="md:hidden mt-0.5 text-[10px] text-muted-foreground/50 space-y-0.5">
|
||||
{meta.model && <div>model: {meta.model}</div>}
|
||||
{meta.hostname && <div>host: {meta.hostname}</div>}
|
||||
{meta.cwd && <div>cwd: {meta.cwd}</div>}
|
||||
{meta.skill && <div className="text-blue-400/50">skill: {meta.skill}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user