Let crew managers edit a bot's workspace instructions where they work with it

A bot's instructions and memory for one workspace were only reachable
from the workspace's settings. The editor is now shared and also opens
from the conversation menu in that workspace ("Instructions here",
loaded on open through api/admin/workspaces/workspace-bot) and from the
workspace rows on the bot's settings page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 13:17:32 +01:00
co-authored by Claude Opus 5.5
parent 84f8c0cac8
commit 2d85177c59
14 changed files with 431 additions and 87 deletions
@@ -0,0 +1,14 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { WorkspaceBotRequest } from "@/src/schemas/workspace-schema";
import type { WorkspaceBotInstructions } from "@/src/types";
/**
* A bot's instructions and memory for one workspace, for its editor.
*/
export default async function fetchWorkspaceBotInstructions({ workspace_id, bot_id }: WorkspaceBotRequest) {
return await fetchApi<WorkspaceBotRequest, APIResponseObject<WorkspaceBotInstructions>>(
`/api/admin/workspaces/workspace-bot`,
{ method: "POST", body: { workspace_id, bot_id } },
);
}
@@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import type { WorkspaceBotInstructions } from "@/src/types";
import fetchWorkspaceBotInstructions from "../(functions)/fetch-workspace-bot-instructions";
type Params = {
workspace_id: number;
bot_id: number;
/**
* Loads each time this turns true (the editor opens), so it starts from
* what was last saved, the bot's own edits included.
*/
enabled: boolean;
};
/**
* The bot's instructions and memory for this workspace, loaded when their
* editor opens: page props leave them out.
*/
export default function useWorkspaceBotInstructions({ workspace_id, bot_id, enabled }: Params) {
const [instructions, setInstructions] = useState<WorkspaceBotInstructions>();
const [error, setError] = useState<string>();
useEffect(() => {
/** Cleared on close, so the next open never shows an old copy. */
setInstructions(undefined);
setError(undefined);
if (!enabled || !workspace_id || !bot_id) return;
let cancelled = false;
fetchWorkspaceBotInstructions({ workspace_id, bot_id })
.then((res) => {
if (cancelled) return;
if (res.success && res.singleRes) {
setInstructions(res.singleRes);
} else {
setError(res.msg || "Couldn't load the instructions.");
}
})
.catch(() => {
if (!cancelled) setError("Couldn't load the instructions.");
});
return () => {
cancelled = true;
};
}, [enabled, workspace_id, bot_id]);
return { instructions, error };
}
@@ -70,7 +70,7 @@ export default function BotProfileModal({ bot }: Props) {
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{bot.kind == "coordinator"
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
: `${name}'s core instructions apply everywhere. What it does in one workspace is under Instructions here in the menu of its conversation there.`}
</Span>
</Stack>
{!open ? null : system_prompt != undefined ? (
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Loading from "@moduletrace/twui/elements/Loading";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { NotebookPen } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import WorkspaceBotForm from "@/src/components/general/workspace-bot-form";
import useWorkspaceBotInstructions from "../(hooks)/use-workspace-bot-instructions";
import HeaderMenuItem from "./header-menu-item";
type Props = {
bot: BotWithLastMessage;
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
on_open?: () => void;
};
/**
* The header menu's row, in a workspace conversation, that opens what the
* bot has for this workspace alone: its instructions here and its memory
* here (crew managers). Both load when it opens, so it shows the bot's
* own latest edits.
*/
export default function BotWorkspaceInstructionsModal({ bot, workspace, on_open }: Props) {
const [open, setOpen] = useState(false);
const name = bot.name || "This bot";
const place = workspace.name || "this workspace";
const { instructions, error } = useWorkspaceBotInstructions({
workspace_id: Number(workspace.id),
bot_id: Number(bot.id),
enabled: open,
});
useEffect(() => {
if (open) on_open?.();
}, [open]);
return (
<Modal
target={
<HeaderMenuItem
icon={NotebookPen}
title="Instructions here"
description={`${name}'s instructions and memory in ${place}`}
/>
}
targetWrapperProps={{ className: "w-full" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{`${name} in ${place}`}</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`On top of ${name}'s core instructions, which apply everywhere. These apply only in ${place}, and ${name} updates them itself as it learns how this workspace works.`}
</Span>
</Stack>
{!open ? null : instructions ? (
<WorkspaceBotForm
workspace_id={Number(workspace.id)}
bot_id={Number(bot.id)}
bot_name={bot.name}
system_prompt={instructions.system_prompt}
memory={instructions.memory}
onCancel={() => setOpen(false)}
onSaved={() => setOpen(false)}
/>
) : error ? (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">{error}</Span>
) : (
<Stack className="w-full py-10 items-center">
<Loading />
</Stack>
)}
</Modal>
);
}
@@ -15,6 +15,7 @@ import BotTasksModal from "./bot-tasks-modal";
import BotRoutinesModal from "./bot-routines-modal";
import BotModelsModal from "./bot-models-modal";
import BotAdminMenuItem from "./bot-admin-menu-item";
import BotWorkspaceInstructionsModal from "./bot-workspace-instructions-modal";
import HeaderMenuItem from "./header-menu-item";
import NewSessionButton from "./new-session-button";
import CopyChatMenuItem from "./copy-chat-menu-item";
@@ -56,7 +57,8 @@ type Props = {
/**
* The conversation header's menu: the sessions, a new session, copying
* the session, the bot's computer, its models, in a workspace making it the admin there, its
* the session, the bot's computer, its models, in a workspace its
* instructions there and making it the admin there, its
* settings and, while it works, stopping it. A
* row that opens a modal closes the menu first, so the modal never sits
* behind an open menu.
@@ -77,6 +79,13 @@ export default function ConversationHeaderMenu({
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
/**
* A bot's instructions for one workspace, only there, for crew
* managers (the coordinator's too: only the super admin sees it).
*/
const can_edit_here = Boolean(
workspace?.id && canManageCrew({ user_types: pageProps?.user_types }),
);
/**
* Admin is per workspace, so only in one, for a worker, and only for
* those who hand out roles.
@@ -161,6 +170,13 @@ export default function ConversationHeaderMenu({
on_open={on_open}
onSaved={on_model_saved}
/>
{can_edit_here && workspace?.id ? (
<BotWorkspaceInstructionsModal
bot={bot}
workspace={workspace}
on_open={on_open}
/>
) : null}
{can_set_admin && workspace?.id ? (
<BotAdminMenuItem
bot_id={Number(bot.id)}