Skip to content

Commit ee1cef2

Browse files
authored
Merge pull request #91 from httpsphl/feat/hide-sidebar
feat(app): hide the bots list to keep only the chat and the panel
2 parents 616591d + 92044ea commit ee1cef2

11 files changed

Lines changed: 174 additions & 9 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export const shell = {
88
restore: "Restore",
99
close: "Close",
1010
},
11+
/** The button that hides the crews and bots on the left (Ctrl+B). */
12+
sidebar: {
13+
hide: "Hide the bots list",
14+
show: "Show the bots list",
15+
},
1116
zoom: {
1217
level: (percent: number, isDefault: boolean) =>
1318
isDefault ? `${percent}% (default)` : `${percent}%`,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export const shell: Messages["shell"] = {
77
restore: "Restaurar",
88
close: "Cerrar",
99
},
10+
/** The button that hides the crews and bots on the left (Ctrl+B). */
11+
sidebar: {
12+
hide: "Ocultar la lista de bots",
13+
show: "Mostrar la lista de bots",
14+
},
1015
zoom: {
1116
level: (percent: number, isDefault: boolean) =>
1217
isDefault ? `${percent}% (predeterminado)` : `${percent}%`,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export const shell: Messages["shell"] = {
77
restore: "Restaurar",
88
close: "Fechar",
99
},
10+
/** The button that hides the crews and bots on the left (Ctrl+B). */
11+
sidebar: {
12+
hide: "Esconder a lista de bots",
13+
show: "Mostrar a lista de bots",
14+
},
1015
zoom: {
1116
level: (percent: number, isDefault: boolean) =>
1217
isDefault ? `${percent}% (padrão)` : `${percent}%`,

‎app/src/index.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
@import "./mascot.css";
99
@import "./mascot-flame.css";
1010
@import "./panels.css";
11+
@import "./sidebar.css";
1112
@import "./effort.css";
1213
@import "./motion-less.css";
1314
@import "./paused.css";

‎app/src/motion.css‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,13 +222,16 @@
222222
animation: caret 0.9s steps(2, jump-none) infinite;
223223
}
224224

225-
.live-dot {
226-
position: relative;
227-
display: inline-block;
228-
width: 7px;
229-
height: 7px;
230-
border-radius: 9999px;
231-
background: var(--danger);
225+
/* In the components layer, so `absolute` and the like can place it. */
226+
@layer components {
227+
.live-dot {
228+
position: relative;
229+
display: inline-block;
230+
width: 7px;
231+
height: 7px;
232+
border-radius: 9999px;
233+
background: var(--danger);
234+
}
232235
}
233236

234237
.live-dot::after {

‎app/src/shell/Workspace.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Callout } from "../ui/Callout";
1818
import { useBotAlerts } from "./alerts";
1919
import { useAttentionMark } from "./attention";
2020
import { useFolderNotices } from "./folders";
21+
import { SidebarSlot, SidebarToggle } from "./sidebarToggle";
2122
import { useOpenAtSignIn } from "./signIn";
2223
import { TitleBar } from "./TitleBar";
2324
import { useTray } from "./tray";
@@ -57,7 +58,9 @@ export function Workspace() {
5758
} else {
5859
main = (
5960
<div className="flex min-h-0 flex-1">
60-
<Sidebar />
61+
<SidebarSlot>
62+
<Sidebar />
63+
</SidebarSlot>
6164
<main className="flex min-w-0 flex-1 flex-col [view-transition-name:main-pane]">
6265
{runtimeError ? (
6366
<div className="border-line border-b p-3">
@@ -92,6 +95,7 @@ export function Workspace() {
9295
</>
9396
}
9497
>
98+
{hasCrews && <SidebarToggle />}
9599
<Breadcrumb />
96100
</TitleBar>
97101
{main}

‎app/src/shell/prefs.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export const prefs = {
5656
enterSends: pref<boolean>("botloft.enterSends", true, ON_OFF),
5757
/** The browser and the screens open by themselves when a bot starts on them. */
5858
followBot: pref<boolean>("botloft.followBot", true, ON_OFF),
59+
/** The crews and bots show on the left; off, only the selection does. */
60+
sidebar: pref<boolean>("botloft.sidebar", true, ON_OFF),
5961
/** No motion in the window, whatever Windows says. */
6062
lessMotion: pref<boolean>("botloft.lessMotion", false, ON_OFF),
6163
/** With the bots working, closing the window leaves Botloft near the clock. */
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../lib/fake";
4+
import { crewOpened, renderApp } from "../test/app";
5+
import { prefs } from "./prefs";
6+
7+
afterEach(() => {
8+
cleanup();
9+
prefs.sidebar.reset();
10+
});
11+
12+
const list = () => screen.getByRole("navigation", { name: "Crews", hidden: true });
13+
const slot = () => list().closest(".sidebar-slot") as HTMLElement;
14+
15+
async function oneCrew() {
16+
const fake = new FakeBotloft();
17+
const crew = fake.addCrew("Ops");
18+
const scout = fake.addBot(crew.id, "Scout");
19+
renderApp(fake);
20+
await crewOpened("Ops");
21+
return { fake, scout };
22+
}
23+
24+
describe("hiding the bots list", () => {
25+
test("the title bar button hides the list and brings it back, and it is remembered", async () => {
26+
await oneCrew();
27+
expect(slot().dataset.open).toBe("true");
28+
fireEvent.click(screen.getByRole("button", { name: "Hide the bots list" }));
29+
expect(slot().dataset.open).toBe("false");
30+
expect(slot().hasAttribute("inert")).toBe(true);
31+
expect(localStorage.getItem("botloft.sidebar")).toBe("false");
32+
fireEvent.click(screen.getByRole("button", { name: "Show the bots list" }));
33+
expect(slot().dataset.open).toBe("true");
34+
expect(slot().hasAttribute("inert")).toBe(false);
35+
});
36+
37+
test("Ctrl+B does the same", async () => {
38+
await oneCrew();
39+
fireEvent.keyDown(window, { key: "b", ctrlKey: true });
40+
expect(slot().dataset.open).toBe("false");
41+
fireEvent.keyDown(window, { key: "B", ctrlKey: true });
42+
expect(slot().dataset.open).toBe("true");
43+
fireEvent.keyDown(window, { key: "b" });
44+
expect(slot().dataset.open).toBe("true");
45+
});
46+
47+
test("with the list hidden, a dot on the button says a bot is waiting", async () => {
48+
const { fake, scout } = await oneCrew();
49+
fireEvent.click(screen.getByRole("button", { name: "Hide the bots list" }));
50+
const button = screen.getByRole("button", { name: "Show the bots list" });
51+
expect(button.parentElement?.querySelector(".live-dot")).toBeNull();
52+
fake.setBotState(scout.id, "needs_approval");
53+
await waitFor(() => expect(button.parentElement?.querySelector(".live-dot")).not.toBeNull());
54+
});
55+
});

‎app/src/shell/sidebarToggle.tsx‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Hiding the crews and bots on the left (spec 15.1), to keep only the chat
2+
// and the bot's panel on a small screen. The choice is remembered.
3+
4+
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
5+
import { type ReactNode, useEffect } from "react";
6+
import { useT } from "../i18n";
7+
import { useApp } from "../store/context";
8+
import { attentionCount } from "./attention";
9+
import { prefs, usePref } from "./prefs";
10+
11+
export const toggleSidebar = () => prefs.sidebar.set(!prefs.sidebar.get());
12+
13+
/** The button in the title bar, with a dot while the list is hidden and something waits. */
14+
export function SidebarToggle() {
15+
const t = useT().shell.sidebar;
16+
const open = usePref(prefs.sidebar);
17+
const waiting = useApp((state) => attentionCount(state) > 0);
18+
const Icon = open ? PanelLeftClose : PanelLeftOpen;
19+
return (
20+
<span className="relative grid">
21+
<button
22+
type="button"
23+
aria-label={open ? t.hide : t.show}
24+
title={`${open ? t.hide : t.show} (Ctrl+B)`}
25+
aria-pressed={!open}
26+
onClick={toggleSidebar}
27+
className="grid h-7 w-7 place-items-center rounded-md text-ink-soft hover:bg-sunken hover:text-ink"
28+
>
29+
<Icon aria-hidden size={15} />
30+
</button>
31+
{!open && waiting && (
32+
<span
33+
aria-hidden
34+
className="live-dot pointer-events-none absolute top-0.5 right-0.5"
35+
style={{ background: "var(--warn)" }}
36+
/>
37+
)}
38+
</span>
39+
);
40+
}
41+
42+
/** Holds the list, sliding it out to the left when hidden. */
43+
export function SidebarSlot({ children }: { children: ReactNode }) {
44+
const open = usePref(prefs.sidebar);
45+
useSidebarKey();
46+
return (
47+
<div
48+
inert={!open}
49+
aria-hidden={!open}
50+
data-open={open}
51+
className="sidebar-slot flex shrink-0 overflow-hidden"
52+
>
53+
{children}
54+
</div>
55+
);
56+
}
57+
58+
/** Ctrl+B shows or hides the list, as in code editors. */
59+
function useSidebarKey(): void {
60+
useEffect(() => {
61+
const onKey = (event: KeyboardEvent) => {
62+
if (!event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
63+
return;
64+
}
65+
if (event.key.toLowerCase() === "b") {
66+
event.preventDefault();
67+
toggleSidebar();
68+
}
69+
};
70+
window.addEventListener("keydown", onKey);
71+
return () => window.removeEventListener("keydown", onKey);
72+
}, []);
73+
}

‎app/src/sidebar.css‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the list keeps
2+
its width and slides out under the left edge while the chat widens.
3+
Reduced motion makes it instant (motion.css). */
4+
5+
.sidebar-slot {
6+
width: 18rem;
7+
transition: width 320ms cubic-bezier(0.4, 0, 0.2, 1);
8+
}
9+
10+
.sidebar-slot[data-open="false"] {
11+
width: 0;
12+
}

0 commit comments

Comments
 (0)