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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import type { MessageType } from "@/types/MessageType";
|
import type { MessageType } from "@/types/MessageType";
|
||||||
import { postMessage } from "@/api";
|
import { postMessage } from "@/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -10,13 +10,13 @@ interface Props {
|
|||||||
onMessageSent: () => void;
|
onMessageSent: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MSG_TYPES: { value: MessageType; label: string; color: string }[] = [
|
const TYPE_META: Record<MessageType, { prefix: string; color: string; key: string }> = {
|
||||||
{ value: "text", label: "TXT", color: "" },
|
text: { prefix: ">", color: "text-muted-foreground", key: "1" },
|
||||||
{ value: "code", label: "COD", color: "text-[var(--color-msg-code)]" },
|
code: { prefix: "//", color: "text-[var(--color-msg-code)]", key: "2" },
|
||||||
{ value: "result", label: "RES", color: "text-[var(--color-msg-result)]" },
|
result: { prefix: "=>", color: "text-[var(--color-msg-result)]", key: "3" },
|
||||||
{ value: "error", label: "ERR", color: "text-[var(--color-msg-error)]" },
|
error: { prefix: "!!", color: "text-[var(--color-msg-error)]", key: "4" },
|
||||||
{ value: "plan", label: "PLN", color: "text-[var(--color-msg-plan)]" },
|
plan: { prefix: "::", color: "text-[var(--color-msg-plan)]", key: "5" },
|
||||||
];
|
};
|
||||||
|
|
||||||
export function ComposeBox({
|
export function ComposeBox({
|
||||||
channelId,
|
channelId,
|
||||||
@@ -27,6 +27,22 @@ export function ComposeBox({
|
|||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [msgType, setMsgType] = useState<MessageType>("text");
|
const [msgType, setMsgType] = useState<MessageType>("text");
|
||||||
const [sending, setSending] = useState(false);
|
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() {
|
async function handleSend() {
|
||||||
if (!content.trim() || sending) return;
|
if (!content.trim() || sending) return;
|
||||||
@@ -46,67 +62,91 @@ export function ComposeBox({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="border-t-2 border-border bg-card px-3 py-2.5 md:px-4 pb-[env(safe-area-inset-bottom,10px)]">
|
<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 && (
|
{replyTo && (
|
||||||
<div className="flex items-center gap-2 mb-2 text-[11px] font-mono text-muted-foreground">
|
<div className="flex items-center gap-1.5 mb-1.5 text-[10px] font-mono">
|
||||||
<span className="text-primary font-bold">^</span>
|
<span className="text-primary">^</span>
|
||||||
<span>replying to #{replyTo.slice(0, 8)}</span>
|
<span className="text-muted-foreground">#{replyTo.slice(0, 8)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClearReply}
|
onClick={onClearReply}
|
||||||
className="text-muted-foreground hover:text-primary font-bold min-w-[32px] min-h-[32px] md:min-w-0 md:min-h-0 flex items-center"
|
className="text-muted-foreground hover:text-primary ml-1"
|
||||||
>
|
>
|
||||||
[x]
|
x
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-end gap-2">
|
||||||
{/* Type selector — blocky, no-radius buttons */}
|
{/* Type prefix — click to cycle */}
|
||||||
<div className="flex border-2 border-border">
|
<button
|
||||||
{MSG_TYPES.map((t) => (
|
type="button"
|
||||||
<button
|
onClick={() => cycleType(1)}
|
||||||
type="button"
|
className={cn(
|
||||||
key={t.value}
|
"font-mono text-sm font-bold pb-1 min-w-[20px] transition-colors select-none",
|
||||||
onClick={() => setMsgType(t.value)}
|
meta.color,
|
||||||
className={cn(
|
)}
|
||||||
"h-8 w-8 md:h-7 md:w-7 text-[9px] font-mono font-bold uppercase transition-colors border-r border-border last:border-r-0",
|
title={`${msgType} (Tab to cycle, Ctrl+1-5 to select)`}
|
||||||
msgType === t.value
|
>
|
||||||
? "bg-primary text-primary-foreground"
|
{meta.prefix}
|
||||||
: cn("text-muted-foreground hover:text-foreground hover:bg-muted/50", t.color),
|
</button>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input — thick border, no radius */}
|
{/* Auto-growing textarea — no send button */}
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
ref={inputRef}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
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..."
|
placeholder="message"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className="flex-1 bg-input text-sm font-mono text-foreground placeholder:text-muted-foreground/50 px-3 py-1.5 h-9 md:h-8 border-2 border-border focus:outline-none focus:border-primary"
|
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",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send — hot orange */}
|
{/* Subtle type indicator — shows current type name */}
|
||||||
<button
|
<span className={cn(
|
||||||
type="button"
|
"text-[9px] font-mono uppercase tracking-widest pb-1 transition-opacity",
|
||||||
onClick={handleSend}
|
meta.color,
|
||||||
disabled={sending || !content.trim()}
|
content ? "opacity-60" : "opacity-30",
|
||||||
className="h-9 md:h-8 px-4 font-sans text-xs font-bold uppercase tracking-wider bg-primary text-primary-foreground border-2 border-primary hover:bg-primary/80 disabled:opacity-20 transition-opacity"
|
)}>
|
||||||
>
|
{msgType}
|
||||||
{sending ? "..." : "Send"}
|
</span>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user