Skip to content

Commit 233adfe

Browse files
committed
Merge main, the delete stack, #63 and #61 into feat/effort-context-app
Brings in feat/command-explanation (#61), which carries feat/bot-effort-context (#63), main and the delete stack with their conflicts resolved, so this PR merges in any order with them. No conflict of its own.
2 parents 84f3b74 + 86c7315 commit 233adfe

164 files changed

Lines changed: 7080 additions & 708 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/src/dev/seedBrowser.ts‎

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
// Dev only: Scout searching in its browser in the fake preview, so the
2-
// browser panel has something live to show. The "page" is drawn on a
3-
// canvas and sent as JPEG frames, like the daemon's screencast.
2+
// browser panel has something live to show: three tabs, the search in the
3+
// active one. The "page" is drawn on a canvas, the size the panel asks for,
4+
// and sent as JPEG frames, like the daemon's screencast.
45

56
import type { FakeBotloft } from "../lib/fake";
7+
import type { PageSize } from "../lib/fakePages";
68
import type { BotId } from "../lib/protocol.gen";
9+
import { activeTab, drawPage } from "./seedPage";
710

8-
const W = 1280;
9-
const H = 800;
11+
const SITE = "https://recipes.example/";
12+
13+
const RECIPES = ["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"];
1014

1115
interface Scene {
1216
url: string;
@@ -15,16 +19,16 @@ interface Scene {
1519
results: boolean;
1620
}
1721

18-
function draw(scene: Scene): string {
22+
function draw(scene: Scene, size: PageSize): string {
1923
const canvas = document.createElement("canvas");
20-
canvas.width = W;
21-
canvas.height = H;
24+
canvas.width = size.width;
25+
canvas.height = size.height;
2226
const g = canvas.getContext("2d");
2327
if (!g) {
2428
return "";
2529
}
2630
g.fillStyle = "#ffffff";
27-
g.fillRect(0, 0, W, H);
31+
g.fillRect(0, 0, size.width, size.height);
2832
g.fillStyle = "#1f2937";
2933
g.font = "600 34px system-ui, sans-serif";
3034
g.fillText(scene.results ? "Results" : "Find a recipe", 120, 150);
@@ -43,15 +47,12 @@ function draw(scene: Scene): string {
4347
g.fillStyle = "#ffffff";
4448
g.fillText("Search", 938, 226);
4549
if (scene.results) {
46-
for (let row = 0; row < 4; row++) {
50+
// A taller page shows more of the list.
51+
for (let row = 0; 300 + row * 110 < size.height - 60; row++) {
4752
const y = 300 + row * 110;
4853
g.fillStyle = "#1d4ed8";
4954
g.font = "600 24px system-ui, sans-serif";
50-
g.fillText(
51-
["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"][row] ?? "",
52-
120,
53-
y,
54-
);
55+
g.fillText(RECIPES[row % RECIPES.length] ?? "", 120, y);
5556
g.fillStyle = "#e5e7eb";
5657
g.fillRect(120, y + 18, 820, 14);
5758
g.fillRect(120, y + 42, 560, 14);
@@ -60,10 +61,10 @@ function draw(scene: Scene): string {
6061
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
6162
}
6263

63-
/** Scout searches for a recipe, over and over. */
64+
/** Scout searches for a recipe, over and over, while the browser is its. */
6465
export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
6566
const home: Scene = {
66-
url: "https://recipes.example/",
67+
url: SITE,
6768
title: "Recipes",
6869
query: "",
6970
results: false,
@@ -116,15 +117,25 @@ export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
116117
);
117118
let scene = home;
118119
let step = 0;
119-
fake.browser.open(scout, home.url, home.title);
120-
fake.browser.frame(scout, draw(scene));
120+
// Two pages it opened on the way, then the one it works in.
121+
fake.browser.open(scout, "https://bakers.example/forum/hydration", "Hydration tips · Bakers");
122+
fake.browser.openTab(scout, "https://mill.example/flour/rye", "Rye flour · The Mill");
123+
fake.browser.openTab(scout, home.url, home.title);
124+
fake.browser.paint(scout, (size) => {
125+
const tab = activeTab(fake, scout);
126+
return tab?.url.startsWith(SITE) ? draw(scene, size) : drawPage(tab, size);
127+
});
121128
setInterval(() => {
129+
// In the owner's hands, the bot waits.
130+
if (fake.browser.state(scout).control === "owner") {
131+
return;
132+
}
122133
const next = steps[step % steps.length];
123134
step += 1;
124135
if (next) {
125136
scene = next(scene);
126137
fake.browser.open(scout, scene.url, scene.title);
127-
fake.browser.frame(scout, draw(scene));
138+
fake.browser.repaint(scout);
128139
}
129140
}, 2200);
130141
}

‎app/src/dev/seedChats.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ The report is rebuilt on every visit, and Monday is when everyone opens it.
3333
3434
**Not changing:** the report's layout or its sources.`;
3535

36+
/** The kind of command an owner cannot read at a glance. */
37+
const CHECK_LINKS = `cd "C:/Work/Research" && python - <<'EOF'
38+
import re, urllib.request
39+
40+
text = open("week-39-report.md", encoding="utf-8").read()
41+
for url in sorted(set(re.findall(r"https?://[^\\s)]+", text))):
42+
try:
43+
urllib.request.urlopen(url, timeout=10)
44+
except Exception as error:
45+
print("broken:", url, error)
46+
EOF`;
47+
3648
interface Crew {
3749
scout: BotId;
3850
writer: BotId;
@@ -126,12 +138,18 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
126138
talk.read(check.delivery.id);
127139
at(39);
128140
chat.reply(crew.reviewer, "I'll run the link checker first, then compare each figure.");
129-
const command = JSON.stringify({
130-
command: "npm run check-links",
131-
description: "Check the links",
141+
// A command with what the bot says it is for, and one without (spec 10.1).
142+
const why = "Opens every link in the report draft and lists the ones that no longer work";
143+
const links = JSON.stringify({ command: CHECK_LINKS, description: why });
144+
const flat = CHECK_LINKS.split(/\s+/).join(" ").slice(0, 160);
145+
chat.tool(crew.reviewer, "Bash", { summary: flat, explanation: why, input: links });
146+
chat.ask(crew.reviewer, "Bash", flat, links, why);
147+
const figures = JSON.stringify({ command: "npm run compare-figures -- --section 2" });
148+
chat.tool(crew.reviewer, "Bash", {
149+
summary: "npm run compare-figures -- --section 2",
150+
input: figures,
132151
});
133-
chat.tool(crew.reviewer, "Bash", { summary: "Check the links", input: command });
134-
chat.ask(crew.reviewer, "Bash", "npm run check-links", command);
152+
chat.ask(crew.reviewer, "Bash", "npm run compare-figures -- --section 2", figures);
135153
at(12);
136154
const late = talk.say({
137155
from: crew.scout,

‎app/src/dev/seedHands.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
// the owner's clicks and keys, so taking the browser can be tried end to end.
44

55
import type { FakeBotloft } from "../lib/fake";
6+
import type { PageSize } from "../lib/fakePages";
67
import type { BrowserInput, CrewId } from "../lib/protocol.gen";
8+
import { activeTab, drawPage } from "./seedPage";
9+
10+
const SITE = "https://flour.example/";
711

8-
const W = 1280;
9-
const H = 800;
1012
const FIELDS = { user: 300, password: 390 } as const;
1113
type Field = keyof typeof FIELDS;
1214

@@ -17,16 +19,16 @@ interface Form {
1719
signedIn: boolean;
1820
}
1921

20-
function draw(form: Form): string {
22+
function draw(form: Form, size: PageSize): string {
2123
const canvas = document.createElement("canvas");
22-
canvas.width = W;
23-
canvas.height = H;
24+
canvas.width = size.width;
25+
canvas.height = size.height;
2426
const g = canvas.getContext("2d");
2527
if (!g) {
2628
return "";
2729
}
2830
g.fillStyle = "#f7f4ee";
29-
g.fillRect(0, 0, W, H);
31+
g.fillRect(0, 0, size.width, size.height);
3032
g.fillStyle = "#3b2a1a";
3133
g.font = "600 36px Georgia, serif";
3234
if (form.signedIn) {
@@ -90,21 +92,24 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
9092
const clerk = fake.addBot(crewId, "Clerk", "Places the bakery's orders");
9193
fake.setBotState(clerk.id, "needs_approval", 2);
9294
const form: Form = { user: "", password: "", field: null, signedIn: false };
93-
const login = "https://flour.example/login";
95+
const login = `${SITE}login`;
96+
const onSite = () => activeTab(fake, clerk.id)?.url.startsWith(SITE) ?? false;
9497
fake.browser.open(clerk.id, login, "Sign in · Flour Co.");
95-
fake.browser.frame(clerk.id, draw(form));
98+
fake.browser.paint(clerk.id, (size) =>
99+
onSite() ? draw(form, size) : drawPage(activeTab(fake, clerk.id), size),
100+
);
96101
fake.chat.tool(clerk.id, "mcp__botloft__browser_open", { summary: login, status: "done" });
97102
const task = "Sign in to Flour Co. with the bakery's account";
98103
fake.chat.tool(clerk.id, "mcp__botloft__browser_ask_owner", { summary: task, status: "running" });
99104
fake.browser.ask(clerk.id, task);
100105
fake.browser.onInput(clerk.id, (input) => {
101-
if (form.signedIn) {
106+
if (form.signedIn || !onSite()) {
102107
return;
103108
}
104109
if (handle(form, input)) {
105110
form.signedIn = true;
106-
fake.browser.open(clerk.id, "https://flour.example/orders", "Orders · Flour Co.");
111+
fake.browser.open(clerk.id, `${SITE}orders`, "Orders · Flour Co.");
107112
}
108-
fake.browser.frame(clerk.id, draw(form));
113+
fake.browser.repaint(clerk.id);
109114
});
110115
}

‎app/src/dev/seedPage.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Dev only: any page of the fake preview's browsers that no seed draws
2+
// itself, such as a tab in the background or an address the owner typed.
3+
// A heading with the tab's title, its site, and lines of grey "text".
4+
5+
import type { FakeBotloft } from "../lib/fake";
6+
import type { PageSize } from "../lib/fakePages";
7+
import type { BotId, BrowserTab } from "../lib/protocol.gen";
8+
9+
/** The bot's active tab, if its browser has any. */
10+
export function activeTab(fake: FakeBotloft, botId: BotId): BrowserTab | null {
11+
return fake.browser.state(botId).tabs.find((tab) => tab.active) ?? null;
12+
}
13+
14+
export function drawPage(tab: BrowserTab | null, size: PageSize): string {
15+
const canvas = document.createElement("canvas");
16+
canvas.width = size.width;
17+
canvas.height = size.height;
18+
const g = canvas.getContext("2d");
19+
if (!g) {
20+
return "";
21+
}
22+
g.fillStyle = "#ffffff";
23+
g.fillRect(0, 0, size.width, size.height);
24+
if (tab && tab.url !== "about:blank") {
25+
g.fillStyle = "#111827";
26+
g.font = "600 40px Georgia, serif";
27+
g.fillText(tab.title || tab.url, 120, 160);
28+
g.fillStyle = "#6b7280";
29+
g.font = "20px system-ui, sans-serif";
30+
g.fillText(tab.url, 120, 204);
31+
g.fillStyle = "#e5e7eb";
32+
for (let y = 270; y < size.height - 80; y += 34) {
33+
g.fillRect(120, y, y % 5 === 0 ? 640 : 960, 14);
34+
}
35+
}
36+
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
37+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Settings, "Archived": the bots and crews the owner archived, which the
2+
// rest of the app does not show, with a way to delete each for good
3+
// (spec 7.6).
4+
5+
import { Users } from "lucide-react";
6+
import { type ReactNode, useEffect, useState } from "react";
7+
import { useT } from "../../i18n";
8+
import { errorText } from "../../lib/api";
9+
import { fromNow } from "../../lib/format";
10+
import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
11+
import { useApi, useApp } from "../../store/context";
12+
import { Button } from "../../ui/Button";
13+
import { Callout } from "../../ui/Callout";
14+
import { BotAvatar } from "../bots/BotAvatar";
15+
import { DeleteBot } from "../bots/DeleteBot";
16+
import { DeleteCrew } from "../crews/DeleteCrew";
17+
import { Section } from "./settingsParts";
18+
19+
type Asking = { bot: Bot } | { crew: Crew } | null;
20+
21+
export function ArchivedSettings() {
22+
const s = useT().account.settings;
23+
const api = useApi();
24+
const active = useApp((state) => state.crews);
25+
const [archive, setArchive] = useState<Archive | null>(null);
26+
const [failed, setFailed] = useState<string | null>(null);
27+
const [asking, setAsking] = useState<Asking>(null);
28+
29+
useEffect(() => {
30+
let alive = true;
31+
api.call("archive.list").then(
32+
(list) => alive && setArchive(list),
33+
(error) => alive && setFailed(errorText(error)),
34+
);
35+
return () => {
36+
alive = false;
37+
};
38+
}, [api]);
39+
40+
if (failed) {
41+
return (
42+
<Section title={s.archived}>
43+
<Callout tone="danger" title={s.archivedLoadFailed}>
44+
{failed}
45+
</Callout>
46+
</Section>
47+
);
48+
}
49+
if (!archive) {
50+
return <Section title={s.archived}>{null}</Section>;
51+
}
52+
53+
// A bot of an archived crew goes with its crew; the others are listed.
54+
const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id);
55+
const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId));
56+
const forget = (gone: Asking) =>
57+
setArchive({
58+
crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)),
59+
bots: archive.bots.filter(
60+
(bot) =>
61+
!(gone && "bot" in gone && gone.bot.id === bot.id) &&
62+
!(gone && "crew" in gone && gone.crew.id === bot.crewId),
63+
),
64+
});
65+
66+
return (
67+
<Section title={s.archived}>
68+
<p className="text-ink-soft text-sm leading-relaxed">{s.archivedIntro}</p>
69+
{archive.crews.length + bots.length === 0 ? (
70+
<p className="text-muted text-sm">{s.archivedEmpty}</p>
71+
) : (
72+
<ul aria-label={s.archived} className="flex flex-col divide-y divide-line">
73+
{archive.crews.map((crew) => (
74+
<Row
75+
key={crew.id}
76+
icon={
77+
<span className="grid size-7 place-items-center rounded-full bg-sunken text-muted">
78+
<Users aria-hidden size={14} />
79+
</span>
80+
}
81+
name={crew.name}
82+
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
83+
onDelete={() => setAsking({ crew })}
84+
/>
85+
))}
86+
{bots.map((bot) => (
87+
<Row
88+
key={bot.id}
89+
icon={<BotAvatar color={bot.color} size={28} />}
90+
name={bot.name}
91+
line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))}
92+
onDelete={() => setAsking({ bot })}
93+
/>
94+
))}
95+
</ul>
96+
)}
97+
{asking && "bot" in asking && (
98+
<DeleteBot
99+
bot={asking.bot}
100+
onClose={() => setAsking(null)}
101+
onDeleted={() => forget(asking)}
102+
/>
103+
)}
104+
{asking && "crew" in asking && (
105+
<DeleteCrew
106+
crew={asking.crew}
107+
bots={botsOf(asking.crew).length}
108+
onClose={() => setAsking(null)}
109+
onDeleted={() => forget(asking)}
110+
/>
111+
)}
112+
</Section>
113+
);
114+
}
115+
116+
function Row({
117+
icon,
118+
name,
119+
line,
120+
onDelete,
121+
}: {
122+
icon: ReactNode;
123+
name: string;
124+
line: string;
125+
onDelete(): void;
126+
}) {
127+
const s = useT().account.settings;
128+
return (
129+
<li className="flex items-center gap-3 py-2">
130+
{icon}
131+
<div className="min-w-0 flex-1">
132+
<p className="truncate font-medium text-sm">{name}</p>
133+
<p className="truncate text-muted text-xs">{line}</p>
134+
</div>
135+
<Button variant="danger" size="sm" label={s.deleteArchivedOne(name)} onClick={onDelete}>
136+
{s.deleteArchived}
137+
</Button>
138+
</li>
139+
);
140+
}

0 commit comments

Comments
 (0)