Add the harness, envs, per-bot models and chat attachments
- Harness: providers, model catalog synced hourly, automatic free picks, cooldowns, tool loop with context trimming, MCP computer tools - Bots queue runs replies and tasks through the harness; jobs wait (attempt refunded) while every model is resting - Envs: Settings > Environment, scoped general/workspace/computer, written to computers and redacted from tool output - Models: Settings > Models, per-harness provider select defaulting to OpenRouter, and a models modal on each bot's chat (the coordinator's edits the defaults) - Chat: thinking row while a reply runs, file attachments (upload, paste, drop) that replies read as images, audio and document text - Fix media uploads failing after a user's first file, and private files being served from the public media path Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { FileText, Film, Image as ImageIcon, Mic } from "lucide-react";
|
||||
import type { MediaType } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
media_type?: MediaType;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The icon for a file's type.
|
||||
*/
|
||||
export default function AttachmentIcon({ media_type, size = 14 }: Props) {
|
||||
const Icon =
|
||||
media_type == "image"
|
||||
? ImageIcon
|
||||
: media_type == "audio"
|
||||
? Mic
|
||||
: media_type == "video"
|
||||
? Film
|
||||
: FileText;
|
||||
|
||||
return <Icon size={size} aria-hidden className="shrink-0" />;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
||||
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
|
||||
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* A worker's `grabBotHarnessOverview`; for the coordinator, the
|
||||
* defaults (`grabHarnessOverview`).
|
||||
*/
|
||||
harnesses: HarnessOverview[];
|
||||
/**
|
||||
* Gets the fresh harnesses after each save.
|
||||
*/
|
||||
onSaved?: (harnesses: HarnessOverview[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Models settings form (`HarnessSettingsForm`) for one bot. A worker
|
||||
* saves its own picks ("same as the default" drops one). The coordinator
|
||||
* runs on the defaults, so it saves the defaults themselves, which only a
|
||||
* super admin can.
|
||||
*/
|
||||
export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
/**
|
||||
* Remounts the form when the data changes under it.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
||||
const saved = (fresh: HarnessOverview[], msg?: string) => {
|
||||
setHarnesses(fresh);
|
||||
setNotice(msg || "Saved.");
|
||||
setVersion((prev) => prev + 1);
|
||||
onSaved?.(fresh);
|
||||
};
|
||||
|
||||
return (
|
||||
<HarnessSettingsForm
|
||||
key={version}
|
||||
harnesses={harnesses}
|
||||
notice={notice}
|
||||
save={async ({ settings }) => {
|
||||
if (bot.kind == "coordinator") {
|
||||
const res = await postHarnessApi({
|
||||
path: "save-settings",
|
||||
body: { settings },
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
const res = await saveBotHarnessSettings({
|
||||
bot_id: Number(bot.id),
|
||||
settings: settings.map((setting) => ({
|
||||
...setting,
|
||||
inherit: Boolean(setting.inherit),
|
||||
})),
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
|
||||
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronDown, CpuAi } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import BotHarnessForm from "./bot-harness-form";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
harnesses?: HarnessOverview[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header's model button: shows the model the bot thinks with and
|
||||
* opens every model it uses (text, media reading, transcription, speech,
|
||||
* images) in the Models settings form. On the coordinator that form is the
|
||||
* defaults, for super admins only; everyone else gets the button read only.
|
||||
*/
|
||||
export default function BotModelsModal({ bot, harnesses: initial }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!harnesses?.length || !bot.id) return null;
|
||||
|
||||
const is_coordinator = bot.kind == "coordinator";
|
||||
const can_edit =
|
||||
!is_coordinator ||
|
||||
isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
const name = bot.name || "This bot";
|
||||
const current =
|
||||
harnesses.find((h) => h.harness == "text")?.current?.name ||
|
||||
"No model set up";
|
||||
|
||||
const target = (
|
||||
<Button
|
||||
title={
|
||||
can_edit
|
||||
? `${name}'s models`
|
||||
: `${name} uses the default models. A super admin can change them here or in Settings, under Models.`
|
||||
}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={!can_edit}
|
||||
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
|
||||
>
|
||||
<CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" />
|
||||
<Span className="truncate">{current}</Span>
|
||||
{can_edit ? (
|
||||
<ChevronDown size={14} aria-hidden className="shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!can_edit) {
|
||||
return (
|
||||
<Stack className="ml-auto max-w-[40%] min-w-0 shrink">
|
||||
{target}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={target}
|
||||
targetWrapperProps={{
|
||||
className: "ml-auto max-w-[40%] min-w-0 shrink",
|
||||
}}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-3xl bg-paper px-6 pt-6 pb-0"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch pb-2">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{is_coordinator ? "Default models" : `${name}'s models`}
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{is_coordinator
|
||||
? `${name} and every bot left on the default use these. Providers are in Settings, under Models.`
|
||||
: `${name} uses the default models unless you pick its own.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<BotHarnessForm
|
||||
bot={bot}
|
||||
harnesses={harnesses}
|
||||
onSaved={setHarnesses}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the last message while the bot is working on a reply (or waiting
|
||||
* for a model to come back), so a slow reply doesn't look like none.
|
||||
*/
|
||||
export default function BotThinkingRow({ bot }: Props) {
|
||||
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
|
||||
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
|
||||
|
||||
if (!thinking && !waiting) return null;
|
||||
|
||||
return (
|
||||
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
|
||||
<BotStone
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={thinking ? "working" : "asleep"}
|
||||
size={28}
|
||||
/>
|
||||
<Span className="text-sm leading-5 text-ink-secondary animate-pulse motion-reduce:animate-none">
|
||||
{thinking ? `${bot.name || "The bot"} is thinking…` : `${BotWaitingStatusLine}…`}
|
||||
</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,30 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import pickFiles from "../(utils)/pick-files";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
onFiles: (params: { files: File[] }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachments are not designed yet; the control is present but inert.
|
||||
* Opens the file picker for the next message's attachments.
|
||||
*/
|
||||
export default function ComposerAttachButton() {
|
||||
export default function ComposerAttachButton({ disabled, onFiles }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title="Attachments are not available yet"
|
||||
title={
|
||||
disabled
|
||||
? `Up to ${AppData.BotAttachmentMaxFiles} files per message`
|
||||
: "Attach files"
|
||||
}
|
||||
type="button"
|
||||
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"
|
||||
disabled={disabled}
|
||||
onClick={() => pickFiles({ onFiles })}
|
||||
className="shrink-0 p-1! -m-1 text-ink dark:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { X } from "lucide-react";
|
||||
import type { PendingAttachment } from "../(hooks)/use-composer-attachments";
|
||||
import AttachmentIcon from "./attachment-icon";
|
||||
|
||||
type Props = Omit<PendingAttachment, "key"> & {
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A file waiting to go with the next message: uploading, ready, or failed
|
||||
* (with why, on hover). Removable until sent.
|
||||
*/
|
||||
export default function ComposerAttachmentChip({
|
||||
name,
|
||||
status,
|
||||
attachment,
|
||||
error,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row
|
||||
title={status == "failed" ? `${name}: ${error}` : name}
|
||||
className={`max-w-56 gap-2! flex-nowrap items-center rounded-default-lg border border-solid bg-surface pl-3 pr-1 py-1 text-[13px] leading-[18px] ${status == "failed" ? "border-ink text-ink-secondary line-through" : "border-hairline text-ink"}`}
|
||||
>
|
||||
{status == "uploading" ? (
|
||||
<Loading size="smaller" />
|
||||
) : (
|
||||
<AttachmentIcon media_type={attachment?.media_type} />
|
||||
)}
|
||||
<Span className="truncate">{name}</Span>
|
||||
<Button
|
||||
title={`Remove ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
className="shrink-0 p-1!"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { PendingAttachment } from "../(hooks)/use-composer-attachments";
|
||||
import ComposerAttachmentChip from "./composer-attachment-chip";
|
||||
|
||||
type Props = {
|
||||
pending: PendingAttachment[];
|
||||
onRemove: (params: { key: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The next message's files, just above the composer rule.
|
||||
*/
|
||||
export default function ComposerAttachments({ pending, onRemove }: Props) {
|
||||
if (!pending.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="gap-2! px-6 pb-3">
|
||||
{pending.map((item) => (
|
||||
<ComposerAttachmentChip
|
||||
key={item.key}
|
||||
name={item.name}
|
||||
status={item.status}
|
||||
attachment={item.attachment}
|
||||
error={item.error}
|
||||
onRemove={() => onRemove({ key: item.key })}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,32 @@
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
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 type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
onSent: (params: { message: BUN_SQLITE_COBBLE_BOT_MESSAGES }) => void;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => 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.
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
* just above it. Files can also be pasted or dropped in.
|
||||
*/
|
||||
export default function Composer({ bot, onSent }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const files = useComposerAttachments();
|
||||
|
||||
const can_send = Boolean(text.trim()) && !sending;
|
||||
const can_send =
|
||||
Boolean(text.trim() || files.media_ids.length) && !sending && !files.uploading;
|
||||
|
||||
async function send() {
|
||||
if (!can_send || !bot.id) return;
|
||||
@@ -29,7 +34,7 @@ export default function Composer({ bot, onSent }: Props) {
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await sendBotMessage({ bot_id: bot.id, text });
|
||||
const res = await sendBotMessage({ bot_id: bot.id, text, media_ids: files.media_ids });
|
||||
|
||||
setSending(false);
|
||||
|
||||
@@ -39,6 +44,7 @@ export default function Composer({ bot, onSent }: Props) {
|
||||
}
|
||||
|
||||
setText("");
|
||||
files.clear();
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
|
||||
@@ -50,26 +56,48 @@ export default function Composer({ bot, onSent }: Props) {
|
||||
}
|
||||
|
||||
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>
|
||||
<Stack
|
||||
className="shrink-0 gap-0! items-stretch"
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer.files.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
files.add({ files: [...e.dataTransfer.files] });
|
||||
}}
|
||||
>
|
||||
<ComposerAttachments pending={files.pending} onRemove={files.remove} />
|
||||
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
|
||||
<ComposerAttachButton disabled={files.full} onFiles={files.add} />
|
||||
<Textarea
|
||||
name="message"
|
||||
aria-label={`Message ${bot.name}`}
|
||||
placeholder={`Message ${bot.name}`}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
const pasted = [...e.clipboardData.files];
|
||||
|
||||
if (!pasted.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
files.add({ files: pasted });
|
||||
}}
|
||||
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 || files.uploading} onClick={send} />
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,25 @@ 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 type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import { crewStatusSentence } from "@/src/utils/crew-status";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
bots: BotWithLastMessage[];
|
||||
/**
|
||||
* The bot's models (the defaults for the coordinator).
|
||||
*/
|
||||
harnesses?: HarnessOverview[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 64px header. The coordinator's status is the crew sentence.
|
||||
* 64px header. The coordinator's status is the crew sentence. On the
|
||||
* right, the model button that opens the bot's models (the defaults on
|
||||
* the coordinator, for super admins).
|
||||
*/
|
||||
export default function ConversationHeader({ bot, bots }: Props) {
|
||||
export default function ConversationHeader({ bot, bots, harnesses }: Props) {
|
||||
const status =
|
||||
bot.kind === "coordinator"
|
||||
? crewStatusSentence({ bots })
|
||||
@@ -36,6 +43,7 @@ export default function ConversationHeader({ bot, bots }: Props) {
|
||||
{status}
|
||||
</Span>
|
||||
</Stack>
|
||||
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { BotMessageAttachment } from "@/src/types";
|
||||
import AttachmentIcon from "./attachment-icon";
|
||||
|
||||
/**
|
||||
* One file on a message: an image as a thumbnail, anything else as a
|
||||
* named chip. Both open the file in a new tab.
|
||||
*/
|
||||
export default function MessageAttachment({
|
||||
name,
|
||||
media_type,
|
||||
url,
|
||||
thumbnail_url,
|
||||
}: BotMessageAttachment) {
|
||||
if (media_type == "image") {
|
||||
return (
|
||||
<Link href={url} target="_blank" title={name} className="block">
|
||||
<Img
|
||||
src={thumbnail_url || url}
|
||||
alt={name}
|
||||
className="max-h-48 max-w-64 rounded-default-lg border border-solid border-hairline object-cover"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
title={name}
|
||||
className="flex max-w-64 items-center gap-2 rounded-default-lg border border-solid border-hairline bg-surface px-3 py-2 text-[13px] leading-[18px] text-ink no-underline hover:border-hairline-strong"
|
||||
>
|
||||
<AttachmentIcon media_type={media_type} />
|
||||
<Span className="truncate">{name}</Span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { BotMessageAttachment } from "@/src/types";
|
||||
import MessageAttachment from "./message-attachment";
|
||||
|
||||
type Props = {
|
||||
attachments?: BotMessageAttachment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A message's files, right-aligned above its bubble.
|
||||
*/
|
||||
export default function MessageAttachments({ attachments }: Props) {
|
||||
if (!attachments?.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="max-w-[80%] gap-2! justify-end">
|
||||
{attachments.map((attachment) => (
|
||||
<MessageAttachment key={attachment.id} {...attachment} />
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
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 type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
|
||||
import groupMessages from "../(utils)/group-messages";
|
||||
import BotThinkingRow from "./bot-thinking-row";
|
||||
import MessageGroupBlock from "./message-group-block";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
|
||||
messages: BotMessageWithAttachments[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Bottom-anchored: the stack sits 24px above the composer rule and grows
|
||||
* upward. Empty space belongs at the top. New messages announce politely.
|
||||
* While the bot works on a reply, a thinking row sits under the last one.
|
||||
*/
|
||||
export default function MessageThread({ bot, messages }: Props) {
|
||||
const groups = groupMessages({ messages });
|
||||
@@ -27,6 +28,7 @@ export default function MessageThread({ bot, messages }: Props) {
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock key={group.key} bot={bot} group={group} />
|
||||
))}
|
||||
<BotThinkingRow bot={bot} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserBubble from "./user-bubble";
|
||||
import UserMessage from "./user-message";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
|
||||
/**
|
||||
* Right-aligned ink bubbles, one timestamp under the group.
|
||||
* Right-aligned ink bubbles (each message's files above it), 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 || ""} />
|
||||
<UserMessage key={message.id} {...message} />
|
||||
))}
|
||||
<GroupTimestamp at={group.at} />
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import MessageAttachments from "./message-attachments";
|
||||
import UserBubble from "./user-bubble";
|
||||
|
||||
/**
|
||||
* One user message: its files, then its text (a file-only message has no
|
||||
* bubble).
|
||||
*/
|
||||
export default function UserMessage({ text, attachments }: BotMessageWithAttachments) {
|
||||
return (
|
||||
<>
|
||||
<MessageAttachments attachments={attachments} />
|
||||
{text ? <UserBubble text={text} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user