Skip to content

Commit 4c43aa9

Browse files
authored
Merge pull request #155 from httpsphl/feat/icon-rail
feat(app): an icon rail for the app's places
2 parents a86dc62 + 36c37a4 commit 4c43aa9

15 files changed

Lines changed: 214 additions & 121 deletions

File tree

‎app/src/features/account/AccountArea.tsx‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
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).
1+
// The owner at the foot of the rail, like the account corner of a chat
2+
// app: their initial, and a menu with their name and Claude plan, usage,
3+
// settings, language, what's new and help (spec 15.1).
44

55
import {
66
ChartColumn,
77
Check,
88
ChevronRight,
9-
ChevronsUpDown,
109
CircleHelp,
1110
Languages,
1211
Settings,
@@ -53,10 +52,11 @@ export function AccountArea() {
5352
};
5453

5554
return (
56-
<div ref={root} className="relative shrink-0 border-line border-t p-2">
55+
<div ref={root} className="relative flex shrink-0 justify-center py-2">
5756
{open && (
5857
<AccountMenu
5958
name={name}
59+
detail={detail}
6060
email={account?.claude?.email ?? null}
6161
onShow={show}
6262
onClose={() => setOpen(false)}
@@ -67,20 +67,16 @@ export function AccountArea() {
6767
aria-haspopup="menu"
6868
aria-expanded={open}
6969
aria-label={a.open(name)}
70+
title={detail ? `${name} · ${detail}` : name}
7071
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+
className={`grid size-10 place-items-center rounded-xl hover:bg-sunken ${open ? "bg-sunken" : ""}`}
7273
>
7374
<span
7475
aria-hidden
7576
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-ink font-semibold text-canvas text-sm"
7677
>
7778
{Array.from(name)[0]?.toUpperCase()}
7879
</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" />
8480
</button>
8581
{dialog === "usage" && <UsageDialog onClose={() => setDialog(null)} />}
8682
{dialog === "settings" && <SettingsDialog onClose={() => setDialog(null)} />}
@@ -101,11 +97,13 @@ function describe(
10197

10298
function AccountMenu({
10399
name,
100+
detail,
104101
email,
105102
onShow,
106103
onClose,
107104
}: {
108105
name: string;
106+
detail: string;
109107
email: string | null;
110108
onShow(which: "usage" | "settings"): void;
111109
onClose(): void;
@@ -125,13 +123,17 @@ function AccountMenu({
125123
<div
126124
role="menu"
127125
aria-label={name}
128-
className={`absolute right-2 bottom-full left-2 mb-1 origin-bottom ${POPOVER}`}
126+
className={`absolute bottom-2 left-full ml-1 w-64 origin-bottom-left ${POPOVER}`}
129127
>
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-
)}
128+
<div className="px-2.5 pt-1.5 pb-2">
129+
<p className="truncate font-medium text-sm">{name}</p>
130+
{detail && <p className="truncate text-muted text-xs">{detail}</p>}
131+
{email && (
132+
<p className="truncate text-muted text-xs" data-selectable>
133+
{email}
134+
</p>
135+
)}
136+
</div>
135137
<Item icon={ChartColumn} onClick={() => onShow("usage")}>
136138
{m.usage}
137139
</Item>

‎app/src/features/account/account.test.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ async function openAccount() {
2121
}
2222

2323
describe("account area", () => {
24-
test("the owner and their plan show at the bottom of the sidebar, even before a crew", async () => {
24+
test("the owner sits at the foot of the rail, even before a crew, with their plan", async () => {
2525
renderApp();
2626
await screen.findByRole("heading", { name: "Welcome to Botloft" });
2727
const button = screen.getByRole("button", { name: "Ana Lima: account and settings" });
28-
expect(button.textContent).toContain("Ana Lima");
29-
expect(button.textContent).toContain("Max plan");
28+
expect(button.textContent).toBe("A");
29+
expect(button.title).toBe("Ana Lima · Max plan");
30+
fireEvent.click(button);
31+
const menu = screen.getByRole("menu", { name: "Ana Lima" });
32+
expect(menu.textContent).toContain("Max plan");
3033
});
3134

3235
test("the title bar keeps no settings once connected", async () => {

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,14 @@ import { CountBadge } from "../../ui/Badge";
1313
import { Button } from "../../ui/Button";
1414
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
1515
import { APP_OPENED } from "../../ui/motion";
16-
import { AccountArea } from "../account/AccountArea";
1716
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
1817
import { useBotActions } from "../bots/botActions";
1918
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
2019
import { ListAvatar } from "../bots/ListAvatar";
2120
import { toolAction, toolTitle } from "../chat/toolNames";
2221
import { CrewDialog } from "./CrewDialog";
2322
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
24-
import { QuestionsEntry, RoutinesEntry, row, SearchEntry } from "./SidebarPages";
23+
import { row } from "./SidebarPages";
2524

2625
/** Crews as sections and their bots as conversations (spec 15.1). */
2726
export function Sidebar() {
@@ -63,16 +62,12 @@ export function Sidebar() {
6362
onClick={() => setCreating(true)}
6463
/>
6564
</div>
66-
<SearchEntry />
67-
<QuestionsEntry />
68-
<RoutinesEntry />
6965
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
7066
{crews.map((crew) => (
7167
<CrewEntry key={crew.id} crew={crew} />
7268
))}
7369
</ul>
7470
</nav>
75-
<AccountArea />
7671
{creating && <CrewDialog onClose={() => setCreating(false)} />}
7772
</div>
7873
);
Lines changed: 2 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,6 @@
1-
// The pages at the top of the sidebar: the search (spec 8.8), the
2-
// question box (spec 23.6) and every routine (spec 20.9).
3-
4-
import { AlarmClock, MessageCircleQuestion, Search } from "lucide-react";
5-
import { useT } from "../../i18n";
6-
import { useApp } from "../../store/context";
7-
import { openQuestions } from "../../store/questions";
8-
import { CountBadge } from "../../ui/Badge";
1+
// What the sidebar's rows share. The pages that used to sit at its top
2+
// (search, questions, routines) are on the rail now (shell/Rail.tsx).
93

104
/** A row of the sidebar, as the conversations are. */
115
export const row =
126
"relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken";
13-
14-
/** The search (spec 8.8). */
15-
export function SearchEntry() {
16-
const t = useT();
17-
const open = useApp((state) => state.page === "search");
18-
const openPage = useApp((state) => state.openPage);
19-
return (
20-
<div className="shrink-0 px-2">
21-
<button
22-
type="button"
23-
aria-current={open ? "page" : undefined}
24-
title={t.search.open}
25-
onClick={() => openPage("search")}
26-
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
27-
>
28-
<Search aria-hidden size={16} className="text-muted" />
29-
<span className="min-w-0 flex-1 truncate font-medium">{t.search.label}</span>
30-
<kbd className="font-sans text-[11px] text-muted">Ctrl K</kbd>
31-
</button>
32-
</div>
33-
);
34-
}
35-
36-
/** The question box (spec 23.6), with how many questions wait. */
37-
export function QuestionsEntry() {
38-
const t = useT();
39-
const words = t.questions.box;
40-
const count = useApp((state) => openQuestions(state).length);
41-
const open = useApp((state) => state.page === "questions");
42-
const openPage = useApp((state) => state.openPage);
43-
return (
44-
<div className="shrink-0 px-2">
45-
<button
46-
type="button"
47-
aria-current={open ? "page" : undefined}
48-
title={words.open(count)}
49-
onClick={() => openPage("questions")}
50-
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
51-
>
52-
<MessageCircleQuestion
53-
aria-hidden
54-
size={16}
55-
className={count > 0 ? "text-warn" : "text-muted"}
56-
/>
57-
<span className="min-w-0 flex-1 truncate font-medium">{words.label}</span>
58-
{count > 0 && <CountBadge tone="warn" count={count} label={words.open(count)} />}
59-
</button>
60-
</div>
61-
);
62-
}
63-
64-
/** Every routine of every bot (spec 20.9), shown once there is one. */
65-
export function RoutinesEntry() {
66-
const t = useT();
67-
const any = useApp((state) => Object.keys(state.routines).length > 0);
68-
const open = useApp((state) => state.page === "routines");
69-
const openPage = useApp((state) => state.openPage);
70-
if (!any && !open) {
71-
return null;
72-
}
73-
return (
74-
<div className="shrink-0 px-2">
75-
<button
76-
type="button"
77-
aria-current={open ? "page" : undefined}
78-
onClick={() => openPage("routines")}
79-
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
80-
>
81-
<AlarmClock aria-hidden size={16} className="text-muted" />
82-
<span className="min-w-0 flex-1 truncate font-medium">{t.routines.page.title}</span>
83-
</button>
84-
</div>
85-
);
86-
}

‎app/src/features/questions/questions.test.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
22
import { afterEach, describe, expect, test } from "vitest";
33
import { FakeBotloft } from "../../lib/fake";
4-
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
4+
import { crewOpened, openBot, rail, renderApp, sidebar } from "../../test/app";
55

66
afterEach(cleanup);
77

@@ -16,7 +16,7 @@ function crew() {
1616
return { fake, scout, writer };
1717
}
1818

19-
const entry = () => within(sidebar()).getByRole("button", { name: /^Questions/ });
19+
const entry = () => within(rail()).getByRole("button", { name: "Questions" });
2020

2121
describe("questions to the owner", () => {
2222
test("a question in the chat is answered with a ready answer", async () => {
@@ -52,7 +52,8 @@ describe("questions to the owner", () => {
5252
const { fake, scout, writer } = crew();
5353
const { host } = renderApp(fake);
5454
await crewOpened("Ops");
55-
expect(entry().textContent).toBe("Questions");
55+
// No count while nothing waits.
56+
expect(entry().textContent).toBe("");
5657
expect(host.attention).toBe(false);
5758

5859
act(() => {

‎app/src/features/routines/routinesPage.test.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,11 @@ async function openPage(fake: FakeBotloft) {
4242
}
4343

4444
describe("the routines page", () => {
45-
test("is in the sidebar only once there is a routine", async () => {
45+
test("is on the rail, and says how to get one before any routine", async () => {
4646
const fake = new FakeBotloft();
4747
fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources");
48-
renderApp(fake);
49-
await crewOpened("Ops");
50-
expect(screen.queryByRole("button", { name: "Routines" })).toBeNull();
48+
await openPage(fake);
49+
expect(screen.getByText("No routines yet")).toBeDefined();
5150
});
5251

5352
test("lists what comes up across crews, interval routines as keeping watch", async () => {

‎app/src/features/search/search.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
22
import { afterEach, describe, expect, test } from "vitest";
33
import { FakeBotloft } from "../../lib/fake";
4-
import { crewOpened, renderApp, sidebar } from "../../test/app";
4+
import { crewOpened, rail, renderApp } from "../../test/app";
55
import { snippetParts } from "./SearchResult";
66

77
afterEach(cleanup);
@@ -56,7 +56,7 @@ describe("searching the chats", () => {
5656
const { fake } = world();
5757
renderApp(fake);
5858
await crewOpened("Ops");
59-
fireEvent.click(within(sidebar()).getByRole("button", { name: /^Search/ }));
59+
fireEvent.click(within(rail()).getByRole("button", { name: "Search" }));
6060
const all = await search("saturday");
6161
expect(within(all).getAllByRole("listitem")).toHaveLength(1);
6262
fireEvent.click(screen.getByRole("button", { name: /All crews/ }));

‎app/src/i18n/en/shell.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export const shell = {
99
close: "Close",
1010
},
1111
/** The button that hides the crews and bots on the left (Ctrl+B). */
12+
/** The icons at the far left. */
13+
rail: {
14+
label: "Places",
15+
home: "Home",
16+
},
1217
sidebar: {
1318
hide: "Hide the bots list",
1419
show: "Show the bots list",

‎app/src/i18n/es/shell.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const shell: Messages["shell"] = {
88
close: "Cerrar",
99
},
1010
/** The button that hides the crews and bots on the left (Ctrl+B). */
11+
rail: {
12+
label: "Lugares",
13+
home: "Inicio",
14+
},
1115
sidebar: {
1216
hide: "Ocultar la lista de bots",
1317
show: "Mostrar la lista de bots",

‎app/src/i18n/pt-BR/shell.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const shell: Messages["shell"] = {
88
close: "Fechar",
99
},
1010
/** The button that hides the crews and bots on the left (Ctrl+B). */
11+
rail: {
12+
label: "Lugares",
13+
home: "Início",
14+
},
1115
sidebar: {
1216
hide: "Esconder a lista de bots",
1317
show: "Mostrar a lista de bots",

0 commit comments

Comments
 (0)