diff --git a/Cargo.lock b/Cargo.lock index 51a3492b..e1e7a46d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-updater", "toml 1.1.6+spec-1.1.0", ] @@ -3114,6 +3115,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4096,6 +4121,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7527c5fd1678032d116c1a82e5d609c67370e56f858ae6cfd376bbca37fe0490" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.13.0" diff --git a/app/package.json b/app/package.json index f08fb286..21a96647 100644 --- a/app/package.json +++ b/app/package.json @@ -21,6 +21,7 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2.12.0", + "@tauri-apps/plugin-dialog": "^2.8.0", "@tauri-apps/plugin-updater": "^2.13.0", "lucide-react": "^1.48.0", "react": "^19.3.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 9226cf85..7463e88b 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: '@tauri-apps/api': specifier: ^2.12.0 version: 2.12.0 + '@tauri-apps/plugin-dialog': + specifier: ^2.8.0 + version: 2.8.0 '@tauri-apps/plugin-updater': specifier: ^2.13.0 version: 2.13.0 @@ -665,6 +668,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.8.0': + resolution: {integrity: sha512-HSSD5C8UFE9ijD1wue84F7jjaIReFmj8AAPtS+xyjYRltJafIc4BiWHY7SJ6vbbew6u+YljEJHfyNfy5VwywXg==} + '@tauri-apps/plugin-updater@2.13.0': resolution: {integrity: sha512-9iwTSOlxMPZGI6M84Nd00rv6g+xERo8g4GDvG9cazOao+HjdIHZYCcvkvSP6JGn7Z50/R8t2jbR+PxQxf2qKMQ==} @@ -1970,6 +1976,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.12.0 '@tauri-apps/cli-win32-x64-msvc': 2.12.0 + '@tauri-apps/plugin-dialog@2.8.0': + dependencies: + '@tauri-apps/api': 2.12.0 + '@tauri-apps/plugin-updater@2.13.0': dependencies: '@tauri-apps/api': 2.12.0 diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index af714b01..23e08d68 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ dirs = "7.0.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" tauri-plugin-updater = "2" toml = "1.1.6" diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 88596018..9eff8b62 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -12,6 +12,8 @@ "core:window:allow-toggle-maximize", "core:window:allow-close", "core:window:allow-set-overlay-icon", - "updater:default" + "core:webview:allow-set-webview-zoom", + "updater:default", + "dialog:allow-open" ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c969bd49..ae76d0ad 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -99,6 +99,8 @@ pub fn run() { tauri::Builder::default() // Checks the release feed and installs signed updates (spec 15.5). .plugin(tauri_plugin_updater::Builder::new().build()) + // The folder picker for a crew's work folder (spec 5). + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ daemon_status, daemon_install, 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..9840cf36 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -11,6 +11,7 @@ import { FakeHost } from "../lib/fakeHost"; import type { Host } from "../lib/host"; import { PROTOCOL_VERSION } from "../lib/protocol.gen"; import { seedChats } from "./seedChats"; +import { seedChief } from "./seedChief"; export function previewProps(search: string): { host: Host; connect: Connect } { const params = new URLSearchParams(search); @@ -19,7 +20,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 +35,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 +53,33 @@ 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(); + }; + // A plain browser has no folder picker: every pick gives this folder. + host.nextFolder = "D:\\Projects\\Bakery site"; + 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"); @@ -62,15 +89,25 @@ function seed(fake: FakeBotloft): void { "Checks facts and tone before anything ships", ); const analyst = fake.addBot(research.id, "Analyst", "Crunches the numbers behind each claim"); + const planner = fake.addBot(research.id, "Planner", "Plans changes before touching the code"); + void fake.call("bots.setPermissionMode", { botId: planner.id, mode: "plan" }); + void fake.call("bots.setModel", { botId: analyst.id, model: "haiku" }); + // What Claude Code reported: the plan's default, and the chosen Haiku. + for (const bot of [scout, writer, reviewer, planner]) { + fake.bot(bot.id).modelInUse = "claude-opus-5-5"; + } + fake.bot(analyst.id).modelInUse = "claude-haiku-4-5-20251001"; fake.setBotState(scout.id, "idle", 3); fake.setBotState(writer.id, "busy", 2); fake.setBotState(reviewer.id, "needs_approval", 5); fake.setBotState(analyst.id, "rate_limited", 1); + fake.setBotState(planner.id, "needs_approval", 2); seedChats(fake, { scout: scout.id, writer: writer.id, reviewer: reviewer.id, analyst: analyst.id, + planner: planner.id, }); const ops = fake.addCrew("Ops"); const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays"); @@ -78,4 +115,5 @@ function seed(fake: FakeBotloft): void { void fake.call("crews.setPaused", { crewId: ops.id, paused: true }); fake.setBotState(deploy.id, "offline"); fake.setBotState(watcher.id, "offline"); + seedChief(fake); } diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index 047ef34e..d06a23f9 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -1,4 +1,4 @@ -// Dev only: a morning's work between four research bots, for the preview. +// Dev only: a morning's work between five research bots, for the preview. import type { FakeBotloft } from "../lib/fake"; import type { BotId } from "../lib/protocol.gen"; @@ -23,11 +23,22 @@ const SUMMARY = `Done. The summary is in \`shared/summaries/week-39.md\`. Your chart matches the numbers in the first paper. I asked @writer to turn this into the weekly report.`; +const PLAN = `## Cache the weekly report + +The report is rebuilt on every visit, and Monday is when everyone opens it. + +1. Build the report once when the week closes and save it in \`shared/cache/\`. +2. Serve the saved copy; rebuild it only when a source file changes. +3. Add a test that the saved copy matches a fresh build. + +**Not changing:** the report's layout or its sources.`; + interface Crew { scout: BotId; writer: BotId; reviewer: BotId; analyst: BotId; + planner: BotId; } const MINUTE = 60 * 1000; @@ -124,5 +135,16 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { text: "The account reached its usage limit. Messages wait until it resets.", }); chat.turn(crew.analyst, "rate_limit"); + + at(8); + void fake.call("messages.send", { + botId: crew.planner, + body: "The report pages are slow on Mondays. Plan a fix, but don't change anything yet.", + }); + at(7); + chat.finish(chat.tool(crew.planner, "Grep", { summary: "buildReport" }), "3 matches"); + const plan = JSON.stringify({ plan: PLAN }); + chat.tool(crew.planner, "ExitPlanMode", { summary: "Cache the weekly report", input: plan }); + chat.ask(crew.planner, "ExitPlanMode", "Cache the weekly report", plan); fake.now = end; } diff --git a/app/src/dev/seedChief.ts b/app/src/dev/seedChief.ts new file mode 100644 index 00000000..1e7ceac0 --- /dev/null +++ b/app/src/dev/seedChief.ts @@ -0,0 +1,57 @@ +// Dev only: a crew led by its chief, who asks the owner for a new bot. + +import type { FakeBotloft } from "../lib/fake"; +import { SUGGEST_TOOL } from "../lib/fakeChat"; + +const PLAN = `Here's how I'd split it: + +1. **Pages and copy**: I write the menu, hours and "about us" text myself. +2. **Look and feel**: this needs a designer. The crew has none, so I'm suggesting one. +3. **Build**: once the design is ready, @writer and I turn it into the site in the work folder. + +I'll hand out the first tasks as soon as you approve the designer.`; + +const DESIGNER = { + name: "Designer", + role: "Designs the pages: layout, colors and photos", + instructions: + "Design the bakery's site: home, menu, hours and contact. Put the mockups in design/ in the work folder, one image per page, and tell @chief when each is ready. Keep it warm and simple; the owner likes the colors of the shop's sign.", + model: "sonnet", + reason: "Nobody in the crew designs; the site needs a look before anyone builds it.", +}; + +export function seedChief(fake: FakeBotloft): void { + void fake + .call("crews.create", { + name: "Bakery site", + workFolder: "D:\\Projects\\Bakery site", + lead: { + name: "Chief", + role: "Leads the crew: plans the work, suggests new bots and hands out tasks", + instructions: "Build and keep up the website of my bakery.", + }, + }) + .then((crew) => { + const chief = crew.leadBotId; + if (!chief) { + return; + } + const writer = fake.addBot(crew.id, "Writer", "Writes the pages' text"); + fake.setBotState(writer.id, "idle", 1); + fake.bot(chief).modelInUse = "claude-opus-5-5"; + const { chat } = fake; + void fake.call("messages.send", { + botId: chief, + body: "Let's build the website for my bakery. What do you need?", + }); + chat.finish( + chat.tool(chief, "mcp__botloft__crew_roster", {}), + '{"bots":[{"handle":"writer"}]}', + ); + chat.reply(chief, PLAN); + const input = JSON.stringify(DESIGNER); + chat.tool(chief, SUGGEST_TOOL, { summary: "Designer", input }); + chat.ask(chief, SUGGEST_TOOL, "Designer", input); + fake.setBotState(chief, "needs_approval", 1); + }); +} diff --git a/app/src/features/account/AccountArea.tsx b/app/src/features/account/AccountArea.tsx new file mode 100644 index 00000000..b60b71b4 --- /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..8f7cb67e --- /dev/null +++ b/app/src/features/account/UsageDialog.tsx @@ -0,0 +1,68 @@ +// 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..d98950c0 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -1,23 +1,98 @@ // 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. +// +// Given a `mood`, the mascot moves (motion.css): it blinks now and then, +// flickers while the bot works, hops while it waits for the owner and +// sleeps while paused. Without one it stays still, as in the chat history. + +import type { CSSProperties } from "react"; +import type { Bot } from "../../lib/protocol.gen"; 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 type Mood = "idle" | "working" | "waiting" | "tired" | "sleeping"; + +/** How the mascot of `bot` moves, from what the bot is doing. */ +export function moodOf(bot: Pick, crewPaused = false): Mood { + if (bot.paused || crewPaused || bot.state === "offline" || bot.state === "archived") { + return "sleeping"; + } + switch (bot.state) { + case "busy": + case "launching": + return "working"; + case "needs_approval": + return "waiting"; + case "rate_limited": + return "tired"; + default: + return "idle"; + } +} + +/** A delay from the color, so a crew's mascots do not blink together. */ +function blinkDelay(color: string): string { + let hash = 0; + for (const char of color) { + hash = (hash * 31 + char.charCodeAt(0)) % 7000; + } + return `-${hash}ms`; +} + +export function BotAvatar({ + color, + size = 28, + framed = false, + mood, +}: { + color: string; + size?: number; + framed?: boolean; + mood?: Mood | undefined; +}) { + const style = mood ? ({ "--blink-delay": blinkDelay(color) } as CSSProperties) : undefined; return ( - - - - - + + + + + + + + + + + ); } diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx index 49de0e25..756ae5da 100644 --- a/app/src/features/bots/BotDialog.tsx +++ b/app/src/features/bots/BotDialog.tsx @@ -2,13 +2,21 @@ import { Check } from "lucide-react"; import { type FormEvent, useState } from "react"; import { useT } from "../../i18n"; import { errorText } from "../../lib/api"; -import { AVATAR_PALETTE, type Bot, type CrewId, FIELD_LIMITS } from "../../lib/protocol.gen"; +import { + AVATAR_PALETTE, + type Bot, + type BotModel, + type CrewId, + FIELD_LIMITS, +} from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Dialog } from "../../ui/Dialog"; -import { TextArea, TextField } from "../../ui/Field"; +import { SelectField, TextArea, TextField } from "../../ui/Field"; import { BotAvatar } from "./BotAvatar"; +const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"]; + type Props = { onClose(): void } & ({ crewId: CrewId; bot?: undefined } | { bot: Bot }); /** Creates a bot in `crewId`, or edits `bot`. */ @@ -22,6 +30,7 @@ export function BotDialog(props: Props) { const [role, setRole] = useState(editing?.role ?? ""); const [instructions, setInstructions] = useState(editing?.instructions ?? ""); const [color, setColor] = useState(editing?.color); + const [model, setModel] = useState(editing?.model ?? "default"); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -39,6 +48,10 @@ export function BotDialog(props: Props) { ...(instructions !== editing.instructions && { instructions }), ...(color !== undefined && color !== editing.color && { color }), }); + if (model !== editing.model) { + // The bot restarts on it once nothing is in progress. + bot = await api.call("bots.setModel", { botId: editing.id, model }); + } } else { bot = await api.call("bots.create", { crewId: props.crewId, @@ -46,6 +59,7 @@ export function BotDialog(props: Props) { role, instructions, ...(color !== undefined && { color }), + ...(model !== "default" && { model }), }); } putBot(bot); @@ -101,6 +115,13 @@ export function BotDialog(props: Props) { placeholder={t.bots.dialog.instructionsPlaceholder} hint={editing ? t.bots.dialog.instructionsHint : undefined} /> + setModel(event.target.value as BotModel)} + options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))} + hint={t.chat.model.cost} + />
    {t.bots.dialog.color} @@ -113,7 +134,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/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index 96f228a9..0cd88b7b 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -1,5 +1,6 @@ import { Archive, + Crown, Ellipsis, FolderOpen, PanelRight, @@ -8,6 +9,7 @@ import { Play, RefreshCcw, RotateCw, + ShieldOff, } from "lucide-react"; import { useState } from "react"; import { useT } from "../../i18n"; @@ -17,9 +19,10 @@ import { Button } from "../../ui/Button"; import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; -import { BotAvatar } from "./BotAvatar"; +import { BotAvatar, moodOf } from "./BotAvatar"; import { BotDialog } from "./BotDialog"; import { BotStateBadge } from "./BotStateBadge"; +import { ChiefBadge, isChief } from "./ChiefBadge"; type Open = "edit" | "archive" | "fresh" | null; @@ -40,6 +43,8 @@ export function BotHeader({ const api = useApi(); const host = useHost(); const putBot = useApp((state) => state.putBot); + const putCrew = useApp((state) => state.putCrew); + const chief = isChief(bot, crew); const [open, setOpen] = useState(null); const close = () => setOpen(null); const stopped = bot.paused || crew.paused; @@ -48,6 +53,10 @@ export function BotHeader({ attempt(paused ? words.failed.pause : words.failed.resume, async () => putBot(await api.call("bots.setPaused", { botId: bot.id, paused })), ); + const setChief = (on: boolean) => + attempt(words.failed.chief, async () => + putCrew(await api.call("crews.setLead", { crewId: crew.id, botId: on ? bot.id : null })), + ); const restart = (fresh: boolean) => attempt(words.failed.restart, async () => putBot(await api.call("bots.restart", { botId: bot.id, fresh })), @@ -55,7 +64,7 @@ export function BotHeader({ return (
    - +

    {bot.name}

    @@ -65,6 +74,16 @@ export function BotHeader({
    + {chief && } + {bot.permissionMode === "bypass_permissions" && ( + + + {t.chat.mode.badge} + + )} {bot.role || words.noRole}
    @@ -92,6 +111,11 @@ export function BotHeader({ icon={Ellipsis} items={[ { label: words.edit, icon: Pencil, onSelect: () => setOpen("edit") }, + { + label: chief ? words.stopChief : words.makeChief, + icon: Crown, + onSelect: () => setChief(!chief), + }, { label: words.restartFresh, icon: RefreshCcw, diff --git a/app/src/features/bots/BotStateBadge.tsx b/app/src/features/bots/BotStateBadge.tsx index b7163d73..8a9b830a 100644 --- a/app/src/features/bots/BotStateBadge.tsx +++ b/app/src/features/bots/BotStateBadge.tsx @@ -82,9 +82,15 @@ export function BotStateBadge({ return ( - + {view.label} ); diff --git a/app/src/features/bots/ChiefBadge.tsx b/app/src/features/bots/ChiefBadge.tsx new file mode 100644 index 00000000..56dc244d --- /dev/null +++ b/app/src/features/bots/ChiefBadge.tsx @@ -0,0 +1,29 @@ +// Marks the bot that leads its crew (spec 10.2): a crown, with "Chief" in +// full where there is room. + +import { Crown } from "lucide-react"; +import { useT } from "../../i18n"; +import type { Bot, Crew } from "../../lib/protocol.gen"; + +export const isChief = (bot: Bot, crew: Crew) => crew.leadBotId === bot.id; + +export function ChiefBadge({ crew, compact = false }: { crew: Crew; compact?: boolean }) { + const c = useT().bots.chief; + if (compact) { + return ( + + + {c.badge} + + ); + } + return ( + + + {c.badge} + + ); +} diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index cc7e1fdd..aa64fef3 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -2,18 +2,19 @@ // and Deny. Once answered it shrinks to one line. import { Ban, Check, Hand, TimerOff } from "lucide-react"; -import { useId, useState } from "react"; +import { useId } from "react"; import { useT } from "../../i18n"; import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; -import { useApi } from "../../store/context"; import { Button } from "../../ui/Button"; -import { attempt } from "../../ui/toast"; +import { PLAN_TOOL, PlanCard } from "./PlanCard"; +import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; import { pretty, toolLabel } from "./ToolLines"; +import { useAnswer } from "./useAnswer"; function Answered({ approval }: { approval: ApprovalItem }) { const t = useT(); const label = toolLabel(approval.toolName); - const line = "flex min-w-0 items-center gap-2 px-1.5 py-1 text-sm"; + const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm"; switch (approval.status) { case "allowed": return ( @@ -44,48 +45,50 @@ function Answered({ approval }: { approval: ApprovalItem }) { } export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { - const api = useApi(); + switch (approval.toolName) { + case PLAN_TOOL: + return ; + case SUGGEST_TOOL: + return ; + default: + return ; + } +} + +function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { const t = useT(); - const [note, setNote] = useState(""); - const [busy, setBusy] = useState(false); + const { note, setNote, busy, answer } = useAnswer(approval, { + allow: t.chat.approval.allowFailed, + deny: t.chat.approval.denyFailed, + }); const noteId = useId(); if (approval.status !== "pending") { return ; } const label = toolLabel(approval.toolName); - const answer = async (allow: boolean) => { - setBusy(true); - const trimmed = note.trim(); - await attempt(allow ? t.chat.approval.allowFailed : t.chat.approval.denyFailed, () => - api.call("approvals.answer", { - approvalId: approval.approvalId, - allow, - ...(allow || !trimmed ? {} : { note: trimmed }), - }), - ); - setBusy(false); - }; return (
    -

    - +

    + + + {t.chat.approval.wants(bot.name, label)}

    {approval.summary && ( -

    +

    {approval.summary}

    )} -
    +
    {t.chat.approval.fullInput}
               {pretty(approval.input)}
    @@ -99,7 +102,7 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
             value={note}
             onChange={(event) => setNote(event.target.value)}
             placeholder={t.chat.approval.notePlaceholder}
    -        className="mt-3 h-8 w-full rounded-[3px] border border-line-strong bg-canvas px-2.5 text-sm outline-none placeholder:text-muted focus:border-accent"
    +        className="mt-3 h-9 w-full rounded-xl border border-line-strong bg-canvas px-3 text-sm outline-none placeholder:text-muted focus:border-muted"
           />
           
    @@ -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} > {children}
    : children; +} + function Working() { const t = useT(); return ( @@ -45,10 +53,10 @@ function Working() { className="flex items-center gap-1 py-1 text-muted" aria-label={t.chat.run.working} > - {[0, 150, 300].map((delay) => ( + {[0, 160, 320].map((delay) => ( ))} @@ -67,9 +75,12 @@ export function BotRun({ }) { const first = items[0]; return ( -
  • - -
    + // A turn shows up as the bot starts it, still empty; what it does then + // arrives inside, so a run with items never animates as a whole. +
  • + {/* Only the run in progress moves, like the bot it stands for. */} + +
    {bot.name} {first && ( @@ -84,18 +95,27 @@ export function BotRun({ case "tool": return ; case "reply": + // It was already on screen as it was written. return ; case "approval": - return ; + return ( + + + + ); case "turn": - return ; + return ( + + + + ); default: return null; } })} {live?.draft && ( -
    - +
    +
    )} {live && !live.draft && live.working && } diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index f1b25d84..2fc1a641 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -6,6 +6,7 @@ import { type ClipboardEvent, type FormEvent, type KeyboardEvent, + useEffect, useId, useLayoutEffect, useRef, @@ -17,6 +18,8 @@ import { fileSize } from "../../lib/format"; import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; import { useApi } from "../../store/context"; import { rememberImage } from "./images"; +import { ModelPicker } from "./ModelPicker"; +import { ModePicker } from "./ModePicker"; import type { Files, PendingFile } from "./useFiles"; const MAX_HEIGHT_PX = 240; @@ -24,9 +27,9 @@ const MAX_HEIGHT_PX = 240; function Chip({ file, onRemove }: { file: PendingFile; onRemove(): void }) { const t = useT(); return ( -
  • +
  • {file.preview ? ( - + ) : ( )} @@ -64,12 +67,21 @@ export function ChatComposer({ const t = useT(); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); + /** A new mode or model that waits for the bot to finish what it is doing. */ + const [later, setLater] = useState(null); const field = useRef(null); const picker = useRef(null); const fieldId = useId(); const tooLong = text.length > FIELD_LIMITS.message; const empty = !text.trim() && files.files.length === 0; + const working = bot.state === "busy" || bot.state === "needs_approval"; + useEffect(() => { + if (!working) { + setLater(null); + } + }, [working]); + // Grows with the text up to a limit, then scrolls. useLayoutEffect(() => { const element = field.current; @@ -130,14 +142,18 @@ export function ChatComposer({ }; return ( -
    - {stopped &&

    {t.chat.composer.paused(bot.name)}

    } -
    + + {stopped && ( +

    {t.chat.composer.paused(bot.name)}

    + )} + {later && ( +

    + {later} +

    + )} +
    {files.files.length > 0 && ( -
      +
        {files.files.map((file) => ( files.remove(file.key)} /> ))} @@ -155,15 +171,15 @@ export function ChatComposer({ onKeyDown={onKeyDown} onPaste={onPaste} placeholder={t.chat.composer.placeholder(bot.name)} - className="block max-h-60 w-full resize-none bg-transparent px-3.5 pt-3 pb-1 leading-relaxed outline-none placeholder:text-muted" + className="block max-h-60 w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 leading-relaxed outline-none placeholder:text-muted" /> -
        +
        @@ -178,18 +194,27 @@ export function ChatComposer({ event.target.value = ""; }} /> - + + {tooLong ? t.chat.composer.tooLong(text.length, FIELD_LIMITS.message) - : t.chat.composer.keys} + : text && t.chat.composer.keys} +
        diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 82b7e59d..8e8754ff 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -2,13 +2,14 @@ // written, and the composer. Files dropped anywhere on it are attached. import { LoaderCircle, Paperclip } from "lucide-react"; -import { type DragEvent, type ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { type DragEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n"; import { day } from "../../lib/format"; import type { Bot } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; -import { BotAvatar } from "../bots/BotAvatar"; +import { SeenSince } from "../../ui/motion"; +import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; @@ -31,6 +32,10 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { /** Distance from the bottom to keep while older items load above. */ const anchor = useRef(null); const [dragging, setDragging] = useState(false); + // What was there when the owner opened this chat stays still; what + // arrives while they look animates in. + // biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat + const openedAt = useMemo(() => Date.now(), [bot.id]); const last = chat.items.at(-1); /** Changes whenever the end of the chat does. */ const end = `${chat.items.length}/${last?.id}/${last?.updatedAt}/${chat.draft.length}`; @@ -67,10 +72,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)} +
      • ); } @@ -78,7 +83,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { return ; } if (row.kind === "notice") { - return ; + return ; } const isLast = index === rows.length - 1; return ( @@ -126,7 +131,7 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { element.scrollHeight - element.scrollTop - element.clientHeight < STICKY_PX; }} > -
        +
        {chat.error && ( {chat.error} @@ -147,18 +152,22 @@ export function ChatView({ bot, stopped }: { bot: Bot; stopped: boolean }) { )} {chat.items.length === 0 && !chat.loading && !chat.error && !live && (
        - + + +

        {t.chat.view.emptyTitle(bot.name)}

        {bot.role &&

        {bot.role}

        }

        {t.chat.view.emptyBody}

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

        {t.chat.view.dropToAttach} diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 39eb95a1..84e41f93 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -6,6 +6,7 @@ import { useT } from "../../i18n"; import { when } from "../../lib/format"; import type { Bot, Message } from "../../lib/protocol.gen"; import { useApp } from "../../store/context"; +import { useArrival } from "../../ui/motion"; import { BotAvatar } from "../bots/BotAvatar"; import { DeliveryStatus } from "../messages/DeliveryStatus"; import { AttachmentList } from "./AttachmentList"; @@ -20,12 +21,13 @@ function Time({ at }: { at: number }) { function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) { const delivery = useApp((state) => state.deliveries[message.id]); + const arrival = useArrival(message.createdAt); return ( -

      • +
      • {message.body && (

        {message.body} @@ -46,7 +48,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} @@ -59,9 +61,14 @@ function OtherMessage({ message }: { message: Message }) { const delivery = useApp((state) => state.deliveries[message.id]); const system = message.fromKind === "system"; const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); + const arrival = useArrival(message.createdAt); return ( -

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

        {message.body} diff --git a/app/src/features/chat/Markdown.tsx b/app/src/features/chat/Markdown.tsx index 0f659c72..f1cdaaca 100644 --- a/app/src/features/chat/Markdown.tsx +++ b/app/src/features/chat/Markdown.tsx @@ -60,9 +60,16 @@ const components: Components = { img: ({ src, alt }) => , }; -export const Markdown = memo(function Markdown({ text }: { text: string }) { +/** `streaming` shows a caret after the text, while the bot still writes it. */ +export const Markdown = memo(function Markdown({ + text, + streaming = false, +}: { + text: string; + streaming?: boolean; +}) { return ( -

        +
        {text} diff --git a/app/src/features/chat/ModePicker.tsx b/app/src/features/chat/ModePicker.tsx new file mode 100644 index 00000000..22b6f0af --- /dev/null +++ b/app/src/features/chat/ModePicker.tsx @@ -0,0 +1,137 @@ +// How much the bot may do without asking (spec 7.4), picked below the chat +// as in Claude Code. Bypassing permissions asks first: the bot is then not +// held to its folder. + +import { + ChevronDown, + FilePen, + Hand, + ListTodo, + type LucideIcon, + ShieldOff, + WandSparkles, +} from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import type { Bot, PermissionMode } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Confirm } from "../../ui/Confirm"; +import { POPOVER } from "../../ui/surface"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; +import { PickerOption } from "./PickerOption"; + +const MODES: { mode: PermissionMode; icon: LucideIcon }[] = [ + { mode: "auto", icon: WandSparkles }, + { mode: "default", icon: Hand }, + { mode: "accept_edits", icon: FilePen }, + { mode: "plan", icon: ListTodo }, +]; + +export function ModePicker({ bot, onLater }: { bot: Bot; onLater(text: string): void }) { + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const chief = useApp((state) => state.crews[bot.crewId]?.leadBotId === bot.id); + const m = useT().chat.mode; + const [open, setOpen] = useState(false); + const [confirming, setConfirming] = useState(false); + const root = useRef(null); + const close = useCallback(() => setOpen(false), []); + useDismiss(open, root, close); + + const current = bot.permissionMode; + const bypass = current === "bypass_permissions"; + const Icon = bypass ? ShieldOff : (MODES.find((entry) => entry.mode === current)?.icon ?? Hand); + + const choose = async (mode: PermissionMode) => { + setOpen(false); + if (mode === current) { + return; + } + // The bot restarts into the new mode once nothing is in progress. + const working = bot.state === "busy" || bot.state === "needs_approval"; + await attempt(m.failed, async () => { + putBot(await api.call("bots.setPermissionMode", { botId: bot.id, mode })); + if (working) { + onLater(m.later(bot.name, m.names[mode])); + } + }); + }; + + return ( +
        + {open && ( +
        +

        {m.title}

        + {MODES.map(({ mode, icon }) => ( + choose(mode)} + /> + ))} +
        + { + setOpen(false); + if (!bypass) { + setConfirming(true); + } + }} + trailing={ + + {m.turnOn} + + } + /> +
        + )} + + {confirming && ( + choose("bypass_permissions")} + onClose={() => setConfirming(false)} + > +

        {m.bypassBody(bot.name)}

        +

        {m.bypassRisk}

        + {chief &&

        {m.bypassChief(bot.name)}

        } +

        {m.bypassAdvice}

        +
        + )} +
        + ); +} diff --git a/app/src/features/chat/ModelPicker.tsx b/app/src/features/chat/ModelPicker.tsx new file mode 100644 index 00000000..f0d37a12 --- /dev/null +++ b/app/src/features/chat/ModelPicker.tsx @@ -0,0 +1,97 @@ +// Which Claude model the bot runs on (spec 7.4), picked below the chat as +// in Claude's apps. The plan's default shows the model Claude Code named. + +import { ChevronDown } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import { modelFamily, modelName } from "../../lib/models"; +import type { Bot, BotModel } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { POPOVER } from "../../ui/surface"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; +import { PickerOption } from "./PickerOption"; + +const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"]; + +export function ModelPicker({ bot, onLater }: { bot: Bot; onLater(text: string): void }) { + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const m = useT().chat.model; + const [open, setOpen] = useState(false); + const root = useRef(null); + const close = useCallback(() => setOpen(false), []); + useDismiss(open, root, close); + + const current = bot.model; + const inUse = bot.modelInUse ? modelName(bot.modelInUse) : null; + // The version once Claude Code has named it; until then, the choice. + const label = + current === "default" + ? (inUse ?? m.short) + : bot.modelInUse && modelFamily(bot.modelInUse) === current && inUse + ? inUse + : m.names[current]; + + const choose = async (model: BotModel) => { + setOpen(false); + if (model === current) { + return; + } + // The bot restarts on the new model once nothing is in progress. + const working = bot.state === "busy" || bot.state === "needs_approval"; + await attempt(m.failed, async () => { + putBot(await api.call("bots.setModel", { botId: bot.id, model })); + if (working) { + onLater(m.later(bot.name, m.names[model])); + } + }); + }; + + const hint = (model: BotModel) => + model === "default" ? m.hints.default(bot.name, inUse) : m.hints[model](bot.name); + + return ( +
        + {open && ( +
        +

        {m.title}

        + {MODELS.map((model) => ( + choose(model)} + /> + ))} +

        + {m.cost} +

        +
        + )} + +
        + ); +} diff --git a/app/src/features/chat/NoticeRow.tsx b/app/src/features/chat/NoticeRow.tsx index c9ef6526..8a0f2d67 100644 --- a/app/src/features/chat/NoticeRow.tsx +++ b/app/src/features/chat/NoticeRow.tsx @@ -4,20 +4,23 @@ import { CircleAlert, Info, TriangleAlert } from "lucide-react"; import { type Messages, useT } from "../../i18n"; import type { NoticeItem } from "../../lib/protocol.gen"; +import { useArrival } from "../../ui/motion"; 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 }) { +/** `at` is when the notice came, so a new one animates in. */ +export function NoticeRow({ notice, at = 0 }: { notice: NoticeItem; at?: number }) { const t = useT(); const { icon: Icon, tone, frame } = LOOK[notice.level]; + const arrival = useArrival(at); return (
      • @@ -36,6 +39,8 @@ function noticeText(notice: NoticeItem, words: Messages["chat"]["notice"]): stri return words.usageLimit; case "turn_failed": return words.turnFailed(notice.text); + case "model_unavailable": + return words.modelUnavailable; default: return notice.text; } diff --git a/app/src/features/chat/PickerOption.tsx b/app/src/features/chat/PickerOption.tsx new file mode 100644 index 00000000..6c6a843e --- /dev/null +++ b/app/src/features/chat/PickerOption.tsx @@ -0,0 +1,47 @@ +// One choice in a picker below the chat (mode, model): a name, a line +// about what it means for the bot, and a check on the current one. + +import { Check, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { POPOVER_ITEM } from "../../ui/surface"; + +export function PickerOption({ + icon: Icon, + name, + hint, + checked, + danger = false, + trailing, + onSelect, +}: { + icon?: LucideIcon; + name: string; + hint: string; + checked: boolean; + danger?: boolean; + trailing?: ReactNode; + onSelect(): void; +}) { + return ( + + ); +} diff --git a/app/src/features/chat/PlanCard.tsx b/app/src/features/chat/PlanCard.tsx new file mode 100644 index 00000000..44157ecd --- /dev/null +++ b/app/src/features/chat/PlanCard.tsx @@ -0,0 +1,110 @@ +// A bot in plan mode asking to go ahead (spec 10.1): Claude Code asks for +// it as the ExitPlanMode tool, with the plan. Approving lets the bot work; +// sending it back keeps it planning, with what should change. + +import { Check, ChevronRight, ListTodo, PencilLine, TimerOff } from "lucide-react"; +import { useId } from "react"; +import { useT } from "../../i18n"; +import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import { Button } from "../../ui/Button"; +import { Markdown } from "./Markdown"; +import { useAnswer } from "./useAnswer"; + +export const PLAN_TOOL = "ExitPlanMode"; + +/** The plan in the request's input; the input itself if it is not JSON. */ +export function planText(input: string): string { + try { + const parsed: unknown = JSON.parse(input); + if (typeof parsed === "object" && parsed !== null && "plan" in parsed) { + const { plan } = parsed; + if (typeof plan === "string") { + return plan; + } + } + } catch { + // Cut short or not JSON: show it as it came. + } + return input; +} + +export function PlanCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const p = useT().chat.plan; + const { note, setNote, busy, answer } = useAnswer(approval, { + allow: p.approveFailed, + deny: p.keepFailed, + }); + const noteId = useId(); + const plan = planText(approval.input); + if (approval.status !== "pending") { + return ; + } + + return ( +

        +

        + + + + {p.ready(bot.name)} +

        +
        + +
        +
        + +