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
28 changes: 26 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 Down
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>
);
}
90 changes: 90 additions & 0 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog title={s.title} onClose={onClose} width="lg">
<div className="flex flex-col gap-6">
<Section title={s.appearance}>
<Field label={s.theme}>
<Choices label={s.theme} value={theme} options={themes} onChange={setTheme} />
</Field>
<Field label={s.size} hint={s.sizeHint}>
<Choices<ZoomLevel> label={s.size} value={zoom} options={sizes} onChange={setZoom} />
</Field>
</Section>
<Section title={s.language}>
<Choices
label={s.language}
value={locale}
options={languages}
onChange={setLocaleChoice}
/>
</Section>
<Section title={s.about}>
<ul className="text-ink-soft text-sm leading-relaxed" data-selectable>
{system && <li>{s.botloft(system.daemonVersion)}</li>}
{system?.claudeVersion && <li>{s.claudeCode(system.claudeVersion)}</li>}
{system?.account.claude?.email && <li>{system.account.claude.email}</li>}
</ul>
</Section>
</div>
</Dialog>
);
}

function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-3">
<h3 className="font-semibold text-muted text-xs uppercase tracking-[0.12em]">{title}</h3>
{children}
</section>
);
}

function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<p className="font-medium text-sm">{label}</p>
{children}
{hint && <p className="text-muted text-xs">{hint}</p>}
</div>
);
}
Loading
Loading