Skip to content

Commit b38710e

Browse files
authored
Merge pull request #16 from httpsphl/feat/look
A new look: avatars without the square, a bigger app, an account area and rounder corners
2 parents d5e3e43 + 25c3078 commit b38710e

66 files changed

Lines changed: 1318 additions & 200 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src-tauri/capabilities/default.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"core:window:allow-toggle-maximize",
1313
"core:window:allow-close",
1414
"core:window:allow-set-overlay-icon",
15+
"core:webview:allow-set-webview-zoom",
1516
"updater:default"
1617
]
1718
}

‎app/src/App.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import type { Host } from "./lib/host";
66
import { TitleBar } from "./shell/TitleBar";
77
import { useThemeRoot } from "./shell/theme";
88
import { Workspace } from "./shell/Workspace";
9+
import { useZoomRoot } from "./shell/zoom";
910
import { DaemonProvider, HostProvider } from "./store/context";
1011
import { Toaster } from "./ui/toast";
1112

1213
export function App({ host, connect }: { host: Host; connect: Connect }) {
1314
useThemeRoot();
15+
useZoomRoot(host);
1416
const [link] = useState(() => createLink(host, connect));
1517
const current = useStore(link, (state) => state.current);
1618
useEffect(() => {
@@ -25,7 +27,7 @@ export function App({ host, connect }: { host: Host; connect: Connect }) {
2527
</DaemonProvider>
2628
) : (
2729
<div className="flex h-full flex-col">
28-
<TitleBar />
30+
<TitleBar language />
2931
<Onboarding link={link} />
3032
</div>
3133
)}

‎app/src/dev/preview.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function previewProps(search: string): { host: Host; connect: Connect } {
1919
return liveProps(live);
2020
}
2121
const fake = new FakeBotloft();
22-
const host = new FakeHost();
22+
const host = previewHost();
2323
if (params.has("stopped")) {
2424
host.status = {
2525
state: "stopped",
@@ -34,7 +34,7 @@ export function previewProps(search: string): { host: Host; connect: Connect } {
3434
}
3535

3636
function liveProps(port: number): { host: Host; connect: Connect } {
37-
const host = new FakeHost();
37+
const host = previewHost();
3838
host.status = {
3939
state: "running",
4040
port,
@@ -52,7 +52,31 @@ function liveProps(port: number): { host: Host; connect: Connect } {
5252
};
5353
}
5454

55+
/** A FakeHost whose zoom shows in a plain browser, like the app's does. */
56+
function previewHost(): FakeHost {
57+
const host = new FakeHost();
58+
host.setZoom = (factor) => {
59+
host.zoom = factor;
60+
document.documentElement.style.zoom = String(factor);
61+
return Promise.resolve();
62+
};
63+
return host;
64+
}
65+
5566
function seed(fake: FakeBotloft): void {
67+
const now = Date.now();
68+
fake.system = {
69+
...fake.system,
70+
usage: {
71+
status: "allowed",
72+
resetsAt: now + 2 * 3600_000,
73+
observedAt: now - 5 * 60_000,
74+
windows: [
75+
{ name: "five_hour", utilization: 0.34, resetsAt: now + 2 * 3600_000 },
76+
{ name: "seven_day", utilization: 0.62, resetsAt: now + 3 * 86_400_000 },
77+
],
78+
},
79+
};
5680
const research = fake.addCrew("Research");
5781
const scout = fake.addBot(research.id, "Scout", "Finds sources and summarizes them");
5882
const writer = fake.addBot(research.id, "Writer", "Turns notes into a weekly report");
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// The owner at the bottom of the sidebar, like the account corner of a
2+
// chat app: their name and Claude plan, and a menu with usage, settings,
3+
// language, what's new and help (spec 15.1).
4+
5+
import {
6+
ChartColumn,
7+
Check,
8+
ChevronRight,
9+
ChevronsUpDown,
10+
CircleHelp,
11+
Languages,
12+
Settings,
13+
Sparkles,
14+
} from "lucide-react";
15+
import { type ReactNode, useCallback, useRef, useState } from "react";
16+
import { LOCALES, setLocaleChoice, systemLocale, useLocale, useT } from "../../i18n";
17+
import type { ClaudeAccount } from "../../lib/protocol.gen";
18+
import { useApp, useHost } from "../../store/context";
19+
import { POPOVER, POPOVER_ITEM } from "../../ui/surface";
20+
import { attempt } from "../../ui/toast";
21+
import { useDismiss } from "../../ui/useDismiss";
22+
import { SettingsDialog } from "./SettingsDialog";
23+
import { UsageDialog } from "./UsageDialog";
24+
25+
const RELEASES = "https://github.com/httpsphl/botloft/releases";
26+
const HELP = "https://github.com/httpsphl/botloft#readme";
27+
28+
/** "max" reads "Max"; an unknown plan keeps its words. */
29+
function planName(plan: string): string {
30+
const words = plan.replaceAll("_", " ").trim();
31+
return words.charAt(0).toUpperCase() + words.slice(1);
32+
}
33+
34+
export function AccountArea() {
35+
const a = useT().account;
36+
const account = useApp((state) => state.system?.account ?? null);
37+
const signedOut = useApp((state) => state.system?.claudeSignedIn === false);
38+
const [open, setOpen] = useState(false);
39+
const [dialog, setDialog] = useState<"usage" | "settings" | null>(null);
40+
const root = useRef<HTMLDivElement>(null);
41+
42+
useDismiss(
43+
open,
44+
root,
45+
useCallback(() => setOpen(false), []),
46+
);
47+
48+
const name = account?.name.trim() || "Botloft";
49+
const detail = describe(account?.claude ?? null, signedOut, a);
50+
const show = (which: "usage" | "settings") => {
51+
setOpen(false);
52+
setDialog(which);
53+
};
54+
55+
return (
56+
<div ref={root} className="relative shrink-0 border-line border-t p-2">
57+
{open && (
58+
<AccountMenu
59+
name={name}
60+
email={account?.claude?.email ?? null}
61+
onShow={show}
62+
onClose={() => setOpen(false)}
63+
/>
64+
)}
65+
<button
66+
type="button"
67+
aria-haspopup="menu"
68+
aria-expanded={open}
69+
aria-label={a.open(name)}
70+
onClick={() => setOpen(!open)}
71+
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-sunken ${open ? "bg-sunken" : ""}`}
72+
>
73+
<span
74+
aria-hidden
75+
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-ink font-semibold text-canvas text-sm"
76+
>
77+
{Array.from(name)[0]?.toUpperCase()}
78+
</span>
79+
<span className="min-w-0 flex-1">
80+
<span className="block truncate font-medium text-sm">{name}</span>
81+
{detail && <span className="block truncate text-muted text-xs">{detail}</span>}
82+
</span>
83+
<ChevronsUpDown aria-hidden size={14} className="shrink-0 text-muted" />
84+
</button>
85+
{dialog === "usage" && <UsageDialog onClose={() => setDialog(null)} />}
86+
{dialog === "settings" && <SettingsDialog onClose={() => setDialog(null)} />}
87+
</div>
88+
);
89+
}
90+
91+
function describe(
92+
claude: ClaudeAccount | null,
93+
signedOut: boolean,
94+
a: ReturnType<typeof useT>["account"],
95+
): string {
96+
if (claude?.plan) {
97+
return a.plan(planName(claude.plan));
98+
}
99+
return claude?.organization ?? claude?.email ?? (signedOut ? a.notSignedIn : "");
100+
}
101+
102+
function AccountMenu({
103+
name,
104+
email,
105+
onShow,
106+
onClose,
107+
}: {
108+
name: string;
109+
email: string | null;
110+
onShow(which: "usage" | "settings"): void;
111+
onClose(): void;
112+
}) {
113+
const t = useT();
114+
const m = t.account.menu;
115+
const host = useHost();
116+
const { choice } = useLocale();
117+
const [languages, setLanguages] = useState(false);
118+
const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English";
119+
const open = (url: string) => {
120+
onClose();
121+
return attempt(m.openFailed, () => host.openUrl(url));
122+
};
123+
124+
return (
125+
<div
126+
role="menu"
127+
aria-label={name}
128+
className={`absolute right-2 bottom-full left-2 mb-1 ${POPOVER}`}
129+
>
130+
{email && (
131+
<p className="truncate px-2.5 pt-1.5 pb-2 text-muted text-xs" data-selectable>
132+
{email}
133+
</p>
134+
)}
135+
<Item icon={ChartColumn} onClick={() => onShow("usage")}>
136+
{m.usage}
137+
</Item>
138+
<Item icon={Settings} onClick={() => onShow("settings")}>
139+
{m.settings}
140+
</Item>
141+
<div className="relative">
142+
<Item
143+
icon={Languages}
144+
onClick={() => setLanguages(!languages)}
145+
expanded={languages}
146+
trailing={<ChevronRight aria-hidden size={14} className="text-muted" />}
147+
>
148+
{m.language}
149+
</Item>
150+
{languages && (
151+
<div
152+
role="menu"
153+
aria-label={m.language}
154+
className={`absolute bottom-0 left-full ml-2 min-w-56 ${POPOVER}`}
155+
>
156+
{[{ id: "system" as const, name: t.shell.language.system(systemName) }, ...LOCALES].map(
157+
(entry) => (
158+
<button
159+
key={entry.id}
160+
type="button"
161+
role="menuitemradio"
162+
aria-checked={choice === entry.id}
163+
onClick={() => setLocaleChoice(entry.id)}
164+
className={`h-8 whitespace-nowrap ${POPOVER_ITEM}`}
165+
>
166+
<Check aria-hidden size={14} className={choice === entry.id ? "" : "invisible"} />
167+
{entry.name}
168+
</button>
169+
),
170+
)}
171+
</div>
172+
)}
173+
</div>
174+
<div className="mx-1 my-1 border-line border-t" />
175+
<Item icon={Sparkles} onClick={() => open(RELEASES)}>
176+
{m.whatsNew}
177+
</Item>
178+
<Item icon={CircleHelp} onClick={() => open(HELP)}>
179+
{m.help}
180+
</Item>
181+
</div>
182+
);
183+
}
184+
185+
function Item({
186+
icon: Icon,
187+
onClick,
188+
expanded,
189+
trailing,
190+
children,
191+
}: {
192+
icon: typeof Settings;
193+
onClick(): void;
194+
expanded?: boolean;
195+
trailing?: ReactNode;
196+
children: ReactNode;
197+
}) {
198+
return (
199+
<button
200+
type="button"
201+
role="menuitem"
202+
aria-haspopup={expanded === undefined ? undefined : "menu"}
203+
aria-expanded={expanded}
204+
onClick={onClick}
205+
className={`h-8 text-ink ${POPOVER_ITEM}`}
206+
>
207+
<Icon aria-hidden size={14} className="text-ink-soft" />
208+
<span className="flex-1">{children}</span>
209+
{trailing}
210+
</button>
211+
);
212+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Settings (spec 15.1): theme, size and language, which used to sit in the
2+
// title bar, and the versions for when something needs reporting.
3+
4+
import type { ReactNode } from "react";
5+
import {
6+
LOCALES,
7+
type LocaleChoice,
8+
setLocaleChoice,
9+
systemLocale,
10+
useLocale,
11+
useT,
12+
} from "../../i18n";
13+
import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme";
14+
import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom";
15+
import { useApp } from "../../store/context";
16+
import { Choices } from "../../ui/Choices";
17+
import { Dialog } from "../../ui/Dialog";
18+
19+
export function SettingsDialog({ onClose }: { onClose(): void }) {
20+
const t = useT();
21+
const s = t.account.settings;
22+
const { choice: theme } = useTheme();
23+
const zoom = useZoom();
24+
const { choice: locale } = useLocale();
25+
const system = useApp((state) => state.system);
26+
const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English";
27+
28+
const themes: { value: ThemeChoice; label: string }[] = [
29+
{ value: "system", label: s.themes.system },
30+
{ value: "light", label: s.themes.light },
31+
{ value: "dark", label: s.themes.dark },
32+
];
33+
const sizes = ZOOM_LEVELS.map((level) => ({
34+
value: level,
35+
label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM),
36+
}));
37+
const languages: { value: LocaleChoice; label: string }[] = [
38+
{ value: "system", label: t.shell.language.system(systemName) },
39+
...LOCALES.map((entry) => ({ value: entry.id as LocaleChoice, label: entry.name })),
40+
];
41+
42+
return (
43+
<Dialog title={s.title} onClose={onClose} width="lg">
44+
<div className="flex flex-col gap-6">
45+
<Section title={s.appearance}>
46+
<Field label={s.theme}>
47+
<Choices label={s.theme} value={theme} options={themes} onChange={setTheme} />
48+
</Field>
49+
<Field label={s.size} hint={s.sizeHint}>
50+
<Choices<ZoomLevel> label={s.size} value={zoom} options={sizes} onChange={setZoom} />
51+
</Field>
52+
</Section>
53+
<Section title={s.language}>
54+
<Choices
55+
label={s.language}
56+
value={locale}
57+
options={languages}
58+
onChange={setLocaleChoice}
59+
/>
60+
</Section>
61+
<Section title={s.about}>
62+
<ul className="text-ink-soft text-sm leading-relaxed" data-selectable>
63+
{system && <li>{s.botloft(system.daemonVersion)}</li>}
64+
{system?.claudeVersion && <li>{s.claudeCode(system.claudeVersion)}</li>}
65+
{system?.account.claude?.email && <li>{system.account.claude.email}</li>}
66+
</ul>
67+
</Section>
68+
</div>
69+
</Dialog>
70+
);
71+
}
72+
73+
function Section({ title, children }: { title: string; children: ReactNode }) {
74+
return (
75+
<section className="flex flex-col gap-3">
76+
<h3 className="font-semibold text-muted text-xs uppercase tracking-[0.12em]">{title}</h3>
77+
{children}
78+
</section>
79+
);
80+
}
81+
82+
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
83+
return (
84+
<div className="flex flex-col gap-1.5">
85+
<p className="font-medium text-sm">{label}</p>
86+
{children}
87+
{hint && <p className="text-muted text-xs">{hint}</p>}
88+
</div>
89+
);
90+
}

0 commit comments

Comments
 (0)