Files
apes/ui/colony/src/components/ComposeBox.tsx
limiteinductive 29527a0e71 compose: kill Send button, post-singularity input design
- Enter sends, Shift+Enter for newlines
- Type prefix inline: > for text, // for code, => for result, !! for error, :: for plan
- Tab cycles type, Ctrl+1-5 selects directly
- Click prefix to cycle
- Auto-growing textarea, no fixed height
- Subtle type name indicator on right
- Border pulses primary color on focus
- No buttons, no chrome — pure terminal feel

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

154 lines
4.8 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import type { MessageType } from "@/types/MessageType";
import { postMessage } from "@/api";
import { cn } from "@/lib/utils";
interface Props {
channelId: string;
replyTo: string | null;
onClearReply: () => void;
onMessageSent: () => void;
}
const TYPE_META: Record<MessageType, { prefix: string; color: string; key: string }> = {
text: { prefix: ">", color: "text-muted-foreground", key: "1" },
code: { prefix: "//", color: "text-[var(--color-msg-code)]", key: "2" },
result: { prefix: "=>", color: "text-[var(--color-msg-result)]", key: "3" },
error: { prefix: "!!", color: "text-[var(--color-msg-error)]", key: "4" },
plan: { prefix: "::", color: "text-[var(--color-msg-plan)]", key: "5" },
};
export function ComposeBox({
channelId,
replyTo,
onClearReply,
onMessageSent,
}: Props) {
const [content, setContent] = useState("");
const [msgType, setMsgType] = useState<MessageType>("text");
const [sending, setSending] = useState(false);
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const meta = TYPE_META[msgType];
// Auto-resize textarea
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.height = "0";
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 120)}px`;
}
}, [content]);
// Auto-focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
async function handleSend() {
if (!content.trim() || sending) return;
setSending(true);
try {
await postMessage(channelId, {
content: content.trim(),
type: msgType,
reply_to: replyTo ?? undefined,
});
setContent("");
setMsgType("text");
onClearReply();
onMessageSent();
} finally {
setSending(false);
}
}
function cycleType(direction: 1 | -1) {
const types: MessageType[] = ["text", "code", "result", "error", "plan"];
const idx = types.indexOf(msgType);
const next = (idx + direction + types.length) % types.length;
setMsgType(types[next]);
}
return (
<div className={cn(
"border-t-2 transition-colors px-3 py-2 md:px-4 pb-[env(safe-area-inset-bottom,8px)]",
focused ? "border-primary bg-card" : "border-border bg-background",
)}>
{/* Reply chip */}
{replyTo && (
<div className="flex items-center gap-1.5 mb-1.5 text-[10px] font-mono">
<span className="text-primary">^</span>
<span className="text-muted-foreground">#{replyTo.slice(0, 8)}</span>
<button
type="button"
onClick={onClearReply}
className="text-muted-foreground hover:text-primary ml-1"
>
x
</button>
</div>
)}
<div className="flex items-end gap-2">
{/* Type prefix — click to cycle */}
<button
type="button"
onClick={() => cycleType(1)}
className={cn(
"font-mono text-sm font-bold pb-1 min-w-[20px] transition-colors select-none",
meta.color,
)}
title={`${msgType} (Tab to cycle, Ctrl+1-5 to select)`}
>
{meta.prefix}
</button>
{/* Auto-growing textarea — no send button */}
<textarea
ref={inputRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
cycleType(1);
}
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
cycleType(-1);
}
// Ctrl+1-5 for type
if (e.ctrlKey && e.key >= "1" && e.key <= "5") {
e.preventDefault();
const types: MessageType[] = ["text", "code", "result", "error", "plan"];
setMsgType(types[parseInt(e.key) - 1]);
}
}}
placeholder="message"
disabled={sending}
rows={1}
className={cn(
"flex-1 bg-transparent text-sm font-mono text-foreground placeholder:text-muted-foreground/30 resize-none focus:outline-none leading-relaxed py-0.5",
sending && "opacity-30",
)}
/>
{/* Subtle type indicator — shows current type name */}
<span className={cn(
"text-[9px] font-mono uppercase tracking-widest pb-1 transition-opacity",
meta.color,
content ? "opacity-60" : "opacity-30",
)}>
{msgType}
</span>
</div>
</div>
);
}