Skip to content

Commit 82a8306

Browse files
authored
Merge pull request #52 from httpsphl/feat/read-marks-panel-memory
feat(app): read marks in light blue, and the side panel comes back with the bot
2 parents a6a1605 + 429b5f9 commit 82a8306

11 files changed

Lines changed: 287 additions & 40 deletions

File tree

‎app/src/features/bots/BotView.tsx‎

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { useEffect, useMemo, useState } from "react";
33
import { useShallow } from "zustand/react/shallow";
44
import { useT } from "../../i18n";
55
import type { Bot, Crew } from "../../lib/protocol.gen";
6-
import { routinesOf } from "../../store/app";
6+
import { type BotPanel, routinesOf } from "../../store/app";
77
import { useApp } from "../../store/context";
88
import { Button } from "../../ui/Button";
99
import { Callout } from "../../ui/Callout";
10-
import { PanelClosing } from "../../ui/panelMotion";
10+
import { PanelClosing, PanelRestored } from "../../ui/panelMotion";
1111
import { SidePanel } from "../../ui/SidePanel";
1212
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
1313
import { BrowserPanel } from "../browser/BrowserPanel";
@@ -27,15 +27,25 @@ import { useFollowBot } from "./useFollowBot";
2727

2828
type Pane = "chat" | "routines";
2929
/** What the panel beside the chat shows. */
30-
type Side = "details" | "files" | "browser" | "screens" | null;
30+
type Side = BotPanel | null;
3131

