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
73 changes: 43 additions & 30 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
import { routinesOf } from "../../store/app";
import { type BotPanel, routinesOf } from "../../store/app";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { PanelClosing } from "../../ui/panelMotion";
import { PanelClosing, PanelRestored } from "../../ui/panelMotion";
import { SidePanel } from "../../ui/SidePanel";
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
import { BrowserPanel } from "../browser/BrowserPanel";
Expand All @@ -27,15 +27,25 @@ import { useFollowBot } from "./useFollowBot";

type Pane = "chat" | "routines";
/** What the panel beside the chat shows. */
type Side = "details" | "files" | "browser" | "screens" | null;
type Side = BotPanel | null;

/**
* A bot's conversation and its routines, with its details in a side panel
* (spec 15.1, 20.9).
*/
export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
const t = useT();
const [side, setSide] = useState<Side>(null);
// Kept per bot in the store, not here: the panel the owner left open is
// back when they come back to the bot, and one they closed stays closed
// (spec 15.1).
const side = useApp((state) => state.panels[bot.id] ?? null);
const keepPanel = useApp((state) => state.setPanel);
// The panel that came back is there at once; one opened after it slides.
const [restored, setRestored] = useState(side !== null);
const setSide = (panel: Side) => {
setRestored(false);
keepPanel(bot.id, panel);
};
// The panel on screen: the one open, or the last one while it slides
// closed (spec 15.1).
const [leaving, setLeaving] = useState<Side>(null);
Expand Down Expand Up @@ -116,7 +126,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
setTakeBrowser((count) => count + 1);
}
};
// The panel follows what the bot starts doing (spec 15.1).
// The panel follows what the bot starts doing (spec 15.1), over the one
// that came back too.
useFollowBot({
bot,
side,
Expand Down Expand Up @@ -166,31 +177,33 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
<BotRoutines bot={bot} />
)}
</div>
<PanelClosing.Provider value={closing}>
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{beside === "browser" && (
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
)}
{beside === "screens" && (
<ScreensPanel
bot={bot}
data={screens}
path={screenPath}
onPath={setScreenPath}
onClose={() => setSide(null)}
/>
)}
{beside === "files" && (
<FilesPanel
bot={bot}
data={files}
since={since}
path={shown}
onPath={setShown}
onClose={toggleFiles}
/>
)}
</PanelClosing.Provider>
<PanelRestored.Provider value={restored}>
<PanelClosing.Provider value={closing}>
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{beside === "browser" && (
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
)}
{beside === "screens" && (
<ScreensPanel
bot={bot}
data={screens}
path={screenPath}
onPath={setScreenPath}
onClose={() => setSide(null)}
/>
)}
{beside === "files" && (
<FilesPanel
bot={bot}
data={files}
since={since}
path={shown}
onPath={setShown}
onClose={toggleFiles}
/>
)}
</PanelClosing.Provider>
</PanelRestored.Provider>
</div>
</section>
);
Expand Down
117 changes: 117 additions & 0 deletions app/src/features/bots/panels.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";

beforeEach(() => localStorage.clear());
afterEach(cleanup);

/** A crew "Ops" with @scout and @writer, idle. */
function crew() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
const writer = fake.addBot(ops.id, "Writer", "Writes the notes");
fake.setBotState(scout.id, "idle");
fake.setBotState(writer.id, "idle");
return { fake, scout, writer };
}

async function open(name: string) {
openBot(name);
await screen.findByRole("region", { name: `Chat with ${name}` });
}

async function started(fake: FakeBotloft) {
renderApp(fake);
await crewOpened("Ops");
await open("Scout");
}

const panel = (name: string) => screen.queryByRole("complementary", { name });
const button = (name: RegExp) => screen.getByRole("button", { name });

