S2: Colony chat UI — dark industrial design, JetBrains Mono

- Channel sidebar with create
- Message timeline with type-based styling (code/result/error/plan)
- Agent messages get glow line + AGENT badge
- Agent metadata strip (model, hostname, cwd, skill)
- Reply-to with context preview
- Compose box with message type selector (Alt+1-5)
- 3s polling for live updates (WebSocket in S5)
- Vite proxy to backend, TypeScript strict mode, Biome linting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 19:10:46 +02:00
parent 2698694d08
commit 0b6244390e
11 changed files with 448 additions and 487 deletions

View File

@@ -1,5 +1,4 @@
import type { Message } from "@/types/Message";
import { Badge } from "@/components/ui/badge";
interface Props {
message: Message;
@@ -7,75 +6,134 @@ interface Props {
onReply: (id: string) => void;
}
const TYPE_STYLES: Record<string, string> = {
text: "",
code: "bg-muted font-mono text-sm whitespace-pre-wrap",
result: "border-l-4 border-green-500 pl-3",
error: "border-l-4 border-red-500 pl-3 text-red-700 dark:text-red-400",
plan: "border-l-4 border-blue-500 pl-3",
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)]",
},
};
export function MessageItem({ message, replyTarget, onReply }: Props) {
const isAgent = message.user.role === "agent";
const isDeleted = !!message.deleted_at;
const cfg = TYPE_CONFIG[message.type] || TYPE_CONFIG.text;
const meta = message.metadata as Record<string, unknown> | null;
return (
<div className="group px-4 py-2 hover:bg-muted/50 transition-colors">
<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)]" : ""
}`}
>
{/* Agent glow line */}
{isAgent && (
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-[var(--color-agent-glow)]" />
)}
{/* Reply context */}
{replyTarget && (
<div className="text-xs text-muted-foreground ml-8 mb-1 flex items-center gap-1">
<span className="text-muted-foreground/50">replying to</span>
<div className="ml-6 mb-1 text-[11px] text-muted-foreground flex items-center gap-1 opacity-70">
<span className="select-none">^</span>
<span className="font-medium">{replyTarget.user.display_name}</span>
<span className="truncate max-w-64">{replyTarget.content}</span>
<span className="truncate max-w-80 opacity-60">
{replyTarget.content}
</span>
</div>
)}
<div className="flex items-start gap-2">
<div className={`flex items-start gap-3 ${cfg.border} ${cfg.border ? "pl-3" : ""}`}>
{/* Avatar */}
<div
className={`w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-xs font-bold ${
className={`w-6 h-6 flex-shrink-0 flex items-center justify-center text-[10px] font-bold rounded-sm ${
isAgent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground"
? "bg-[var(--color-agent-glow)] text-foreground"
: "bg-secondary text-muted-foreground"
}`}
>
{message.user.display_name[0]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{message.user.display_name}</span>
{/* 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"}`}>
{message.user.display_name}
</span>
{isAgent && (
<Badge variant="outline" className="text-[10px] py-0 h-4">
agent
</Badge>
<span className="text-[10px] px-1 py-0 bg-[var(--color-agent-glow)] rounded-sm text-[oklch(0.8_0.1_250)]">
AGENT
</span>
)}
{message.type !== "text" && (
<Badge variant="secondary" className="text-[10px] py-0 h-4">
{message.type}
</Badge>
{cfg.label && (
<span className={`text-[10px] font-bold ${cfg.labelColor}`}>
{cfg.label}
</span>
)}
<span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleTimeString()}
<span className="text-muted-foreground tabular-nums">
{new Date(message.created_at).toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
<span className="text-muted-foreground opacity-40 tabular-nums">
#{Number(message.seq)}
</span>
<button
type="button"
onClick={() => onReply(message.id)}
className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground transition-opacity"
className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity ml-auto"
>
reply
REPLY
</button>
</div>
<div className={`mt-0.5 text-sm ${TYPE_STYLES[message.type] || ""}`}>
{/* 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)]" : ""
}`}>
{isDeleted ? (
<span className="italic text-muted-foreground">[deleted]</span>
<span className="italic text-muted-foreground opacity-40">
[deleted]
</span>
) : (
message.content
)}
</div>
{message.metadata && isAgent && (
<div className="mt-1 flex gap-2 text-[10px] text-muted-foreground">
{(message.metadata as Record<string, unknown>).model && (
<span>{String((message.metadata as Record<string, unknown>).model)}</span>
)}
{(message.metadata as Record<string, unknown>).hostname && (
<span>{String((message.metadata as Record<string, unknown>).hostname)}</span>
{/* Agent metadata strip */}
{meta && isAgent && (
<div className="mt-1 flex gap-3 text-[10px] text-muted-foreground opacity-50">
{meta.model && <span>{String(meta.model)}</span>}
{meta.hostname && <span>{String(meta.hostname)}</span>}
{meta.cwd && <span>{String(meta.cwd)}</span>}
{meta.skill && (
<span className="text-[var(--color-msg-plan)]">
{String(meta.skill)}
</span>
)}
</div>
)}