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,18 @@
import Center from "@moduletrace/twui/layout/Center";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
export default function EmptyCrew() {
return (
<Center className="grow h-full">
<Stack className="items-center gap-3!">
<BotStone face="asleep" size={64} />
<Span className="text-sm font-bold text-ink">No bots yet</Span>
<Span className="text-sm text-ink-secondary">
Add a bot and give it a job.
</Span>
</Stack>
</Center>
);
}
@@ -0,0 +1,17 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { SendBotMessageRequest } from "@/src/schemas/bot-schema";
export default async function sendBotMessage({
bot_id,
text,
}: SendBotMessageRequest) {
return await fetchApi<
SendBotMessageRequest,
APIResponseObject<BUN_SQLITE_COBBLE_BOT_MESSAGES>
>(`/api/admin/bots/send-message`, {
method: "POST",
body: { bot_id, text },
});
}
@@ -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>
);
}
@@ -0,0 +1,34 @@
import { useContext, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { AppContext } from "@/src/pages/__root";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
/**
* The 900px conversation column: header, bottom-anchored thread, composer.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
const bot = pageProps?.bot;
const [messages, setMessages] = useState<BUN_SQLITE_COBBLE_BOT_MESSAGES[]>(
pageProps?.bot_messages || [],
);
if (!bot?.id) return null;
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader bot={bot} bots={pageProps?.bots || []} />
<MessageThread bot={bot} messages={messages} />
<Composer
bot={bot}
onSent={({ message }) =>
setMessages((prev) => [...prev, message])
}
/>
</Stack>
);
}
@@ -0,0 +1,49 @@
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import H2 from "@moduletrace/twui/layout/H2";
import { AppContext } from "@/src/pages/__root";
import TaskStepRow from "../(partials)/task-step-row";
import OpenComputerButton from "../(partials)/open-computer-button";
/**
* 280px panel listing the steps the bot is taking. Hidden below 1280px.
*/
export default function WorkingOnPanel() {
const { pageProps } = useContext(AppContext);
const task = pageProps?.bot_task;
return (
<Stack
className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline"
aria-label="Working on"
>
<Span className="pt-[34px] text-xs font-bold leading-4 tracking-[0.06em] uppercase text-ink-secondary">
Working on
</Span>
{task ? (
<>
<H2 className="mt-2.5 text-sm! leading-5! tracking-normal! font-bold text-ink">
{task.title}
</H2>
<Stack className="mt-4 gap-0! items-stretch">
{task.steps.map((step) => (
<TaskStepRow
key={step.id}
label={step.label || ""}
state={step.state || "pending"}
/>
))}
</Stack>
</>
) : (
<Span className="mt-2.5 text-[13px] leading-5 text-ink-secondary">
Nothing in progress.
</Span>
)}
<Stack className="mt-auto h-[88px] shrink-0 gap-0! items-stretch justify-center">
{task ? <OpenComputerButton url={task.computer_url} /> : null}
</Stack>
</Stack>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import groupMessages from "./group-messages";
const MIN = 60 * 1000;
describe("groupMessages", () => {
test("groups consecutive messages by author", () => {
const groups = groupMessages({
messages: [
{ id: 1, author: "user", text: "a", created_at: 0 },
{ id: 2, author: "bot", text: "b", created_at: MIN },
{ id: 3, author: "bot", text: "c", created_at: 2 * MIN },
{ id: 4, author: "user", text: "d", created_at: 3 * MIN },
],
});
expect(groups.map((g) => [g.author, g.messages.length])).toEqual([
["user", 1],
["bot", 2],
["user", 1],
]);
expect(groups[1]!.at).toBe(2 * MIN);
});
test("splits a long pause from the same author", () => {
const groups = groupMessages({
messages: [
{ id: 1, author: "bot", text: "a", created_at: 0 },
{ id: 2, author: "bot", text: "b", created_at: 10 * MIN },
],
});
expect(groups).toHaveLength(2);
});
});
@@ -0,0 +1,50 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
export type MessageGroup = {
key: string;
author: "bot" | "user";
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
/**
* Time of the group's last message, shown once per group.
*/
at: number;
};
type Params = {
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
/**
* Consecutive messages from one author within this gap share a group.
*/
gap_ms?: number;
};
/**
* Splits a conversation (oldest first) into author groups.
*/
export default function groupMessages({
messages,
gap_ms = 5 * 60 * 1000,
}: Params): MessageGroup[] {
const groups: MessageGroup[] = [];
for (const message of messages) {
const author = message.author === "user" ? "user" : "bot";
const at = Number(message.created_at || 0);
const last = groups[groups.length - 1];
if (last && last.author === author && at - last.at <= gap_ms) {
last.messages.push(message);
last.at = at;
continue;
}
groups.push({
key: String(message.id),
author,
messages: [message],
at,
});
}
return groups;
}
@@ -0,0 +1,50 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
if (!user?.id) {
return {
redirect: {
destination: "/auth/login",
},
};
}
const bots = await grabBots({ user_id: user.id });
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
if (!bot?.id) {
return {
redirect: {
destination: "/admin/bots",
},
};
}
if (bot.unread) {
await markBotRead({ bot_id: bot.id, user_id: user.id });
bot.unread = 0;
}
const [bot_messages, bot_task] = await Promise.all([
grabBotMessages({ bot_id: bot.id }),
grabActiveBotTask({ bot_id: bot.id }),
]);
return {
props: {
bots,
bot,
bot_messages,
bot_task,
},
};
};
+23
View File
@@ -0,0 +1,23 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Conversation from "./(sections)/conversation";
import WorkingOnPanel from "./(sections)/working-on-panel";
export default function BotConversationPage() {
return (
<>
<Conversation />
<WorkingOnPanel />
</>
);
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const bot = (params.serverRes?.props as PagePropsType | undefined)?.bot;
return {
title: `${bot?.name || "Bots"} | ${SiteData["SiteName"]}`,
description: bot?.role || `Your crew of bots.`,
};
};
+37
View File
@@ -0,0 +1,37 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the coordinator's conversation. With no crew yet, renders the empty
* shell.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
if (!user?.id) {
return {
redirect: {
destination: "/auth/login",
},
};
}
const bots = await grabBots({ user_id: user.id });
const first_bot = bots[0];
if (first_bot?.id) {
return {
redirect: {
destination: `/admin/bots/${first_bot.id}`,
},
};
}
return {
props: {
bots,
},
};
};
+12
View File
@@ -0,0 +1,12 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import EmptyCrew from "./(sections)/empty-crew";
export default function BotsPage() {
return <EmptyCrew />;
}
export const meta: BunextPageModuleMeta = {
title: `Bots | ${SiteData["SiteName"]}`,
description: `Your crew of bots.`,
};