describe("the panel beside a bot's chat", () => {
test("comes back with the bot, each bot with its own", async () => {
const { fake, scout } = crew();
await started(fake);
fireEvent.click(button(/^Show browser/));
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));

await open("Writer");
expect(panel("Scout's browser")).toBeNull();
expect(panel("Writer's browser")).toBeNull();
fireEvent.click(button(/^Show files/));
expect(panel("Files from Writer")).not.toBeNull();

await open("Scout");
const back = panel("Scout's browser");
expect(back).not.toBeNull();
// It is there at once: only a panel opened by hand or by the bot slides.
expect(back?.className).not.toContain("panel-opening");
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
expect(panel("Files from Scout")).toBeNull();

await open("Writer");
expect(panel("Files from Writer")).not.toBeNull();
// Another one opened now slides, from the width of the one it replaces.
fireEvent.click(button(/^Show details/));
expect(panel("About Writer")?.className).toContain("panel-opening");
});

test("closed by the owner, it stays closed when they come back", async () => {
const { fake, scout } = crew();
await started(fake);
// The bot starts browsing: its browser opens by itself.
act(() => {
fake.browser.open(scout.id, "https://example.com/", "Example");
});
await waitFor(() => expect(panel("Scout's browser")).not.toBeNull());
fireEvent.click(button(/^Hide browser/));

await open("Writer");
await open("Scout");
expect(panel("Scout's browser")).toBeNull();
expect(button(/^Show browser/).getAttribute("aria-label")).toBe(
"Show browser: Scout is using the browser",
);
});

test("one that opened by itself comes back too, and the crew's page in between changes nothing", async () => {
const { fake, scout } = crew();
await started(fake);
act(() => {
fake.screens.draft(scout.id, "C:\\Work\\site\\home.html", "<h1>Home");
});
await waitFor(() => expect(panel("Scout's screens")).not.toBeNull());

fireEvent.click(within(sidebar()).getByRole("button", { name: "Ops" }));
await crewOpened("Ops");
expect(panel("Scout's screens")).toBeNull();
await open("Scout");
expect(panel("Scout's screens")).not.toBeNull();
});

test("what the bot did while the owner was away stays behind its button, beside the panel that came back", async () => {
const { fake, scout } = crew();
await started(fake);
fireEvent.click(button(/^Show files/));
await open("Writer");
act(() => {
fake.browser.open(scout.id, "https://example.com/", "Example");
});

await open("Scout");
expect(panel("Files from Scout")).not.toBeNull();
expect(panel("Scout's browser")).toBeNull();
expect(button(/^Show browser/).getAttribute("aria-label")).toBe(
"Show browser: Scout is using the browser",
);
// What it starts from now on takes the place of that panel, as always.
act(() => {
fake.screens.draft(scout.id, "C:\\Work\\site\\home.html", "<h1>Home");
});
await waitFor(() => expect(panel("Scout's screens")).not.toBeNull());
expect(panel("Files from Scout")).toBeNull();
});
});
4 changes: 3 additions & 1 deletion app/src/features/bots/useFollowBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
// the design area (spec 22.5), whatever panel was open. Each opens once per
// start; closing it leaves the button's dot. The browser in the owner's
// hands is never taken away. The owner can turn this off in Settings
// (`prefs.followBot`): then only the buttons' dots say so.
// (`prefs.followBot`): then only the buttons' dots say so. Coming back to a
// bot is opening it again: the panel the owner left is the one open (the
// store keeps it), and what opens by itself here takes its place.

import { useEffect, useRef } from "react";
import type { Bot } from "../../lib/protocol.gen";
Expand Down
3 changes: 2 additions & 1 deletion app/src/features/messages/DeliveryStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ export function DeliveryStatus({ delivery }: { delivery: Delivery }) {
{text.delivered}
</p>
) : (
// The two marks turn blue, as in chat apps; the word says it too.
<p className={`${line} text-muted`} title={text.readTitle}>
<CheckCheck aria-hidden size={12} />
<CheckCheck aria-hidden size={12} className="text-read" />
{text.read}
</p>
);
Expand Down
71 changes: 71 additions & 0 deletions app/src/features/messages/messages.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import tokens from "../../index.css?raw";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openTab, renderApp } from "../../test/app";

