Fold the conversation header's icons into one menu

The header's sessions, new session, computers, models and settings icons
become rows in a single dropdown off an ellipsis on the right, so the
header keeps one control whatever width it has. A row that opens a modal
closes the menu first, so the modal never sits behind an open menu, and
each row shows what it acts on (the computer's name, the text model).
This commit is contained in:
2026-10-21 16:06:21 +01:00
parent 361b8add9b
commit 40cf101b38
7 changed files with 461 additions and 134 deletions
@@ -1,4 +1,4 @@
import { useContext, useState } from "react";
import { useContext, useEffect, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
@@ -8,6 +8,7 @@ import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import HeaderMenuItem from "./header-menu-item";
type Props = {
bot: BotWithLastMessage;
@@ -16,35 +17,75 @@ type Props = {
* changes the workspace's default for every bot there.
*/
workspace_name?: string | null;
/**
* A row in the conversation header's menu instead of an icon beside it.
*/
menu?: boolean;
/**
* The computer the bot is on, on its menu row.
*/
menu_hint?: string | null;
/**
* A menu row closing the menu as it opens the modal.
*/
on_open?: () => void;
};
/**
* The header's computer icon, beside the model icon: opens what the bot's
* The header's computer icon (or its menu row): opens what the bot's
* computers are, the one it's on checked, and with more than one,
* switches it. Each can be opened in the viewer.
*/
export default function BotComputersModal({ bot, workspace_name }: Props) {
export default function BotComputersModal({
bot,
workspace_name,
menu,
menu_hint,
on_open,
}: Props) {
const { computers, switching_id, error, switchComputer, openComputer } =
useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
useEffect(() => {
if (open) on_open?.();
}, [open]);
if (!current?.id) return null;
const name = bot.name || "This bot";
const can_switch = computers.length > 1;
const title = can_switch
? `Switch ${name}'s computer`
: `About ${name}'s computer`;
return (
<Modal
target={
<HeaderIconButton
icon={Monitor}
title={can_switch ? `Switch ${name}'s computer` : `About ${name}'s computer`}
loading={Boolean(switching_id)}
/>
menu ? (
<HeaderMenuItem
icon={Monitor}
title="Computer"
hint={
menu_hint ||
current.name ||
`Computer ${current.id}`
}
description={title}
loading={Boolean(switching_id)}
/>
) : (
<HeaderIconButton
icon={Monitor}
title={title}
loading={Boolean(switching_id)}
/>
)
}
targetWrapperProps={{ className: "shrink-0" }}
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
@@ -12,6 +12,7 @@ import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import HeaderMenuItem from "./header-menu-item";
import useHarnessOverview from "../(hooks)/use-harness-overview";
type Props = {
@@ -20,21 +21,40 @@ type Props = {
* The workspace the conversation is in: the modal edits its models.
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
/**
* A row in the conversation header's menu instead of an icon beside it.
*/
menu?: boolean;
/**
* The text model the bot runs on, on its menu row.
*/
menu_hint?: string | null;
/**
* A menu row closing the menu as it opens the modal.
*/
on_open?: () => void;
onSaved: (harnesses: HarnessOverview[]) => void;
};
/**
* The header's model icon: opens every model the bot uses (text, media
* reading, transcription, speech, images) in the Models settings form. On
* the coordinator that form is the defaults, for super admins only; a
* worker's (or a workspace's) models are for super admins and admins.
* Everyone else gets the icon disabled, its title saying why.
* The header's model icon (or its menu row): opens every model the bot uses
* (text, media reading, transcription, speech, images) in the Models
* settings form. On the coordinator that form is the defaults, for super
* admins only; a worker's (or a workspace's) models are for super admins
* and admins. Everyone else gets it disabled, its title saying why.
*
* The models load when the pointer or focus reaches the icon, or it
* opens (`useHarnessOverview`), shown at once from the last time when
* this device has them.
* The models load when the pointer or focus reaches it, or it opens
* (`useHarnessOverview`), shown at once from the last time when this device
* has them.
*/
export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
export default function BotModelsModal({
bot,
workspace,
menu,
menu_hint,
on_open,
onSaved,
}: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
/**
@@ -71,26 +91,31 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
useEffect(() => {
if (!open) setDirty(false);
else on_open?.();
}, [open]);
if (!bot.id) return null;
const target = (
<HeaderIconButton
const title = can_edit
? in_workspace
? `Change ${workspace?.name || "the workspace"}'s models`
: is_coordinator
? "Change the default models"
: `Change ${name}'s models`
: is_coordinator
? `${name} uses the default models. A super admin can change them in Settings, under Models.`
: `An admin can change the models ${name} uses.`;
const target = menu ? (
<HeaderMenuItem
icon={CpuAi}
title={
can_edit
? in_workspace
? `Change ${workspace?.name || "the workspace"}'s models`
: is_coordinator
? "Change the default models"
: `Change ${name}'s models`
: is_coordinator
? `${name} uses the default models. A super admin can change them in Settings, under Models.`
: `An admin can change the models ${name} uses.`
}
title="Models"
hint={menu_hint}
description={title}
disabled={!can_edit}
/>
) : (
<HeaderIconButton icon={CpuAi} title={title} disabled={!can_edit} />
);
if (!can_edit) return target;
@@ -99,7 +124,7 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
<Modal
target={target}
targetWrapperProps={{
className: "shrink-0",
className: menu ? "w-full" : "shrink-0",
onPointerEnter: () => setWarm(true),
onFocus: () => setWarm(true),
}}
@@ -0,0 +1,131 @@
import { useContext } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Stack from "@moduletrace/twui/layout/Stack";
import Divider from "@moduletrace/twui/layout/Divider";
import { History, Settings } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import HeaderMenuItem from "./header-menu-item";
import NewSessionButton from "./new-session-button";
type Props = {
bot: BotWithLastMessage;
/**
* The workspace the conversation is in: its name shows under the
* bot's, and the models modal edits its models.
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
/**
* A bot message arrived in another session of this conversation.
*/
has_other_unread?: boolean;
/**
* The computer the bot is on, shown on its row.
*/
computer_name?: string | null;
/**
* The text model the bot runs on, shown on its row.
*/
model_name?: string | null;
/**
* After the models modal saved, so the header shows the new model.
*/
on_model_saved: (harnesses: HarnessOverview[]) => void;
/**
* A row is opening something: the menu closes, leaving only that.
*/
on_open: () => void;
};
/**
* The conversation header's menu: the sessions, a new session, the bot's
* computer, its models and its settings. A row that opens a modal closes
* the menu first, so the modal never sits behind an open menu.
*/
export default function ConversationHeaderMenu({
bot,
workspace,
has_other_unread,
computer_name,
model_name,
on_model_saved,
on_open,
}: Props) {
const { pageProps } = useContext(AppContext);
const can_open_settings =
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
return (
<Paper
role="menu"
className="min-w-[220px] p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
>
<Stack className="w-full gap-0! items-stretch">
<BotSessionsModal
bot={bot}
workspace_id={workspace?.id || null}
has_other_unread={has_other_unread}
target={
<HeaderMenuItem
icon={History}
title="Chat sessions"
hint={has_other_unread ? "New" : undefined}
description={
has_other_unread
? "Sessions: new messages in another session"
: "Sessions"
}
/>
}
target_wrapper_props={{ className: "w-full" }}
on_open={on_open}
/>
<NewSessionButton
menu
bot_id={Number(bot.id)}
workspace_id={workspace?.id || null}
on_open={on_open}
/>
<BotComputersModal
menu
bot={bot}
workspace_name={
workspace?.id
? workspace.name || "This workspace"
: null
}
menu_hint={computer_name}
on_open={on_open}
/>
<BotModelsModal
key={bot.id}
menu
bot={bot}
workspace={workspace}
menu_hint={model_name}
on_open={on_open}
onSaved={on_model_saved}
/>
</Stack>
{can_open_settings ? (
<>
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
<HeaderMenuItem
icon={Settings}
title="Settings"
description={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
</>
) : null}
</Paper>
);
}
@@ -2,25 +2,20 @@ import { useContext, useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import { CpuAi, Layers, Monitor, Settings } from "lucide-react";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { CpuAi, EllipsisVertical, Monitor } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
import HeaderMetaItem from "./header-meta-item";
import Span from "@moduletrace/twui/layout/Span";
import Img from "@moduletrace/twui/layout/Img";
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotProfileModal from "./bot-profile-modal";
import ConversationHeaderMenu from "./conversation-header-menu";
import HeaderMetaItem from "./header-meta-item";
type Props = {
bot: BotWithLastMessage;
@@ -33,10 +28,11 @@ type Props = {
};
/**
* 64px header. Under the name, what the bot runs on: its text model and
* provider, and the computer it's on. On the right, ghost icon buttons
* that open the computers and models modals. Kept here so a save or a
* switch shows under the name at once.
* 64px header. Under the name, what the bot runs on: its model and the
* computer it's on. On the right, one menu holding everything the header
* used to show as icons: sessions, a new session, the computer, the models
* and the bot's settings. Kept here so a save or a switch shows under the
* name at once.
*/
export default function ConversationHeader({
bot,
@@ -46,47 +42,34 @@ export default function ConversationHeader({
const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext);
const [model, setModel] = useState(pageProps?.bot_text_model);
const { open: open_menu, setOpen: set_open_menu } = useStatus();
const computer = computers[0];
const can_open_settings =
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
const computer_name = computer?.id
? computer.name || `Computer ${computer.id}`
: null;
return (
<Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<BotProfileModal bot={bot} />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full line-clamp-1">
{bot.name}
</H1>
<Row className="flex md:hidden min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{workspace?.id ? (
<Row className="gap-2! mb-0.5">
<WorkspaceAvatar
image_media_id={workspace?.image_media_id}
size="xs"
/>
<Span size="smaller">
{workspace.name || "Workspace"}
</Span>
</Row>
) : null}
</Row>
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{workspace?.id ? (
<HeaderMetaItem
image={
<WorkspaceAvatar
image_media_id={workspace?.image_media_id}
size="xs"
/>
}
label="Workspace"
text={workspace.name || "Workspace"}
{workspace?.id ? (
<Row className="min-w-0 w-full gap-2! flex-nowrap">
<WorkspaceAvatar
image_media_id={workspace.image_media_id}
size="xs"
/>
) : null}
<Span size="smaller" className="truncate">
{workspace.name || "Workspace"}
</Span>
</Row>
) : null}
<Row className="min-w-0 w-full gap-x-1! md:gap-x-3! gap-y-0! flex-nowrap">
<HeaderMetaItem
icon={CpuAi}
label="Model"
@@ -96,39 +79,42 @@ export default function ConversationHeader({
: "No model set up"
}
/>
{computer?.id ? (
{computer_name ? (
<HeaderMetaItem
icon={Monitor}
label="Computer"
text={computer.name || `Computer ${computer.id}`}
text={computer_name}
/>
) : null}
</Row>
</Stack>
<Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
<BotSessionsModal
bot={bot}
workspace_id={workspace?.id || null}
has_other_unread={has_other_unread}
/>
<NewSessionButton
bot_id={Number(bot.id)}
workspace_id={workspace?.id || null}
/>
<BotComputersModal
bot={bot}
workspace_name={
workspace?.id
? workspace.name || "This workspace"
: null
<Row className="ml-auto shrink-0 flex-nowrap justify-end">
<Dropdown
position="bottom-right"
externalOpen={open_menu}
externalSetOpen={set_open_menu}
target={
<Button
title="Bot menu"
aria-label="Bot menu"
aria-haspopup="menu"
type="button"
variant="ghost"
color="text"
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<EllipsisVertical size={18} aria-hidden />
</Button>
}
/>
<span className="hidden md:inline-flex">
<BotModelsModal
key={bot.id}
>
<ConversationHeaderMenu
bot={bot}
workspace={workspace}
onSaved={(harnesses) => {
has_other_unread={has_other_unread}
computer_name={computer_name}
model_name={model?.name}
on_model_saved={(harnesses) => {
const current = harnesses.find(
(h) => h.harness == "text",
)?.current;
@@ -142,17 +128,9 @@ export default function ConversationHeader({
: null,
);
}}
on_open={() => set_open_menu(false)}
/>
</span>
{can_open_settings ? (
<span className="hidden md:inline-flex">
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
</span>
) : null}
</Dropdown>
</Row>
</Row>
);
@@ -0,0 +1,104 @@
import type { LucideIcon } from "lucide-react";
import Loading from "@moduletrace/twui/elements/Loading";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
const RowClassName = "w-full h-9 gap-2.5! flex-nowrap items-center px-3";
type Props = {
icon: LucideIcon;
/**
* The row's label.
*/
title: string;
/**
* A short value shown on the right, e.g. the computer's name.
*/
hint?: string | null;
/**
* The tooltip, e.g. why the row is disabled.
*/
description?: string;
/**
* For a row that opens a page; it navigates rather than acting.
*/
href?: string;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
};
/**
* One row in the conversation header's menu: an icon, its label and, when
* given, a short value. It's a link, an action, or a disabled row.
*/
export default function HeaderMenuItem({
icon: Icon,
title,
hint,
description,
href,
onClick,
disabled,
loading,
}: Props) {
const content = (
<>
{loading ? (
<Loading size="small" className="shrink-0" />
) : (
<Icon size={16} aria-hidden className="shrink-0" />
)}
<Span className="min-w-0 grow truncate text-sm leading-5">
{title}
</Span>
{hint ? (
<Span className="shrink-0 truncate text-[11px] leading-4 text-ink-secondary">
{hint}
</Span>
) : null}
</>
);
if (href && !disabled) {
return (
<Link
href={href}
role="menuitem"
title={description || title}
className="block w-full border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
>
<Row className={`${RowClassName} text-ink`}>{content}</Row>
</Link>
);
}
const actionable = !disabled && !loading;
return (
<Row
role="menuitem"
tabIndex={actionable ? 0 : -1}
aria-disabled={disabled}
title={description || title}
onClick={() => {
if (actionable) onClick?.();
}}
onKeyDown={(event) => {
if (!actionable) return;
if (event.key == "Enter" || event.key == " ") {
event.preventDefault();
onClick?.();
}
}}
className={`${RowClassName} ${
actionable
? "text-ink cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
: "text-ink-disabled cursor-not-allowed"
}`}
>
{content}
</Row>
);
}
@@ -2,41 +2,73 @@ import { useState } from "react";
import { MessageSquarePlus } from "lucide-react";
import postSessionApi from "@/src/functions/frontend/post-session-api";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import HeaderMenuItem from "./header-menu-item";
type Props = {
bot_id: number;
workspace_id?: number | null;
/**
* Shown as a row in the conversation header's menu instead of an icon
* beside it.
*/
menu?: boolean;
/**
* A menu row closing the menu before it starts the session.
*/
on_open?: () => void;
};
/**
* Beside the sessions icon: closes this session and starts a fresh one
* that carries a summary of it, then shows it.
* Closes this session and starts a fresh one that carries a summary of
* it, then shows it.
*/
export default function NewSessionButton({ bot_id, workspace_id }: Props) {
export default function NewSessionButton({
bot_id,
workspace_id,
menu,
on_open,
}: Props) {
const [loading, setLoading] = useState(false);
const start = async () => {
setLoading(true);
const res = await postSessionApi({
path: "new-session",
body: { bot_id, workspace_id },
}).catch(() => null);
if (res?.success) {
window.location.reload();
return;
}
setLoading(false);
window.alert(res?.msg || "Couldn't start a new session.");
};
if (menu) {
return (
<HeaderMenuItem
icon={MessageSquarePlus}
title="New session"
loading={loading}
disabled={loading}
onClick={() => {
on_open?.();
start();
}}
/>
);
}
return (
<HeaderIconButton
icon={MessageSquarePlus}
title="New session"
loading={loading}
disabled={loading}
onClick={async () => {
setLoading(true);
const res = await postSessionApi({
path: "new-session",
body: { bot_id, workspace_id },
}).catch(() => null);
if (res?.success) {
window.location.reload();
return;
}
setLoading(false);
window.alert(res?.msg || "Couldn't start a new session.");
}}
onClick={start}
/>
);
}