Build Cobble project structure from the handoff

- Remove the preset document suite (letters, emails, invoices, quotes,
  PDF generation, line items) with its routes, API handlers and tables
- Rebrand to Cobble: site data, Lato, handoff design tokens (light and
  dark) in main.css, brand assets in public/brand, ember stone logo and
  regenerated favicons
- Add bots, bot_messages, bot_tasks and bot_task_steps tables, regenerate
  DB types (BUN_SQLITE_COBBLE_*) and add a sample crew seed (seed:bots)
- Add the chat shell at /admin/bots: rail with pinned coordinator and
  user popover, bottom-anchored conversation with composer, working-on
  panel, and a send-message API scoped to the session user
- Add BotStone and Wordmark components, crew status and relative time
  utils with tests, and update AGENTS.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 17:10:36 +01:00
co-authored by Claude Opus 5.5
parent 81e4a8aecc
commit 3286b80029
340 changed files with 3161 additions and 12060 deletions
@@ -0,0 +1,39 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
import BotMessageLine from "./bot-message-line";
import GroupTimestamp from "./group-timestamp";
type Props = {
bot: BotWithLastMessage;
group: MessageGroup;
};
/**
* Plain text, no bubble. A 28px stone sits on the group's first line only.
*/
export default function BotMessageGroup({ bot, group }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-start">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={28}
className="-mt-1"
/>
<Stack className="min-w-0 grow gap-1! items-start">
{group.messages.map((message) => (
<BotMessageLine
key={message.id}
text={message.text || ""}
needs_you={Boolean(message.needs_you)}
/>
))}
<GroupTimestamp at={group.at} />
</Stack>
</Row>
);
}
@@ -0,0 +1,19 @@
import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P";
import NeedsYouChip from "./needs-you-chip";
type Props = {
text: string;
needs_you?: boolean;
};
export default function BotMessageLine({ text, needs_you }: Props) {
return (
<Stack className="gap-1.5! items-start max-w-full">
<P noMargin className="text-sm leading-5 text-ink whitespace-pre-wrap break-words">
{text}
</P>
{needs_you ? <NeedsYouChip /> : null}
</Stack>
);
}
@@ -0,0 +1,19 @@
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
/**
* Attachments are not designed yet; the control is present but inert.
*/
export default function ComposerAttachButton() {
return (
<Button
title="Attachments are not available yet"
variant="ghost"
color="text"
aria-disabled="true"
className="shrink-0 p-1! -m-1 text-ink dark:text-ink cursor-not-allowed rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={16} aria-hidden />
</Button>
);
}
@@ -0,0 +1,26 @@
import Button from "@moduletrace/twui/layout/Button";
import { ArrowUp } from "lucide-react";
type Props = {
disabled?: boolean;
loading?: boolean;
onClick: () => void;
};
/**
* 32px ink send button with a paper arrow.
*/
export default function ComposerSendButton({ disabled, loading, onClick }: Props) {
return (
<Button
title="Send"
disabled={disabled}
loading={loading}
loadingIconSize="smaller"
onClick={onClick}
className="shrink-0 size-8 p-0! rounded-default focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<ArrowUp size={16} aria-hidden />
</Button>
);
}
@@ -0,0 +1,75 @@
import { useState, type KeyboardEvent } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Textarea from "@moduletrace/twui/form/Textarea";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import sendBotMessage from "../(functions)/send-bot-message";
import ComposerAttachButton from "./composer-attach-button";
import ComposerSendButton from "./composer-send-button";
type Props = {
bot: BotWithLastMessage;
onSent: (params: { message: BUN_SQLITE_COBBLE_BOT_MESSAGES }) => void;
};
/**
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
* Its top rule lines up with the rail's user-row rule.
*/
export default function Composer({ bot, onSent }: Props) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const can_send = Boolean(text.trim()) && !sending;
async function send() {
if (!can_send || !bot.id) return;
setSending(true);
setError(undefined);
const res = await sendBotMessage({ bot_id: bot.id, text });
setSending(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Message not sent");
return;
}
setText("");
onSent({ message: res.singleRes });
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
}
return (
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
<ComposerAttachButton />
<Textarea
name="message"
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
/>
<ComposerSendButton disabled={!can_send} loading={sending} onClick={send} />
</Row>
);
}
@@ -0,0 +1,41 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import { crewStatusSentence } from "@/src/utils/crew-status";
type Props = {
bot: BotWithLastMessage;
bots: BotWithLastMessage[];
};
/**
* 64px header. The coordinator's status is the crew sentence.
*/
export default function ConversationHeader({ bot, bots }: Props) {
const status =
bot.kind === "coordinator"
? crewStatusSentence({ bots })
: bot.status_line || "";
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{bot.name}
</H1>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate">
{status}
</Span>
</Stack>
</Row>
);
}
@@ -0,0 +1,10 @@
import Span from "@moduletrace/twui/layout/Span";
import relativeTime from "@/src/utils/relative-time";
export default function GroupTimestamp({ at }: { at: number }) {
return (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at })}
</Span>
);
}
@@ -0,0 +1,17 @@
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
import UserMessageGroup from "./user-message-group";
import BotMessageGroup from "./bot-message-group";
type Props = {
bot: BotWithLastMessage;
group: MessageGroup;
};
export default function MessageGroupBlock({ bot, group }: Props) {
if (group.author === "user") {
return <UserMessageGroup group={group} />;
}
return <BotMessageGroup bot={bot} group={group} />;
}
@@ -0,0 +1,33 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import groupMessages from "../(utils)/group-messages";
import MessageGroupBlock from "./message-group-block";
type Props = {
bot: BotWithLastMessage;
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
*/
export default function MessageThread({ bot, messages }: Props) {
const groups = groupMessages({ messages });
return (
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
<Stack
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full gap-4! items-stretch px-6 pt-6 pb-6"
>
{groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} />
))}
</Stack>
</Stack>
);
}
@@ -0,0 +1,12 @@
import Span from "@moduletrace/twui/layout/Span";
/**
* Belongs to the message, never the avatar.
*/
export default function NeedsYouChip() {
return (
<Span className="inline-flex h-5 items-center px-3 rounded-default-xs bg-sunken text-[11px] font-bold leading-4 text-ink">
Needs you
</Span>
);
}
@@ -0,0 +1,19 @@
import Button from "@moduletrace/twui/layout/Button";
/**
* Opens the machine the bot is working on. Disabled until the task has one.
*/
export default function OpenComputerButton({ url }: { url?: string | null }) {
return (
<Button
title="Open computer"
color="gray"
href={url || undefined}
target="_blank"
disabled={!url}
className="w-full h-9 rounded-default text-[13px] font-bold bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
Open computer
</Button>
);
}
@@ -0,0 +1,28 @@
import Span from "@moduletrace/twui/layout/Span";
import { Check } from "lucide-react";
/**
* 14px step mark. Pending has no mark; its label is muted.
*/
export default function TaskStepMark({ state }: { state: string }) {
if (state === "done") {
return (
<Check
size={14}
strokeWidth={1.6 * (24 / 14)}
className="shrink-0 text-ink"
aria-label="Done"
/>
);
}
if (state === "active") {
return (
<Span className="size-3.5 shrink-0 flex items-center justify-center" aria-label="In progress" role="img">
<Span className="size-1.5 rounded-full bg-ink" />
</Span>
);
}
return <Span className="size-3.5 shrink-0" aria-hidden />;
}
@@ -0,0 +1,34 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import TaskStepMark from "./task-step-mark";
type Props = {
label: string;
state: string;
};
/**
* 28px pitch. Done is a tick, active a dot with a bold label.
*/
export default function TaskStepRow({ label, state }: Props) {
const is_active = state === "active";
return (
<Row
className="h-7 gap-2.5! flex-nowrap items-center"
aria-current={is_active ? "step" : undefined}
>
<TaskStepMark state={state} />
<Span
className={twMerge(
"text-[13px] leading-5 truncate",
is_active ? "font-bold text-ink" : "text-ink-secondary",
state === "pending" && "text-ink-disabled",
)}
>
{label}
</Span>
</Row>
);
}
@@ -0,0 +1,12 @@
import P from "@moduletrace/twui/layout/P";
/**
* Ink bubble with paper text; inverts in dark mode.
*/
export default function UserBubble({ text }: { text: string }) {
return (
<P noMargin className="max-w-[80%] rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 whitespace-pre-wrap break-words">
{text}
</P>
);
}
@@ -0,0 +1,18 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { MessageGroup } from "../(utils)/group-messages";
import UserBubble from "./user-bubble";
import GroupTimestamp from "./group-timestamp";
/**
* Right-aligned ink bubbles, one timestamp under the group.
*/
export default function UserMessageGroup({ group }: { group: MessageGroup }) {
return (
<Stack className="w-full gap-1! items-end">
{group.messages.map((message) => (
<UserBubble key={message.id} text={message.text || ""} />
))}
<GroupTimestamp at={group.at} />
</Stack>
);
}