diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 88596018..b65a8a00 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-close", "core:window:allow-set-overlay-icon", + "core:webview:allow-set-webview-zoom", "updater:default" ] } diff --git a/app/src/App.tsx b/app/src/App.tsx index cd9f0390..cf384c2f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -6,11 +6,13 @@ import type { Host } from "./lib/host"; import { TitleBar } from "./shell/TitleBar"; import { useThemeRoot } from "./shell/theme"; import { Workspace } from "./shell/Workspace"; +import { useZoomRoot } from "./shell/zoom"; import { DaemonProvider, HostProvider } from "./store/context"; import { Toaster } from "./ui/toast"; export function App({ host, connect }: { host: Host; connect: Connect }) { useThemeRoot(); + useZoomRoot(host); const [link] = useState(() => createLink(host, connect)); const current = useStore(link, (state) => state.current); useEffect(() => { @@ -25,7 +27,7 @@ export function App({ host, connect }: { host: Host; connect: Connect }) { ) : (
- +
)} diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index dc13ffb4..2317aa42 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -19,7 +19,7 @@ export function previewProps(search: string): { host: Host; connect: Connect } { return liveProps(live); } const fake = new FakeBotloft(); - const host = new FakeHost(); + const host = previewHost(); if (params.has("stopped")) { host.status = { state: "stopped", @@ -34,7 +34,7 @@ export function previewProps(search: string): { host: Host; connect: Connect } { } function liveProps(port: number): { host: Host; connect: Connect } { - const host = new FakeHost(); + const host = previewHost(); host.status = { state: "running", port, @@ -52,7 +52,31 @@ function liveProps(port: number): { host: Host; connect: Connect } { }; } +/** A FakeHost whose zoom shows in a plain browser, like the app's does. */ +function previewHost(): FakeHost { + const host = new FakeHost(); + host.setZoom = (factor) => { + host.zoom = factor; + document.documentElement.style.zoom = String(factor); + return Promise.resolve(); + }; + return host; +} + function seed(fake: FakeBotloft): void { + const now = Date.now(); + fake.system = { + ...fake.system, + usage: { + status: "allowed", + resetsAt: now + 2 * 3600_000, + observedAt: now - 5 * 60_000, + windows: [ + { name: "five_hour", utilization: 0.34, resetsAt: now + 2 * 3600_000 }, + { name: "seven_day", utilization: 0.62, resetsAt: now + 3 * 86_400_000 }, + ], + }, + }; const research = fake.addCrew("Research"); const scout = fake.addBot(research.id, "Scout", "Finds sources and summarizes them"); const writer = fake.addBot(research.id, "Writer", "Turns notes into a weekly report"); diff --git a/app/src/features/account/AccountArea.tsx b/app/src/features/account/AccountArea.tsx new file mode 100644 index 00000000..9a657469 --- /dev/null +++ b/app/src/features/account/AccountArea.tsx @@ -0,0 +1,212 @@ +// The owner at the bottom of the sidebar, like the account corner of a +// chat app: their name and Claude plan, and a menu with usage, settings, +// language, what's new and help (spec 15.1). + +import { + ChartColumn, + Check, + ChevronRight, + ChevronsUpDown, + CircleHelp, + Languages, + Settings, + Sparkles, +} from "lucide-react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; +import { LOCALES, setLocaleChoice, systemLocale, useLocale, useT } from "../../i18n"; +import type { ClaudeAccount } from "../../lib/protocol.gen"; +import { useApp, useHost } from "../../store/context"; +import { POPOVER, POPOVER_ITEM } from "../../ui/surface"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; +import { SettingsDialog } from "./SettingsDialog"; +import { UsageDialog } from "./UsageDialog"; + +const RELEASES = "https://github.com/httpsphl/botloft/releases"; +const HELP = "https://github.com/httpsphl/botloft#readme"; + +/** "max" reads "Max"; an unknown plan keeps its words. */ +function planName(plan: string): string { + const words = plan.replaceAll("_", " ").trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +export function AccountArea() { + const a = useT().account; + const account = useApp((state) => state.system?.account ?? null); + const signedOut = useApp((state) => state.system?.claudeSignedIn === false); + const [open, setOpen] = useState(false); + const [dialog, setDialog] = useState<"usage" | "settings" | null>(null); + const root = useRef(null); + + useDismiss( + open, + root, + useCallback(() => setOpen(false), []), + ); + + const name = account?.name.trim() || "Botloft"; + const detail = describe(account?.claude ?? null, signedOut, a); + const show = (which: "usage" | "settings") => { + setOpen(false); + setDialog(which); + }; + + return ( +
+ {open && ( + setOpen(false)} + /> + )} + + {dialog === "usage" && setDialog(null)} />} + {dialog === "settings" && setDialog(null)} />} +
+ ); +} + +function describe( + claude: ClaudeAccount | null, + signedOut: boolean, + a: ReturnType["account"], +): string { + if (claude?.plan) { + return a.plan(planName(claude.plan)); + } + return claude?.organization ?? claude?.email ?? (signedOut ? a.notSignedIn : ""); +} + +function AccountMenu({ + name, + email, + onShow, + onClose, +}: { + name: string; + email: string | null; + onShow(which: "usage" | "settings"): void; + onClose(): void; +}) { + const t = useT(); + const m = t.account.menu; + const host = useHost(); + const { choice } = useLocale(); + const [languages, setLanguages] = useState(false); + const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English"; + const open = (url: string) => { + onClose(); + return attempt(m.openFailed, () => host.openUrl(url)); + }; + + return ( +
+ {email && ( +

+ {email} +

+ )} + onShow("usage")}> + {m.usage} + + onShow("settings")}> + {m.settings} + +
+ setLanguages(!languages)} + expanded={languages} + trailing={} + > + {m.language} + + {languages && ( +
+ {[{ id: "system" as const, name: t.shell.language.system(systemName) }, ...LOCALES].map( + (entry) => ( + + ), + )} +
+ )} +
+
+ open(RELEASES)}> + {m.whatsNew} + + open(HELP)}> + {m.help} + +
+ ); +} + +function Item({ + icon: Icon, + onClick, + expanded, + trailing, + children, +}: { + icon: typeof Settings; + onClick(): void; + expanded?: boolean; + trailing?: ReactNode; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx new file mode 100644 index 00000000..5514d8ac --- /dev/null +++ b/app/src/features/account/SettingsDialog.tsx @@ -0,0 +1,90 @@ +// Settings (spec 15.1): theme, size and language, which used to sit in the +// title bar, and the versions for when something needs reporting. + +import type { ReactNode } from "react"; +import { + LOCALES, + type LocaleChoice, + setLocaleChoice, + systemLocale, + useLocale, + useT, +} from "../../i18n"; +import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme"; +import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom"; +import { useApp } from "../../store/context"; +import { Choices } from "../../ui/Choices"; +import { Dialog } from "../../ui/Dialog"; + +export function SettingsDialog({ onClose }: { onClose(): void }) { + const t = useT(); + const s = t.account.settings; + const { choice: theme } = useTheme(); + const zoom = useZoom(); + const { choice: locale } = useLocale(); + const system = useApp((state) => state.system); + const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English"; + + const themes: { value: ThemeChoice; label: string }[] = [ + { value: "system", label: s.themes.system }, + { value: "light", label: s.themes.light }, + { value: "dark", label: s.themes.dark }, + ]; + const sizes = ZOOM_LEVELS.map((level) => ({ + value: level, + label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM), + })); + const languages: { value: LocaleChoice; label: string }[] = [ + { value: "system", label: t.shell.language.system(systemName) }, + ...LOCALES.map((entry) => ({ value: entry.id as LocaleChoice, label: entry.name })), + ]; + + return ( + +
+
+ + + + + label={s.size} value={zoom} options={sizes} onChange={setZoom} /> + +
+
+ +
+
+
    + {system &&
  • {s.botloft(system.daemonVersion)}
  • } + {system?.claudeVersion &&
  • {s.claudeCode(system.claudeVersion)}
  • } + {system?.account.claude?.email &&
  • {system.account.claude.email}
  • } +
+
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+

{label}

+ {children} + {hint &&

{hint}

} +
+ ); +} diff --git a/app/src/features/account/UsageDialog.tsx b/app/src/features/account/UsageDialog.tsx new file mode 100644 index 00000000..777cc0c1 --- /dev/null +++ b/app/src/features/account/UsageDialog.tsx @@ -0,0 +1,65 @@ +// How much of the Claude plan the bots have used (spec 8.1), from the +// usage Claude Code reports while bots work. + +import { useT } from "../../i18n"; +import { fromNow } from "../../lib/format"; +import type { UsageWindow } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; +import { Callout } from "../../ui/Callout"; +import { Dialog } from "../../ui/Dialog"; + +export function UsageDialog({ onClose }: { onClose(): void }) { + const u = useT().account.usage; + const usage = useApp((state) => state.system?.usage ?? null); + const limited = + usage !== null && usage.status !== "allowed" && usage.status !== "allowed_warning"; + + return ( + +
+