3232
/**
3333
* A bot's conversation and its routines, with its details in a side panel
3434
* (spec 15.1, 20.9).
3535
*/
3636
export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
3737
const t = useT();
38-
const [side, setSide] = useState<Side>(null);
38+
// Kept per bot in the store, not here: the panel the owner left open is
39+
// back when they come back to the bot, and one they closed stays closed
40+
// (spec 15.1).
41+
const side = useApp((state) => state.panels[bot.id] ?? null);
42+
const keepPanel = useApp((state) => state.setPanel);
43+
// The panel that came back is there at once; one opened after it slides.
44+
const [restored, setRestored] = useState(side !== null);
45+
const setSide = (panel: Side) => {
46+
setRestored(false);
47+
keepPanel(bot.id, panel);
48+
};
3949
// The panel on screen: the one open, or the last one while it slides
4050
// closed (spec 15.1).
4151
const [leaving, setLeaving] = useState<Side>(null);
@@ -116,7 +126,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
116126
setTakeBrowser((count) => count + 1);
117127
}
118128
};
119-
// The panel follows what the bot starts doing (spec 15.1).
129+
// The panel follows what the bot starts doing (spec 15.1), over the one
130+
// that came back too.
120131
useFollowBot({
121132
bot,
122133
side,
@@ -166,31 +177,33 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
166177
<BotRoutines bot={bot} />
167178
)}
168179
</div>
169-
<PanelClosing.Provider value={closing}>
170-
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
171-
{beside === "browser" && (
172-
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
173-
)}
174-
{beside === "screens" && (
175-
<ScreensPanel
176-
bot={bot}
177-
data={screens}
178-
path={screenPath}
179-
onPath={setScreenPath}
180-
onClose={() => setSide(null)}
181-
/>
182-
)}
183-
{beside === "files" && (
184-
<FilesPanel
185-
bot={bot}
186-
data={files}
187-
since={since}
188-
path={shown}
189-
onPath={setShown}
190-
onClose={toggleFiles}
191-
/>
192-
)}
193-
</PanelClosing.Provider>
180+
<PanelRestored.Provider value={restored}>
181+
<PanelClosing.Provider value={closing}>
182+
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
183+
{beside === "browser" && (
184+
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
185+
)}
186+
{beside === "screens" && (
187+
<ScreensPanel
188+
bot={bot}
189+
data={screens}
190+
path={screenPath}
191+
onPath={setScreenPath}
192+
onClose={() => setSide(null)}
193+
/>
194+
)}
195+
{beside === "files" && (
196+
<FilesPanel
197+
bot={bot}
198+
data={files}
199+
since={since}
200+
path={shown}
201+
onPath={setShown}
202+
onClose={toggleFiles}
203+
/>
204+
)}
205+
</PanelClosing.Provider>
206+
</PanelRestored.Provider>
194207
</div>
195208
</section>
196209
);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
5+
6+
beforeEach(() => localStorage.clear());
7+
afterEach(cleanup);
8+
9+
/** A crew "Ops" with @scout and @writer, idle. */
10+
function crew() {
11+
const fake = new FakeBotloft();
12+
const ops = fake.addCrew("Ops");
13+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
14+
const writer = fake.addBot(ops.id, "Writer", "Writes the notes");
15+
fake.setBotState(scout.id, "idle");
16+
fake.setBotState(writer.id, "idle");
17+
return { fake, scout, writer };
18+
}
19+
20+
async function open(name: string) {
21+
openBot(name);
22+
await screen.findByRole("region", { name: `Chat with ${name}` });
23+
}
24+
25+
async function started(fake: FakeBotloft) {
26+
renderApp(fake);
27+
await crewOpened("Ops");
28+
await open("Scout");
29+
}
30+
31+
const panel = (name: string) => screen.queryByRole("complementary", { name });
32+
const button = (name: RegExp) => screen.getByRole("button", { name });
33+
34+
describe("the panel beside a bot's chat", () => {
35+
test("comes back with the bot, each bot with its own", async () => {
36+
const { fake, scout } = crew();
37+
await started(fake);
38+
fireEvent.click(button(/^Show browser/));
39+
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
40+
41+
await open("Writer");
42+
expect(panel("Scout's browser")).toBeNull();
43+
expect(panel("Writer's browser")).toBeNull();
44+
fireEvent.click(button(/^Show files/));
45+
expect(panel("Files from Writer")).not.toBeNull();
46+
47+
await open("Scout");
48+
const back = panel("Scout's browser");
49+
expect(back).not.toBeNull();
50+
// It is there at once: only a panel opened by hand or by the bot slides.
51+
expect(back?.className).not.toContain("panel-opening");
52+
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
53+
expect(panel("Files from Scout")).toBeNull();
54+
55+
await open("Writer");
56+
expect(panel("Files from Writer")).not.toBeNull();
57+
// Another one opened now slides, from the width of the one it replaces.
58+
fireEvent.click(button(/^Show details/));
59+
expect(panel("About Writer")?.className).toContain("panel-opening");
60+
});
61+
62+
test("closed by the owner, it stays closed when they come back", async () => {
63+
const { fake, scout } = crew();
64+
await started(fake);
65+
// The bot starts browsing: its browser opens by itself.
66+
act(() => {
67+
fake.browser.open(scout.id, "https://example.com/", "Example");
68+
});
69+
await waitFor(() => expect(panel("Scout's browser")).not.toBeNull());
70+
fireEvent.click(button(/^Hide browser/));
71+
72+
await open("Writer");
73+
await open("Scout");
74+
expect(panel("Scout's browser")).toBeNull();
75+
expect(button(/^Show browser/).getAttribute("aria-label")).toBe(
76+
"Show browser: Scout is using the browser",
77+
);
78+
});
79+
80+
test("one that opened by itself comes back too, and the crew's page in between changes nothing", async () => {
81+
const { fake, scout } = crew();
82+
await started(fake);
83+
act(() => {
84+
fake.screens.draft(scout.id, "C:\\Work\\site\\home.html", "<h1>Home");
85+
});
86+
await waitFor(() => expect(panel("Scout's screens")).not.toBeNull());
87+
88+
fireEvent.click(within(sidebar()).getByRole("button", { name: "Ops" }));
89+
await crewOpened("Ops");
90+
expect(panel("Scout's screens")).toBeNull();
91+
await open("Scout");
92+
expect(panel("Scout's screens")).not.toBeNull();
93+
});
94+
95+
test("what the bot did while the owner was away stays behind its button, beside the panel that came back", async () => {
96+
const { fake, scout } = crew();
97+
await started(fake);
98+
fireEvent.click(button(/^Show files/));
99+
await open("Writer");
100+
act(() => {
101+
fake.browser.open(scout.id, "https://example.com/", "Example");
102+
});
103+
104+
await open("Scout");
105+
expect(panel("Files from Scout")).not.toBeNull();
106+
expect(panel("Scout's browser")).toBeNull();
107+
expect(button(/^Show browser/).getAttribute("aria-label")).toBe(
108+
"Show browser: Scout is using the browser",
109+
);
110+
// What it starts from now on takes the place of that panel, as always.
111+
act(() => {
112+
fake.screens.draft(scout.id, "C:\\Work\\site\\home.html", "<h1>Home");
113+
});
114+
await waitFor(() => expect(panel("Scout's screens")).not.toBeNull());
115+
expect(panel("Files from Scout")).toBeNull();
116+
});
117+
});

‎app/src/features/bots/useFollowBot.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
// the design area (spec 22.5), whatever panel was open. Each opens once per
55
// start; closing it leaves the button's dot. The browser in the owner's
66
// hands is never taken away. The owner can turn this off in Settings
7-
// (`prefs.followBot`): then only the buttons' dots say so.
7+
// (`prefs.followBot`): then only the buttons' dots say so. Coming back to a
8+
// bot is opening it again: the panel the owner left is the one open (the
9+
// store keeps it), and what opens by itself here takes its place.
810

911
import { useEffect, useRef } from "react";
1012
import type { Bot } from "../../lib/protocol.gen";

‎app/src/features/messages/DeliveryStatus.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ export function DeliveryStatus({ delivery }: { delivery: Delivery }) {
2222
{text.delivered}
2323
</p>
2424
) : (
25+
// The two marks turn blue, as in chat apps; the word says it too.
2526
<p className={`${line} text-muted`} title={text.readTitle}>
26-
<CheckCheck aria-hidden size={12} />
27+
<CheckCheck aria-hidden size={12} className="text-read" />
2728
{text.read}
2829
</p>
2930
);

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
22
import { afterEach, describe, expect, test, vi } from "vitest";
3+
import tokens from "../../index.css?raw";
34
import { FakeBotloft } from "../../lib/fake";
45
import { crewOpened, openTab, renderApp } from "../../test/app";
56

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