Expand Down Expand Up @@ -50,6 +51,34 @@ describe("messages", () => {
expect(within(sentRow).getByText("Delivered")).toBeDefined();
});

test("a message the bot has read gets its two marks in blue, with the word beside them", async () => {
const { fake, lead, writer } = crew();
const { delivery } = fake.conversation.say({
from: lead.id,
to: writer.id,
body: "Draft the notes",
});
fake.conversation.deliver(delivery.id, "sent");
renderApp(fake);
await crewOpened("Ops");
openTab("Timeline");
const row = (await within(messages()).findByText("Draft the notes")).closest(
"li",
) as HTMLElement;
// Delivered is the dim line it always was.
expect(within(row).getByText("Delivered").className).toContain("text-muted");
expect(row.querySelector(".text-read")).toBeNull();

act(() => {
fake.conversation.read(delivery.id);
});
const read = within(row).getByText("Read");
// Only the marks take the color (a token, spec 15.3); the word stays as readable as before.
expect(read.querySelector("svg")?.classList.contains("text-read")).toBe(true);
expect(read.className).toContain("text-muted");
expect(read.className).not.toContain("text-read");
});

test("older messages load on demand", async () => {
const { fake, lead, writer } = crew();
for (let n = 1; n <= 60; n += 1) {
Expand Down Expand Up @@ -140,3 +169,45 @@ describe("tasks", () => {
expect(screen.getByText("Done: 3 pages")).toBeDefined();
});
});

/** The color tokens of one theme in index.css, by name. */
function theme(selector: string): Record<string, string> {
const block = tokens.slice(tokens.indexOf(`${selector} {`)).split("}")[0] ?? "";
return Object.fromEntries(
[...block.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6})/g)].map((found) => [found[1], found[2]]),
);
}

/** WCAG contrast between two `#rrggbb` colors. */
function contrast(one: string, other: string): number {
const [high = 0, low = 0] = [one, other]
.map((hex) => {
const [r = 0, g = 0, b = 0] = [1, 3, 5].map((at) => {
const value = Number.parseInt(hex.slice(at, at + 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
})
.sort((a, b) => b - a);
return (high + 0.05) / (low + 0.05);
}

describe("the read color", () => {
test.each([
["light", ":root"],
["dark", `[data-theme="dark"]`],
])(
"in the %s theme the marks stand out from every surface, in a blue of their own",
(_, selector) => {
const colors = theme(selector);
const read = colors.read ?? "";
expect(read).toMatch(/^#[0-9a-f]{6}$/);
// What an icon needs (3:1), on the chat, a card and a sunken row.
for (const surface of ["canvas", "panel", "sunken"]) {
expect(contrast(read, colors[surface] ?? "")).toBeGreaterThanOrEqual(3);
}
// Lighter than the blue of a bot at work, which says something else.
expect(contrast(read, "#000000")).toBeGreaterThan(contrast(colors.work ?? "", "#000000"));
},
);
});
5 changes: 5 additions & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
--warn: #a35f00;
--danger: #c2372a;
--idle-state: #6f6f69;
/* The check marks of a message the bot has read: a lighter blue than
--work, still 3:1 or more on canvas, panel and sunken in both themes. */
--read: #1787c9;
/* The thin edge around a bot's avatar, so light colors hold their shape. */
--avatar-edge: rgb(0 0 0 / 0.2);
/* Under menus, pickers and dialogs. */
Expand All @@ -50,6 +53,7 @@
--warn: #f0b24a;
--danger: #ff6d60;
--idle-state: #8c8c86;
--read: #5cc4f2;
--avatar-edge: rgb(255 255 255 / 0.14);
--lift: rgb(0 0 0 / 0.5);
}
Expand All @@ -69,6 +73,7 @@
--color-warn: var(--warn);
--color-danger: var(--danger);
--color-quiet: var(--idle-state);
--color-read: var(--read);
--shadow-lift: 0 12px 32px -8px var(--lift), 0 2px 8px -2px var(--lift);
}

Expand Down
Loading
Loading