{u.intro}

+ {usage === null || usage.windows.length === 0 ? ( +

{u.empty}

+ ) : ( + <> + {limited && } +
    + {usage.windows.map((window) => ( + + ))} +
+

{u.updated(fromNow(usage.observedAt))}

+ + )} +
+
+ ); +} + +function Window({ window }: { window: UsageWindow }) { + const u = useT().account.usage; + const percent = Math.round(Math.min(1, Math.max(0, window.utilization)) * 100); + const name = u.windows[window.name] ?? window.name.replaceAll("_", " "); + const tone = percent >= 90 ? "bg-danger" : percent >= 70 ? "bg-warn" : "bg-work"; + return ( +
  • +
    + {name} + {u.used(percent)} +
    +
    +
    +
    + {window.resetsAt !== null && ( + {u.resets(fromNow(window.resetsAt))} + )} +
  • + ); +} diff --git a/app/src/features/account/account.test.tsx b/app/src/features/account/account.test.tsx new file mode 100644 index 00000000..d4871001 --- /dev/null +++ b/app/src/features/account/account.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "../../App"; +import type { Client } from "../../lib/client"; +import { FakeBotloft } from "../../lib/fake"; +import { FakeHost } from "../../lib/fakeHost"; +import { setTheme } from "../../shell/theme"; + +afterEach(() => { + cleanup(); + setTheme("system"); +}); + +function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { + render( fake as Client} />); + return { fake, host }; +} + +async function openAccount() { + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); +} + +describe("account area", () => { + test("the owner and their plan show at the bottom of the sidebar, even before a crew", async () => { + renderApp(); + await screen.findByRole("heading", { name: "Welcome to Botloft" }); + const button = screen.getByRole("button", { name: "Ana Lima: account and settings" }); + expect(button.textContent).toContain("Ana Lima"); + expect(button.textContent).toContain("Max plan"); + }); + + test("the title bar keeps no settings once connected", async () => { + renderApp(); + await screen.findByRole("heading", { name: "Welcome to Botloft" }); + expect(screen.queryByRole("button", { name: "Language" })).toBeNull(); + expect(screen.queryByRole("button", { name: /Theme/ })).toBeNull(); + expect(screen.queryByRole("button", { name: "Size" })).toBeNull(); + }); + + test("the menu shows the email and opens the pages", async () => { + const { host } = renderApp(); + await openAccount(); + const menu = screen.getByRole("menu", { name: "Ana Lima" }); + expect(menu.textContent).toContain("ana@example.com"); + fireEvent.click(within(menu).getByRole("menuitem", { name: "What's new" })); + await openAccount(); + fireEvent.click(screen.getByRole("menuitem", { name: "Help" })); + await waitFor(() => + expect(host.opened).toEqual([ + "https://github.com/httpsphl/botloft/releases", + "https://github.com/httpsphl/botloft#readme", + ]), + ); + }); + + test("usage shows each window, how much is used and when it resets", async () => { + const fake = new FakeBotloft(); + const now = Date.now(); + fake.system = { + ...fake.system, + usage: { + status: "allowed", + resetsAt: now + 2 * 3600_000, + observedAt: now, + windows: [ + { name: "five_hour", utilization: 0.34, resetsAt: now + 2 * 3600_000 }, + { name: "seven_day", utilization: 0.915, resetsAt: null }, + ], + }, + }; + renderApp(fake); + await openAccount(); + fireEvent.click(screen.getByRole("menuitem", { name: "Usage" })); + const dialog = screen.getByRole("dialog", { name: "Usage" }); + expect(within(dialog).getByRole("progressbar", { name: "5-hour window" })).toBeDefined(); + expect(dialog.textContent).toContain("34% used"); + expect(dialog.textContent).toContain("Resets in 2 hr."); + expect(dialog.textContent).toContain("92% used"); + }); + + test("usage waits for a bot's first reply", async () => { + renderApp(); + await openAccount(); + fireEvent.click(screen.getByRole("menuitem", { name: "Usage" })); + expect(screen.getByText("Usage shows up after a bot's first reply.")).toBeDefined(); + }); + + test("settings change the theme and the language", async () => { + renderApp(); + await openAccount(); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("radio", { name: "Dark" })); + expect(document.documentElement.dataset.theme).toBe("dark"); + fireEvent.click(within(dialog).getByRole("radio", { name: "Español" })); + expect(screen.getByRole("dialog", { name: "Configuración" })).toBeDefined(); + fireEvent.click(screen.getByRole("radio", { name: /Idioma del sistema/ })); + expect(screen.getByRole("dialog", { name: "Settings" }).textContent).toContain("Botloft 0.1.0"); + }); + + test("the setup screens keep the language menu, since they have no sidebar", async () => { + const host = new FakeHost(); + host.status = { state: "stopped", port: 45710, home: "C:\\data" }; + host.afterInstall = new Error("no way"); + renderApp(new FakeBotloft(), host); + await screen.findByRole("alert"); + expect(screen.getByRole("button", { name: "Language" })).toBeDefined(); + }); +}); diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 5e09be56..b871572d 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -1,19 +1,39 @@ // The Botloft mascot in the bot's own color (spec 15.3). The color only // tells bots apart; state is shown elsewhere with an icon and a label. +// +// A bot's avatar has no background: just the mascot, with a thin edge so +// light colors keep their shape on the light theme. `framed` puts the +// mascot on the black square of the app icon, for Botloft itself (the +// title bar, messages from the daemon), whose white mascot needs it. const BODY = "M468 58C600 105 730 180 800 290C830 340 845 400 870 440C885 465 915 475 930 455C948 430 945 400 938 378C1060 440 1170 600 1210 800C1240 950 1235 1120 1190 1254L70 1254L60 1254C10 1130 0 950 60 800C100 700 150 650 180 590C210 520 220 440 260 385C290 340 330 312 372 292C350 330 340 370 348 390C355 410 380 412 395 398C440 355 500 300 510 220C518 150 495 100 468 58Z"; -export function BotAvatar({ color, size = 28 }: { color: string; size?: number }) { +export function BotAvatar({ + color, + size = 28, + framed = false, +}: { + color: string; + size?: number; + framed?: boolean; +}) { return ( - + diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx index 49de0e25..e241bb8d 100644 --- a/app/src/features/bots/BotDialog.tsx +++ b/app/src/features/bots/BotDialog.tsx @@ -113,7 +113,7 @@ export function BotDialog(props: Props) { aria-label={t.bots.dialog.swatch(swatch)} aria-pressed={color === swatch} onClick={() => setColor(swatch)} - className={`relative rounded-[3px] p-0.5 ${color === swatch ? "ring-2 ring-accent" : ""}`} + className={`relative rounded-lg p-0.5 ${color === swatch ? "ring-2 ring-accent" : ""}`} > {color === swatch && ( diff --git a/app/src/features/chat/AttachmentList.tsx b/app/src/features/chat/AttachmentList.tsx index 6994f428..bf71169c 100644 --- a/app/src/features/chat/AttachmentList.tsx +++ b/app/src/features/chat/AttachmentList.tsx @@ -43,7 +43,7 @@ function FileCard({ ? FileText : File; return ( -
    +

    @@ -64,7 +64,7 @@ function FileCard({ host.openPath(attachmentFolder(workspace, attachment)), ) } - className="grid h-7 w-7 shrink-0 place-items-center rounded-[3px] text-muted hover:bg-sunken hover:text-ink" + className="grid h-7 w-7 shrink-0 place-items-center rounded-lg text-muted hover:bg-sunken hover:text-ink" > @@ -93,7 +93,7 @@ function Thumbnail({ } if (source.kind === "loading") { return ( -

    +
    setOpen(true)} - className="overflow-hidden rounded-md border border-line bg-sunken" + className="overflow-hidden rounded-xl border border-line bg-sunken" title={attachment.name} > +
  • -
    +
    {bot.name} {first && ( diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 82b7e59d..27af1591 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -67,10 +67,10 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { const list: ReactNode[] = rows.map((row, index) => { if (row.kind === "day") { return ( -
  • - - {day(row.at)} - +
  • + + {day(row.at)} +
  • ); } @@ -126,7 +126,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { element.scrollHeight - element.scrollTop - element.clientHeight < STICKY_PX; }} > -
    +
    {chat.error && ( {chat.error} @@ -153,12 +153,12 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) {

    {t.chat.view.emptyBody}

    )} -
      +
        {list}
    -
    +
    {dragging && ( -
    +

    {t.chat.view.dropToAttach} diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 39eb95a1..69e2bb64 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -21,11 +21,11 @@ function Time({ at }: { at: number }) { function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) { const delivery = useApp((state) => state.deliveries[message.id]); return ( -

  • +
  • {message.body && (

    {message.body} @@ -46,7 +46,7 @@ function TaskTag({ message }: { message: Message }) { } const Icon = message.kind === "task" ? ListTodo : Reply; return ( - + {message.kind === "task" ? t.chat.inbound.task : t.chat.inbound.result} @@ -60,8 +60,12 @@ function OtherMessage({ message }: { message: Message }) { const system = message.fromKind === "system"; const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); return ( -

  • - +
  • +
    {name} @@ -70,7 +74,7 @@ function OtherMessage({ message }: { message: Message }) {

    {message.body} diff --git a/app/src/features/chat/NoticeRow.tsx b/app/src/features/chat/NoticeRow.tsx index c9ef6526..72c7a5b7 100644 --- a/app/src/features/chat/NoticeRow.tsx +++ b/app/src/features/chat/NoticeRow.tsx @@ -6,9 +6,9 @@ import { type Messages, useT } from "../../i18n"; import type { NoticeItem } from "../../lib/protocol.gen"; const LOOK = { - info: { icon: Info, tone: "text-work", frame: "border-line" }, - warning: { icon: TriangleAlert, tone: "text-warn", frame: "border-warn/50" }, - error: { icon: CircleAlert, tone: "text-danger", frame: "border-danger/50" }, + info: { icon: Info, tone: "text-work", frame: "border-line bg-panel" }, + warning: { icon: TriangleAlert, tone: "text-warn", frame: "border-warn/40 bg-warn/6" }, + error: { icon: CircleAlert, tone: "text-danger", frame: "border-danger/40 bg-danger/6" }, } as const; export function NoticeRow({ notice }: { notice: NoticeItem }) { @@ -17,7 +17,7 @@ export function NoticeRow({ notice }: { notice: NoticeItem }) { return (

  • diff --git a/app/src/features/crews/CrewBots.tsx b/app/src/features/crews/CrewBots.tsx index 22fdfbf1..3257b277 100644 --- a/app/src/features/crews/CrewBots.tsx +++ b/app/src/features/crews/CrewBots.tsx @@ -12,7 +12,7 @@ export function CrewBots({ crew, bots, onNewBot }: { crew: Crew; bots: Bot[]; on const selectBot = useApp((state) => state.selectBot); if (bots.length === 0) { return ( -

    +

    {t.crews.bots.empty(crew.name)}

    {t.crews.bots.emptyHint}

    -
      - {crews.map((crew) => ( - - ))} -
    +
    + + {creating && setCreating(false)} />} - +
    ); } -const row = "relative flex w-full items-center text-left hover:bg-sunken"; -const marker = "before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-accent"; +const row = "relative flex w-full items-center rounded-lg text-left hover:bg-sunken"; function CrewEntry({ crew }: { crew: Crew }) { const t = useT(); @@ -58,7 +58,7 @@ function CrewEntry({ crew }: { crew: Crew }) { type="button" aria-current={selected ? "page" : undefined} onClick={() => selectCrew(crew.id)} - className={`${row} h-8 gap-2 pr-3 pl-4 font-semibold text-sm ${selected ? `bg-sunken ${marker}` : ""}`} + className={`${row} h-8 gap-2 px-2.5 font-semibold text-sm ${selected ? "bg-sunken" : ""}`} > {crew.name} {crew.paused && ( @@ -89,7 +89,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { aria-current={selected ? "page" : undefined} aria-label={`${bot.name}, ${stateView(bot, crew.paused).label}`} onClick={() => selectBot(bot.id)} - className={`${row} gap-2.5 py-2 pr-3 pl-4 ${selected ? `bg-sunken ${marker}` : ""}`} + className={`${row} gap-2.5 px-2.5 py-2 ${selected ? "bg-sunken" : ""}`} > diff --git a/app/src/features/messages/Composer.tsx b/app/src/features/messages/Composer.tsx index 25d4c243..8ed6dd85 100644 --- a/app/src/features/messages/Composer.tsx +++ b/app/src/features/messages/Composer.tsx @@ -73,7 +73,7 @@ export function Composer({ aria-label={t.messages.composer.recipient} value={to ?? ""} onChange={(event) => setChosen(event.target.value)} - className="h-7 rounded-[3px] border border-line-strong bg-canvas px-1.5 text-sm" + className="h-7 rounded-lg border border-line-strong bg-canvas px-1.5 text-sm" > {bots.map((bot) => (