54+
test("a message the bot has read gets its two marks in blue, with the word beside them", async () => {
55+
const { fake, lead, writer } = crew();
56+
const { delivery } = fake.conversation.say({
57+
from: lead.id,
58+
to: writer.id,
59+
body: "Draft the notes",
60+
});
61+
fake.conversation.deliver(delivery.id, "sent");
62+
renderApp(fake);
63+
await crewOpened("Ops");
64+
openTab("Timeline");
65+
const row = (await within(messages()).findByText("Draft the notes")).closest(
66+
"li",
67+
) as HTMLElement;
68+
// Delivered is the dim line it always was.
69+
expect(within(row).getByText("Delivered").className).toContain("text-muted");
70+
expect(row.querySelector(".text-read")).toBeNull();
71+
72+
act(() => {
73+
fake.conversation.read(delivery.id);
74+
});
75+
const read = within(row).getByText("Read");
76+
// Only the marks take the color (a token, spec 15.3); the word stays as readable as before.
77+
expect(read.querySelector("svg")?.classList.contains("text-read")).toBe(true);
78+
expect(read.className).toContain("text-muted");
79+
expect(read.className).not.toContain("text-read");
80+
});
81+
5382
test("older messages load on demand", async () => {
5483
const { fake, lead, writer } = crew();
5584
for (let n = 1; n <= 60; n += 1) {
@@ -140,3 +169,45 @@ describe("tasks", () => {
140169
expect(screen.getByText("Done: 3 pages")).toBeDefined();
141170
});
142171
});
172+
173+
/** The color tokens of one theme in index.css, by name. */
174+
function theme(selector: string): Record<string, string> {
175+
const block = tokens.slice(tokens.indexOf(`${selector} {`)).split("}")[0] ?? "";
176+
return Object.fromEntries(
177+
[...block.matchAll(/--([\w-]+):\s*(#[0-9a-f]{6})/g)].map((found) => [found[1], found[2]]),
178+
);
179+
}
180+
181+
/** WCAG contrast between two `#rrggbb` colors. */
182+
function contrast(one: string, other: string): number {
183+
const [high = 0, low = 0] = [one, other]
184+
.map((hex) => {
185+
const [r = 0, g = 0, b = 0] = [1, 3, 5].map((at) => {
186+
const value = Number.parseInt(hex.slice(at, at + 2), 16) / 255;
187+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
188+
});
189+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
190+
})
191+
.sort((a, b) => b - a);
192+
return (high + 0.05) / (low + 0.05);
193+
}
194+
195+
describe("the read color", () => {
196+
test.each([
197+
["light", ":root"],
198+
["dark", `[data-theme="dark"]`],
199+
])(
200+
"in the %s theme the marks stand out from every surface, in a blue of their own",
201+
(_, selector) => {
202+
const colors = theme(selector);
203+
const read = colors.read ?? "";
204+
expect(read).toMatch(/^#[0-9a-f]{6}$/);
205+
// What an icon needs (3:1), on the chat, a card and a sunken row.
206+
for (const surface of ["canvas", "panel", "sunken"]) {
207+
expect(contrast(read, colors[surface] ?? "")).toBeGreaterThanOrEqual(3);
208+
}
209+
// Lighter than the blue of a bot at work, which says something else.
210+
expect(contrast(read, "#000000")).toBeGreaterThan(contrast(colors.work ?? "", "#000000"));
211+
},
212+
);
213+
});

‎app/src/index.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
--warn: #a35f00;
2929
--danger: #c2372a;
3030
--idle-state: #6f6f69;
31+
/* The check marks of a message the bot has read: a lighter blue than
32+
--work, still 3:1 or more on canvas, panel and sunken in both themes. */
33+
--read: #1787c9;
3134
/* The thin edge around a bot's avatar, so light colors hold their shape. */
3235
--avatar-edge: rgb(0 0 0 / 0.2);
3336
/* Under menus, pickers and dialogs. */
@@ -50,6 +53,7 @@
5053
--warn: #f0b24a;
5154
--danger: #ff6d60;
5255
--idle-state: #8c8c86;
56+
--read: #5cc4f2;
5357
--avatar-edge: rgb(255 255 255 / 0.14);
5458
--lift: rgb(0 0 0 / 0.5);
5559
}
@@ -69,6 +73,7 @@
6973
--color-warn: var(--warn);
7074
--color-danger: var(--danger);
7175
--color-quiet: var(--idle-state);
76+
--color-read: var(--read);
7277
--shadow-lift: 0 12px 32px -8px var(--lift), 0 2px 8px -2px var(--lift);
7378
}
7479

0 commit comments

Comments
 (0)