Let a bot take any colour

A bot's identity was one of seven hues, and the hue picked both its colour
and its stone shape. Now its colour is free and its stone is its own:

- `bots.color` holds any `#rrggbb`, `bots.stone` the flat-cut shape it's
  drawn on. `bots.hue` stays as the palette column they replaced, read only
  as a fallback; `src/scripts/move-bot-colour-to-owners.ts` backfills an
  install from it (safe to rerun).
- `src/utils/bot-color.ts` is the only place a colour is validated or
  resolved (`resolveBotColor`/`resolveBotStone` carry the old hue forward,
  `botColorNeedsPaperFace` flips the eyes to paper on a very dark body), and
  `src/utils/bot-stone-props.ts` gives every row its props, so a row saved
  before this still renders as it did.
- `BotStone` takes the colour as the body's SVG `fill`, the one documented
  exception to the token rule: an arbitrary hex has no token to point at.
  `UnreadDot` follows it.
- The picker keeps the palette tiles (still shape and colour together) and
  adds a native swatch and a hex field, so a custom colour keeps its stone.
  Same place as before: the header avatar's modal and Settings > Bots, for
  crew managers. The coordinator stays hue-less.
- A recolour reaches other open tabs through `pushBotUpdated`.
This commit is contained in:
2026-10-22 11:55:36 +01:00
parent 2eccfe9e13
commit 1a6249afd3
59 changed files with 877 additions and 237 deletions
+11
View File
@@ -1,3 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
import { logError } from "@/src/functions/backend/logger";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
@@ -61,6 +63,13 @@ async function push({
workspace_ids,
}: Params) {
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
/** Its colour and stone, so a recolour reaches every open tab. */
const identity = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT color, stone FROM bots WHERE id = ? LIMIT 1`,
values: [bot_id],
})
).payload?.[0];
for (const viewer_id of audience) {
if (users && !users.has(String(viewer_id))) continue;
@@ -72,6 +81,8 @@ async function push({
data: {
id: Number(bot_id),
...(await grabBotFace({ bot_id, user_id: viewer_id })),
color: identity?.color || null,
stone: identity?.stone || null,
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
...(workspace_ids !== undefined ? { workspace_ids } : {}),
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
+1 -1
View File
@@ -29,7 +29,7 @@ type Props = ComponentProps<typeof BotStone> & {
/**
* A bot's avatar: its custom picture when it has one (a rounded square,
* never a circle, so it isn't mistaken for a person), else its stone. Its
* hue still marks it everywhere else, e.g. the unread dot.
* colour still marks it everywhere else, e.g. the unread dot.
*/
export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
const saved = profileImageUrls({ media_id: image_media_id });
+39 -11
View File
@@ -1,19 +1,35 @@
import { twMerge } from "tailwind-merge";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { BotHueFillClasses } from "@/src/dict/bot-hue-classes-dict";
import type { BotFace, BotStoneShape } from "@/src/dict/bot-dict";
import {
CoordinatorBodyPath,
StoneBodyPaths,
StoneViewBox,
} from "@/src/data/stone-geometry";
import { botColorNeedsPaperFace, resolveBotStone } from "@/src/utils/bot-color";
import BotStoneFace from "./bot-stone-face";
type Props = {
/**
* The bot's identity hue. Omit (or `null`) for the coordinator, which
* renders hue-less: ink body, paper eyes, inverted in dark mode.
* The bot's identity colour (`#rrggbb`). Omit (or `null`) for the
* coordinator, which renders hue-less: ink body, paper eyes, inverted in
* dark mode.
*
* A bot's colour is user data, so it reaches the SVG as a `fill`
* attribute rather than a theme token or a Tailwind class: an arbitrary
* hex has no token to point at. This is the one place the palette rule
* bends; everything else in the app still uses `--color-*`.
*/
hue?: BotHue | null;
color?: string | null;
/**
* Which flat-cut stone to draw. Defaults to the shape this bot used to
* be tied to, else the first one.
*/
stone?: BotStoneShape | null;
/**
* Historical palette hue, only to pick the shape and colour of a bot
* saved before they were stored apart.
*/
hue?: string | null;
face?: BotFace;
size?: number;
/**
@@ -30,17 +46,25 @@ type Props = {
* eyes. Never put it in a circle, ring or badge, or add an outline or shadow.
*/
export default function BotStone({
color,
stone,
hue,
face = "idle",
size = 32,
label,
className,
}: Props) {
const is_coordinator = !hue;
const body_path = hue ? StoneBodyPaths[hue] : CoordinatorBodyPath;
const body_class = hue ? BotHueFillClasses[hue] : "fill-ink";
const eye_fill = is_coordinator ? "fill-paper" : "fill-dark";
const eye_stroke = is_coordinator ? "stroke-paper" : "stroke-dark";
const is_coordinator = !color;
const body_path = is_coordinator
? CoordinatorBodyPath
: StoneBodyPaths[resolveBotStone({ stone, hue })];
/**
* A very dark body keeps its eyes readable in paper; the coordinator is
* always paper, since its body is ink.
*/
const paper_face = is_coordinator || botColorNeedsPaperFace({ color: color! });
const eye_fill = paper_face ? "fill-paper" : "fill-dark";
const eye_stroke = paper_face ? "stroke-paper" : "stroke-dark";
return (
<svg
@@ -52,7 +76,11 @@ export default function BotStone({
aria-hidden={label ? undefined : true}
className={twMerge("shrink-0", className)}
>
<path d={body_path} className={body_class} />
<path
d={body_path}
fill={is_coordinator ? undefined : color!}
className={is_coordinator ? "fill-ink" : undefined}
/>
<BotStoneFace
face={face}
fill_class={eye_fill}
+3 -1
View File
@@ -1,4 +1,5 @@
import type { ComponentProps } from "react";
import { BotHues } from "@/src/dict/bot-dict";
import BotStone from "./bot-stone";
type Props = {
@@ -13,7 +14,8 @@ type Props = {
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
return (
<BotStone
hue="ember"
color={BotHues[0].color}
stone={BotHues[0].value}
size={icon_size}
label="Logo Icon"
className={img_props?.className}
+3 -2
View File
@@ -6,7 +6,8 @@ import Button from "@moduletrace/twui/layout/Button";
import type { TaskWithNames } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time";
@@ -40,7 +41,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
<BotAvatar
image_media_id={task.bot_image_media_id}
hue={(task.bot_kind == "coordinator" ? null : task.bot_hue || null) as BotHue | null}
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
face={(task.bot_face || "idle") as BotFace}
size={28}
/>
+52 -2
View File
@@ -1,40 +1,90 @@
/**
* Bot identity hues. A bot keeps its hue for life; a hue never means state.
* The coordinator has no hue.
* The palette a bot starts from: a suggested colour and the stone shape that
* goes with it. A bot's colour is free (any `#rrggbb`), so these are only
* the tiles the picker offers and the colours a new bot is given. Shape is
* stored separately (`bots.stone`), so a custom colour keeps its stone.
*/
export const BotHues = [
{
title: "Ember",
value: "ember",
color: "#e4572e",
},
{
title: "Amber",
value: "amber",
color: "#e9a23b",
},
{
title: "Moss",
value: "moss",
color: "#7fae3c",
},
{
title: "Teal",
value: "teal",
color: "#16a394",
},
{
title: "Cobalt",
value: "cobalt",
color: "#3b7dd8",
},
{
title: "Violet",
value: "violet",
color: "#7c5cd6",
},
{
title: "Rose",
value: "rose",
color: "#d6538c",
},
] as const;
/**
* Historical palette name. Kept for `bots.hue`, which is only read as a
* fallback for a bot saved before `color` and `stone` existed.
*/
export type BotHue = (typeof BotHues)[number]["value"];
/**
* The stone shapes. One flat cut each, from the handoff icon set. Shape is
* the bot's own, independent of its colour.
*/
export const BotStoneShapes = [
{
title: "Ember stone",
value: "ember",
},
{
title: "Amber stone",
value: "amber",
},
{
title: "Moss stone",
value: "moss",
},
{
title: "Teal stone",
value: "teal",
},
{
title: "Cobalt stone",
value: "cobalt",
},
{
title: "Violet stone",
value: "violet",
},
{
title: "Rose stone",
value: "rose",
},
] as const;
export type BotStoneShape = (typeof BotStoneShapes)[number]["value"];
export const BotKinds = [
{
title: "Worker",
-24
View File
@@ -1,24 +0,0 @@
import type { BotHue } from "./bot-dict";
/**
* Static Tailwind class names per hue, so the classes survive the build.
*/
export const BotHueFillClasses: Record<BotHue, string> = {
ember: "fill-hue-ember",
amber: "fill-hue-amber",
moss: "fill-hue-moss",
teal: "fill-hue-teal",
cobalt: "fill-hue-cobalt",
violet: "fill-hue-violet",
rose: "fill-hue-rose",
};
export const BotHueBgClasses: Record<BotHue, string> = {
ember: "bg-hue-ember",
amber: "bg-hue-amber",
moss: "bg-hue-moss",
teal: "bg-hue-teal",
cobalt: "bg-hue-cobalt",
violet: "bg-hue-violet",
rose: "bg-hue-rose",
};
@@ -8,7 +8,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/
export default async function grabCrewOptions() {
const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, name, role, hue, image_media_id FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`,
sql: `SELECT id, name, role, color, stone, hue, image_media_id FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name, description, image_media_id FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`,
+12 -4
View File
@@ -1,6 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import type { BotHue } from "@/src/dict/bot-dict";
import type { BotStoneShape } from "@/src/dict/bot-dict";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
import attachDefaultExtensions from "../extensions/attach-default-extensions";
@@ -12,7 +12,14 @@ type Params = {
user_id: number | string;
name: string;
role?: string;
hue: BotHue;
/**
* Its identity colour, `#rrggbb`.
*/
color: string;
/**
* Which flat-cut stone it's drawn on.
*/
stone: BotStoneShape;
/**
* Its core instructions (markdown).
*/
@@ -26,7 +33,7 @@ type Params = {
* computer of its own until it's given one; in a workspace it uses the
* workspace's.
*/
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
export default async function createBot({ user_id, name, role, color, stone, system_prompt }: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
@@ -41,7 +48,8 @@ export default async function createBot({ user_id, name, role, hue, system_promp
user_id: Number(user_id),
name,
kind: "worker",
hue,
color,
stone,
role: role || undefined,
system_prompt,
},
+23 -8
View File
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotHue } from "@/src/dict/bot-dict";
import type { BotStoneShape } from "@/src/dict/bot-dict";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
@@ -10,16 +10,31 @@ type Params = {
bot_id: number | string;
name: string;
role?: string;
hue: BotHue;
/**
* Its identity colour, `#rrggbb`.
*/
color: string;
/**
* Which flat-cut stone it's drawn on.
*/
stone: BotStoneShape;
system_prompt: string;
};
/**
* Saves one of the user's bots: name, job, hue and core instructions. The
* coordinator keeps its name and stays hue-less, so only its instructions
* change. Returns the saved bot.
* Saves one of the user's bots: name, job, colour, stone and core
* instructions. The coordinator keeps its name and stays hue-less, so only
* its instructions change. Returns the saved bot.
*/
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
export default async function updateBot({
user_id,
bot_id,
name,
role,
color,
stone,
system_prompt,
}: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
@@ -37,8 +52,8 @@ export default async function updateBot({ user_id, bot_id, name, role, hue, syst
});
} else {
await BunSQLite.sql({
sql: `UPDATE bots SET name = ?, role = ?, hue = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [name, role || null, hue, system_prompt, Date.now(), bot.id],
sql: `UPDATE bots SET name = ?, role = ?, color = ?, stone = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [name, role || null, color, stone, system_prompt, Date.now(), bot.id],
});
}
@@ -35,7 +35,7 @@ export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Par
}
const res = await BunSQLite.sql<CronJobWithNames>({
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, ${face.sql},
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone, ${face.sql},
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
FROM cron_jobs c
JOIN bots b ON b.id = c.bot_id
+1 -1
View File
@@ -25,7 +25,7 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
const face = botFaceSql({ user_id, alias: "b", prefix: "bot_" });
const row_res = await BunSQLite.sql<TaskDetail>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id
+1 -1
View File
@@ -83,7 +83,7 @@ export default async function grabTasks({
const face = botFaceSql({ user_id: user_id || 0, alias: "b", prefix: "bot_" });
const res = await BunSQLite.sql<TaskWithNames>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
(SELECT n.text FROM task_notes n WHERE n.task_id = t.id ORDER BY n.id DESC LIMIT 1) AS latest_note,
(SELECT COUNT(*) FROM task_media tm WHERE tm.task_id = t.id) AS media_count
@@ -24,6 +24,8 @@ export default async function grabWorkspaceActivity({
b.name AS bot_name,
b.image_media_id AS bot_image_media_id,
b.hue AS bot_hue,
b.color AS bot_color,
b.stone AS bot_stone,
${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text
+9 -4
View File
@@ -5,7 +5,7 @@ import { AppData } from "@/src/data/app-data";
import pushBotCreated from "@/src/bots-queue/push-bot-created";
import createBot from "@/src/functions/backend/db/bots/create-bot";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import pickFreeHue from "@/src/utils/pick-free-hue";
import pickFreeColor from "@/src/utils/pick-free-color";
import botRef from "@/src/utils/bot-ref";
import type { HarnessTool } from "../../types";
@@ -76,19 +76,24 @@ export default function createBotTool({
);
}
const hues =
const colors =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT hue FROM bots`,
sql: `SELECT color FROM bots`,
values: [],
})
).payload || [];
const { color, stone } = pickFreeColor({
taken: colors.map((bot) => bot.color),
});
const bot_id = await createBot({
user_id,
name: args.name,
role: args.role,
hue: pickFreeHue({ taken: hues.map((bot) => bot.hue) }),
color,
stone,
system_prompt: args.instructions,
});
+10 -3
View File
@@ -1,9 +1,9 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { BotHue } from "@/src/dict/bot-dict";
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
import updateBot from "@/src/functions/backend/db/bots/update-bot";
import { paletteColor, resolveBotStone } from "@/src/utils/bot-color";
import type { HarnessTool } from "../../types";
type Params = {
@@ -24,7 +24,8 @@ type Args = {
/**
* Lets the coordinator change an existing crew member: its name, its
* one-line job, and its core instructions (the start of its prompt in its
* own chat and in every workspace). Its hue and picture stay as they are.
* own chat and in every workspace). Its colour, stone and picture stay as
* they are.
* Workspace-only instructions have their own tool
* (`set_bot_workspace_instructions`).
*/
@@ -71,7 +72,13 @@ export default function updateBotTool({ user_id, workers }: Params): HarnessTool
bot_id: worker.id,
name: args.name ?? worker.name ?? "Bot",
role: args.role ?? worker.role ?? "",
hue: (worker.hue || "ember") as BotHue,
/**
* Never changed here: the user's pick is theirs. A bot saved
* before colours were free keeps the colour it showed.
*/
color:
worker.color || paletteColor({ hue: worker.hue }) || "#e4572e",
stone: resolveBotStone({ stone: worker.stone, hue: worker.hue }),
system_prompt: args.instructions ?? worker.system_prompt ?? "",
});
+14 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type {
BotCreatedData,
BotRemovedData,
@@ -78,7 +78,19 @@ export default function useLiveBots({ bots, open_bot_id, workspace_id = null }:
return prev.map((bot) =>
bot.id == data.id
? { ...bot, face: data.face, status_line: data.status_line }
? {
...bot,
face: data.face,
status_line: data.status_line,
...(data.color !== undefined
? { color: data.color || undefined }
: {}),
...(data.stone !== undefined
? {
stone: (data.stone || "") as BUN_SQLITE_COBBLE_BOTS["stone"],
}
: {}),
}
: bot,
);
}),
+5 -4
View File
@@ -2,8 +2,9 @@ import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import botStoneProps from "@/src/utils/bot-stone-props";
import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot";
@@ -16,7 +17,7 @@ type Props = {
* One bot in the all-bots modal: stone, name, job, last activity.
*/
export default function AllBotsRow({ bot, href }: Props) {
const hue = (bot.hue || null) as BotHue | null;
const stone_props = botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue });
const at = bot.last_message_at || bot.last_activity_at;
return (
@@ -25,7 +26,7 @@ export default function AllBotsRow({ bot, href }: Props) {
title={bot.name}
className="flex h-14 items-center gap-3 rounded-default 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"
>
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
<BotAvatar image_media_id={bot.image_media_id} {...stone_props} face={(bot.face || "idle") as BotFace} />
<Stack className="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">
@@ -37,7 +38,7 @@ export default function AllBotsRow({ bot, href }: Props) {
{relativeTime({ at: Number(at) })}
</Span>
) : null}
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
{bot.unread && stone_props.color ? <UnreadDot color={stone_props.color} /> : null}
</Link>
);
}
@@ -15,7 +15,7 @@ type Props = (typeof BotHues)[number] & {
* One colour tile: a stone in that hue over its name. Selected is a strong
* border, so it never depends on the colour alone.
*/
export default function BotHueOption({ title, value, selected, disabled, onSelect }: Props) {
export default function BotColourOption({ title, value, color, selected, disabled, onSelect }: Props) {
return (
<Button
title={title}
@@ -35,8 +35,18 @@ export default function BotHueOption({ title, value, selected, disabled, onSelec
)}
>
<Stack className="w-full min-w-0 gap-1! items-center">
<BotStone hue={value} face={selected ? "done" : "idle"} size={28} />
<Span className={twMerge("text-xs leading-4 text-ink-secondary truncate", selected && "font-bold text-ink")}>
<BotStone
color={color}
stone={value}
face={selected ? "done" : "idle"}
size={28}
/>
<Span
className={twMerge(
"text-xs leading-4 text-ink-secondary truncate",
selected && "font-bold text-ink",
)}
>
{title}
</Span>
</Stack>
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { BotHues, type BotStoneShape } from "@/src/dict/bot-dict";
import { normalizeBotColor, paletteColorTitle } from "@/src/utils/bot-color";
import BotColourOption from "./bot-colour-option";
import BotColourSwatch from "./bot-colour-swatch";
type Props = {
/**
* The bot's colour as `#rrggbb`.
*/
color: string;
/**
* Which stone shape it's drawn on. Picking a palette tile sets both, so a
* palette bot looks exactly as it always did.
*/
stone: BotStoneShape;
disabled?: boolean;
onChange: (picked: { color: string; stone: BotStoneShape }) => void;
};
/**
* Picks a bot's colour: the palette tiles (each on its own stone), or any
* colour at all from the swatch or the hex field. A custom colour keeps the
* stone the bot already has, so its shape stays its own.
*/
export default function BotColourPicker({ color, stone, disabled, onChange }: Props) {
const [hex, setHex] = useState(color);
const [invalid, setInvalid] = useState(false);
const picked = paletteValueFor({ color });
const palette_title = paletteColorTitle({ color });
useEffect(() => setHex(color), [color]);
const commitHex = (value: string) => {
setHex(value);
const normalized = normalizeBotColor({ color: value });
setInvalid(Boolean(value.trim()) && !normalized);
if (normalized) onChange({ color: normalized, stone });
};
return (
<Stack className="w-full gap-2!">
<Span className="text-xs text-ink-secondary">Colour</Span>
<Row
role="radiogroup"
aria-label="Colour"
className="w-full grid grid-cols-4 sm:grid-cols-7 gap-2!"
>
{BotHues.map((option) => (
<BotColourOption
key={option.value}
{...option}
selected={option.value == picked}
disabled={disabled}
onSelect={() =>
onChange({ color: option.color, stone: option.value })
}
/>
))}
</Row>
<Row className="w-full flex-nowrap items-end gap-2!">
<Stack className="shrink-0 gap-1!">
<Span className="text-xs text-ink-secondary">Any colour</Span>
<BotColourSwatch
color={color}
disabled={disabled}
title="Pick a colour"
onChange={(next) => onChange({ color: next, stone })}
/>
</Stack>
<Input
type="text"
id="bot-form-color-hex"
title="Hex value"
label="Hex value"
showLabel
autoComplete="off"
placeholder="#7c5cd6"
value={hex}
disabled={disabled}
className="grow"
variant={invalid ? "error" : "normal"}
info={invalid ? "Use a colour like #7c5cd6" : undefined}
onChange={(e) => commitHex(e.target.value)}
onBlur={() => !invalid && setHex(color)}
/>
</Row>
<Span className="text-xs text-ink-secondary">
{palette_title
? "A palette colour. Pick any colour, or type a hex, to make it its own."
: "Its own colour. Its stone keeps its shape."}
</Span>
</Stack>
);
}
/**
* The palette entry a colour is, by comparing hexes.
*/
function paletteValueFor({ color }: { color: string }): string | null {
const normalized = normalizeBotColor({ color });
if (!normalized) return null;
return BotHues.find((entry) => entry.color == normalized)?.value || null;
}
@@ -0,0 +1,36 @@
import { twMerge } from "tailwind-merge";
type Props = {
/**
* The colour as `#rrggbb`; `input[type=color]` only takes that form.
*/
color: string;
disabled?: boolean;
title: string;
onChange: (color: string) => void;
};
/**
* The native colour picker, dressed as a swatch. twui has no colour input,
* and its `Input` wraps every type in a padded field with a clear button,
* which a swatch doesn't want, so this is the field itself. The value goes
* straight into the same `onChange` the hex field and the tiles use.
*/
export default function BotColourSwatch({ color, disabled, title, onChange }: Props) {
return (
<input
type="color"
title={title}
aria-label={title}
value={color}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={twMerge(
"size-9 shrink-0 cursor-pointer appearance-none rounded-default",
"border border-solid border-hairline bg-surface p-0.5",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
);
}
@@ -3,7 +3,8 @@ import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import BotStone from "@/src/components/general/bot-stone";
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
import type { BotStoneShape } from "@/src/dict/bot-dict";
import { paletteColorTitle } from "@/src/utils/bot-color";
type Props = {
name: string;
@@ -11,7 +12,8 @@ type Props = {
/**
* Null for the coordinator.
*/
hue: BotHue | null;
color: string | null;
stone: BotStoneShape;
image_media_id?: number | string | null;
picked_src?: string;
};
@@ -21,23 +23,50 @@ type Props = {
* or stone, name and job, and its colour (a small stone beside the name
* when a picture hides the big one).
*/
export default function BotFormPreview({ name, role, hue, image_media_id, picked_src }: Props) {
export default function BotFormPreview({
name,
role,
color,
stone,
image_media_id,
picked_src,
}: Props) {
const has_picture = Boolean(picked_src || image_media_id);
const hue_title = BotHues.find((h) => h.value == hue)?.title;
/** A palette colour by name, else a colour the user made up. */
const color_title = color
? paletteColorTitle({ color }) || "Its own colour"
: null;
return (
<Row className="min-w-0 grow gap-3! flex-nowrap items-center">
<BotAvatar hue={hue} size={56} image_media_id={image_media_id} src={picked_src} />
<BotAvatar
color={color}
stone={stone}
size={56}
image_media_id={image_media_id}
src={picked_src}
/>
<Stack className="min-w-0 gap-0!">
<Row className="min-w-0 w-full gap-1.5! flex-nowrap items-center">
<Span className="text-sm font-bold leading-5 text-ink truncate">{name.trim() || "Its name"}</Span>
{has_picture && hue ? <BotStone hue={hue} size={18} label={`${hue_title} bot`} /> : null}
<Span className="text-sm font-bold leading-5 text-ink truncate">
{name.trim() || "Its name"}
</Span>
{has_picture && color ? (
<BotStone
color={color}
stone={stone}
size={18}
label={`${color_title} bot`}
/>
) : null}
</Row>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{role.trim() || "Its job"}
</Span>
{hue_title ? (
<Span className="text-[11px] leading-4 text-ink-secondary">{hue_title}</Span>
{color_title ? (
<Span className="text-[11px] leading-4 text-ink-secondary">
{color_title}
</Span>
) : null}
</Stack>
</Row>
+23 -12
View File
@@ -6,14 +6,15 @@ import CodeEditor from "@/src/components/general/code-editor";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import ImagePickField from "@/src/components/general/image-pick-field";
import { AppContext } from "@/src/pages/__root";
import type { BotHue } from "@/src/dict/bot-dict";
import type { BotStoneShape } from "@/src/dict/bot-dict";
import setProfileImage from "@/src/functions/frontend/set-profile-image";
import postAdminApi from "../(functions)/post-admin-api";
import usePickedImage from "../(hooks)/use-picked-image";
import pickFreeHue from "@/src/utils/pick-free-hue";
import { resolveBotColor, resolveBotStone } from "@/src/utils/bot-color";
import pickFreeColor from "@/src/utils/pick-free-color";
import AddFormActions from "./add-form-actions";
import BotColourPicker from "./bot-colour-picker";
import BotFormPreview from "./bot-form-preview";
import BotHuePicker from "./bot-hue-picker";
type Props = {
/**
@@ -46,10 +47,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
const [name, setName] = useState(bot?.name || "");
const [role, setRole] = useState(bot?.role || "");
const [hue, setHue] = useState<BotHue>(
/** The colour and stone a new bot starts with: the palette's next free one. */
const [color, setColor] = useState(
() =>
(bot?.hue as BotHue) ||
pickFreeHue({ taken: crew.map((b) => b.hue) }),
resolveBotColor({ color: bot?.color, hue: bot?.hue }) ||
pickFreeColor({ taken: crew.map((b) => b.color) }).color,
);
const [stone, setStone] = useState<BotStoneShape>(() =>
resolveBotStone({ stone: bot?.stone, hue: bot?.hue }),
);
const [system_prompt, setSystemPrompt] = useState(bot?.system_prompt || "");
const [join, setJoin] = useState(!bot && Boolean(workspace?.id));
@@ -79,13 +84,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
bot_id: bot.id,
name: name || bot.name,
role,
hue,
color,
stone,
system_prompt,
},
})
: await postAdminApi<{ bot_id: number }>({
path: "bots/create-bot",
body: { name, role, hue, system_prompt, workspace_id },
body: { name, role, color, stone, system_prompt, workspace_id },
});
const bot_id =
bot?.id ||
@@ -148,7 +154,8 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
<BotFormPreview
name={name}
role={role}
hue={is_coordinator ? null : hue}
color={is_coordinator ? null : color}
stone={stone}
image_media_id={image.image_media_id}
picked_src={image.data_url}
/>
@@ -182,10 +189,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
info="One line, shown under its name."
onChange={(e) => setRole(e.target.value)}
/>
<BotHuePicker
hue={hue}
<BotColourPicker
color={color}
stone={stone}
disabled={loading}
onChange={setHue}
onChange={({ color: picked, stone: picked_stone }) => {
setColor(picked);
setStone(picked_stone);
}}
/>
</>
)}
@@ -1,40 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
import BotHueOption from "./bot-hue-option";
type Props = {
hue: BotHue;
disabled?: boolean;
onChange: (hue: BotHue) => void;
};
/**
* Picks a bot's colour from tiles that show each hue on a stone.
*/
export default function BotHuePicker({ hue, disabled, onChange }: Props) {
return (
<Stack className="w-full gap-2!">
<Span className="text-xs text-ink-secondary">Colour</Span>
<Row
role="radiogroup"
aria-label="Colour"
className="w-full grid grid-cols-4 sm:grid-cols-7 gap-2!"
>
{BotHues.map((option) => (
<BotHueOption
key={option.value}
{...option}
selected={hue == option.value}
disabled={disabled}
onSelect={() => onChange(option.value)}
/>
))}
</Row>
<Span className="text-xs text-ink-secondary">
Its colour for life: how you tell it apart, even with a picture.
</Span>
</Stack>
);
}
+5 -4
View File
@@ -5,7 +5,8 @@ import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotWithLastMessage } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot";
@@ -31,7 +32,7 @@ type Props = {
* RailBotCard, which also holds the sessions.
*/
export default function BotRow({ bot, href, selected, subtitle }: Props) {
const hue = (bot.hue || null) as BotHue | null;
const stone_props = botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue });
const snippet =
bot.status_line ||
subtitle ||
@@ -52,7 +53,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
selected && "hover:bg-transparent hover:dark:bg-transparent",
)}
>
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
<BotAvatar image_media_id={bot.image_media_id} {...stone_props} face={(bot.face || "idle") as BotFace} />
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{bot.name}
@@ -67,7 +68,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
{relativeTime({ at: Number(at) })}
</Span>
) : null}
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
{bot.unread && stone_props.color ? <UnreadDot color={stone_props.color} /> : null}
</Stack>
</Link>
);
+3 -2
View File
@@ -3,7 +3,8 @@ import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
@@ -21,7 +22,7 @@ export default function LinkBotRow({ bot, linking, disabled, onLink }: Props) {
<Row className="h-14 items-center gap-3 rounded-default px-3 flex-nowrap hover:bg-sunken transition-colors duration-[var(--m-fast)]">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
/>
<Stack className="grow min-w-0 gap-0!">
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
@@ -13,7 +14,7 @@ export default function RailSeeAllStone({ bot }: Props) {
return (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
size={18}
/>
@@ -5,7 +5,8 @@ import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import UserAvatar from "@/src/components/general/user-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import {
SettingsRecordBack,
SettingsRecordSections,
@@ -91,7 +92,7 @@ export default function RailSettingsAside({ pathname }: Props) {
avatar = (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
/>
);
+17 -12
View File
@@ -1,19 +1,24 @@
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";
type Props = {
/**
* The bot's colour (`#rrggbb`), painted straight onto the dot: a free
* colour has no token to point at, so it's a fill attribute here rather
* than a `bg-*` class.
*/
color: string;
};
/**
* 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.
* The unread marker on a rail row. It keeps the bot's colour rather than
* an accent, and it never replaces the snippet or time.
*/
export default function UnreadDot({ hue }: { hue: BotHue }) {
export default function UnreadDot({ color }: Props) {
return (
<Span
className={twMerge("size-1.5 rounded-full mr-1", BotHueBgClasses[hue])}
aria-label="Unread"
role="img"
/>
<Span className="shrink-0" aria-label="Unread" role="img">
<svg width={6} height={6} viewBox="0 0 6 6" aria-hidden="true">
<circle cx={3} cy={3} r={3} fill={color} />
</svg>
</Span>
);
}
@@ -1,7 +1,8 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
import BotMessageLine from "./bot-message-line";
@@ -20,7 +21,7 @@ export default function BotMessageGroup({ bot, group }: Props) {
<Row className="w-full gap-3! flex-nowrap items-start">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
size={28}
className="-mt-1"
@@ -6,7 +6,8 @@ import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BotWithLastMessage } from "@/src/types";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import { AppContext } from "@/src/pages/__root";
@@ -35,7 +36,7 @@ export default function BotProfileModal({ bot }: Props) {
const avatar = (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
size={35}
/>
@@ -2,7 +2,8 @@ import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import { AppContext } from "@/src/pages/__root";
import { CrewBotsContext } from "../(data)/crew-bots-context";
@@ -37,7 +38,7 @@ export default function BotRefChip({ bot_id }: Props) {
>
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={18}
@@ -2,7 +2,8 @@ import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import {
BotComputerBusyStatusLine,
BotGettingMessageStatusLine,
@@ -80,7 +81,7 @@ export default function BotThinkingRow({
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={waiting ? "asleep" : "working"}
size={28}
/>
@@ -3,7 +3,8 @@ import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
@@ -30,7 +31,7 @@ export default function ComposerMentionChip({ node, selected }: ReactNodeViewPro
{bot ? (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={18}
@@ -2,7 +2,8 @@ import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { ComposerMentionItem } from "../(utils)/search-mention-items";
type Props = {
@@ -35,7 +36,7 @@ export default function ComposerMentionOption({ item, active, onPick }: Props) {
{bot ? (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={22}
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { CronJobWithNames } from "@/src/types";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
@@ -29,7 +30,7 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete
avatar={
<BotAvatar
image_media_id={cron_job.bot_image_media_id}
hue={(cron_job.bot_kind == "coordinator" ? null : cron_job.bot_hue || null) as BotHue | null}
{...botStoneProps({ color: cron_job.bot_color, stone: cron_job.bot_stone, hue: cron_job.bot_hue })}
face={(cron_job.bot_face || "idle") as BotFace}
/>
}
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BotWithLastMessage } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
@@ -23,7 +24,7 @@ export default function BotSettingsRow({ bot, busy, onDelete }: Props) {
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
/>
}
@@ -4,7 +4,8 @@ import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageSquare } from "lucide-react";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BotWithLastMessage } from "@/src/types";
import SettingsGroup from "../../../(partials)/settings-group";
@@ -29,7 +30,7 @@ export default function BotOverviewGroup({ bot }: Props) {
<Row className="w-full gap-4! flex-nowrap items-center">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
size={40}
/>
@@ -1,6 +1,7 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotWithFace } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import SettingsListRow from "../../../(partials)/settings-list-row";
import SettingsRowAction from "../../../(partials)/settings-row-action";
@@ -30,7 +31,7 @@ export default function ComputerBotRow({
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
/>
}
@@ -1,6 +1,7 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
@@ -37,7 +38,11 @@ export default function UserAccessGroups({ person, crew_bots, crew_workspaces, o
subtitle: bot.role,
href: `/admin/settings/bots/${bot.id}`,
avatar: (
<BotAvatar image_media_id={bot.image_media_id} hue={(bot.hue || null) as BotHue | null} face="idle" />
<BotAvatar
image_media_id={bot.image_media_id}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face="idle"
/>
),
}))}
onUpdated={onUpdated}
@@ -5,7 +5,8 @@ import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageSquare, NotebookPen, X } from "lucide-react";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import type { WorkspaceBot } from "@/src/types";
type Props = {
@@ -47,7 +48,7 @@ export default function WorkspaceBotRow({
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
/>
<Link
@@ -1,6 +1,7 @@
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { WorkspaceActivityItem } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
@@ -26,7 +27,7 @@ export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
avatar={
<BotAvatar
image_media_id={item.bot_image_media_id}
hue={(item.bot_hue || null) as BotHue | null}
{...botStoneProps({ color: item.bot_color, stone: item.bot_stone, hue: item.bot_hue })}
face={(item.bot_face || "idle") as BotFace}
/>
}
@@ -1,5 +1,6 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { WorkspaceBot } from "@/src/types";
@@ -22,7 +23,7 @@ export default function WorkspaceDashboardBotRow({ workspace_id, bot }: Props) {
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
{...botStoneProps({ color: bot.color, stone: bot.stone, hue: bot.hue })}
face={(bot.face || "idle") as BotFace}
/>
}
+2 -2
View File
@@ -33,13 +33,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<{ bot_id: number }
throw new AppError(`This user can only read records.`);
}
const { name, role, hue, system_prompt, workspace_id } = parseWithSchema({ schema: CreateBotRequestSchema, data: body });
const { name, role, color, stone, system_prompt, workspace_id } = parseWithSchema({ schema: CreateBotRequestSchema, data: body });
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
throw new AppError(`Workspace not found.`);
}
const bot_id = await createBot({ user_id: user.id, name, role, hue, system_prompt });
const bot_id = await createBot({ user_id: user.id, name, role, color, stone, system_prompt });
if (workspace_id) {
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
+4 -4
View File
@@ -13,8 +13,8 @@ import type {
} from "@moduletrace/bunext/types";
/**
* Saves one of the session user's bots (name, job, hue, instructions).
* Returns the bot.
* Saves one of the session user's bots (name, job, colour, stone,
* instructions). Returns the bot.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_BOTS>> = async ({ req, body }) => {
try {
@@ -32,12 +32,12 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
throw new AppError(`This user can only read records.`);
}
const { bot_id, name, role, hue, system_prompt } = parseWithSchema({
const { bot_id, name, role, color, stone, system_prompt } = parseWithSchema({
schema: UpdateBotRequestSchema,
data: body,
});
const bot = await updateBot({ user_id: user.id, bot_id, name, role, hue, system_prompt });
const bot = await updateBot({ user_id: user.id, bot_id, name, role, color, stone, system_prompt });
await logEvent({
title: "Bot changed",
+14 -3
View File
@@ -1,10 +1,10 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import {
BotHues,
BotMessageReactions,
type BotHue,
BotStoneShapes,
type BotMessageReactionEmoji,
type BotStoneShape,
} from "@/src/dict/bot-dict";
/**
@@ -115,7 +115,18 @@ export type ListBotSessionsRequest = z.infer<typeof ListBotSessionsRequestSchema
export const CreateBotRequestSchema = z.object({
name: z.string().trim().min(1, "Give the bot a name").max(40, "Keep the name under 40 characters"),
role: z.string().trim().max(120, "Keep the role to one line").default(""),
hue: z.enum(BotHues.map((h) => h.value) as [BotHue, ...BotHue[]]),
/**
* Its identity colour, `#rrggbb`.
*/
color: z
.string()
.trim()
.toLowerCase()
.regex(/^#[0-9a-f]{6}$/, "Pick a colour like #7c5cd6"),
/**
* Which flat-cut stone it's drawn on.
*/
stone: z.enum(BotStoneShapes.map((s) => s.value) as [BotStoneShape, ...BotStoneShape[]]),
system_prompt: z
.string()
.trim()
+61
View File
@@ -0,0 +1,61 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { paletteColor } from "@/src/utils/bot-color";
/**
* One-off: bots used to carry a palette `hue`, which decided both their
* colour and their stone shape. They now store a free `color` (`#rrggbb`)
* and their own `stone`, so a bot keeps the shape it always had while the
* user can pick any colour. This fills both from the old hue.
*
* Safe to run more than once: a bot that already has a colour and a stone
* is left alone. Run it with
* `bun src/scripts/move-bot-colour-to-owners.ts`, then it can be deleted
* along with the `hue` column.
*/
async function moveBotColourToOwners() {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, kind, hue, color, stone FROM bots`,
});
const bots = res.payload || [];
let moved = 0;
for (const bot of bots) {
if (!bot.id) continue;
/** The coordinator stays hue-less, whatever it carried. */
if (bot.kind == "coordinator") {
if (bot.color || bot.stone || bot.hue) {
await BunSQLite.sql({
sql: `UPDATE bots SET color = NULL, stone = 'ember', hue = NULL, updated_at = ? WHERE id = ?`,
values: [Date.now(), bot.id],
});
}
continue;
}
/**
* A bot with a colour has been through this already. A bot without
* one hasn't, so its `stone` is only the column's default and its
* hue is the truth: both come from there.
*/
if (bot.color) continue;
const color = paletteColor({ hue: bot.hue }) || paletteColor({ hue: "ember" })!;
const stone = bot.hue || "ember";
await BunSQLite.sql({
sql: `UPDATE bots SET color = ?, stone = ?, updated_at = ? WHERE id = ?`,
values: [color, stone, Date.now(), bot.id],
});
moved++;
}
console.log(`Done: ${moved} bot(s) given a colour and a stone, ${bots.length} checked.`);
}
await moveBotColourToOwners();
process.exit(0);
+7 -1
View File
@@ -13,6 +13,7 @@ import type {
BotMessageAuthor,
BotTaskStepState,
} from "@/src/dict/bot-dict";
import { paletteColor } from "@/src/utils/bot-color";
import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
@@ -49,6 +50,10 @@ type SeedTask = {
type SeedBot = {
name: string;
/**
* The palette entry it's drawn with, which gives it both its colour and
* its stone. Null for the coordinator.
*/
hue: BotHue | null;
role: string | null;
face: BotFace;
@@ -221,7 +226,8 @@ async function insertBot({
user_id,
name: bot.name,
kind: bot.hue ? "worker" : "coordinator",
hue: bot.hue || undefined,
color: paletteColor({ hue: bot.hue }) || undefined,
stone: bot.hue || undefined,
role: bot.role || undefined,
},
],
+12
View File
@@ -83,6 +83,8 @@ export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
bot_name?: string | null;
bot_kind?: string | null;
bot_hue?: string | null;
bot_color?: string | null;
bot_stone?: string | null;
bot_face?: string | null;
bot_image_media_id?: number | null;
workspace_name?: string | null;
@@ -96,6 +98,8 @@ export type TaskWithNames = BUN_SQLITE_COBBLE_TASKS & {
bot_name?: string | null;
bot_kind?: string | null;
bot_hue?: string | null;
bot_color?: string | null;
bot_stone?: string | null;
bot_face?: string | null;
bot_image_media_id?: number | null;
workspace_name?: string | null;
@@ -160,6 +164,8 @@ export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & {
bot_name?: string | null;
bot_image_media_id?: number | null;
bot_hue?: string | null;
bot_color?: string | null;
bot_stone?: string | null;
bot_face?: string | null;
};
@@ -275,6 +281,12 @@ export type BotUpdatedData = {
* change). A workspace's crew drops a bot that's no longer in it.
*/
workspace_ids?: number[];
/**
* Its colour and stone, when the push read them (a recolour, or any bot
* change): every rail row and avatar takes them without a reload.
*/
color?: string | null;
stone?: string | null;
};
/**
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import {
botColorNeedsPaperFace,
normalizeBotColor,
paletteColor,
paletteColorTitle,
resolveBotColor,
resolveBotStone,
} from "./bot-color";
describe("bot-color", () => {
test("normalizes a colour to lowercase #rrggbb", () => {
expect(normalizeBotColor({ color: " #E4572E " })).toBe("#e4572e");
});
test("expands the short form", () => {
expect(normalizeBotColor({ color: "#7C5" })).toBe("#77cc55");
});
test("refuses anything that isn't a colour", () => {
expect(normalizeBotColor({ color: "violet" })).toBeNull();
expect(normalizeBotColor({ color: "#12345" })).toBeNull();
expect(normalizeBotColor({ color: "" })).toBeNull();
expect(normalizeBotColor({ color: null })).toBeNull();
});
test("names a palette colour, and leaves a custom one unnamed", () => {
expect(paletteColorTitle({ color: "#7c5cd6" })).toBe("Violet");
expect(paletteColorTitle({ color: "#123456" })).toBeNull();
});
test("a bot's own colour wins over its old hue", () => {
expect(resolveBotColor({ color: "#123456", hue: "violet" })).toBe("#123456");
});
test("a bot saved before colours were free shows its hue's colour", () => {
expect(resolveBotColor({ hue: "teal" })).toBe("#16a394");
});
test("the coordinator has no colour", () => {
expect(resolveBotColor({})).toBeNull();
});
test("keeps its own stone, else the shape its hue used to be", () => {
expect(resolveBotStone({ stone: "rose", hue: "teal" })).toBe("rose");
expect(resolveBotStone({ hue: "teal" })).toBe("teal");
expect(resolveBotStone({})).toBe("ember");
});
test("only a very dark colour flips the face to paper", () => {
expect(botColorNeedsPaperFace({ color: "#000000" })).toBe(true);
expect(botColorNeedsPaperFace({ color: "#0e1116" })).toBe(true);
expect(botColorNeedsPaperFace({ color: "#7c5cd6" })).toBe(false);
expect(botColorNeedsPaperFace({ color: "#ffffff" })).toBe(false);
});
test("a palette entry knows its own colour", () => {
expect(paletteColor({ hue: "ember" })).toBe("#e4572e");
expect(paletteColor({ hue: "nonsense" })).toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { BotHues, BotStoneShapes, type BotStoneShape } from "@/src/dict/bot-dict";
/**
* A bot's colour is free (`#rrggbb`) and its stone shape is its own, so the
* two are stored apart. These helpers are the only place that decides what a
* bot actually renders in: the colour to fill its stone with and the shape
* to draw it on.
*/
const HexColorPattern = /^#[0-9a-f]{6}$/;
/**
* A stored colour as `#rrggbb`, or null when it isn't one. Accepts the short
* `#rgb` form someone typed, so a hand-entered value still saves.
*/
export function normalizeBotColor({ color }: { color?: string | null }): string | null {
const trimmed = (color || "").trim().toLowerCase();
if (!trimmed) return null;
const short = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
return HexColorPattern.test(trimmed) ? trimmed : null;
}
/**
* The hex a palette entry stands for, by its name.
*/
export function paletteColor({ hue }: { hue?: string | null }): string | null {
return BotHues.find((entry) => entry.value == hue)?.color || null;
}
/**
* The colour a bot renders in: its own, else the palette colour of a bot
* saved before colours were free, else (the coordinator) nothing at all.
*/
export function resolveBotColor({
color,
hue,
}: {
color?: string | null;
hue?: string | null;
}): string | null {
return normalizeBotColor({ color }) || paletteColor({ hue });
}
/**
* The stone a bot is drawn on: its own shape, else the palette shape it used
* to be tied to, else the first stone.
*/
export function resolveBotStone({
stone,
hue,
}: {
stone?: string | null;
hue?: string | null;
}): BotStoneShape {
const shape = BotStoneShapes.find((entry) => entry.value == stone)?.value;
if (shape) return shape;
const carried = BotStoneShapes.find((entry) => entry.value == hue)?.value;
return carried || BotStoneShapes[0].value;
}
/**
* Relative luminance of a `#rrggbb` colour, 0 (black) to 1 (white), so the
* stone's face can be flipped to paper on a dark body and stays readable.
*/
export function botColorLuminance({ color }: { color: string }): number {
const hex = normalizeBotColor({ color }) || "#ffffff";
const [red, green, blue] = [1, 3, 5].map((at) => {
const value = parseInt(hex.slice(at, at + 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
}
/**
* Whether the stone's eyes must be drawn in paper instead of ink. Only a
* very dark body needs it, so a mid-tone keeps the ink face every other
* stone has.
*/
export function botColorNeedsPaperFace({ color }: { color: string }): boolean {
return botColorLuminance({ color }) < 0.12;
}
/**
* The palette name a colour is, for a label ("Violet"); null for a colour
* the user made up, which the UI shows as a custom one.
*/
export function paletteColorTitle({ color }: { color?: string | null }): string | null {
const normalized = normalizeBotColor({ color });
if (!normalized) return null;
return BotHues.find((entry) => entry.color == normalized)?.title || null;
}
+34
View File
@@ -0,0 +1,34 @@
import type { BotStoneShape } from "@/src/dict/bot-dict";
import { resolveBotColor, resolveBotStone } from "./bot-color";
type Params = {
/**
* The bot's own colour. Null or missing for the coordinator.
*/
color?: string | null;
stone?: string | null;
/**
* The historical palette hue, only for a bot saved before colours were
* free. Rows from otherwise-typed queries select it as `bot_hue`.
*/
hue?: string | null;
};
type BotStoneProps = {
color: string | null;
stone: BotStoneShape;
};
/**
* The stone props for a bot row: which colour to fill it with and which
* shape to draw, whether the row carries the new columns or only the
* palette hue they replaced. Every `<BotAvatar color={...} stone={...} />`
* call site goes through this, so a row saved before colours were free
* still renders as it did.
*/
export default function botStoneProps({ color, stone, hue }: Params): BotStoneProps {
return {
color: resolveBotColor({ color, hue }),
stone: resolveBotStone({ stone, hue }),
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import pickFreeColor from "./pick-free-color";
describe("pick-free-color", () => {
test("takes the first palette colour nobody has", () => {
expect(pickFreeColor({ taken: [] })).toEqual({ color: "#e4572e", stone: "ember" });
});
test("skips the colours already taken", () => {
expect(pickFreeColor({ taken: ["#e4572e", "#e9a23b", null] })).toEqual({
color: "#7fae3c",
stone: "moss",
});
});
test("falls back to the least used colour", () => {
const everyone = ["#e4572e", "#e9a23b", "#7fae3c", "#16a394", "#3b7dd8", "#7c5cd6"];
expect(pickFreeColor({ taken: [...everyone, "#e4572e"] })).toEqual({
color: "#d6538c",
stone: "rose",
});
});
test("counts a colour however it's cased", () => {
expect(pickFreeColor({ taken: ["#E4572E"] }).color).toBe("#e9a23b");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { BotHues, type BotStoneShape } from "@/src/dict/bot-dict";
type Params = {
/**
* The colours already in use, so the new bot differs from the crew.
*/
taken: (string | null | undefined)[];
};
/**
* The colour a new bot starts with: the first palette colour nobody on the
* crew has yet, else the least used one. It comes with that palette entry's
* stone, so a new bot looks like every bot did before colours were free.
* The user can then pick any colour they like.
*/
export default function pickFreeColor({ taken }: Params): {
color: string;
stone: BotStoneShape;
} {
const counts = new Map<string, number>(BotHues.map((entry) => [entry.color, 0]));
for (const color of taken) {
const key = (color || "").toLowerCase();
if (counts.has(key)) counts.set(key, counts.get(key)! + 1);
}
const picked = [...BotHues].sort(
(a, b) => counts.get(a.color)! - counts.get(b.color)!,
)[0]!;
return { color: picked.color, stone: picked.value };
}
-14
View File
@@ -1,14 +0,0 @@
import { describe, expect, test } from "bun:test";
import pickFreeHue from "./pick-free-hue";
describe("pickFreeHue", () => {
test("takes the first hue nobody has", () => {
expect(pickFreeHue({ taken: [] })).toBe("ember");
expect(pickFreeHue({ taken: ["ember", null, "amber"] })).toBe("moss");
});
test("falls back to the least used hue", () => {
const all = ["ember", "amber", "moss", "teal", "cobalt", "violet", "rose"];
expect(pickFreeHue({ taken: [...all, ...all.filter((h) => h != "teal")] })).toBe("teal");
});
});
-22
View File
@@ -1,22 +0,0 @@
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
type Params = {
/**
* Hues the crew already has.
*/
taken: (string | null | undefined)[];
};
/**
* The first hue nobody on the crew has yet, else the least used one, so a
* new bot is told apart at a glance.
*/
export default function pickFreeHue({ taken }: Params): BotHue {
const counts = new Map<string, number>(BotHues.map((hue) => [hue.value, 0]));
for (const hue of taken) {
if (hue && counts.has(hue)) counts.set(hue, counts.get(hue)! + 1);
}
return [...BotHues].sort((a, b) => counts.get(a.value)! - counts.get(b.value)!)[0]!.value;
}