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,121 +1,120 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { useCallback, useEffect, useRef, useState } from "react";
import type { Channel } from "@/types/Channel";
import type { Message } from "@/types/Message";
import { getChannels, getMessages } from "@/api";
import { ChannelSidebar } from "@/components/ChannelSidebar";
import { MessageItem } from "@/components/MessageItem";
import { ComposeBox } from "@/components/ComposeBox";
function App() {
const [count, setCount] = useState(0)
export default function App() {
const [channels, setChannels] = useState<Channel[]>([]);
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [replyTo, setReplyTo] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const loadChannels = useCallback(async () => {
const chs = await getChannels();
setChannels(chs);
if (!activeChannelId && chs.length > 0) {
setActiveChannelId(chs[0].id);
}
}, [activeChannelId]);
const loadMessages = useCallback(async () => {
if (!activeChannelId) return;
const msgs = await getMessages(activeChannelId);
setMessages(msgs);
}, [activeChannelId]);
useEffect(() => {
loadChannels();
}, [loadChannels]);
useEffect(() => {
loadMessages();
setReplyTo(null);
}, [loadMessages]);
// Auto-scroll on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
// Poll until WebSocket (S5)
useEffect(() => {
const interval = setInterval(loadMessages, 3000);
return () => clearInterval(interval);
}, [loadMessages]);
const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId);
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="flex h-full">
<ChannelSidebar
channels={channels}
activeId={activeChannelId}
onSelect={(id) => setActiveChannelId(id)}
onChannelCreated={loadChannels}
/>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
<div className="flex-1 flex flex-col min-w-0">
{/* Channel header */}
<div className="px-4 py-2 border-b border-border flex items-center gap-2">
{activeChannel ? (
<>
<span className="text-muted-foreground">#</span>
<span className="font-bold text-[14px]">
{activeChannel.name}
</span>
{activeChannel.description && (
<span className="text-[11px] text-muted-foreground ml-2">
{activeChannel.description}
</span>
)}
<span className="ml-auto text-[10px] text-muted-foreground tabular-nums">
{messages.length} msg
</span>
</>
) : (
<span className="text-muted-foreground text-[12px]">
select a channel
</span>
)}
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 && activeChannelId && (
<div className="flex items-center justify-center h-full text-muted-foreground text-[12px]">
no messages yet
</div>
)}
{messages.map((msg) => (
<MessageItem
key={msg.id}
message={msg}
replyTarget={
msg.reply_to ? messagesById.get(msg.reply_to) : undefined
}
onReply={setReplyTo}
/>
))}
</div>
{/* Compose */}
{activeChannelId && (
<ComposeBox
channelId={activeChannelId}
replyTo={replyTo}
onClearReply={() => setReplyTo(null)}
onMessageSent={loadMessages}
/>
)}
</div>
</div>
);
}
export default App