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,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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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.`,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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.`,
|
||||
};
|
||||
Reference in New Issue
Block a user