Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 23 additions & 5 deletions app/src/dev/seedChats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ The report is rebuilt on every visit, and Monday is when everyone opens it.

**Not changing:** the report's layout or its sources.`;

/** The kind of command an owner cannot read at a glance. */
const CHECK_LINKS = `cd "C:/Work/Research" && python - <<'EOF'
import re, urllib.request

text = open("week-39-report.md", encoding="utf-8").read()
for url in sorted(set(re.findall(r"https?://[^\\s)]+", text))):
try:
urllib.request.urlopen(url, timeout=10)
except Exception as error:
print("broken:", url, error)
EOF`;

interface Crew {
scout: BotId;
writer: BotId;
Expand Down Expand Up @@ -126,12 +138,18 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
talk.read(check.delivery.id);
at(39);
chat.reply(crew.reviewer, "I'll run the link checker first, then compare each figure.");
const command = JSON.stringify({
command: "npm run check-links",
description: "Check the links",
// A command with what the bot says it is for, and one without (spec 10.1).
const why = "Opens every link in the report draft and lists the ones that no longer work";
const links = JSON.stringify({ command: CHECK_LINKS, description: why });
const flat = CHECK_LINKS.split(/\s+/).join(" ").slice(0, 160);
chat.tool(crew.reviewer, "Bash", { summary: flat, explanation: why, input: links });
chat.ask(crew.reviewer, "Bash", flat, links, why);
const figures = JSON.stringify({ command: "npm run compare-figures -- --section 2" });
chat.tool(crew.reviewer, "Bash", {
summary: "npm run compare-figures -- --section 2",
input: figures,
});
chat.tool(crew.reviewer, "Bash", { summary: "Check the links", input: command });
chat.ask(crew.reviewer, "Bash", "npm run check-links", command);
chat.ask(crew.reviewer, "Bash", "npm run compare-figures -- --section 2", figures);
at(12);
const late = talk.say({
from: crew.scout,
Expand Down
140 changes: 140 additions & 0 deletions app/src/features/account/ArchivedSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Settings, "Archived": the bots and crews the owner archived, which the
// rest of the app does not show, with a way to delete each for good
// (spec 7.6).

import { Users } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { fromNow } from "../../lib/format";
import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { BotAvatar } from "../bots/BotAvatar";
import { DeleteBot } from "../bots/DeleteBot";
import { DeleteCrew } from "../crews/DeleteCrew";
import { Section } from "./settingsParts";

type Asking = { bot: Bot } | { crew: Crew } | null;

export function ArchivedSettings() {
const s = useT().account.settings;
const api = useApi();
const active = useApp((state) => state.crews);
const [archive, setArchive] = useState<Archive | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [asking, setAsking] = useState<Asking>(null);

useEffect(() => {
let alive = true;
api.call("archive.list").then(
(list) => alive && setArchive(list),
(error) => alive && setFailed(errorText(error)),
);
return () => {
alive = false;
};
}, [api]);

if (failed) {
return (
<Section title={s.archived}>
<Callout tone="danger" title={s.archivedLoadFailed}>
{failed}
</Callout>
</Section>
);
}
if (!archive) {
return <Section title={s.archived}>{null}</Section>;
}

// A bot of an archived crew goes with its crew; the others are listed.
const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id);
const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId));
const forget = (gone: Asking) =>
setArchive({
crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)),
bots: archive.bots.filter(
(bot) =>
!(gone && "bot" in gone && gone.bot.id === bot.id) &&
!(gone && "crew" in gone && gone.crew.id === bot.crewId),
),
});

return (
<Section title={s.archived}>
<p className="text-ink-soft text-sm leading-relaxed">{s.archivedIntro}</p>
{archive.crews.length + bots.length === 0 ? (
<p className="text-muted text-sm">{s.archivedEmpty}</p>
) : (
<ul aria-label={s.archived} className="flex flex-col divide-y divide-line">
{archive.crews.map((crew) => (
<Row
key={crew.id}
icon={
<span className="grid size-7 place-items-center rounded-full bg-sunken text-muted">
<Users aria-hidden size={14} />
</span>
}
name={crew.name}
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
onDelete={() => setAsking({ crew })}
/>
))}
{bots.map((bot) => (
<Row
key={bot.id}
icon={<BotAvatar color={bot.color} size={28} />}
name={bot.name}
line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))}
onDelete={() => setAsking({ bot })}
/>
))}
</ul>
)}
{asking && "bot" in asking && (
<DeleteBot
bot={asking.bot}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
{asking && "crew" in asking && (
<DeleteCrew
crew={asking.crew}
bots={botsOf(asking.crew).length}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
</Section>
);
}

function Row({
icon,
name,
line,
onDelete,
}: {
icon: ReactNode;
name: string;
line: string;
onDelete(): void;
}) {
const s = useT().account.settings;
return (
<li className="flex items-center gap-3 py-2">
{icon}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{name}</p>
<p className="truncate text-muted text-xs">{line}</p>
</div>
<Button variant="danger" size="sm" label={s.deleteArchivedOne(name)} onClick={onDelete}>
{s.deleteArchived}
</Button>
</li>
);
}
9 changes: 7 additions & 2 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Settings (spec 15.1), in parts listed on the left: general (what
// Botloft does in the background, the language), the chat, notifications,
// the look, and the versions for when something needs reporting.
// the look, what was archived, and the versions for when something needs
// reporting.

import {
Archive,
Bell,
Info,
type LucideIcon,
Expand All @@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog";
import { AboutSettings } from "./AboutSettings";
import { AlertsSettings } from "./AlertsSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { ArchivedSettings } from "./ArchivedSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

type Page = "general" | "chat" | "alerts" | "appearance" | "about";
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "alerts", icon: Bell },
{ id: "appearance", icon: Palette },
{ id: "archived", icon: Archive },
{ id: "about", icon: Info },
];

Expand Down Expand Up @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
{page === "chat" && <ChatSettings />}
{page === "alerts" && <AlertsSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "archived" && <ArchivedSettings />}
{page === "about" && <AboutSettings />}
</div>
</div>
Expand Down
90 changes: 90 additions & 0 deletions app/src/features/account/archived.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { RpcError } from "../../lib/rpc";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

/** Opens Settings on its "Archived" part. */
async function openArchived() {
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
const dialog = screen.getByRole("dialog", { name: "Settings" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
return dialog;
}

/** Ops with Writer and an archived Scout, and an archived crew Docs of one bot. */
async function someArchived() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const docs = fake.addCrew("Docs");
const scout = fake.addBot(ops.id, "Scout");
fake.addBot(ops.id, "Writer");
const editor = fake.addBot(docs.id, "Editor");
await fake.call("bots.archive", { botId: scout.id });
await fake.call("crews.archive", { crewId: docs.id });
renderApp(fake);
await crewOpened("Ops");
return { fake, ops, docs, scout, editor };
}

describe("settings, archived", () => {
test("lists what was archived, which the app shows nowhere else", async () => {
await someArchived();
const dialog = await openArchived();
const list = await within(dialog).findByRole("list", { name: "Archived" });
const rows = within(list).getAllByRole("listitem");
expect(rows.map((row) => row.textContent)).toEqual([
"DocsCrew · 1 bot · archived nowDelete",
"ScoutBot in Ops · archived nowDelete",
]);
// A bot of an archived crew goes with its crew, not on its own.
expect(within(list).queryByText("Editor")).toBeNull();
expect(dialog.textContent).toContain("Botloft still keeps their conversations");
});

test("deletes an archived bot, saying it leaves rather than stops", async () => {
const { fake, scout } = await someArchived();
const dialog = await openArchived();
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Scout" }));
const confirm = screen.getByRole("dialog", { name: "Delete Scout?" });
expect(confirm.textContent).toContain("Scout leaves Botloft for good");
expect(confirm.textContent).not.toContain("stops now");
expect(within(confirm).getByText(scout.workspace)).toBeDefined();
fireEvent.click(within(confirm).getByRole("button", { name: "Delete bot" }));
await waitFor(() => expect(within(dialog).queryByText("Scout")).toBeNull());
expect(fake.bots.has(scout.id)).toBe(false);
expect(within(dialog).getByText("Docs")).toBeDefined();
});

test("deletes an archived crew with its bots, then has nothing left", async () => {
const { fake, docs, scout, editor } = await someArchived();
await fake.call("bots.delete", { botId: scout.id });
const dialog = await openArchived();
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Docs" }));
const confirm = screen.getByRole("dialog", { name: "Delete Docs?" });
expect(confirm.textContent).toContain("Docs and its bot leave Botloft for good");
expect(within(confirm).getByText(docs.workFolder)).toBeDefined();
fireEvent.click(within(confirm).getByRole("button", { name: "Delete crew" }));
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();
expect(fake.crews.has(docs.id)).toBe(false);
expect(fake.bots.has(editor.id)).toBe(false);
});

test("says so when nothing is archived, or when the list cannot be read", async () => {
const fake = new FakeBotloft();
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");
const dialog = await openArchived();
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();

fake.failNext("archive.list", new RpcError(-32603, "internal error"));
fireEvent.click(within(dialog).getByRole("tab", { name: "About" }));
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
const alert = await within(dialog).findByRole("alert");
expect(alert.textContent).toContain("Could not load what is archived");
});
});
55 changes: 55 additions & 0 deletions app/src/features/bots/DeleteBot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Confirm } from "../../ui/Confirm";
import { KeptFolder } from "../../ui/KeptFolder";
import { attempt } from "../../ui/toast";

/**
* Asks before deleting a bot for good (spec 7.6), saying what goes and
* what stays, with the choice to send its folder to the Recycle Bin.
* `chiefOf` is the crew it leads, if it leads one.
*/
export function DeleteBot({
bot,
chiefOf,
onClose,
onDeleted,
}: {
bot: Bot;
chiefOf?: string | undefined;
onClose(): void;
onDeleted?(): void;
}) {
const words = useT().bots.header;
const api = useApi();
const dropBot = useApp((state) => state.dropBot);
// The folder stays unless the owner says otherwise.
const [recycle, setRecycle] = useState(false);
return (
<Confirm
title={words.deleteConfirm.title(bot.name)}
confirmLabel={words.deleteConfirm.confirm}
onClose={onClose}
onConfirm={() =>
attempt(words.failed.delete, async () => {
const asked = recycle ? { botId: bot.id, recycleFolder: true } : { botId: bot.id };
dropBot((await api.call("bots.delete", asked)).botId);
onDeleted?.();
})
}
>
<p>
{words.deleteConfirm.removed(bot.name, bot.archivedAt === null)}
{chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`}
</p>
<KeptFolder
path={bot.workspace}
recycle={{ label: words.deleteConfirm.recycle, checked: recycle, onChange: setRecycle }}
>
{recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)}
</KeptFolder>
</Confirm>
);
}
Loading
Loading