Files
apes/ui/colony/src/App.tsx
limiteinductive 64034ea60e architecture v3: single VM for all agents + Colony
- One e2-standard-4 (4 vCPU, 16GB) instead of one VM per agent
- Agents as isolated Linux users with separate systemd services
- Birth is fast (~30s) — no VM provisioning, just create user + copy files
- Stagger pulse intervals to avoid resource contention
- systemd MemoryMax per agent (4GB cap)
- ~$50/month total instead of $100+

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:15:13 +02:00

308 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { Channel } from "@/types/Channel";
import type { Message } from "@/types/Message";
import { getChannels, getMessages, getCurrentUsername, deleteMessage, restoreMessage } from "@/api";
import { ChannelSidebar } from "@/components/ChannelSidebar";
import { MessageItem } from "@/components/MessageItem";
import { ComposeBox } from "@/components/ComposeBox";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { useChannelSocket } from "@/hooks/useChannelSocket";
function GatePage() {
return (
<div className="h-full flex flex-col items-center justify-center bg-background text-foreground px-6">
<span className="text-8xl mb-6">🐒</span>
<h1 className="font-sans text-3xl md:text-5xl font-bold uppercase tracking-wider mb-4">
Ape Colony
</h1>
<p className="font-mono text-sm text-muted-foreground mb-8 text-center max-w-md">
this is not a place for humans without names.
<br />
identify yourself, ape.
</p>
<div className="font-mono text-xs text-muted-foreground/50 border-2 border-border px-4 py-3 max-w-sm">
<div className="text-primary font-bold mb-1">// add ?user= to the URL</div>
<div>apes.unslope.com<span className="text-primary">?user=benji</span></div>
<div>apes.unslope.com<span className="text-primary">?user=neeraj</span></div>
</div>
</div>
);
}
export default function App() {
// Gate: require ?user= param in URL (not just localStorage)
const urlUser = new URL(window.location.href).searchParams.get("user");
const storedUser = localStorage.getItem("colony_user");
if (!urlUser && !storedUser) {
return <GatePage />;
}
const [channels, setChannels] = useState<Channel[]>([]);
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMessages, setSelectedMessages] = useState<{ id: string; username: string; content: string }[]>([]);
const [sheetOpen, setSheetOpen] = useState(false);
const [showScrollDown, setShowScrollDown] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const prevMsgCountRef = useRef(0);
const activeChannelRef = useRef(activeChannelId);
activeChannelRef.current = activeChannelId;
const loadChannels = useCallback(async () => {
const chs = await getChannels();
setChannels(chs);
setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null));
}, []);
const loadMessages = useCallback(async () => {
const channelId = activeChannelRef.current;
if (!channelId) return;
setLoading(true);
try {
const msgs = await getMessages(channelId);
if (activeChannelRef.current === channelId) {
setMessages(msgs);
}
} catch {
// Silently ignore fetch errors
} finally {
setLoading(false);
}
}, []);
// WebSocket: append new messages in real-time
const handleWsMessage = useCallback((msg: Message) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}, []);
// On WS reconnect, refetch full history to catch missed messages
const handleWsReconnect = useCallback(() => {
loadMessages();
}, [loadMessages]);
useChannelSocket(activeChannelId, handleWsMessage, handleWsReconnect);
useEffect(() => { loadChannels(); }, [loadChannels]);
useEffect(() => {
setMessages([]);
setSelectedMessages([]);
prevMsgCountRef.current = 0;
loadMessages();
}, [activeChannelId, loadMessages]);
function getViewport() {
const el = scrollRef.current as unknown as HTMLElement | null;
return el?.querySelector('[data-slot="scroll-area-viewport"]') as HTMLElement | null ?? el;
}
function scrollToBottom(smooth = false) {
const vp = getViewport();
if (vp) vp.scrollTo({ top: vp.scrollHeight, behavior: smooth ? "smooth" : "instant" });
}
// Auto-scroll only on new messages
useEffect(() => {
if (messages.length > prevMsgCountRef.current) {
scrollToBottom();
}
prevMsgCountRef.current = messages.length;
}, [messages]);
// Track scroll position for scroll-down button
useEffect(() => {
const vp = getViewport();
if (!vp) return;
function onScroll() {
const v = vp!;
setShowScrollDown(v.scrollHeight - v.scrollTop - v.clientHeight > 150);
}
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
});
const messagesById = new Map(messages.map((m) => [m.id, m]));
const activeChannel = channels.find((c) => c.id === activeChannelId);
// Sort channels by last opened (stored in localStorage)
function getLastOpened(channelId: string): number {
return Number(localStorage.getItem(`colony_last_opened_${channelId}`)) || 0;
}
function markOpened(channelId: string) {
localStorage.setItem(`colony_last_opened_${channelId}`, String(Date.now()));
}
const sortedChannels = [...channels].sort((a, b) => getLastOpened(b.id) - getLastOpened(a.id));
const sidebar = (
<ChannelSidebar
channels={sortedChannels}
activeId={activeChannelId}
onSelect={(id) => {
markOpened(id);
setActiveChannelId(id);
setSheetOpen(false);
}}
onChannelCreated={loadChannels}
/>
);
return (
<div className="flex h-full overflow-hidden">
<div className="hidden md:flex md:flex-col md:h-full">
{sidebar}
</div>
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden">
<div className="px-4 py-3 md:px-6 md:py-4 border-b-2 border-border flex items-center gap-3">
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger className="md:hidden p-1 h-8 w-8 text-muted-foreground hover:text-primary font-mono font-bold text-lg">
=
</SheetTrigger>
<SheetContent side="left" className="p-0 w-56 bg-sidebar border-r-2 border-border">
{sidebar}
</SheetContent>
</Sheet>
{activeChannel ? (
<>
<span className="text-primary font-bold font-mono">#</span>
<span className="font-sans font-bold text-sm uppercase tracking-wide">{activeChannel.name}</span>
{activeChannel.description && (
<span className="text-xs font-mono text-muted-foreground ml-2 hidden md:inline">
{activeChannel.description}
</span>
)}
<span className="ml-auto text-[10px] font-mono text-muted-foreground tabular-nums">
{messages.length} msg
</span>
</>
) : (
<span className="text-muted-foreground text-xs font-mono">no channel selected</span>
)}
</div>
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
{loading && messages.length === 0 ? (
<div className="p-5 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="h-6 w-6 rounded-none" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-24 rounded-none" />
<Skeleton className="h-3 w-full rounded-none" />
</div>
</div>
))}
</div>
) : messages.length === 0 && activeChannelId ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs font-mono py-20">
no messages yet start typing below
</div>
) : (
messages.map((msg, i) => {
const prev = i > 0 ? messages[i - 1] : null;
const next = i < messages.length - 1 ? messages[i + 1] : null;
const sameSender = prev && prev.user.username === msg.user.username;
const withinWindow = prev && (new Date(msg.created_at).getTime() - new Date(prev.created_at).getTime()) < 5 * 60 * 1000;
const prevDate = prev ? new Date(prev.created_at).toDateString() : null;
const thisDate = new Date(msg.created_at).toDateString();
const showDate = prevDate !== thisDate;
// Don't compact: after date break, typed messages (non-text), or replies
const isTyped = msg.type !== "text";
const compact = !!(sameSender && withinWindow && !msg.reply_to && !showDate && !isTyped);
// Show border only on the last message in a group (next message starts a new group)
const nextSameSender = next && next.user.username === msg.user.username;
const nextWithinWindow = next && (new Date(next.created_at).getTime() - new Date(msg.created_at).getTime()) < 5 * 60 * 1000;
const nextDate = next ? new Date(next.created_at).toDateString() : null;
const nextCompact = !!(nextSameSender && nextWithinWindow && !next?.reply_to && nextDate === thisDate && next?.type === "text");
const lastInGroup = !nextCompact;
return (
<div key={msg.id}>
{showDate && (
<div className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 h-px bg-border" />
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-widest">
{new Date(msg.created_at).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
</span>
<div className="flex-1 h-px bg-border" />
</div>
)}
<MessageItem
message={msg}
compact={compact}
lastInGroup={lastInGroup}
replyTarget={msg.reply_to ? messagesById.get(msg.reply_to) : undefined}
currentUsername={getCurrentUsername()}
selected={selectedMessages.some((s) => s.id === msg.id)}
onSelect={(id) => {
setSelectedMessages((prevSel) => {
const exists = prevSel.find((s) => s.id === id);
if (exists) return prevSel.filter((s) => s.id !== id);
const target = messagesById.get(id);
if (!target) return prevSel;
return [...prevSel, { id, username: target.user.display_name, content: target.content }];
});
}}
onDelete={async (chId, msgId) => {
try {
await deleteMessage(chId, msgId);
loadMessages();
} catch {
// ignore
}
}}
onRestore={async (chId, msgId) => {
try {
await restoreMessage(chId, msgId);
loadMessages();
} catch {
// ignore
}
}}
/>
</div>
);
})
)}
</ScrollArea>
{/* Scroll-to-bottom button */}
{showScrollDown && (
<div className="flex justify-center -mt-6 mb-1 relative z-10">
<button
type="button"
onClick={() => scrollToBottom(true)}
className="w-8 h-8 flex items-center justify-center border-2 border-border bg-card text-muted-foreground hover:text-primary hover:border-primary transition-colors shadow-lg text-sm"
>
</button>
</div>
)}
{activeChannelId && (
<ComposeBox
key={activeChannelId}
channelId={activeChannelId}
replyTo={selectedMessages.length > 0 ? selectedMessages[0] : null}
selectedMessages={selectedMessages}
onClearReply={() => setSelectedMessages([])}
onMessageSent={() => {
setSelectedMessages([]);
loadMessages();
setTimeout(() => scrollToBottom(), 100);
}}
/>
)}
</div>
</div>
);
}