From 40cf101b38a670cb45ee5fc126ab8a0a90666b42 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 21 Oct 2026 16:06:21 +0100 Subject: [PATCH] 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). --- src/components/general/bot-sessions-modal.tsx | 18 ++- .../(partials)/bot-computers-modal.tsx | 59 ++++++-- .../[bot_id]/(partials)/bot-models-modal.tsx | 71 ++++++--- .../(partials)/conversation-header-menu.tsx | 131 ++++++++++++++++ .../(partials)/conversation-header.tsx | 142 ++++++++---------- .../[bot_id]/(partials)/header-menu-item.tsx | 104 +++++++++++++ .../(partials)/new-session-button.tsx | 70 ++++++--- 7 files changed, 461 insertions(+), 134 deletions(-) create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx diff --git a/src/components/general/bot-sessions-modal.tsx b/src/components/general/bot-sessions-modal.tsx index e7fbe97..de8ab0c 100644 --- a/src/components/general/bot-sessions-modal.tsx +++ b/src/components/general/bot-sessions-modal.tsx @@ -1,4 +1,9 @@ -import { useState, type ComponentProps, type ReactNode } from "react"; +import { + useEffect, + useState, + type ComponentProps, + type ReactNode, +} from "react"; import Input from "@moduletrace/twui/form/Input"; import Modal from "@moduletrace/twui/elements/Modal"; import Button from "@moduletrace/twui/layout/Button"; @@ -25,6 +30,10 @@ type Props = { */ target?: ReactNode; target_wrapper_props?: ComponentProps["targetWrapperProps"]; + /** + * Runs as it opens, e.g. closing the menu it was opened from. + */ + on_open?: () => void; }; /** @@ -39,6 +48,7 @@ export default function BotSessionsModal({ has_other_unread, target, target_wrapper_props, + on_open, }: Props) { const [open, setOpen] = useState(false); const sessions = useBotSessions({ @@ -48,6 +58,12 @@ export default function BotSessionsModal({ }); const name = bot.name || "the bot"; + useEffect(() => { + if (open) { + on_open?.(); + } + }, [open]); + return ( 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 ( + menu ? ( + + ) : ( + + ) } - targetWrapperProps={{ className: "shrink-0" }} + targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }} open={open} setOpen={setOpen} className="max-w-lg bg-paper p-6" diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx index 6adba43..81b7863 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx @@ -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 = ( - + ) : ( + ); if (!can_edit) return target; @@ -99,7 +124,7 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) { setWarm(true), onFocus: () => setWarm(true), }} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx new file mode 100644 index 0000000..b36cffa --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx @@ -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 ( + + + + } + target_wrapper_props={{ className: "w-full" }} + on_open={on_open} + /> + + + + + {can_open_settings ? ( + <> + + + + ) : null} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx index c931bd9..5880dc2 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx @@ -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 ( -

+

{bot.name}

- - {workspace?.id ? ( - - - - {workspace.name || "Workspace"} - - - ) : null} - - - {workspace?.id ? ( - - } - label="Workspace" - text={workspace.name || "Workspace"} + {workspace?.id ? ( + + - ) : null} + + {workspace.name || "Workspace"} + + + ) : null} + + - {computer?.id ? ( + {computer_name ? ( ) : null}
- - - - + + + } - /> - - + { + 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)} /> - - {can_open_settings ? ( - - - - ) : null} +
); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx b/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx new file mode 100644 index 0000000..cd6bbe2 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx @@ -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 ? ( + + ) : ( + + )} + + {title} + + {hint ? ( + + {hint} + + ) : null} + + ); + + if (href && !disabled) { + return ( + + {content} + + ); + } + + const actionable = !disabled && !loading; + + return ( + { + 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} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/new-session-button.tsx b/src/pages/admin/bots/[bot_id]/(partials)/new-session-button.tsx index 14851ce..f454735 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/new-session-button.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/new-session-button.tsx @@ -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 ( + { + on_open?.(); + start(); + }} + /> + ); + } + return ( { - 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} /> ); }