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

import {
ChartColumn,
Check,
ChevronRight,
ChevronsUpDown,
CircleHelp,
Languages,
Settings,
Expand Down Expand Up @@ -53,10 +52,11 @@ export function AccountArea() {
};

return (
<div ref={root} className="relative shrink-0 border-line border-t p-2">
<div ref={root} className="relative flex shrink-0 justify-center py-2">
{open && (
<AccountMenu
name={name}
detail={detail}
email={account?.claude?.email ?? null}
onShow={show}
onClose={() => setOpen(false)}
Expand All @@ -67,20 +67,16 @@ export function AccountArea() {
aria-haspopup="menu"
aria-expanded={open}
aria-label={a.open(name)}
title={detail ? `${name} · ${detail}` : 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" : ""}`}
className={`grid size-10 place-items-center rounded-xl 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)} />}
Expand All @@ -101,11 +97,13 @@ function describe(

function AccountMenu({
name,
detail,
email,
onShow,
onClose,
}: {
name: string;
detail: string;
email: string | null;
onShow(which: "usage" | "settings"): void;
onClose(): void;
Expand All @@ -125,13 +123,17 @@ function AccountMenu({
<div
role="menu"
aria-label={name}
className={`absolute right-2 bottom-full left-2 mb-1 origin-bottom ${POPOVER}`}
className={`absolute bottom-2 left-full ml-1 w-64 origin-bottom-left ${POPOVER}`}
>
{email && (
<p className="truncate px-2.5 pt-1.5 pb-2 text-muted text-xs" data-selectable>
{email}
</p>
)}
<div className="px-2.5 pt-1.5 pb-2">
<p className="truncate font-medium text-sm">{name}</p>
{detail && <p className="truncate text-muted text-xs">{detail}</p>}
{email && (
<p className="truncate text-muted text-xs" data-selectable>
{email}
</p>
)}
</div>
<Item icon={ChartColumn} onClick={() => onShow("usage")}>
{m.usage}
</Item>
Expand Down
9 changes: 6 additions & 3 deletions app/src/features/account/account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ async function openAccount() {
}

describe("account area", () => {
test("the owner and their plan show at the bottom of the sidebar, even before a crew", async () => {
test("the owner sits at the foot of the rail, even before a crew, with their plan", 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");
expect(button.textContent).toBe("A");
expect(button.title).toBe("Ana Lima · Max plan");
fireEvent.click(button);
const menu = screen.getByRole("menu", { name: "Ana Lima" });
expect(menu.textContent).toContain("Max plan");
});

test("the title bar keeps no settings once connected", async () => {
Expand Down
7 changes: 1 addition & 6 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@ import { CountBadge } from "../../ui/Badge";
import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
import { APP_OPENED } from "../../ui/motion";
import { AccountArea } from "../account/AccountArea";
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
import { useBotActions } from "../bots/botActions";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
import { ListAvatar } from "../bots/ListAvatar";
import { toolAction, toolTitle } from "../chat/toolNames";
import { CrewDialog } from "./CrewDialog";
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
import { QuestionsEntry, RoutinesEntry, row, SearchEntry } from "./SidebarPages";
import { row } from "./SidebarPages";

/** Crews as sections and their bots as conversations (spec 15.1). */
export function Sidebar() {
Expand Down Expand Up @@ -63,16 +62,12 @@ export function Sidebar() {
onClick={() => setCreating(true)}
/>
</div>
<SearchEntry />
<QuestionsEntry />
<RoutinesEntry />
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{crews.map((crew) => (
<CrewEntry key={crew.id} crew={crew} />
))}
</ul>
</nav>
<AccountArea />
{creating && <CrewDialog onClose={() => setCreating(false)} />}
</div>
);
Expand Down
84 changes: 2 additions & 82 deletions app/src/features/crews/SidebarPages.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,6 @@
// The pages at the top of the sidebar: the search (spec 8.8), the
// question box (spec 23.6) and every routine (spec 20.9).

import { AlarmClock, MessageCircleQuestion, Search } from "lucide-react";
import { useT } from "../../i18n";
import { useApp } from "../../store/context";
import { openQuestions } from "../../store/questions";
import { CountBadge } from "../../ui/Badge";
// What the sidebar's rows share. The pages that used to sit at its top
// (search, questions, routines) are on the rail now (shell/Rail.tsx).

/** A row of the sidebar, as the conversations are. */
export const row =
"relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken";

/** The search (spec 8.8). */
export function SearchEntry() {
const t = useT();
const open = useApp((state) => state.page === "search");
const openPage = useApp((state) => state.openPage);
return (
<div className="shrink-0 px-2">
<button
type="button"
aria-current={open ? "page" : undefined}
title={t.search.open}
onClick={() => openPage("search")}
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
>
<Search aria-hidden size={16} className="text-muted" />
<span className="min-w-0 flex-1 truncate font-medium">{t.search.label}</span>
<kbd className="font-sans text-[11px] text-muted">Ctrl K</kbd>
</button>
</div>
);
}

/** The question box (spec 23.6), with how many questions wait. */
export function QuestionsEntry() {
const t = useT();
const words = t.questions.box;
const count = useApp((state) => openQuestions(state).length);
const open = useApp((state) => state.page === "questions");
const openPage = useApp((state) => state.openPage);
return (
<div className="shrink-0 px-2">
<button
type="button"
aria-current={open ? "page" : undefined}
title={words.open(count)}
onClick={() => openPage("questions")}
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
>
<MessageCircleQuestion
aria-hidden
size={16}
className={count > 0 ? "text-warn" : "text-muted"}
/>
<span className="min-w-0 flex-1 truncate font-medium">{words.label}</span>
{count > 0 && <CountBadge tone="warn" count={count} label={words.open(count)} />}
</button>
</div>
);
}

/** Every routine of every bot (spec 20.9), shown once there is one. */
export function RoutinesEntry() {
const t = useT();
const any = useApp((state) => Object.keys(state.routines).length > 0);
const open = useApp((state) => state.page === "routines");
const openPage = useApp((state) => state.openPage);
if (!any && !open) {
return null;
}
return (
<div className="shrink-0 px-2">
<button
type="button"
aria-current={open ? "page" : undefined}
onClick={() => openPage("routines")}
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
>
<AlarmClock aria-hidden size={16} className="text-muted" />
<span className="min-w-0 flex-1 truncate font-medium">{t.routines.page.title}</span>
</button>
</div>
);
}
7 changes: 4 additions & 3 deletions app/src/features/questions/questions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
import { crewOpened, openBot, rail, renderApp, sidebar } from "../../test/app";

afterEach(cleanup);

Expand All @@ -16,7 +16,7 @@ function crew() {
return { fake, scout, writer };
}

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

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

act(() => {
Expand Down
7 changes: 3 additions & 4 deletions app/src/features/routines/routinesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,11 @@ async function openPage(fake: FakeBotloft) {
}

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

test("lists what comes up across crews, interval routines as keeping watch", async () => {
Expand Down
4 changes: 2 additions & 2 deletions app/src/features/search/search.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, renderApp, sidebar } from "../../test/app";
import { crewOpened, rail, renderApp } from "../../test/app";
import { snippetParts } from "./SearchResult";

afterEach(cleanup);
Expand Down Expand Up @@ -56,7 +56,7 @@ describe("searching the chats", () => {
const { fake } = world();
renderApp(fake);
await crewOpened("Ops");
fireEvent.click(within(sidebar()).getByRole("button", { name: /^Search/ }));
fireEvent.click(within(rail()).getByRole("button", { name: "Search" }));
const all = await search("saturday");
expect(within(all).getAllByRole("listitem")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /All crews/ }));
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/en/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export const shell = {
close: "Close",
},
/** The button that hides the crews and bots on the left (Ctrl+B). */
/** The icons at the far left. */
rail: {
label: "Places",
home: "Home",
},
sidebar: {
hide: "Hide the bots list",
show: "Show the bots list",
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/es/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const shell: Messages["shell"] = {
close: "Cerrar",
},
/** The button that hides the crews and bots on the left (Ctrl+B). */
rail: {
label: "Lugares",
home: "Inicio",
},
sidebar: {
hide: "Ocultar la lista de bots",
show: "Mostrar la lista de bots",
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/pt-BR/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const shell: Messages["shell"] = {
close: "Fechar",
},
/** The button that hides the crews and bots on the left (Ctrl+B). */
rail: {
label: "Lugares",
home: "Início",
},
sidebar: {
hide: "Esconder a lista de bots",
show: "Mostrar a lista de bots",
Expand Down
Loading
Loading