Show a newly created bot in the rail at once

The rail was built from page props and only knew bots it already had, so a
bot the coordinator created did not appear until a reload.

create_bot and add_bot_to_workspace now push server:bot-created
(BotCreatedData) to everyone who sees the bot: its rail row, the
workspaces it is in, and whether it belongs in that user's own chat list.
useLiveBots inserts it, after the pinned coordinator outside a workspace
and only when the open workspace is one it joined. liveRelay pushes created
bots too, so a bot added by another process still shows.
This commit is contained in:
2026-10-19 12:17:56 +01:00
parent c1cb313bd0
commit fccaa6e280
9 changed files with 180 additions and 3 deletions
+59
View File
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
import isBotInOwnChat from "@/src/functions/backend/db/bots/is-bot-in-own-chat";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot: BUN_SQLITE_COBBLE_BOTS;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the bot.
*/
users?: Set<string>;
};
/**
* Pushes `server:bot-created` to everyone who sees a bot that just joined
* the crew or a workspace: its rail row, the workspaces it's in, and
* whether it also belongs in that user's own chat list.
*/
export default async function pushBotCreated({ bot, users }: Params) {
if (!bot.id) return;
const bot_id = Number(bot.id);
const workspace_ids = (
(
await BunSQLite.sql<{ workspace_id: number }>({
sql: `SELECT workspace_id FROM workspace_bots WHERE bot_id = ?`,
values: [bot_id],
})
).payload || []
).map((row) => Number(row.workspace_id));
const data: BotWithLastMessage = {
...bot,
unread: 0,
last_activity_at: null,
last_message_text: null,
last_message_at: null,
};
for (const user_id of await grabBotAudience({ bot_id })) {
if (users && !users.has(String(user_id))) continue;
sendToUser({
user_id,
data: {
event: "server:bot-created",
data: {
bot: data,
workspace_ids,
in_own_chat: await isBotInOwnChat({ bot_id, user_id }),
},
},
});
}
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* Whether a bot belongs in a user's own chat list (`chatBotsSql`), as
* opposed to only a workspace they're in. Used to tell a client which
* rail a newly created bot shows up in.
*/
export default async function isBotInOwnChat({ bot_id, user_id }: Params) {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<{ present: number }>({
sql: `SELECT 1 AS present FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [bot_id, ...access.values],
});
return Boolean(res.payload?.[0]?.present);
}
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import pushBotCreated from "@/src/bots-queue/push-bot-created";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import type { HarnessTool } from "../../types";
@@ -74,6 +75,8 @@ export default function addBotToWorkspaceTool({
bot_id: worker.id,
});
await pushBotCreated({ bot: worker });
return `${worker.name} joined ${workspace.name}.`;
},
};
+13
View File
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
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";
@@ -81,6 +82,18 @@ export default function createBotTool({
await addBotToWorkspace({ user_id, workspace_id: target.id, bot_id });
}
const created =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ?`,
values: [bot_id],
})
).payload?.[0] || null;
if (created) {
await pushBotCreated({ bot: created });
}
const where = target?.name ? ` and joins ${target.name}` : "";
return `${args.name} is on the crew (bot #${bot_id})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."}`;
+27 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotUpdatedData, BotWithLastMessage } from "@/src/types";
import type { BotCreatedData, BotUpdatedData, BotWithLastMessage } from "@/src/types";
import useServerEvent from "./use-server-event";
type Params = {
@@ -23,6 +23,32 @@ type Params = {
export default function useLiveBots({ bots, open_bot_id, workspace_id = null }: Params) {
const [live, setLive] = useState(bots);
useServerEvent({
event: "server:bot-created",
onData: (data: BotCreatedData) => {
/**
* In a workspace only its own crew is listed; outside one, the
* bots in the user's own chat (a member doesn't get a bot that
* only joined a workspace).
*/
const here = workspace_id
? data.workspace_ids.includes(Number(workspace_id))
: data.in_own_chat;
if (!here) return;
setLive((prev) => {
if (prev.some((bot) => bot.id == data.bot.id)) return prev;
/** The coordinator stays pinned first; a new worker is the latest. */
const first_worker = prev.findIndex((bot) => bot.kind != "coordinator");
const at = first_worker < 0 ? prev.length : first_worker;
return [...prev.slice(0, at), data.bot, ...prev.slice(at)];
});
},
});
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) =>
+5 -1
View File
@@ -3,6 +3,7 @@ import { AppData } from "@/src/data/app-data";
import { logError } from "@/src/functions/backend/logger";
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
import relayChangedBots from "./relay-changed-bots";
import relayCreatedBots from "./relay-created-bots";
import relayNewBotMessages from "./relay-new-bot-messages";
import relayNewJobUpdates from "./relay-new-job-updates";
import type { LiveRelayCursor } from "./types";
@@ -50,7 +51,10 @@ export default async function liveRelay() {
* Overlap one interval, so a change written in the same
* millisecond as the last look isn't missed.
*/
await relayChangedBots({ since: cursor.bots_at - AppData.LiveRelayPollMs, users });
const bots_since = cursor.bots_at - AppData.LiveRelayPollMs;
await relayCreatedBots({ since: bots_since, users });
await relayChangedBots({ since: bots_since, users });
cursor.bots_at = now;
} catch (error) {
await logError({ title: `Live relay failed`, error }).catch(() => {});
+33
View File
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import pushBotCreated from "@/src/bots-queue/push-bot-created";
type Params = {
/**
* Unix ms: bots created since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* Pushes `server:bot-created` for every bot created since `since` to the
* connected users who see it (`pushBotCreated`), so a bot another process
* added still shows in the rail. Pushes are idempotent, so overlapping
* windows only repeat them.
*/
export default async function relayCreatedBots({ since, users }: Params) {
if (!users.size) return;
const bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE created_at > ?`,
values: [since],
})
).payload || [];
for (const bot of bots) {
await pushBotCreated({ bot, users });
}
}
+15
View File
@@ -124,6 +124,16 @@ export type BotJobUpdateData = BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & {
session_id: number | null;
};
/**
* `server:bot-created` data: a bot the user can now see, the workspaces
* it's in, and whether it belongs in their own chat list (the main rail).
*/
export type BotCreatedData = {
bot: BotWithLastMessage;
workspace_ids: number[];
in_own_chat: boolean;
};
/**
* `server:bot-updated` data: the bot's new face and status line, and its
* active task (null when there is none).
@@ -535,6 +545,11 @@ export const WebSocketEvents = [
* `bot_messages` row)
*/
"server:bot-message",
/**
* A bot joined the crew or one of the user's workspaces (`data`:
* `BotCreatedData`)
*/
"server:bot-created",
/**
* A bot's face or status line changed (`data`: `BotUpdatedData`)
*/