From 741a782e363fcbbc20cb7ccfa3a102c5f6ca701e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:45:33 -0300 Subject: [PATCH 1/9] feat(app): bot avatars without the black square A bot's avatar is now just the mascot in its color, with a thin edge (dark on the light theme, light on the dark one) so light colors such as the palette's beige keep their shape. Botloft's own white mascot, in the title bar and on the daemon's messages, keeps the black square of the app icon: without it, it would vanish on the light theme. --- app/src/features/bots/BotAvatar.tsx | 28 ++++++++++++++++++++---- app/src/features/chat/InboundRow.tsx | 6 ++++- app/src/features/messages/MessageRow.tsx | 2 +- app/src/index.css | 3 +++ app/src/shell/TitleBar.tsx | 2 +- docs/spec.md | 2 +- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 5e09be56..a6b89766 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/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 39eb95a1..6f6e9ec4 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -61,7 +61,11 @@ function OtherMessage({ message }: { message: Message }) { const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); return (
  • - +
    {name} diff --git a/app/src/features/messages/MessageRow.tsx b/app/src/features/messages/MessageRow.tsx index d917abad..638ed0ea 100644 --- a/app/src/features/messages/MessageRow.tsx +++ b/app/src/features/messages/MessageRow.tsx @@ -32,7 +32,7 @@ function Sender({ message, bots }: { message: Message; bots: Record case "system": return ( - + {t.messages.row.system} ); diff --git a/app/src/index.css b/app/src/index.css index 5cc62aa2..8aadd283 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -23,6 +23,8 @@ --warn: #a35f00; --danger: #c2372a; --idle-state: #6f6f69; + /* The thin edge around a bot's avatar, so light colors hold their shape. */ + --avatar-edge: rgb(0 0 0 / 0.2); } [data-theme="dark"] { @@ -41,6 +43,7 @@ --warn: #f0b24a; --danger: #ff6d60; --idle-state: #8c8c86; + --avatar-edge: rgb(255 255 255 / 0.14); } @theme inline { diff --git a/app/src/shell/TitleBar.tsx b/app/src/shell/TitleBar.tsx index 61874ef6..42905a3d 100644 --- a/app/src/shell/TitleBar.tsx +++ b/app/src/shell/TitleBar.tsx @@ -27,7 +27,7 @@ export function TitleBar({ children, status }: { children?: ReactNode; status?: className="flex h-9 shrink-0 items-center border-line border-b bg-panel" >
    - + Botloft diff --git a/docs/spec.md b/docs/spec.md index 7150cf35..77c7d4cd 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -596,7 +596,7 @@ No chat: - Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar. - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho. -Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em `app/app-icon.svg`. O ícone do app é o mascote branco sobre fundo preto. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. +Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em `app/app-icon.svg`. O ícone do app é o mascote branco sobre fundo preto. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação, sem fundo e com um contorno fino e discreto (escuro no tema claro, claro no escuro) para as cores claras não sumirem. O mascote branco do próprio Botloft (barra de título, mensagens do daemon) fica sobre o quadrado preto do ícone do app, que é o que o torna visível no tema claro. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. ### 15.4 Instalador e sidecar From bafd759987b7c1f9ce7cd136b937eba0c45e1cfa Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:52:10 -0300 Subject: [PATCH 2/9] feat(app): a bigger app by default, with a size menu The whole window is now drawn at 125% (the webview's zoom), so text, spacing, icons and avatars grow together; at 100% the app looked tiny on a monitor without Windows scaling. A Size menu in the title bar picks 100%, 110%, 125% or 150%, Ctrl+= and Ctrl+- step through them and Ctrl+0 goes back to the default. The choice is remembered and applied before the first paint, so the window never flashes small. --- app/src-tauri/capabilities/default.json | 1 + app/src/App.tsx | 2 + app/src/dev/preview.ts | 15 ++++- app/src/i18n/en/shell.ts | 5 ++ app/src/i18n/es/shell.ts | 5 ++ app/src/i18n/pt-BR/shell.ts | 5 ++ app/src/lib/fakeHost.ts | 7 ++ app/src/lib/host.ts | 2 + app/src/lib/tauriHost.ts | 2 + app/src/main.tsx | 9 ++- app/src/shell/TitleBar.tsx | 21 +++++- app/src/shell/zoom.test.tsx | 65 ++++++++++++++++++ app/src/shell/zoom.ts | 88 +++++++++++++++++++++++++ docs/spec.md | 2 + 14 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 app/src/shell/zoom.test.tsx create mode 100644 app/src/shell/zoom.ts 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..49b9f7bf 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(() => { diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index dc13ffb4..075cd70b 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,6 +52,17 @@ 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 research = fake.addCrew("Research"); const scout = fake.addBot(research.id, "Scout", "Finds sources and summarizes them"); diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts index 8beff70e..1d9397fd 100644 --- a/app/src/i18n/en/shell.ts +++ b/app/src/i18n/en/shell.ts @@ -15,6 +15,11 @@ export const shell = { label: (theme: string) => `Theme: ${theme}`, hint: (theme: string) => `Theme: ${theme} (click to change)`, }, + zoom: { + label: "Size", + level: (percent: number, isDefault: boolean) => + isDefault ? `${percent}% (default)` : `${percent}%`, + }, language: { label: "Language", /** The "follow Windows" entry, with the language that gives. */ diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts index c36d0857..f3afa329 100644 --- a/app/src/i18n/es/shell.ts +++ b/app/src/i18n/es/shell.ts @@ -14,6 +14,11 @@ export const shell: Messages["shell"] = { label: (theme: string) => `Tema: ${theme}`, hint: (theme: string) => `Tema: ${theme} (haz clic para cambiarlo)`, }, + zoom: { + label: "Tamaño", + level: (percent: number, isDefault: boolean) => + isDefault ? `${percent}% (predeterminado)` : `${percent}%`, + }, language: { label: "Idioma", system: (name: string) => `Idioma del sistema: ${name}`, diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts index 6abb735e..62be083c 100644 --- a/app/src/i18n/pt-BR/shell.ts +++ b/app/src/i18n/pt-BR/shell.ts @@ -14,6 +14,11 @@ export const shell: Messages["shell"] = { label: (theme: string) => `Tema: ${theme}`, hint: (theme: string) => `Tema: ${theme} (clique para trocar)`, }, + zoom: { + label: "Tamanho", + level: (percent: number, isDefault: boolean) => + isDefault ? `${percent}% (padrão)` : `${percent}%`, + }, language: { label: "Idioma", system: (name: string) => `Idioma do sistema: ${name}`, diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index f4190df1..d9dc2192 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -32,6 +32,8 @@ export class FakeHost implements Host { /** What `checkForUpdate` finds, or its error. */ update: AppUpdate | null | Error = null; updateChecks = 0; + /** The window's size, as the app last set it. */ + zoom = 1; maximized = false; closed = false; attention = false; @@ -57,6 +59,11 @@ export class FakeHost implements Host { return Promise.resolve(this.signInResult); } + setZoom(factor: number): Promise { + this.zoom = factor; + return Promise.resolve(); + } + checkForUpdate(): Promise { this.updateChecks += 1; return this.update instanceof Error diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index 2c9b3150..7dc88ccf 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -57,6 +57,8 @@ export interface Host { * waits for it to end. Resolves whether it signed in. */ signInToClaude(claudePath: string): Promise; + /** Draws the whole window at `factor` times its size (1 = 100%). */ + setZoom(factor: number): Promise; /** A newer version of the app, or null when this one is the latest. */ checkForUpdate(): Promise; readOwnerToken(): Promise; diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index b3630472..7dea36c4 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Image } from "@tauri-apps/api/image"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { check } from "@tauri-apps/plugin-updater"; import type { AppUpdate, DaemonStatus, Host } from "./host"; @@ -70,6 +71,7 @@ export function tauriHost(): Host { installDaemon: () => invoke("daemon_install"), restartDaemon: () => invoke("daemon_restart"), signInToClaude: (path) => invoke("claude_sign_in", { path }), + setZoom: (factor) => getCurrentWebview().setZoom(factor), checkForUpdate, readOwnerToken: () => invoke("read_owner_token"), openPath: (path) => invoke("open_path", { path }), diff --git a/app/src/main.tsx b/app/src/main.tsx index 3b1f9880..76bde7fa 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -6,6 +6,7 @@ import type { Connect } from "./features/onboarding/link"; import { connect, rpcUrl } from "./lib/client"; import type { Host } from "./lib/host"; import { tauriHost } from "./lib/tauriHost"; +import { currentZoom } from "./shell/zoom"; import "./index.css"; const root = document.getElementById("root"); @@ -28,10 +29,12 @@ async function props(): Promise<{ host: Host; connect: Connect }> { throw new Error("Botloft runs inside its desktop app"); } -props().then(({ host, connect: open }) => +props().then(({ host, connect: open }) => { + // Before the first paint, so the window never flashes at the small size. + host.setZoom(currentZoom()).catch(() => {}); createRoot(root).render( , - ), -); + ); +}); diff --git a/app/src/shell/TitleBar.tsx b/app/src/shell/TitleBar.tsx index 42905a3d..2bc8db0b 100644 --- a/app/src/shell/TitleBar.tsx +++ b/app/src/shell/TitleBar.tsx @@ -1,13 +1,14 @@ // The window's own title bar (spec 15.3: `decorations: false`), with the // Windows controls on the right. Empty space drags the window. -import { Copy, Languages, Minus, Monitor, Moon, Square, Sun, X } from "lucide-react"; +import { ALargeSmall, Copy, Languages, Minus, Monitor, Moon, Square, Sun, X } from "lucide-react"; import { type ReactNode, useEffect, useState } from "react"; import { BotAvatar } from "../features/bots/BotAvatar"; import { LOCALES, setLocaleChoice, systemLocale, useLocale, useT } from "../i18n"; import { useHost } from "../store/context"; import { Menu } from "../ui/Menu"; import { setTheme, type ThemeChoice, useTheme } from "./theme"; +import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS } from "./zoom"; const NEXT_THEME: Record = { system: "light", @@ -37,6 +38,7 @@ export function TitleBar({ children, status }: { children?: ReactNode; status?:
    {status} + + {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/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index eb2239bb..4a88b85f 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -7,6 +7,7 @@ import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; +import { AccountArea } from "../account/AccountArea"; import { BotAvatar } from "../bots/BotAvatar"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; import { CrewDialog } from "./CrewDialog"; @@ -17,29 +18,29 @@ export function Sidebar() { const crews = useApp(useShallow(crewList)); const [creating, setCreating] = useState(false); return ( - + ); } diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts new file mode 100644 index 00000000..da35e870 --- /dev/null +++ b/app/src/i18n/en/account.ts @@ -0,0 +1,43 @@ +// The account area at the bottom of the sidebar: who the owner is, the +// plan's usage, settings, language, what's new and help. + +export const account = { + open: (name: string) => `${name}: account and settings`, + plan: (plan: string) => `${plan} plan`, + notSignedIn: "Not signed in to Claude", + menu: { + usage: "Usage", + settings: "Settings", + language: "Language", + whatsNew: "What's new", + help: "Help", + openFailed: "Could not open the page", + }, + usage: { + title: "Usage", + intro: "How much of your Claude plan your bots have used. It updates whenever a bot works.", + empty: "Usage shows up after a bot's first reply.", + windows: { + five_hour: "5-hour window", + seven_day: "This week", + seven_day_opus: "This week, Opus", + seven_day_sonnet: "This week, Sonnet", + } as Record, + used: (percent: number) => `${percent}% used`, + resets: (relative: string) => `Resets ${relative}`, + limited: "Limit reached. Your bots wait until it resets.", + updated: (relative: string) => `Updated ${relative}`, + }, + settings: { + title: "Settings", + appearance: "Appearance", + theme: "Theme", + themes: { system: "System", light: "Light", dark: "Dark" }, + size: "Size", + sizeHint: "Ctrl+= and Ctrl+- change the size too, and Ctrl+0 goes back to the default.", + language: "Language", + about: "About", + claudeCode: (version: string) => `Claude Code ${version}`, + botloft: (version: string) => `Botloft ${version}`, + }, +}; diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index 7e0ceee4..774883e6 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -1,5 +1,6 @@ // English, the reference: every other language has exactly this shape. +import { account } from "./account"; import { bots } from "./bots"; import { chat } from "./chat"; import { common } from "./common"; @@ -9,6 +10,16 @@ import { onboarding } from "./onboarding"; import { shell } from "./shell"; import { updates } from "./updates"; -export const en = { common, shell, onboarding, updates, bots, chat, crews, messages }; +export const en = { + common, + shell, + onboarding, + updates, + account, + bots, + chat, + crews, + messages, +}; export type Messages = typeof en; diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts index 1d9397fd..745c3686 100644 --- a/app/src/i18n/en/shell.ts +++ b/app/src/i18n/en/shell.ts @@ -8,15 +8,7 @@ export const shell = { restore: "Restore", close: "Close", }, - theme: { - system: "system", - light: "light", - dark: "dark", - label: (theme: string) => `Theme: ${theme}`, - hint: (theme: string) => `Theme: ${theme} (click to change)`, - }, zoom: { - label: "Size", level: (percent: number, isDefault: boolean) => isDefault ? `${percent}% (default)` : `${percent}%`, }, diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts new file mode 100644 index 00000000..8a18cf30 --- /dev/null +++ b/app/src/i18n/es/account.ts @@ -0,0 +1,43 @@ +import type { Messages } from "../en"; + +export const account: Messages["account"] = { + open: (name: string) => `${name}: cuenta y configuración`, + plan: (plan: string) => `Plan ${plan}`, + notSignedIn: "Sin sesión en Claude", + menu: { + usage: "Uso", + settings: "Configuración", + language: "Idioma", + whatsNew: "Novedades", + help: "Ayuda", + openFailed: "No se pudo abrir la página", + }, + usage: { + title: "Uso", + intro: + "Cuánto de tu plan de Claude han usado tus bots. Se actualiza cada vez que un bot trabaja.", + empty: "El uso aparece después de la primera respuesta de un bot.", + windows: { + five_hour: "Ventana de 5 horas", + seven_day: "Esta semana", + seven_day_opus: "Esta semana, Opus", + seven_day_sonnet: "Esta semana, Sonnet", + }, + used: (percent: number) => `${percent}% usado`, + resets: (relative: string) => `Se renueva ${relative}`, + limited: "Límite alcanzado. Tus bots esperan hasta que se renueve.", + updated: (relative: string) => `Actualizado ${relative}`, + }, + settings: { + title: "Configuración", + appearance: "Apariencia", + theme: "Tema", + themes: { system: "Sistema", light: "Claro", dark: "Oscuro" }, + size: "Tamaño", + sizeHint: "Ctrl+= y Ctrl+- también cambian el tamaño, y Ctrl+0 vuelve al predeterminado.", + language: "Idioma", + about: "Acerca de", + claudeCode: (version: string) => `Claude Code ${version}`, + botloft: (version: string) => `Botloft ${version}`, + }, +}; diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index 2da37bda..45a4b409 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -1,6 +1,7 @@ // Spanish. import type { Messages } from "../en"; +import { account } from "./account"; import { bots } from "./bots"; import { chat } from "./chat"; import { common } from "./common"; @@ -10,4 +11,14 @@ import { onboarding } from "./onboarding"; import { shell } from "./shell"; import { updates } from "./updates"; -export const es: Messages = { common, shell, onboarding, updates, bots, chat, crews, messages }; +export const es: Messages = { + common, + shell, + onboarding, + updates, + account, + bots, + chat, + crews, + messages, +}; diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts index f3afa329..8020d799 100644 --- a/app/src/i18n/es/shell.ts +++ b/app/src/i18n/es/shell.ts @@ -7,15 +7,7 @@ export const shell: Messages["shell"] = { restore: "Restaurar", close: "Cerrar", }, - theme: { - system: "sistema", - light: "claro", - dark: "oscuro", - label: (theme: string) => `Tema: ${theme}`, - hint: (theme: string) => `Tema: ${theme} (haz clic para cambiarlo)`, - }, zoom: { - label: "Tamaño", level: (percent: number, isDefault: boolean) => isDefault ? `${percent}% (predeterminado)` : `${percent}%`, }, diff --git a/app/src/i18n/i18n.test.tsx b/app/src/i18n/i18n.test.tsx index 5deb2fa5..d033b37b 100644 --- a/app/src/i18n/i18n.test.tsx +++ b/app/src/i18n/i18n.test.tsx @@ -56,13 +56,13 @@ describe("languages", () => { render( fake as Client} />); expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); - fireEvent.click(screen.getByRole("button", { name: "Language" })); + // The language lives in the account menu at the bottom of the sidebar. + fireEvent.click(screen.getByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Language" })); fireEvent.click(screen.getByRole("menuitemradio", { name: /Português/ })); expect(screen.getByRole("heading", { name: "Boas-vindas ao Botloft" })).toBeDefined(); expect(document.documentElement.lang).toBe("pt-BR"); expect(localStorage.getItem("botloft.locale")).toBe("pt-BR"); - - fireEvent.click(screen.getByRole("button", { name: "Idioma" })); expect( screen.getByRole("menuitemradio", { name: /Português/ }).getAttribute("aria-checked"), ).toBe("true"); diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts new file mode 100644 index 00000000..aaa52320 --- /dev/null +++ b/app/src/i18n/pt-BR/account.ts @@ -0,0 +1,43 @@ +import type { Messages } from "../en"; + +export const account: Messages["account"] = { + open: (name: string) => `${name}: conta e configurações`, + plan: (plan: string) => `Plano ${plan}`, + notSignedIn: "Sem conta do Claude conectada", + menu: { + usage: "Uso", + settings: "Configurações", + language: "Idioma", + whatsNew: "Novidades", + help: "Ajuda", + openFailed: "Não foi possível abrir a página", + }, + usage: { + title: "Uso", + intro: + "Quanto do seu plano do Claude os seus bots já usaram. Atualiza sempre que um bot trabalha.", + empty: "O uso aparece depois da primeira resposta de um bot.", + windows: { + five_hour: "Janela de 5 horas", + seven_day: "Esta semana", + seven_day_opus: "Esta semana, Opus", + seven_day_sonnet: "Esta semana, Sonnet", + }, + used: (percent: number) => `${percent}% usado`, + resets: (relative: string) => `Renova ${relative}`, + limited: "Limite atingido. Seus bots esperam até ele renovar.", + updated: (relative: string) => `Atualizado ${relative}`, + }, + settings: { + title: "Configurações", + appearance: "Aparência", + theme: "Tema", + themes: { system: "Sistema", light: "Claro", dark: "Escuro" }, + size: "Tamanho", + sizeHint: "Ctrl+= e Ctrl+- também mudam o tamanho, e Ctrl+0 volta ao padrão.", + language: "Idioma", + about: "Sobre", + claudeCode: (version: string) => `Claude Code ${version}`, + botloft: (version: string) => `Botloft ${version}`, + }, +}; diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index e15ad594..a3aa413b 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -1,6 +1,7 @@ // Brazilian Portuguese. import type { Messages } from "../en"; +import { account } from "./account"; import { bots } from "./bots"; import { chat } from "./chat"; import { common } from "./common"; @@ -10,4 +11,14 @@ import { onboarding } from "./onboarding"; import { shell } from "./shell"; import { updates } from "./updates"; -export const ptBR: Messages = { common, shell, onboarding, updates, bots, chat, crews, messages }; +export const ptBR: Messages = { + common, + shell, + onboarding, + updates, + account, + bots, + chat, + crews, + messages, +}; diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts index 62be083c..bb82a25a 100644 --- a/app/src/i18n/pt-BR/shell.ts +++ b/app/src/i18n/pt-BR/shell.ts @@ -7,15 +7,7 @@ export const shell: Messages["shell"] = { restore: "Restaurar", close: "Fechar", }, - theme: { - system: "sistema", - light: "claro", - dark: "escuro", - label: (theme: string) => `Tema: ${theme}`, - hint: (theme: string) => `Tema: ${theme} (clique para trocar)`, - }, zoom: { - label: "Tamanho", level: (percent: number, isDefault: boolean) => isDefault ? `${percent}% (padrão)` : `${percent}%`, }, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index c8d9a0b1..a361d7d6 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -42,6 +42,10 @@ export class FakeBotloft implements BotloftApi { runtimeError: null, claudePath: "C:\\Users\\owner\\.local\\bin\\claude.exe", claudeSignedIn: true, + account: { + name: "Ana Lima", + claude: { email: "ana@example.com", plan: "max", organization: null }, + }, deliveries: { pending: 0, dead: 0 }, usage: null, }; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 9fb5ac0c..171c2a2f 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -190,6 +190,29 @@ resetsAt: number | null, windows: Array, */ observedAt: number, }; +/** + * What `claude auth status` says about the account (spec 7.3). + */ +export type ClaudeAccount = { email: string | null, +/** + * As Claude Code says it: `max`, `pro`, `team`, `enterprise`... + */ +plan: string | null, organization: string | null, }; + +/** + * The owner, as the app shows them in its account area (spec 15.1). + */ +export type OwnerAccount = { +/** + * The Windows account's display name, or the user name without one. + */ +name: string, +/** + * The Claude account Claude Code is signed in to; `null` when signed out + * or not checked yet. + */ +claude: ClaudeAccount | null, }; + export type SystemStatus = { daemonVersion: string, protocol: number, uptimeMs: number, /** * Version reported by `claude --version`; `null` until the runtime probe runs. @@ -206,7 +229,11 @@ claudePath: string | null, /** * Whether Claude Code is signed in (`claude auth status`); `null` until checked. */ -claudeSignedIn: boolean | null, deliveries: DeliveryBacklog, +claudeSignedIn: boolean | null, +/** + * Who the owner is, for the account area of the app. + */ +account: OwnerAccount, deliveries: DeliveryBacklog, /** * The Claude account's usage as last reported; `null` before any turn. */ diff --git a/app/src/shell/TitleBar.tsx b/app/src/shell/TitleBar.tsx index 2bc8db0b..52bff414 100644 --- a/app/src/shell/TitleBar.tsx +++ b/app/src/shell/TitleBar.tsx @@ -1,27 +1,24 @@ // The window's own title bar (spec 15.3: `decorations: false`), with the -// Windows controls on the right. Empty space drags the window. +// Windows controls on the right. Empty space drags the window. Theme, size +// and language live in the account area of the sidebar (spec 15.1). -import { ALargeSmall, Copy, Languages, Minus, Monitor, Moon, Square, Sun, X } from "lucide-react"; +import { Copy, Languages, Minus, Square, X } from "lucide-react"; import { type ReactNode, useEffect, useState } from "react"; import { BotAvatar } from "../features/bots/BotAvatar"; import { LOCALES, setLocaleChoice, systemLocale, useLocale, useT } from "../i18n"; import { useHost } from "../store/context"; import { Menu } from "../ui/Menu"; -import { setTheme, type ThemeChoice, useTheme } from "./theme"; -import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS } from "./zoom"; -const NEXT_THEME: Record = { - system: "light", - light: "dark", - dark: "system", -}; -const THEME_ICON = { system: Monitor, light: Sun, dark: Moon }; - -export function TitleBar({ children, status }: { children?: ReactNode; status?: ReactNode }) { - const t = useT(); - const { choice } = useTheme(); - const ThemeIcon = THEME_ICON[choice]; - const themeName = t.shell.theme[choice]; +export function TitleBar({ + children, + status, + language = false, +}: { + children?: ReactNode; + status?: ReactNode; + /** Shows the language menu, for the setup screens that have no sidebar. */ + language?: boolean; +}) { return (
    {status} - - - + {language && }
    ); } -/** How big the app is drawn (spec 15.3); Ctrl+= and Ctrl+- do the same. */ -function ZoomMenu() { - const t = useT(); - const zoom = useZoom(); - return ( - ({ - label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM), - checked: zoom === level, - onSelect: () => setZoom(level), - }))} - /> - ); -} - /** Picks the app's language; "system" follows Windows (spec 15.6). */ function LanguageMenu() { const t = useT(); diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 4bf49ca5..c2d3937d 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -39,7 +39,13 @@ export function Workspace() { } else if (!loaded) { main = null; } else if (!hasCrews) { - main = ; + // The sidebar stays, with the account area, before the first crew too. + main = ( +
    + + +
    + ); } else { main = (
    diff --git a/app/src/shell/zoom.test.tsx b/app/src/shell/zoom.test.tsx index b70be565..f9c611bb 100644 --- a/app/src/shell/zoom.test.tsx +++ b/app/src/shell/zoom.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +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"; @@ -27,14 +27,16 @@ describe("app size", () => { expect(host.zoom).toBe(1.25); }); - test("the size menu sets the size and remembers it", async () => { + test("settings set the size and remember it", async () => { const host = renderApp(); await screen.findByRole("heading", { name: "Welcome to Botloft" }); - fireEvent.click(screen.getByRole("button", { name: "Size" })); + fireEvent.click(screen.getByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const sizes = screen.getByRole("radiogroup", { name: "Size" }); expect( - screen.getByRole("menuitemradio", { name: "125% (default)" }).getAttribute("aria-checked"), - ).toBe("true"); - fireEvent.click(screen.getByRole("menuitemradio", { name: "150%" })); + (within(sizes).getByRole("radio", { name: "125% (default)" }) as HTMLInputElement).checked, + ).toBe(true); + fireEvent.click(within(sizes).getByRole("radio", { name: "150%" })); await waitFor(() => expect(host.zoom).toBe(1.5)); expect(localStorage.getItem("botloft.zoom")).toBe("1.5"); }); diff --git a/app/src/ui/Choices.tsx b/app/src/ui/Choices.tsx new file mode 100644 index 00000000..b6b18db5 --- /dev/null +++ b/app/src/ui/Choices.tsx @@ -0,0 +1,45 @@ +import { useId } from "react"; + +/** + * A short set of options where one is chosen, shown side by side. Real + * radio buttons underneath, so the arrow keys and screen readers work. + */ +export function Choices({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: readonly { value: T; label: string }[]; + onChange(value: T): void; +}) { + const name = useId(); + return ( +
    + {options.map((option) => { + const chosen = option.value === value; + return ( + + ); + })} +
    + ); +} diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index db1db5a5..238c7cc6 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -96,6 +96,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index 342c2df9..94696202 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -60,11 +60,36 @@ pub struct SystemStatus { pub claude_path: Option, /// Whether Claude Code is signed in (`claude auth status`); `null` until checked. pub claude_signed_in: Option, + /// Who the owner is, for the account area of the app. + pub account: OwnerAccount, pub deliveries: DeliveryBacklog, /// The Claude account's usage as last reported; `null` before any turn. pub usage: Option, } +/// The owner, as the app shows them in its account area (spec 15.1). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct OwnerAccount { + /// The Windows account's display name, or the user name without one. + pub name: String, + /// The Claude account Claude Code is signed in to; `null` when signed out + /// or not checked yet. + pub claude: Option, +} + +/// What `claude auth status` says about the account (spec 7.3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ClaudeAccount { + pub email: Option, + /// As Claude Code says it: `max`, `pro`, `team`, `enterprise`... + pub plan: Option, + pub organization: Option, +} + /// The account's usage, from Claude Code's `rate_limit_event` (spec 8.1). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 7b4a6488..98cd9b40 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -33,7 +33,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_Security_Authentication_Identity"] } [target."cfg(windows)".build-dependencies] embed-resource = "3.0.11" diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 1440b7d4..5fb51a5e 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -12,12 +12,12 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] pub use unix::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, register_task, + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, restrict_to_current_user, run_task, stop_task, user_environment, }; #[cfg(windows)] pub use windows::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, register_task, + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, restrict_to_current_user, run_task, stop_task, user_environment, }; @@ -98,6 +98,11 @@ mod tests { assert!(!has("CLAUDE_CODE_MESSAGING_SOCKET")); } + #[test] + fn the_owner_has_a_name() { + assert!(!owner_name().trim().is_empty()); + } + #[test] fn a_job_can_be_created_and_terminated_empty() { let job = ProcessJob::new().expect("job"); diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index de9772d8..738a38e0 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -68,6 +68,11 @@ pub fn user_environment() -> io::Result> { .collect()) } +/// The login name; Unix has no display name to ask for. +pub fn owner_name() -> String { + std::env::var("USER").unwrap_or_default() +} + /// Nothing to leave: Unix never opens a console for the daemon. pub fn leave_own_console() {} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index fce4924d..426b9876 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -4,6 +4,7 @@ mod console; mod env; mod job; +mod owner; mod power; mod task; mod task_xml; @@ -11,6 +12,7 @@ mod task_xml; pub use console::leave_own_console; pub use env::user_environment; pub use job::ProcessJob; +pub use owner::owner_name; pub use power::KeepAwake; pub use task::{delete_task, find_task, register_task, run_task, stop_task}; diff --git a/crates/botloftd/src/platform/windows/owner.rs b/crates/botloftd/src/platform/windows/owner.rs new file mode 100644 index 00000000..8b97666d --- /dev/null +++ b/crates/botloftd/src/platform/windows/owner.rs @@ -0,0 +1,26 @@ +//! The owner's name as Windows shows it, for the app's account area. + +use windows::Win32::Security::Authentication::Identity::{GetUserNameExW, NameDisplay}; +use windows::core::PWSTR; + +/// The account's display name ("Ana Lima"), or the user name without one. +pub fn owner_name() -> String { + display_name() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| std::env::var("USERNAME").unwrap_or_default()) +} + +fn display_name() -> Option { + let mut len = 0u32; + // SAFETY: a size query with no buffer; it fails and sets `len` to the + // size needed, counting the final NUL. + let _ = unsafe { GetUserNameExW(NameDisplay, None, &mut len) }; + if len == 0 { + return None; + } + let mut buf = vec![0u16; len as usize]; + // SAFETY: `buf` holds `len` UTF-16 units; on success `len` becomes the + // length of the name without the NUL. + let found = unsafe { GetUserNameExW(NameDisplay, Some(PWSTR(buf.as_mut_ptr())), &mut len) }; + found.then(|| String::from_utf16_lossy(&buf[..len as usize])) +} diff --git a/crates/botloftd/src/runtime/claude.rs b/crates/botloftd/src/runtime/claude.rs index 68509653..08ec832a 100644 --- a/crates/botloftd/src/runtime/claude.rs +++ b/crates/botloftd/src/runtime/claude.rs @@ -6,6 +6,10 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; +use botloft_core::protocol::ClaudeAccount; + +use super::AuthStatus; + /// First version with the inbox as a named pipe on native Windows. pub const MIN_VERSION: (u32, u32, u32) = (2, 1, 234); const PROBE_TIMEOUT: Duration = Duration::from_secs(15); @@ -108,16 +112,34 @@ pub fn probe(path: &Path, env: &[(OsString, OsString)]) -> Result io::Result { +pub fn auth_status(path: &Path, env: &[(OsString, OsString)]) -> io::Result { let output = run(path, &["auth", "status", "--json"], env)?; - Ok(parse_signed_in(&output.stdout).unwrap_or(output.status.success())) + Ok(parse_auth_status(&output.stdout).unwrap_or(AuthStatus { + signed_in: output.status.success(), + account: None, + })) } -fn parse_signed_in(stdout: &[u8]) -> Option { - serde_json::from_slice::(stdout) - .ok()? - .get("loggedIn")? - .as_bool() +/// Reads `loggedIn`, and when signed in `email`, `subscriptionType` and +/// `orgName` (seen with 2.1.284). +fn parse_auth_status(stdout: &[u8]) -> Option { + let json = serde_json::from_slice::(stdout).ok()?; + let signed_in = json.get("loggedIn")?.as_bool()?; + let text = |key: &str| { + json.get(key) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }; + Some(AuthStatus { + signed_in, + account: signed_in.then(|| ClaudeAccount { + email: text("email"), + plan: text("subscriptionType"), + organization: text("orgName"), + }), + }) } /// Runs Claude Code without a window and with `env` only, giving up after @@ -176,16 +198,31 @@ mod tests { } #[test] - fn reads_the_sign_in_from_auth_status() { + fn reads_the_sign_in_and_account_from_auth_status() { + let signed_in = parse_auth_status( + br#"{"loggedIn": true, "authMethod": "claude.ai", "email": "ana@example.com", + "orgName": "Exemplo", "subscriptionType": "max"}"#, + ) + .expect("parsed"); + assert!(signed_in.signed_in); assert_eq!( - parse_signed_in(br#"{"loggedIn": true, "authMethod": "claude.ai"}"#), - Some(true) + signed_in.account, + Some(ClaudeAccount { + email: Some("ana@example.com".into()), + plan: Some("max".into()), + organization: Some("Exemplo".into()), + }) ); + let signed_out = + parse_auth_status(br#"{"loggedIn": false, "authMethod": "none"}"#).expect("parsed"); assert_eq!( - parse_signed_in(br#"{"loggedIn": false, "authMethod": "none"}"#), - Some(false) + signed_out, + AuthStatus { + signed_in: false, + account: None + } ); - assert_eq!(parse_signed_in(b"Logged in"), None); + assert_eq!(parse_auth_status(b"Logged in"), None); } #[test] diff --git a/crates/botloftd/src/runtime/fake.rs b/crates/botloftd/src/runtime/fake.rs index 0a2a158c..c7f95b4e 100644 --- a/crates/botloftd/src/runtime/fake.rs +++ b/crates/botloftd/src/runtime/fake.rs @@ -12,7 +12,11 @@ use tokio::sync::{Notify, mpsc}; use serde_json::Value; -use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SignInCheck, SpawnSpec}; +use botloft_core::protocol::ClaudeAccount; + +use super::{ + AuthCheck, AuthStatus, EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec, +}; #[derive(Clone, Default)] pub struct FakeRuntime { @@ -93,10 +97,18 @@ impl FakeRuntime { } impl Runtime for FakeRuntime { - fn signed_in(&self, _program: std::path::PathBuf) -> SignInCheck { + fn auth_status(&self, _program: std::path::PathBuf) -> AuthCheck { let mut state = lock(&self.state); state.sign_in_checks += 1; - Box::pin(std::future::ready(Ok(state.signed_in))) + let signed_in = state.signed_in; + Box::pin(std::future::ready(Ok(AuthStatus { + signed_in, + account: signed_in.then(|| ClaudeAccount { + email: Some("owner@example.com".into()), + plan: Some("max".into()), + organization: None, + }), + }))) } fn spawn(&self, spec: SpawnSpec) -> io::Result { diff --git a/crates/botloftd/src/runtime/mod.rs b/crates/botloftd/src/runtime/mod.rs index 54ffe902..49591ac9 100644 --- a/crates/botloftd/src/runtime/mod.rs +++ b/crates/botloftd/src/runtime/mod.rs @@ -12,6 +12,7 @@ use std::io; use std::path::PathBuf; use std::pin::Pin; +use botloft_core::protocol::ClaudeAccount; use bytes::Bytes; use tokio::sync::mpsc; @@ -49,13 +50,21 @@ pub struct Process { pub control: Box, } -/// The answer to "is Claude Code signed in?", computed off the async workers. -pub type SignInCheck = Pin> + Send>>; +/// What `claude auth status` says: signed in or not, and to which account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthStatus { + pub signed_in: bool, + pub account: Option, +} + +/// An [`AuthStatus`], computed off the async workers. +pub type AuthCheck = Pin> + Send>>; pub trait Runtime: Send + Sync + 'static { fn spawn(&self, spec: SpawnSpec) -> io::Result; - /// Whether the Claude Code at `program` is signed in (`claude auth status`). - fn signed_in(&self, program: PathBuf) -> SignInCheck; + /// Whether the Claude Code at `program` is signed in, and to which + /// account (`claude auth status`). + fn auth_status(&self, program: PathBuf) -> AuthCheck; } /// Capacity of the event channel between a process and the daemon. When it diff --git a/crates/botloftd/src/runtime/pipe.rs b/crates/botloftd/src/runtime/pipe.rs index 66a344b8..67ffd4d0 100644 --- a/crates/botloftd/src/runtime/pipe.rs +++ b/crates/botloftd/src/runtime/pipe.rs @@ -13,7 +13,7 @@ use tokio::sync::mpsc; use tracing::debug; use super::{ - EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SignInCheck, SpawnSpec, claude, + AuthCheck, EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec, claude, }; use crate::platform::{self, ProcessJob}; @@ -24,11 +24,11 @@ const STDERR_LINE_MAX: usize = 300; pub struct PipeRuntime; impl Runtime for PipeRuntime { - fn signed_in(&self, program: std::path::PathBuf) -> SignInCheck { + fn auth_status(&self, program: std::path::PathBuf) -> AuthCheck { Box::pin(async move { tokio::task::spawn_blocking(move || { let env = platform::user_environment()?; - claude::signed_in(&program, &env) + claude::auth_status(&program, &env) }) .await .map_err(io::Error::other)? diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index b3c42fe4..96a873e1 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -9,7 +9,7 @@ pub mod deliveries; pub mod messages; pub mod tasks; -use botloft_core::protocol::{PROTOCOL_VERSION, SystemStatus, error_code}; +use botloft_core::protocol::{OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code}; use botloft_core::slug; use botloft_core::validate::ValidationError; use botloft_store::StoreError; @@ -58,6 +58,13 @@ impl From for ApiError { pub type ApiResult = Result; +/// The owner's name, asked of Windows once: it does not change while the +/// daemon runs. +fn owner_name() -> String { + static NAME: std::sync::OnceLock = std::sync::OnceLock::new(); + NAME.get_or_init(crate::platform::owner_name).clone() +} + pub fn status(daemon: &Daemon) -> ApiResult { let deliveries = daemon.store().delivery_backlog()?; Ok(SystemStatus { @@ -71,6 +78,10 @@ pub fn status(daemon: &Daemon) -> ApiResult { .claude_path() .map(|path| path.display().to_string()), claude_signed_in: daemon.supervisor.claude_signed_in(), + account: OwnerAccount { + name: owner_name(), + claude: daemon.supervisor.claude_account(), + }, deliveries, usage: daemon.usage(), }) diff --git a/crates/botloftd/src/supervisor/sign_in.rs b/crates/botloftd/src/supervisor/sign_in.rs index f1afec03..d9170e2d 100644 --- a/crates/botloftd/src/supervisor/sign_in.rs +++ b/crates/botloftd/src/supervisor/sign_in.rs @@ -4,7 +4,7 @@ //! When it turns from signed out to signed in, the bots a sign-in error //! stopped start again by themselves: the owner just signed in. -use botloft_core::protocol::BotState; +use botloft_core::protocol::{BotState, ClaudeAccount}; use tokio::time::Instant; use tracing::{debug, info}; @@ -13,6 +13,7 @@ use super::{ClaudeStatus, Inner, REPROBE_AFTER, Supervisor}; #[derive(Debug, Default)] pub(super) struct SignIn { known: Option, + account: Option, checked_at: Option, recheck: bool, } @@ -36,6 +37,11 @@ impl Supervisor { self.lock().sign_in.known } + /// The Claude account Claude Code is signed in to, once checked. + pub fn claude_account(&self) -> Option { + self.lock().sign_in.account.clone() + } + /// Checks Claude Code again now: the sign-in, and the executable if it /// was missing or too old (after the owner signed in or installed it). pub fn refresh_claude(&self) { @@ -64,12 +70,14 @@ impl Supervisor { _ => return, } }; - let checked = self.runtime.signed_in(program).await; + let checked = self.runtime.auth_status(program).await; let mut inner = self.lock(); inner.sign_in.recheck = false; inner.sign_in.checked_at = Some(Instant::now()); match checked { - Ok(signed_in) => { + Ok(status) => { + let signed_in = status.signed_in; + inner.sign_in.account = status.account; let was = inner.sign_in.known.replace(signed_in); if signed_in && was == Some(false) { info!("Claude Code is signed in again; starting the bots it stopped"); diff --git a/crates/botloftd/tests/sign_in.rs b/crates/botloftd/tests/sign_in.rs index 9a74f665..78ecfd7d 100644 --- a/crates/botloftd/tests/sign_in.rs +++ b/crates/botloftd/tests/sign_in.rs @@ -30,6 +30,13 @@ async fn the_status_says_whether_claude_code_is_signed_in() { let status = service::status(&s.daemon).expect("status"); assert_eq!(status.claude_signed_in, Some(true)); assert!(status.claude_path.is_some()); + assert!( + !status.account.name.is_empty(), + "the Windows account's name" + ); + let claude = status.account.claude.expect("the signed-in Claude account"); + assert_eq!(claude.email.as_deref(), Some("owner@example.com")); + assert_eq!(claude.plan.as_deref(), Some("max")); assert_eq!(s.runtime.sign_in_checks(), 1, "checked once, then trusted"); tokio::time::sleep(Duration::from_secs(300)).await; assert_eq!(s.runtime.sign_in_checks(), 1); @@ -43,6 +50,11 @@ async fn signing_in_again_brings_the_stopped_bots_back() { sign_in_error(&s).await; tokio::time::sleep(Duration::from_millis(50)).await; assert_eq!(s.daemon.supervisor.claude_signed_in(), Some(false)); + assert_eq!( + s.daemon.supervisor.claude_account(), + None, + "no account while signed out" + ); // Still signed out: checked every 30 s, the bot stays stopped. tokio::time::sleep(Duration::from_secs(40)).await; diff --git a/docs/spec.md b/docs/spec.md index 35bb298a..a2346129 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -202,7 +202,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **Sessão:** o daemon guarda em `bots.session_id` o id da conversa. O primeiro start usa `--session-id `; os seguintes, `--resume `. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem com um id novo. `bots.restart {fresh: true}` também começa conversa nova. O histórico do chat fica no banco do daemon (seção 8) e não depende do transcript do Claude Code. - Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. - `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart` ou até o daemon ver o Claude Code conectado de novo. -- **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`. O login em si é feito pelo app (15.2). +- **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn` e, conectado, `email`, `subscriptionType` e `orgName`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`, e a conta (e-mail, plano, organização) em `system.status.account.claude`. O login em si é feito pelo app (15.2). - Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. ### 7.4 Spawn @@ -426,7 +426,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | Método | Params | Result | |---|---|---| -| `system.status` | | versão, uptime, versão e caminho do claude (`claudeVersion`, `claudePath`), `runtimeError` (por que os bots não sobem), `claudeSignedIn` (7.3; `null` antes de conferir), backlog de entrega, `usage` (uso da conta, 8.1) | +| `system.status` | | versão, uptime, versão e caminho do claude (`claudeVersion`, `claudePath`), `runtimeError` (por que os bots não sobem), `claudeSignedIn` (7.3; `null` antes de conferir), `account` (o dono para a área da conta do app: `name`, o nome de exibição da conta do Windows, `GetUserNameExW(NameDisplay)`, ou o nome de usuário sem ele, lido uma vez; e `claude`, a conta do Claude com `email`, `plan` e `organization`, ou `null` desconectado), backlog de entrega, `usage` (uso da conta, 8.1) | | `system.refresh` | | pede uma nova conferência do Claude Code (login e, se falhou, o executável) e responde na hora com o `system.status` atual; o app relê o status até ver o resultado | | `crews.list` | | `Crew[]` | | `crews.create` | `name` | `Crew` | @@ -550,7 +550,8 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. +- **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (tema, tamanho, idioma e as versões do Botloft e do Claude Code), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha. - **Área principal com uma crew:** a timeline (messages entre os bots e do dono) e as tasks. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. @@ -588,7 +589,7 @@ Em dev, `pnpm tauri dev` usa o `target\debug\botloftd.exe` como sidecar e instal Ferramenta de trabalho densa e calma: tipografia forte, grid firme, estados dos bots legíveis de longe (cor + ícone + texto, nunca só cor). Sem gradiente, sem sombra pesada, sem visual de template. Tema escuro e claro. Barra de título própria (`decorations: false`) com controles de janela do Windows. -Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), então texto, espaçamento, ícones e avatares crescem juntos. Níveis 100%, 110%, 125% e 150%; o padrão é **125%**, porque o desenho a 100% ficava miúdo num monitor sem ampliação do Windows. Quem já usa a ampliação alta volta para 100% no menu "Tamanho" da barra de título ou com Ctrl+-; Ctrl+= aumenta e Ctrl+0 volta ao padrão. A escolha fica no `localStorage` (`botloft.zoom`) e é aplicada antes da primeira pintura, para a janela não piscar pequena. Acima de 150% a janela mínima (900 px) não cabe o layout. +Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), então texto, espaçamento, ícones e avatares crescem juntos. Níveis 100%, 110%, 125% e 150%; o padrão é **125%**, porque o desenho a 100% ficava miúdo num monitor sem ampliação do Windows. Quem já usa a ampliação alta volta para 100% em Configurações (área da conta) ou com Ctrl+-; Ctrl+= aumenta e Ctrl+0 volta ao padrão. A escolha fica no `localStorage` (`botloft.zoom`) e é aplicada antes da primeira pintura, para a janela não piscar pequena. Acima de 150% a janela mínima (900 px) não cabe o layout. No chat: @@ -619,7 +620,7 @@ Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em ` - O app fala **inglês, português (Brasil) e espanhol**. Todo texto que o dono lê fica em `app/src/i18n//`, um arquivo por área (`common`, `shell`, `onboarding`, `updates`, `bots`, `chat`, `crews`, `messages`). O inglês é a referência: o formato dele é o tipo `Messages`, e um texto que falte ou sobre em outro idioma não compila. Texto com valores é função (`ready(version)`), com o plural escrito para cada idioma. - Componentes leem com `useT()`; código fora do React (toasts, formatação, erros da conexão) com `t()` na hora do uso. -- Escolha no botão de idioma da barra de título (também nas telas de preparo): "Idioma do sistema" segue o Windows (o primeiro idioma suportado entre os preferidos; `pt-PT` vira `pt-BR`; nenhum, inglês) ou um idioma fixo. A escolha fica no `localStorage` do app (`botloft.locale`) e marca ``. +- Escolha na área da conta (Idioma, ou Configurações), e no botão de idioma da barra de título só nas telas de preparo: "Idioma do sistema" segue o Windows (o primeiro idioma suportado entre os preferidos; `pt-PT` vira `pt-BR`; nenhum, inglês) ou um idioma fixo. A escolha fica no `localStorage` do app (`botloft.locale`) e marca ``. - Datas e horas (`lib/format.ts`) usam o idioma escolhido. - O daemon não escreve texto para o dono: avisos vêm com `code` e a linha da conversa com `kind` (8.2, 11.2), e o app escreve. Continuam como vêm: nomes, mensagens, respostas e saídas de ferramenta; o resumo de ferramenta que o daemon faz a partir da entrada (o comando, o arquivo); e as mensagens de erro do daemon (validação, falhas), mostradas como texto técnico. - O instalador NSIS também traz os três idiomas e escolhe pelo idioma do Windows. From 25c3078b8217fc19b38457a271161bdafa4cc7ee Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:38:27 -0300 Subject: [PATCH 4/9] feat(app): a rounder, softer look Buttons, fields and menu rows get 8 px corners, cards and menus 12 px, the chat's bubbles, cards and dialogs 16 px. Menus and dialogs float on a light themed shadow. The chat is a centered column with the day as a pill, and the open conversation in the sidebar is an inset rounded block instead of a full-width bar. Menus share one popover look and one hook that closes them. --- app/src/features/account/AccountArea.tsx | 40 +++++++------------ app/src/features/bots/BotAvatar.tsx | 2 +- app/src/features/bots/BotDialog.tsx | 2 +- app/src/features/chat/AttachmentList.tsx | 8 ++-- app/src/features/chat/BotRun.tsx | 4 +- app/src/features/chat/ChatView.tsx | 16 ++++---- app/src/features/chat/InboundRow.tsx | 10 ++--- app/src/features/chat/NoticeRow.tsx | 8 ++-- app/src/features/crews/CrewBots.tsx | 4 +- app/src/features/crews/Sidebar.tsx | 9 ++--- app/src/features/messages/Composer.tsx | 4 +- .../features/messages/FailedDeliveries.tsx | 2 +- app/src/features/messages/MessageRow.tsx | 2 +- app/src/features/onboarding/Welcome.tsx | 4 +- app/src/features/tasks/TaskList.tsx | 2 +- app/src/index.css | 33 ++++++++++++--- app/src/ui/Button.tsx | 2 +- app/src/ui/Callout.tsx | 8 ++-- app/src/ui/Choices.tsx | 2 +- app/src/ui/Dialog.tsx | 10 ++--- app/src/ui/Field.tsx | 2 +- app/src/ui/Menu.tsx | 33 +++++---------- app/src/ui/surface.ts | 8 ++++ app/src/ui/toast.tsx | 4 +- app/src/ui/useDismiss.ts | 22 ++++++++++ 25 files changed, 134 insertions(+), 107 deletions(-) create mode 100644 app/src/ui/surface.ts create mode 100644 app/src/ui/useDismiss.ts diff --git a/app/src/features/account/AccountArea.tsx b/app/src/features/account/AccountArea.tsx index 2464a4b0..9a657469 100644 --- a/app/src/features/account/AccountArea.tsx +++ b/app/src/features/account/AccountArea.tsx @@ -12,11 +12,13 @@ import { Settings, Sparkles, } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "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"; @@ -37,23 +39,11 @@ export function AccountArea() { const [dialog, setDialog] = useState<"usage" | "settings" | null>(null); const root = useRef(null); - useEffect(() => { - if (!open) { - return; - } - const onPointer = (event: PointerEvent) => { - if (!root.current?.contains(event.target as Node)) { - setOpen(false); - } - }; - const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false); - window.addEventListener("pointerdown", onPointer); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("pointerdown", onPointer); - window.removeEventListener("keydown", onKey); - }; - }, [open]); + useDismiss( + open, + root, + useCallback(() => setOpen(false), []), + ); const name = account?.name.trim() || "Botloft"; const detail = describe(account?.claude ?? null, signedOut, a); @@ -78,7 +68,7 @@ export function AccountArea() { aria-expanded={open} aria-label={a.open(name)} onClick={() => setOpen(!open)} - className={`flex w-full items-center gap-2.5 px-2 py-1.5 text-left hover:bg-sunken ${open ? "bg-sunken" : ""}`} + className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-sunken ${open ? "bg-sunken" : ""}`} > {email && ( -

    +

    {email}

    )} @@ -161,7 +151,7 @@ function AccountMenu({
    {[{ id: "system" as const, name: t.shell.language.system(systemName) }, ...LOCALES].map( (entry) => ( @@ -171,7 +161,7 @@ function AccountMenu({ role="menuitemradio" aria-checked={choice === entry.id} onClick={() => setLocaleChoice(entry.id)} - className="flex h-8 w-full items-center gap-2 whitespace-nowrap px-3 text-left text-sm hover:bg-sunken" + className={`h-8 whitespace-nowrap ${POPOVER_ITEM}`} > {entry.name} @@ -181,7 +171,7 @@ function AccountMenu({
    )}
    -
    +
    open(RELEASES)}> {m.whatsNew} @@ -212,7 +202,7 @@ function Item({ aria-haspopup={expanded === undefined ? undefined : "menu"} aria-expanded={expanded} onClick={onClick} - className="flex h-8 w-full items-center gap-2 px-3 text-left text-ink text-sm hover:bg-sunken" + className={`h-8 text-ink ${POPOVER_ITEM}`} > {children} diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index a6b89766..b871572d 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -25,7 +25,7 @@ export function BotAvatar({ height={size} // A little room around the body, so its edge is not cut at the sides. viewBox={framed ? "0 0 1254 1254" : "-24 -24 1302 1302"} - className={`shrink-0 ${framed ? "rounded-[3px] bg-[#0b0b0b]" : ""}`} + className={`shrink-0 ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`} > 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 6f6e9ec4..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,7 +60,7 @@ function OtherMessage({ message }: { message: Message }) { const system = message.fromKind === "system"; const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); return ( -

  • +
  • {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}

    diff --git a/app/src/index.css b/app/src/index.css index 8aadd283..2f3962ec 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -25,6 +25,8 @@ --idle-state: #6f6f69; /* The thin edge around a bot's avatar, so light colors hold their shape. */ --avatar-edge: rgb(0 0 0 / 0.2); + /* Under menus, pickers and dialogs. */ + --lift: rgb(0 0 0 / 0.12); } [data-theme="dark"] { @@ -44,6 +46,7 @@ --danger: #ff6d60; --idle-state: #8c8c86; --avatar-edge: rgb(255 255 255 / 0.14); + --lift: rgb(0 0 0 / 0.5); } @theme inline { @@ -61,6 +64,7 @@ --color-warn: var(--warn); --color-danger: var(--danger); --color-quiet: var(--idle-state); + --shadow-lift: 0 12px 32px -8px var(--lift), 0 2px 8px -2px var(--lift); } @theme { @@ -124,6 +128,10 @@ margin-top: 0.6em; } + .chat-md > :first-child { + margin-top: 0; + } + .chat-md :is(h1, h2, h3, h4) { font-weight: 600; letter-spacing: -0.01em; @@ -163,7 +171,7 @@ font-size: 0.9em; background: var(--sunken); padding: 0.1em 0.35em; - border-radius: 3px; + border-radius: 5px; } .chat-md pre { @@ -172,8 +180,8 @@ line-height: 1.5; background: var(--sunken); border: 1px solid var(--line); - border-radius: 4px; - padding: 0.7em 0.9em; + border-radius: 10px; + padding: 0.75em 1em; overflow-x: auto; overflow-wrap: normal; } @@ -190,18 +198,31 @@ } .chat-md table { - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; display: block; + width: fit-content; + max-width: 100%; overflow-x: auto; font-size: 0.8125rem; + border: 1px solid var(--line); + border-radius: 10px; } .chat-md :is(th, td) { - border: 1px solid var(--line); - padding: 0.3em 0.6em; + border-bottom: 1px solid var(--line); + padding: 0.4em 0.75em; text-align: left; } + .chat-md :is(th, td) + :is(th, td) { + border-left: 1px solid var(--line); + } + + .chat-md tbody tr:last-child > td { + border-bottom: 0; + } + .chat-md th { background: var(--sunken); font-weight: 600; diff --git a/app/src/ui/Button.tsx b/app/src/ui/Button.tsx index 58644b1f..fab38673 100644 --- a/app/src/ui/Button.tsx +++ b/app/src/ui/Button.tsx @@ -35,7 +35,7 @@ export function Button({ type={type} aria-label={label} title={label} - className={`inline-flex shrink-0 items-center justify-center gap-1.5 rounded-[3px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 ${height} ${padding} ${variants[variant]} ${className}`} + className={`inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg font-medium transition-colors disabled:pointer-events-none disabled:opacity-45 ${height} ${padding} ${variants[variant]} ${className}`} {...rest} > {Icon && } diff --git a/app/src/ui/Callout.tsx b/app/src/ui/Callout.tsx index 661df278..49891ec1 100644 --- a/app/src/ui/Callout.tsx +++ b/app/src/ui/Callout.tsx @@ -4,9 +4,9 @@ import type { ReactNode } from "react"; type Tone = "info" | "warn" | "danger"; const tones: Record = { - info: { icon: Info, frame: "border-line-strong", mark: "text-work" }, - warn: { icon: TriangleAlert, frame: "border-warn/60", mark: "text-warn" }, - danger: { icon: CircleAlert, frame: "border-danger/60", mark: "text-danger" }, + info: { icon: Info, frame: "border-line bg-panel", mark: "text-work" }, + warn: { icon: TriangleAlert, frame: "border-warn/45 bg-warn/6", mark: "text-warn" }, + danger: { icon: CircleAlert, frame: "border-danger/45 bg-danger/6", mark: "text-danger" }, }; export function Callout({ @@ -24,7 +24,7 @@ export function Callout({ return (
    diff --git a/app/src/ui/Choices.tsx b/app/src/ui/Choices.tsx index b6b18db5..3fcf409e 100644 --- a/app/src/ui/Choices.tsx +++ b/app/src/ui/Choices.tsx @@ -23,7 +23,7 @@ export function Choices({ return (