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:
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Activity,
|
||||
CircleHelp,
|
||||
Clock,
|
||||
LogOut,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type RailUserMenuItem = {
|
||||
title: string;
|
||||
/**
|
||||
* Unset for destinations that are not built yet; the item renders
|
||||
* disabled.
|
||||
*/
|
||||
url?: string;
|
||||
Icon: LucideIcon;
|
||||
/**
|
||||
* Rendered last, after a rule.
|
||||
*/
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exactly the five popover items. Nothing else lives in the rail: these
|
||||
* destinations are only reachable from here.
|
||||
*/
|
||||
export const RailUserMenuItems: RailUserMenuItem[] = [
|
||||
{ title: "Activity", url: "/admin/logs", Icon: Activity },
|
||||
{ title: "Routines", Icon: Clock },
|
||||
{ title: "Settings", url: "/admin/settings", Icon: SlidersHorizontal },
|
||||
{ title: "Help", Icon: CircleHelp },
|
||||
{ title: "Sign out", url: "/auth/logout", Icon: LogOut, destructive: true },
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import UnreadDot from "./unread-dot";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
selected?: boolean;
|
||||
/**
|
||||
* Replaces the last-message snippet (the coordinator shows crew status).
|
||||
*/
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 48px rail row. Status is the face: no badges, spinners or state dots.
|
||||
* Selected is ink at 5%, never a hue.
|
||||
*/
|
||||
export default function BotRow({ bot, selected, subtitle }: Props) {
|
||||
const hue = (bot.hue || null) as BotHue | null;
|
||||
const snippet = subtitle ?? bot.last_message_text ?? bot.role ?? "";
|
||||
const at = bot.last_message_at || bot.last_activity_at;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/bots/${bot.id}`}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={bot.name}
|
||||
className={twMerge(
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal",
|
||||
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
selected && "bg-ink/5 hover:bg-ink/5",
|
||||
)}
|
||||
>
|
||||
<BotStone hue={hue} face={(bot.face || "idle") as BotFace} />
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{snippet}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack className="hidden lg:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
|
||||
{at ? (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{relativeTime({ at: Number(at) })}
|
||||
</Span>
|
||||
) : null}
|
||||
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
|
||||
</Stack>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import { crewStatusLine } from "@/src/utils/crew-status";
|
||||
import BotRow from "./bot-row";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
bots: BotWithLastMessage[];
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pinned first. Hue-less, and its subtitle is the crew status, not a
|
||||
* snippet.
|
||||
*/
|
||||
export default function CoordinatorRow({ bot, bots, selected }: Props) {
|
||||
return (
|
||||
<>
|
||||
<BotRow
|
||||
bot={bot}
|
||||
selected={selected}
|
||||
subtitle={crewStatusLine({ bots })}
|
||||
/>
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* 28px add-bot control. The 1px strong border is required for WCAG 1.4.11.
|
||||
* Add-bot flow is not designed yet, so this has no action.
|
||||
*/
|
||||
export default function NewBotButton() {
|
||||
return (
|
||||
<Button
|
||||
title="Add a bot"
|
||||
color="gray"
|
||||
className="hidden lg:flex size-7 p-0! rounded-default-sm 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"
|
||||
>
|
||||
<Span className="relative size-3" aria-hidden>
|
||||
<Span className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[1.5px] bg-ink" />
|
||||
<Span className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-[1.5px] bg-ink" />
|
||||
</Span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Wordmark from "@/src/components/general/wordmark";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import NewBotButton from "./new-bot-button";
|
||||
|
||||
export default function RailHeader() {
|
||||
return (
|
||||
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
|
||||
<Link
|
||||
href="/admin/bots"
|
||||
className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Wordmark height={15} className="hidden lg:block" />
|
||||
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
|
||||
</Link>
|
||||
<NewBotButton />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { RailUserMenuItem } from "../(dict)/rail-user-menu-dict";
|
||||
|
||||
const RowClassName =
|
||||
"w-full h-9 gap-2.5! flex-nowrap items-center px-4";
|
||||
|
||||
export default function RailUserMenuEntry({ title, url, Icon }: RailUserMenuItem) {
|
||||
if (!url) {
|
||||
return (
|
||||
<Row
|
||||
role="menuitem"
|
||||
aria-disabled="true"
|
||||
title={`${title} is not available yet`}
|
||||
className={`${RowClassName} text-ink-disabled cursor-not-allowed`}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
<Span className="text-sm leading-5">{title}</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
role="menuitem"
|
||||
className="block w-full border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
|
||||
>
|
||||
<Row className={`${RowClassName} text-ink`}>
|
||||
<Icon size={16} aria-hidden />
|
||||
<Span className="text-sm leading-5 text-ink">{title}</Span>
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
|
||||
import RailUserMenuEntry from "./rail-user-menu-entry";
|
||||
|
||||
/**
|
||||
* 220px popover: the only element in the shell with elevation.
|
||||
*/
|
||||
export default function RailUserMenu() {
|
||||
const items = RailUserMenuItems.filter((item) => !item.destructive);
|
||||
const destructive = RailUserMenuItems.filter((item) => item.destructive);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
role="menu"
|
||||
className="w-[220px] mb-1 p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<RailUserMenuEntry key={item.title} {...item} />
|
||||
))}
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
{destructive.map((item) => (
|
||||
<RailUserMenuEntry key={item.title} {...item} />
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useContext } from "react";
|
||||
import Dropdown from "@moduletrace/twui/elements/Dropdown";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import RailUserMenu from "./rail-user-menu";
|
||||
|
||||
/**
|
||||
* The signed-in user, opening the popover above. Its top rule sits at the
|
||||
* same height as the composer rule, so the two read as one line.
|
||||
*/
|
||||
export default function RailUserRow() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const user = pageProps?.user;
|
||||
|
||||
const name = user?.first_name || user?.email || "";
|
||||
const initials = [user?.first_name, user?.last_name]
|
||||
.filter(Boolean)
|
||||
.map((part) => part!.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 lg:px-3 mx-0 lg:mx-3 border-0 border-t border-solid border-hairline">
|
||||
<Dropdown
|
||||
position="top-left"
|
||||
className="w-full"
|
||||
targetWrapperProps={{ className: "w-full" }}
|
||||
target={
|
||||
<Row
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`Account menu for ${name}`}
|
||||
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Span className="size-6 shrink-0 rounded-full bg-sunken flex items-center justify-center text-[11px] font-bold leading-none text-ink">
|
||||
{initials}
|
||||
</Span>
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
</Span>
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
Free
|
||||
</Span>
|
||||
</Stack>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="hidden lg:block shrink-0 text-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
>
|
||||
<RailUserMenu />
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotHueBgClasses } from "@/src/dict/bot-hue-classes-dict";
|
||||
|
||||
/**
|
||||
* 6px unread dot in the bot's own hue. Redundant by design (the snippet and
|
||||
* time carry the same information), which is why it may fail 3:1 on some
|
||||
* hues. Never remove the snippet or time.
|
||||
*/
|
||||
export default function UnreadDot({ hue }: { hue: BotHue }) {
|
||||
return (
|
||||
<Span
|
||||
className={twMerge("size-1.5 rounded-full mr-1", BotHueBgClasses[hue])}
|
||||
aria-label="Unread"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useContext } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import RailHeader from "../(partials)/rail-header";
|
||||
import CoordinatorRow from "../(partials)/coordinator-row";
|
||||
import BotRow from "../(partials)/bot-row";
|
||||
import RailUserRow from "../(partials)/rail-user-row";
|
||||
|
||||
/**
|
||||
* Left rail: wordmark, the pinned coordinator, then the crew. Collapses to a
|
||||
* 64px icon rail below 1024px.
|
||||
*/
|
||||
export default function BotsRail() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
const bots = pageProps?.bots || [];
|
||||
const selected_id = pageProps?.bot?.id;
|
||||
const coordinator = bots.find((bot) => bot.kind === "coordinator");
|
||||
const workers = bots.filter((bot) => bot.kind !== "coordinator");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
|
||||
aria-label="Bots"
|
||||
>
|
||||
<RailHeader />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1">
|
||||
{coordinator ? (
|
||||
<CoordinatorRow
|
||||
bot={coordinator}
|
||||
bots={bots}
|
||||
selected={coordinator.id === selected_id}
|
||||
/>
|
||||
) : null}
|
||||
{workers.map((bot) => (
|
||||
<BotRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
selected={bot.id === selected_id}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<RailUserRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import BotsRail from "./(sections)/bots-rail";
|
||||
|
||||
/**
|
||||
* Full-bleed chat shell for `/admin/bots/*`: the rail of bot conversations,
|
||||
* then the page (conversation + working-on panel). No cards, no shadows;
|
||||
* regions are separated by 1px hairlines.
|
||||
*/
|
||||
export default function BotsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
|
||||
<BotsRail />
|
||||
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
|
||||
{children}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user