Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
4 changes: 3 additions & 1 deletion app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -25,7 +27,7 @@ export function App({ host, connect }: { host: Host; connect: Connect }) {
</DaemonProvider>
) : (
<div className="flex h-full flex-col">
<TitleBar />
<TitleBar language />
<Onboarding link={link} />
</div>
)}
Expand Down
38 changes: 36 additions & 2 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -52,7 +52,31 @@ function liveProps(port: number): { host: Host; connect: Connect } {
};
}

/** A FakeHost whose zoom shows in a plain browser, like the app's does. */
function previewHost(): FakeHost {
const host = new FakeHost();
host.setZoom = (factor) => {
host.zoom = factor;
document.documentElement.style.zoom = String(factor);
return Promise.resolve();
};
return host;
}

function seed(fake: FakeBotloft): void {
const now = Date.now();
fake.system = {
...fake.system,
usage: {
status: "allowed",
resetsAt: now + 2 * 3600_000,
observedAt: now - 5 * 60_000,
windows: [
{ name: "five_hour", utilization: 0.34, resetsAt: now + 2 * 3600_000 },
{ name: "seven_day", utilization: 0.62, resetsAt: now + 3 * 86_400_000 },
],
},
};
const research = fake.addCrew("Research");
const scout = fake.addBot(research.id, "Scout", "Finds sources and summarizes them");
const writer = fake.addBot(research.id, "Writer", "Turns notes into a weekly report");
Expand All @@ -62,15 +86,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");
Expand Down
24 changes: 23 additions & 1 deletion app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
212 changes: 212 additions & 0 deletions app/src/features/account/AccountArea.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div ref={root} className="relative shrink-0 border-line border-t p-2">
{open && (
<AccountMenu
name={name}
email={account?.claude?.email ?? null}
onShow={show}
onClose={() => setOpen(false)}
/>
)}
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={a.open(name)}
onClick={() => setOpen(!open)}
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-sunken ${open ? "bg-sunken" : ""}`}
>
<span
aria-hidden
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-ink font-semibold text-canvas text-sm"
>
{Array.from(name)[0]?.toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-sm">{name}</span>
{detail && <span className="block truncate text-muted text-xs">{detail}</span>}
</span>
<ChevronsUpDown aria-hidden size={14} className="shrink-0 text-muted" />
</button>
{dialog === "usage" && <UsageDialog onClose={() => setDialog(null)} />}
{dialog === "settings" && <SettingsDialog onClose={() => setDialog(null)} />}
</div>
);
}

function describe(
claude: ClaudeAccount | null,
signedOut: boolean,
a: ReturnType<typeof useT>["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 (
<div
role="menu"
aria-label={name}
className={`absolute right-2 bottom-full left-2 mb-1 ${POPOVER}`}
>
{email && (
<p className="truncate px-2.5 pt-1.5 pb-2 text-muted text-xs" data-selectable>
{email}
</p>
)}
<Item icon={ChartColumn} onClick={() => onShow("usage")}>
{m.usage}
</Item>
<Item icon={Settings} onClick={() => onShow("settings")}>
{m.settings}
</Item>
<div className="relative">
<Item
icon={Languages}
onClick={() => setLanguages(!languages)}
expanded={languages}
trailing={<ChevronRight aria-hidden size={14} className="text-muted" />}
>
{m.language}
</Item>
{languages && (
<div
role="menu"
aria-label={m.language}
className={`absolute bottom-0 left-full ml-2 min-w-56 ${POPOVER}`}
>
{[{ id: "system" as const, name: t.shell.language.system(systemName) }, ...LOCALES].map(
(entry) => (
<button
key={entry.id}
type="button"
role="menuitemradio"
aria-checked={choice === entry.id}
onClick={() => setLocaleChoice(entry.id)}
className={`h-8 whitespace-nowrap ${POPOVER_ITEM}`}
>
<Check aria-hidden size={14} className={choice === entry.id ? "" : "invisible"} />
{entry.name}
</button>
),
)}
</div>
)}
</div>
<div className="mx-1 my-1 border-line border-t" />
<Item icon={Sparkles} onClick={() => open(RELEASES)}>
{m.whatsNew}
</Item>
<Item icon={CircleHelp} onClick={() => open(HELP)}>
{m.help}
</Item>
</div>
);
}

function Item({
icon: Icon,
onClick,
expanded,
trailing,
children,
}: {
icon: typeof Settings;
onClick(): void;
expanded?: boolean;
trailing?: ReactNode;
children: ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
aria-haspopup={expanded === undefined ? undefined : "menu"}
aria-expanded={expanded}
onClick={onClick}
className={`h-8 text-ink ${POPOVER_ITEM}`}
>
<Icon aria-hidden size={14} className="text-ink-soft" />
<span className="flex-1">{children}</span>
{trailing}
</button>
);
}
Loading
Loading