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:
@@ -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<typeof Modal>["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 (
|
||||
<Modal
|
||||
target={
|
||||
|
||||
@@ -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
|
||||
menu ? (
|
||||
<HeaderMenuItem
|
||||
icon={Monitor}
|
||||
title={can_switch ? `Switch ${name}'s computer` : `About ${name}'s computer`}
|
||||
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,15 +91,12 @@ 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
|
||||
icon={CpuAi}
|
||||
title={
|
||||
can_edit
|
||||
const title = can_edit
|
||||
? in_workspace
|
||||
? `Change ${workspace?.name || "the workspace"}'s models`
|
||||
: is_coordinator
|
||||
@@ -87,10 +104,18 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
: `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.`
|
||||
}
|
||||
: `An admin can change the models ${name} uses.`;
|
||||
|
||||
const target = menu ? (
|
||||
<HeaderMenuItem
|
||||
icon={CpuAi}
|
||||
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">
|
||||
<Row className="min-w-0 w-full gap-2! flex-nowrap">
|
||||
<WorkspaceAvatar
|
||||
image_media_id={workspace?.image_media_id}
|
||||
image_media_id={workspace.image_media_id}
|
||||
size="xs"
|
||||
/>
|
||||
<Span size="smaller">
|
||||
<Span size="smaller" className="truncate">
|
||||
{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"}
|
||||
/>
|
||||
) : 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,26 +2,35 @@ 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);
|
||||
|
||||
return (
|
||||
<HeaderIconButton
|
||||
icon={MessageSquarePlus}
|
||||
title="New session"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
const start = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await postSessionApi({
|
||||
@@ -36,7 +45,30 @@ export default function NewSessionButton({ bot_id, workspace_id }: Props) {
|
||||
|
||||
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={start}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user