diff --git a/Cargo.lock b/Cargo.lock index 038c6baf..84a49b9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "windows", + "windows-core", ] [[package]] diff --git a/app/src/features/account/ArchivedSettings.tsx b/app/src/features/account/ArchivedSettings.tsx new file mode 100644 index 00000000..7666c76d --- /dev/null +++ b/app/src/features/account/ArchivedSettings.tsx @@ -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(null); + const [failed, setFailed] = useState(null); + const [asking, setAsking] = useState(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 ( +
+ + {failed} + +
+ ); + } + if (!archive) { + return
{null}
; + } + + // 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 ( +
+

{s.archivedIntro}

+ {archive.crews.length + bots.length === 0 ? ( +

{s.archivedEmpty}

+ ) : ( +
    + {archive.crews.map((crew) => ( + + + + } + name={crew.name} + line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))} + onDelete={() => setAsking({ crew })} + /> + ))} + {bots.map((bot) => ( + } + name={bot.name} + line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))} + onDelete={() => setAsking({ bot })} + /> + ))} +
+ )} + {asking && "bot" in asking && ( + setAsking(null)} + onDeleted={() => forget(asking)} + /> + )} + {asking && "crew" in asking && ( + setAsking(null)} + onDeleted={() => forget(asking)} + /> + )} +
+ ); +} + +function Row({ + icon, + name, + line, + onDelete, +}: { + icon: ReactNode; + name: string; + line: string; + onDelete(): void; +}) { + const s = useT().account.settings; + return ( +
  • + {icon} +
    +

    {name}

    +

    {line}

    +
    + +
  • + ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index 99bd5eba..c5128067 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -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, @@ -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 }, ]; @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { {page === "chat" && } {page === "alerts" && } {page === "appearance" && } + {page === "archived" && } {page === "about" && } diff --git a/app/src/features/account/archived.test.tsx b/app/src/features/account/archived.test.tsx new file mode 100644 index 00000000..edcdea9c --- /dev/null +++ b/app/src/features/account/archived.test.tsx @@ -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"); + }); +}); diff --git a/app/src/features/bots/DeleteBot.tsx b/app/src/features/bots/DeleteBot.tsx new file mode 100644 index 00000000..9b2a9dfb --- /dev/null +++ b/app/src/features/bots/DeleteBot.tsx @@ -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 ( + + 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?.(); + }) + } + > +

    + {words.deleteConfirm.removed(bot.name, bot.archivedAt === null)} + {chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`} +

    + + {recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)} + +
    + ); +} diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 246b2c9d..58524ce5 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -1,4 +1,4 @@ -import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } from "lucide-react"; +import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react"; import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; @@ -8,8 +8,9 @@ import type { MenuItem } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotDialog } from "./BotDialog"; import { isChief } from "./ChiefBadge"; +import { DeleteBot } from "./DeleteBot"; -type Open = "edit" | "archive" | "fresh" | null; +type Open = "edit" | "archive" | "delete" | "fresh" | null; /** * What the owner can do with a bot from a menu, and the dialogs those @@ -54,6 +55,7 @@ export function useBotActions( onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), }, { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, ]; const dialogs = @@ -85,6 +87,8 @@ export function useBotActions( > {words.archiveConfirm.body} + ) : open === "delete" ? ( + ) : null; return { items, dialogs }; diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx new file mode 100644 index 00000000..ff07d360 --- /dev/null +++ b/app/src/features/bots/delete.test.tsx @@ -0,0 +1,242 @@ +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, openBot, openTab, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with Scout and Writer, open on the crew's page. */ +async function twoBots() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + renderApp(fake); + await crewOpened("Ops"); + return { fake, crew, scout, writer }; +} + +const inSidebar = (name: string) => + within(sidebar()).queryByRole("button", { name: new RegExp(name) }); + +/** Picks Delete in the open bot's menu and returns the confirmation. */ +function askToDelete(name: string) { + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete bot" })); + return screen.getByRole("dialog", { name: `Delete ${name}?` }); +} + +describe("deleting a bot", () => { + test("says what goes and what stays, then removes the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + expect(dialog.textContent).toContain( + "Scout stops now and leaves Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.", + ); + expect(dialog.textContent).toContain("Scout's folder stays on your computer"); + expect(within(dialog).getByText(scout.workspace)).toBeDefined(); + expect(dialog.textContent).not.toContain("without a chief"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + // Back on the crew's page, without the bot. + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(fake.calls.at(-1)).toEqual({ method: "bots.delete", params: { botId: scout.id } }); + expect(fake.bots.has(scout.id)).toBe(false); + expect(inSidebar("Writer")).not.toBeNull(); + }); + + test("cancelling keeps the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(fake.bots.has(scout.id)).toBe(true); + expect(fake.calls.some((call) => call.method === "bots.delete")).toBe(false); + }); + + test("works from the right-click menu, for a bot that is not open", async () => { + const { fake, scout } = await twoBots(); + openBot("Writer"); + await screen.findByRole("heading", { level: 1, name: "Writer" }); + fireEvent.contextMenu(inSidebar("Scout") as HTMLElement, { clientX: 80, clientY: 120 }); + const menu = screen.getByRole("menu", { name: "Actions for Scout" }); + // Delete comes last, after Archive. + expect(within(menu).getAllByRole("menuitem").at(-1)?.textContent).toBe("Delete bot"); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Delete bot" })); + const dialog = screen.getByRole("dialog", { name: "Delete Scout?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(fake.bots.has(scout.id)).toBe(false); + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); + }); + + test("warns that the crew loses its chief", async () => { + const { fake, crew, scout } = await twoBots(); + await fake.call("crews.setLead", { crewId: crew.id, botId: scout.id }); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + expect(dialog.textContent).toContain("Ops will be left without a chief."); + }); + + test("a delete that fails says so and keeps the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fake.failNext("bots.delete", new RpcError(-32603, "internal error")); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await screen.findByText(/Could not delete the bot/); + expect(fake.bots.has(scout.id)).toBe(true); + expect(inSidebar("Scout")).not.toBeNull(); + }); + + test("what it sent stays in the timeline, from a bot no longer in the crew", async () => { + const { fake, scout, writer } = await twoBots(); + fake.conversation.say({ from: scout.id, to: writer.id, body: "The draft is in shared/." }); + await fake.call("bots.delete", { botId: scout.id }); + openTab("Timeline"); + const timeline = screen.getByRole("tabpanel", { name: "Timeline" }); + const message = (await within(timeline).findByText("The draft is in shared/.")).closest("li"); + expect(message?.textContent).toContain("a bot no longer in the crew"); + }); +}); + +describe("deleting a crew", () => { + function askToDeleteCrew(name: string) { + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + return screen.getByRole("dialog", { name: `Delete ${name}?` }); + } + + test("says what goes and what stays, then removes the crew and its bots", async () => { + const { fake, crew, scout } = await twoBots(); + const docs = fake.addCrew("Docs"); + await waitFor(() => + expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(), + ); + const dialog = askToDeleteCrew("Ops"); + expect(dialog.textContent).toContain( + "Ops and its 2 bots stop now and leave Botloft for good, with their conversations, routines and tasks. This can't be undone.", + ); + expect(dialog.textContent).toContain("The folders stay on your computer"); + expect(within(dialog).getByText(crew.workFolder)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + await waitFor(() => + expect(within(sidebar()).queryByRole("button", { name: /Ops/ })).toBeNull(), + ); + expect(fake.calls.at(-1)).toEqual({ method: "crews.delete", params: { crewId: crew.id } }); + expect(fake.bots.has(scout.id)).toBe(false); + expect(inSidebar("Scout")).toBeNull(); + expect(fake.crews.has(docs.id)).toBe(true); + expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(); + }); + + test("an empty crew and a crew of one read right", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + const empty = askToDeleteCrew("Ops"); + expect(empty.textContent).toContain("Ops leaves Botloft for good. This can't be undone."); + fireEvent.click(within(empty).getByRole("button", { name: "Cancel" })); + + fake.addBot(crew.id, "Scout"); + await waitFor(() => expect(inSidebar("Scout")).not.toBeNull()); + expect(askToDeleteCrew("Ops").textContent).toContain("Ops and its bot stop now"); + }); + + test("the last crew gone, the app is back at the start", async () => { + const { fake } = await twoBots(); + const dialog = askToDeleteCrew("Ops"); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); + expect(fake.crews.size).toBe(0); + }); +}); + +describe("sending the folder to the Recycle Bin", () => { + const box = (dialog: HTMLElement, name: string) => + within(dialog).getByRole("checkbox", { name }) as HTMLInputElement; + + test("is off until the owner asks, then the bot's folder goes with it", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + const recycle = box(dialog, "Move this folder to the Recycle Bin"); + expect(recycle.checked).toBe(false); + + fireEvent.click(recycle); + expect(dialog.textContent).toContain( + "Scout's folder goes to the Recycle Bin, where you can still get it back:", + ); + expect(dialog.textContent).not.toContain("stays on your computer"); + expect(within(dialog).getByText(scout.workspace)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(fake.calls.at(-1)).toEqual({ + method: "bots.delete", + params: { botId: scout.id, recycleFolder: true }, + }); + expect(fake.recycled).toEqual([scout.workspace]); + }); + + test("says so when the folder could not go, and where it still is", async () => { + const { fake, scout } = await twoBots(); + fake.recycleError = "Windows cannot put this folder in the Recycle Bin"; + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fireEvent.click(box(dialog, "Move this folder to the Recycle Bin")); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + + const notice = await screen.findByText(/did not go to the Recycle Bin/); + expect(notice.textContent).toBe( + `The folder ${scout.workspace} did not go to the Recycle Bin and is still there: Windows cannot put this folder in the Recycle Bin`, + ); + // The bot is deleted all the same. + expect(fake.bots.has(scout.id)).toBe(false); + expect(fake.recycled).toEqual([]); + }); + + test("a crew's own folder goes whole, and a work folder the owner chose never does", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const site = await fake.call("crews.create", { + name: "Site", + workFolder: "C:\\Projects\\Site", + }); + fake.addBot(ops.id, "Scout"); + renderApp(fake); + await crewOpened("Ops"); + + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + const dialog = screen.getByRole("dialog", { name: "Delete Ops?" }); + fireEvent.click(box(dialog, "Move the crew's folders to the Recycle Bin")); + expect(dialog.textContent).toContain("The crew's folder goes to the Recycle Bin"); + // The crew's folder itself, not only `shared` inside it. + expect(within(dialog).getByText("C:\\Users\\owner\\Botloft\\ops")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + await waitFor(() => expect(fake.recycled).toEqual(["C:\\Users\\owner\\Botloft\\ops"])); + expect(fake.calls.at(-1)?.params).toEqual({ crewId: ops.id, recycleFolder: true }); + + fireEvent.click(within(sidebar()).getByRole("button", { name: /Site/ })); + await crewOpened("Site"); + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + const chosen = screen.getByRole("dialog", { name: "Delete Site?" }); + fireEvent.click(box(chosen, "Move the crew's folders to the Recycle Bin")); + expect(chosen.textContent).toContain("The work folder you chose stays where it is:"); + expect(within(chosen).getByText(site.workFolder)).toBeDefined(); + }); +}); diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 7858cc15..20bceda3 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -71,7 +71,7 @@ function OtherMessage({ message }: { message: Message }) { const sender = useApp((state) => (message.fromBotId ? state.bots[message.fromBotId] : undefined)); const delivery = useApp((state) => state.deliveries[message.id]); const system = message.fromKind === "system"; - const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); + const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.goneBot); const arrival = useArrival(message.createdAt); return (
  • diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index bc53019c..b322903a 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -8,6 +8,7 @@ import { Pencil, Play, Plus, + Trash2, } from "lucide-react"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -27,8 +28,9 @@ import { CrewRoutines } from "../routines/RoutineList"; import { TaskList } from "../tasks/TaskList"; import { CrewBots } from "./CrewBots"; import { CrewDialog } from "./CrewDialog"; +import { DeleteCrew } from "./DeleteCrew"; -type Open = "bot" | "rename" | "archive" | { move: string } | null; +type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null; type Pane = "bots" | "timeline" | "tasks" | "routines"; export function CrewView({ crew }: { crew: Crew }) { @@ -109,6 +111,7 @@ export function CrewView({ crew }: { crew: Crew }) { danger: true, onSelect: () => setOpen("archive"), }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, ]} /> @@ -188,6 +191,7 @@ export function CrewView({ crew }: { crew: Crew }) { {words.archiveBody(bots.length)} )} + {open === "delete" && } ); } diff --git a/app/src/features/crews/DeleteCrew.tsx b/app/src/features/crews/DeleteCrew.tsx new file mode 100644 index 00000000..1c6466f6 --- /dev/null +++ b/app/src/features/crews/DeleteCrew.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { useT } from "../../i18n"; +import type { Crew } 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 crew for good (spec 7.6), saying what goes and + * what stays, with the choice to send its folders to the Recycle Bin. + * `bots` is how many bots go with it. + */ +export function DeleteCrew({ + crew, + bots, + onClose, + onDeleted, +}: { + crew: Crew; + bots: number; + onClose(): void; + onDeleted?(): void; +}) { + const words = useT().crews.view; + const api = useApi(); + const dropCrew = useApp((state) => state.dropCrew); + // The folders stay unless the owner says otherwise. + const [recycle, setRecycle] = useState(false); + // What goes to the bin is the crew's own folder, where `shared` is + // (spec 5); a work folder the owner chose is somewhere else and stays. + const shown = + recycle && !crew.workFolderChosen + ? crew.workFolder.replace(/[\\/]shared$/, "") + : crew.workFolder; + const said = !recycle + ? words.deleteKept + : crew.workFolderChosen + ? words.deleteRecycledChosen + : words.deleteRecycled; + return ( + + attempt(words.failed.delete, async () => { + const asked = recycle ? { crewId: crew.id, recycleFolder: true } : { crewId: crew.id }; + dropCrew((await api.call("crews.delete", asked)).crewId); + onDeleted?.(); + }) + } + > +

    {words.deleteBody(crew.name, bots, crew.archivedAt === null)}

    + + {said} + +
    + ); +} diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx index f26c0988..a8be2fb9 100644 --- a/app/src/features/crews/botMenu.test.tsx +++ b/app/src/features/crews/botMenu.test.tsx @@ -40,6 +40,7 @@ describe("a bot's right-click menu", () => { "Restart with a new conversation", "Open folder", "Archive bot", + "Delete bot", ]); // The crew's page is still the one open. expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); diff --git a/app/src/features/messages/MessageRow.tsx b/app/src/features/messages/MessageRow.tsx index 08075b3d..0db72590 100644 --- a/app/src/features/messages/MessageRow.tsx +++ b/app/src/features/messages/MessageRow.tsx @@ -9,7 +9,7 @@ import { DeliveryStatus } from "./DeliveryStatus"; function BotName({ bot }: { bot: Bot | undefined }) { const t = useT(); if (!bot) { - return {t.messages.row.archivedBot}; + return {t.messages.row.goneBot}; } return ( diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index c4ba71d8..2bc6745c 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -87,6 +87,17 @@ export const account = { size: "Size", sizeHint: "Ctrl+= and Ctrl+- change the size too, and Ctrl+0 goes back to the default.", language: "Language", + archived: "Archived", + archivedIntro: + "Bots and crews you archived are stopped and out of sight, and Botloft still keeps their conversations. Delete one to remove it for good.", + archivedEmpty: "Nothing is archived.", + archivedLoadFailed: "Could not load what is archived", + /** `when` is relative, like "2 days ago". */ + archivedCrew: (bots: number, when: string) => + `Crew · ${bots === 1 ? "1 bot" : `${bots} bots`} · archived ${when}`, + archivedBot: (crew: string, when: string) => `Bot in ${crew} · archived ${when}`, + deleteArchived: "Delete", + deleteArchivedOne: (name: string) => `Delete ${name}`, about: "About", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 47896821..a8234f5e 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -48,6 +48,7 @@ export const bots = { makeChief: "Make crew chief", stopChief: "Stop being chief", archive: "Archive bot", + delete: "Delete bot", failed: { pause: "Could not pause the bot", resume: "Could not resume the bot", @@ -55,6 +56,7 @@ export const bots = { openFolder: "Could not open the folder", chief: "Could not change the chief", archive: "Could not archive the bot", + delete: "Could not delete the bot", }, fresh: { title: "Start a new conversation?", @@ -67,6 +69,18 @@ export const bots = { confirm: "Archive bot", body: "The bot stops and leaves the crew. Messages still waiting for it are not delivered.", }, + deleteConfirm: { + title: (name: string) => `Delete ${name}?`, + confirm: "Delete bot", + /** `running` is false for an archived bot, which stopped long ago. */ + removed: (name: string, running: boolean) => + `${name} ${running ? "stops now and leaves" : "leaves"} Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.`, + chief: (crew: string) => `${crew} will be left without a chief.`, + kept: (name: string) => `${name}'s folder stays on your computer, with everything in it:`, + recycle: "Move this folder to the Recycle Bin", + recycled: (name: string) => + `${name}'s folder goes to the Recycle Bin, where you can still get it back:`, + }, }, dialog: { newTitle: "New bot", diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 30ef29c9..f719d9ce 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -43,7 +43,7 @@ export const chat = { inbound: { task: "Task", result: "Result", - archivedBot: "An archived bot", + goneBot: "A bot no longer in the crew", }, run: { working: "Working", diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 0ea025b5..b84f232e 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -49,6 +49,7 @@ export const crews = { `The bots will work in ${path}. Each one restarts when it finishes what it's doing. Files already made stay where they are.`, move: "Move", archive: "Archive crew", + delete: "Delete crew", tabs: { label: "Crew views", bots: "Bots", @@ -62,12 +63,26 @@ export const crews = { bots === 0 ? "The crew leaves the app." : `The crew leaves the app. ${bots === 1 ? "Its bot stops" : `Its ${bots} bots stop`}, and messages still waiting for them are not delivered.`, + deleteTitle: (crew: string) => `Delete ${crew}?`, + /** `running` is false for an archived crew, which stopped long ago. */ + deleteBody: (crew: string, bots: number, running: boolean) => + bots === 0 + ? `${crew} leaves Botloft for good. This can't be undone.` + : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} ${running ? "stop now and leave" : "leave"} Botloft for good, with their conversations, routines and tasks. This can't be undone.`, + deleteKept: + "The folders stay on your computer, with everything in them: each bot's own folder and the crew's work folder:", + deleteRecycle: "Move the crew's folders to the Recycle Bin", + deleteRecycled: + "The crew's folder goes to the Recycle Bin, with each bot's folder and the work folder in it. You can still get it back from there:", + deleteRecycledChosen: + "Each bot's own folder goes to the Recycle Bin, where you can still get it back. The work folder you chose stays where it is:", failed: { pause: "Could not pause the crew", resume: "Could not resume the crew", changeFolder: "Could not change the folder", openFolder: "Could not open the folder", archive: "Could not archive the crew", + delete: "Could not delete the crew", }, }, bots: { diff --git a/app/src/i18n/en/messages.ts b/app/src/i18n/en/messages.ts index 19bdafd4..01c2b7a7 100644 --- a/app/src/i18n/en/messages.ts +++ b/app/src/i18n/en/messages.ts @@ -33,7 +33,7 @@ export const messages = { row: { you: "You", system: "Botloft", - archivedBot: "an archived bot", + goneBot: "a bot no longer in the crew", to: "to", task: "Task", result: "Result", diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts index 745c3686..7be45d3c 100644 --- a/app/src/i18n/en/shell.ts +++ b/app/src/i18n/en/shell.ts @@ -22,6 +22,9 @@ export const shell = { reconnecting: "Reconnecting…", }, loadFailed: "Could not load your crews", + /** A deleted bot's or crew's folder that could not go to the Recycle Bin. */ + recycleFailed: (path: string) => + `The folder ${path} did not go to the Recycle Bin and is still there`, pickCrew: "Pick a crew on the left, or create one with the + button.", botsCantStart: "Bots can't start", signIn: { diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 9b79d765..828327b5 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -86,6 +86,16 @@ export const account: Messages["account"] = { size: "Tamaño", sizeHint: "Ctrl+= y Ctrl+- también cambian el tamaño, y Ctrl+0 vuelve al predeterminado.", language: "Idioma", + archived: "Archivados", + archivedIntro: + "Los bots y los equipos que archivaste están detenidos y fuera de la vista, y Botloft todavía guarda sus conversaciones. Elimina uno para quitarlo para siempre.", + archivedEmpty: "No hay nada archivado.", + archivedLoadFailed: "No se pudo cargar lo archivado", + archivedCrew: (bots: number, when: string) => + `Equipo · ${bots === 1 ? "1 bot" : `${bots} bots`} · archivado ${when}`, + archivedBot: (crew: string, when: string) => `Bot de ${crew} · archivado ${when}`, + deleteArchived: "Eliminar", + deleteArchivedOne: (name: string) => `Eliminar ${name}`, about: "Acerca de", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index ef9fa0e4..0cb7480a 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -44,6 +44,7 @@ export const bots: Messages["bots"] = { makeChief: "Hacer jefe del equipo", stopChief: "Dejar de ser jefe", archive: "Archivar bot", + delete: "Eliminar bot", failed: { pause: "No se pudo pausar el bot", resume: "No se pudo reanudar el bot", @@ -51,6 +52,7 @@ export const bots: Messages["bots"] = { openFolder: "No se pudo abrir la carpeta", chief: "No se pudo cambiar el jefe", archive: "No se pudo archivar el bot", + delete: "No se pudo eliminar el bot", }, fresh: { title: "¿Empezar una conversación nueva?", @@ -63,6 +65,18 @@ export const bots: Messages["bots"] = { confirm: "Archivar bot", body: "El bot se detiene y sale del equipo. Los mensajes que aún lo esperan no se entregan.", }, + deleteConfirm: { + title: (name: string) => `¿Eliminar ${name}?`, + confirm: "Eliminar bot", + removed: (name: string, running: boolean) => + `${name} ${running ? "se detiene ahora y sale" : "sale"} de Botloft para siempre, con su conversación, sus rutinas y las tareas en las que participaba. No se puede deshacer.`, + chief: (crew: string) => `${crew} se queda sin jefe.`, + kept: (name: string) => + `La carpeta de ${name} sigue en tu ordenador, con todo lo que contiene:`, + recycle: "Enviar esta carpeta a la Papelera", + recycled: (name: string) => + `La carpeta de ${name} va a la Papelera, de donde todavía puedes recuperarla:`, + }, }, dialog: { newTitle: "Nuevo bot", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index a69002a1..6fcc7b2e 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -44,7 +44,7 @@ export const chat: Messages["chat"] = { inbound: { task: "Tarea", result: "Resultado", - archivedBot: "Un bot archivado", + goneBot: "Un bot que ya no está en el equipo", }, run: { working: "Trabajando", diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index b5b164f4..06d239c4 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -46,6 +46,7 @@ export const crews: Messages["crews"] = { `Los bots trabajarán en ${path}. Cada uno se reinicia cuando termine lo que está haciendo. Lo que ya hicieron se queda donde está.`, move: "Mover", archive: "Archivar equipo", + delete: "Eliminar equipo", tabs: { label: "Vistas del equipo", bots: "Bots", @@ -59,12 +60,25 @@ export const crews: Messages["crews"] = { bots === 0 ? "El equipo sale de la app." : `El equipo sale de la app. ${bots === 1 ? "Su bot se detiene" : `Sus ${bots} bots se detienen`}, y los mensajes que aún ${bots === 1 ? "lo" : "los"} esperan no se entregan.`, + deleteTitle: (crew: string) => `¿Eliminar ${crew}?`, + deleteBody: (crew: string, bots: number, running: boolean) => + bots === 0 + ? `${crew} sale de Botloft para siempre. No se puede deshacer.` + : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} ${running ? "se detienen ahora y salen" : "salen"} de Botloft para siempre, con sus conversaciones, rutinas y tareas. No se puede deshacer.`, + deleteKept: + "Las carpetas siguen en tu ordenador, con todo lo que contienen: la carpeta de cada bot y la carpeta de trabajo del equipo:", + deleteRecycle: "Enviar las carpetas del equipo a la Papelera", + deleteRecycled: + "La carpeta del equipo va a la Papelera, con la carpeta de cada bot y la carpeta de trabajo dentro. Todavía puedes recuperarla de allí:", + deleteRecycledChosen: + "La carpeta de cada bot va a la Papelera, de donde todavía puedes recuperarla. La carpeta de trabajo que elegiste se queda donde está:", failed: { pause: "No se pudo pausar el equipo", resume: "No se pudo reanudar el equipo", changeFolder: "No se pudo cambiar la carpeta", openFolder: "No se pudo abrir la carpeta", archive: "No se pudo archivar el equipo", + delete: "No se pudo eliminar el equipo", }, }, bots: { diff --git a/app/src/i18n/es/messages.ts b/app/src/i18n/es/messages.ts index b210d238..cbfdf4f1 100644 --- a/app/src/i18n/es/messages.ts +++ b/app/src/i18n/es/messages.ts @@ -31,7 +31,7 @@ export const messages: Messages["messages"] = { row: { you: "Tú", system: "Botloft", - archivedBot: "un bot archivado", + goneBot: "un bot que ya no está en el equipo", to: "para", task: "Tarea", result: "Resultado", diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts index 8020d799..e4f239d3 100644 --- a/app/src/i18n/es/shell.ts +++ b/app/src/i18n/es/shell.ts @@ -20,6 +20,7 @@ export const shell: Messages["shell"] = { reconnecting: "Reconectando…", }, loadFailed: "No se pudieron cargar tus equipos", + recycleFailed: (path: string) => `La carpeta ${path} no fue a la Papelera y sigue allí`, pickCrew: "Elige un equipo a la izquierda, o crea uno con el botón +.", botsCantStart: "Los bots no pueden iniciar", signIn: { diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index c798b426..7a82b859 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -87,6 +87,16 @@ export const account: Messages["account"] = { size: "Tamanho", sizeHint: "Ctrl+= e Ctrl+- também mudam o tamanho, e Ctrl+0 volta ao padrão.", language: "Idioma", + archived: "Arquivados", + archivedIntro: + "Os bots e as equipes que você arquivou estão parados e fora da vista, e o Botloft ainda guarda as conversas deles. Exclua um para tirá-lo de vez.", + archivedEmpty: "Nada está arquivado.", + archivedLoadFailed: "Não foi possível carregar o que está arquivado", + archivedCrew: (bots: number, when: string) => + `Equipe · ${bots === 1 ? "1 bot" : `${bots} bots`} · arquivada ${when}`, + archivedBot: (crew: string, when: string) => `Bot de ${crew} · arquivado ${when}`, + deleteArchived: "Excluir", + deleteArchivedOne: (name: string) => `Excluir ${name}`, about: "Sobre", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index add69c70..8469dc91 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -44,6 +44,7 @@ export const bots: Messages["bots"] = { makeChief: "Tornar chefe da equipe", stopChief: "Deixar de ser chefe", archive: "Arquivar bot", + delete: "Excluir bot", failed: { pause: "Não foi possível pausar o bot", resume: "Não foi possível retomar o bot", @@ -51,6 +52,7 @@ export const bots: Messages["bots"] = { openFolder: "Não foi possível abrir a pasta", chief: "Não foi possível trocar o chefe", archive: "Não foi possível arquivar o bot", + delete: "Não foi possível excluir o bot", }, fresh: { title: "Começar uma nova conversa?", @@ -63,6 +65,18 @@ export const bots: Messages["bots"] = { confirm: "Arquivar bot", body: "O bot para e sai da equipe. As mensagens que ainda esperam por ele não são entregues.", }, + deleteConfirm: { + title: (name: string) => `Excluir ${name}?`, + confirm: "Excluir bot", + removed: (name: string, running: boolean) => + `${name} ${running ? "para agora e sai" : "sai"} do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte. Não dá para desfazer.`, + chief: (crew: string) => `${crew} fica sem chefe.`, + kept: (name: string) => + `A pasta de ${name} continua no seu computador, com tudo o que está nela:`, + recycle: "Mandar esta pasta para a Lixeira", + recycled: (name: string) => + `A pasta de ${name} vai para a Lixeira, de onde ainda dá para recuperar:`, + }, }, dialog: { newTitle: "Novo bot", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 62d4a9d1..7acb2950 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -44,7 +44,7 @@ export const chat: Messages["chat"] = { inbound: { task: "Tarefa", result: "Resultado", - archivedBot: "Um bot arquivado", + goneBot: "Um bot que não está mais na equipe", }, run: { working: "Trabalhando", diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 526e27ed..36ea056a 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -47,6 +47,7 @@ export const crews: Messages["crews"] = { `Os bots vão trabalhar em ${path}. Cada um reinicia quando terminar o que está fazendo. O que já foi feito fica onde está.`, move: "Mudar", archive: "Arquivar equipe", + delete: "Excluir equipe", tabs: { label: "Visões da equipe", bots: "Bots", @@ -60,12 +61,25 @@ export const crews: Messages["crews"] = { bots === 0 ? "A equipe sai do app." : `A equipe sai do app. ${bots === 1 ? "O bot dela para" : `Os ${bots} bots dela param`}, e as mensagens que ainda esperam por ${bots === 1 ? "ele" : "eles"} não são entregues.`, + deleteTitle: (crew: string) => `Excluir ${crew}?`, + deleteBody: (crew: string, bots: number, running: boolean) => + bots === 0 + ? `${crew} sai do Botloft de vez. Não dá para desfazer.` + : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} ${running ? "param agora e saem" : "saem"} do Botloft de vez, com as conversas, as rotinas e as tarefas. Não dá para desfazer.`, + deleteKept: + "As pastas continuam no seu computador, com tudo o que está nelas: a pasta de cada bot e a pasta de trabalho da equipe:", + deleteRecycle: "Mandar as pastas da equipe para a Lixeira", + deleteRecycled: + "A pasta da equipe vai para a Lixeira, com a pasta de cada bot e a pasta de trabalho dentro. Ainda dá para recuperar de lá:", + deleteRecycledChosen: + "A pasta de cada bot vai para a Lixeira, de onde ainda dá para recuperar. A pasta de trabalho que você escolheu fica onde está:", failed: { pause: "Não foi possível pausar a equipe", resume: "Não foi possível retomar a equipe", changeFolder: "Não foi possível mudar a pasta", openFolder: "Não foi possível abrir a pasta", archive: "Não foi possível arquivar a equipe", + delete: "Não foi possível excluir a equipe", }, }, bots: { diff --git a/app/src/i18n/pt-BR/messages.ts b/app/src/i18n/pt-BR/messages.ts index b48332d5..7b73e49d 100644 --- a/app/src/i18n/pt-BR/messages.ts +++ b/app/src/i18n/pt-BR/messages.ts @@ -32,7 +32,7 @@ export const messages: Messages["messages"] = { row: { you: "Você", system: "Botloft", - archivedBot: "um bot arquivado", + goneBot: "um bot que não está mais na equipe", to: "para", task: "Tarefa", result: "Resultado", diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts index bb82a25a..e95ed305 100644 --- a/app/src/i18n/pt-BR/shell.ts +++ b/app/src/i18n/pt-BR/shell.ts @@ -20,6 +20,7 @@ export const shell: Messages["shell"] = { reconnecting: "Reconectando…", }, loadFailed: "Não foi possível carregar suas equipes", + recycleFailed: (path: string) => `A pasta ${path} não foi para a Lixeira e continua lá`, pickCrew: "Escolha uma equipe à esquerda, ou crie uma com o botão +.", botsCantStart: "Os bots não conseguem iniciar", signIn: { diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 82e5f6ce..6d350ba2 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -58,6 +58,10 @@ export class FakeBotloft implements BotloftApi { /** How many times the app asked for a new Claude Code check. */ refreshes = 0; settings: Settings = { startWithWindows: true, keepAwake: true, approvalWaitMinutes: 60 }; + /** Folders the owner sent to the Recycle Bin with a delete, in order. */ + readonly recycled: string[] = []; + /** Why the next folder cannot go to the bin, if it cannot. */ + recycleError: string | null = null; /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); @@ -183,6 +187,15 @@ export class FakeBotloft implements BotloftApi { ); } + /** The Recycle Bin takes `path`, or the app hears why it did not. */ + recycle(path: string): void { + const error = this.recycleError; + if (error === null) { + this.recycled.push(path); + } + this.emit({ name: "folder.recycled", params: { path, error } }); + } + changedCrew(crew: Crew): Crew { this.emit({ name: "crew.changed", params: crew }); return crew; @@ -221,6 +234,10 @@ export class FakeBotloft implements BotloftApi { }; return this.settings; }, + "archive.list": () => ({ + crews: [...this.crews.values()].filter((crew) => crew.archivedAt !== null), + bots: [...this.bots.values()].filter((bot) => bot.archivedAt !== null), + }), ...crewHandlers(this), ...botHandlers(this), ...this.chat.handlers(), diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 6057818a..449811d7 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -91,7 +91,7 @@ export function botHandlers(fake: FakeBotloft): Pick { } return bot; }, - "bots.delete": ({ botId }) => { + "bots.delete": ({ botId, recycleFolder }) => { const bot = fake.bot(botId, false); purgeBot(fake, bot); // A deleted chief leaves its crew without one. @@ -102,6 +102,9 @@ export function botHandlers(fake: FakeBotloft): Pick { } const deleted = { botId, crewId: bot.crewId }; fake.emit({ name: "bot.deleted", params: deleted }); + if (recycleFolder) { + fake.recycle(bot.workspace); + } return deleted; }, }; diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts index 549817cb..d71539f4 100644 --- a/app/src/lib/fakeCrews.ts +++ b/app/src/lib/fakeCrews.ts @@ -82,8 +82,8 @@ export function crewHandlers(fake: FakeBotloft): Pick { } return crew; }, - "crews.delete": ({ crewId }) => { - fake.crew(crewId, false); + "crews.delete": ({ crewId, recycleFolder }) => { + const crew = fake.crew(crewId, false); for (const bot of [...fake.bots.values()]) { if (bot.crewId === crewId) { purgeBot(fake, bot); @@ -91,6 +91,9 @@ export function crewHandlers(fake: FakeBotloft): Pick { } fake.crews.delete(crewId); fake.emit({ name: "crew.deleted", params: { crewId } }); + if (recycleFolder) { + fake.recycle(`C:\\Users\\owner\\Botloft\\${crew.slug}`); + } return { crewId }; }, }; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 3b0b4bc3..79d1b69f 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -372,6 +372,47 @@ export type BotDeleted = { botId: BotId, crewId: CrewId, }; */ export type CrewDeleted = { crewId: CrewId, }; +/** + * What the owner archived (`archive.list`): out of the app, kept in the + * database, and still there to delete (spec 7.6). `bots` has every + * archived bot, those of archived crews too. + */ +export type Archive = { crews: Array, bots: Array, }; + +/** + * Params of `bots.delete` (spec 7.6). + */ +export type BotsDeleteParams = { botId: BotId, +/** + * Also move the bot's folder to the Recycle Bin; it stays when absent. + */ +recycleFolder?: boolean, }; + +/** + * Params of `crews.delete` (spec 7.6). + */ +export type CrewsDeleteParams = { crewId: CrewId, +/** + * Also move the crew's own folder, with each bot's folder and + * `shared`, to the Recycle Bin; it stays when absent. A work folder + * the owner chose is never moved. + */ +recycleFolder?: boolean, }; + +/** + * Params of the `folder.recycled` notification: how the move of a deleted + * bot's or crew's folder to the Recycle Bin ended (spec 7.6). + */ +export type FolderRecycled = { +/** + * Absolute path the folder had. + */ +path: string, +/** + * Why the folder is still there; `null` when it is in the bin. + */ +error: string | null, }; + /** * Who wrote a message. */ @@ -1038,7 +1079,8 @@ export interface RpcMethods { "crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew }; "crews.setLead": { params: CrewsSetLeadParams; result: Crew }; "crews.archive": { params: CrewIdParams; result: Crew }; - "crews.delete": { params: CrewIdParams; result: CrewDeleted }; + "crews.delete": { params: CrewsDeleteParams; result: CrewDeleted }; + "archive.list": { params: undefined; result: Archive }; "bots.list": { params: BotsListParams; result: Array }; "bots.create": { params: BotsCreateParams; result: Bot }; "bots.update": { params: BotsUpdateParams; result: Bot }; @@ -1046,7 +1088,7 @@ export interface RpcMethods { "bots.setPermissionMode": { params: BotsSetPermissionModeParams; result: Bot }; "bots.setModel": { params: BotsSetModelParams; result: Bot }; "bots.archive": { params: BotIdParams; result: Bot }; - "bots.delete": { params: BotIdParams; result: BotDeleted }; + "bots.delete": { params: BotsDeleteParams; result: BotDeleted }; "bots.restart": { params: BotsRestartParams; result: Bot }; "chat.history": { params: ChatHistoryParams; result: Array }; "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; @@ -1085,6 +1127,7 @@ export interface RpcNotifications { "bot.changed": Bot; "crew.deleted": CrewDeleted; "bot.deleted": BotDeleted; + "folder.recycled": FolderRecycled; "bot.state": BotStateChanged; "chat.item": ChatItemChanged; "chat.delta": ChatDelta; diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 80447e4c..1dcf878d 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -17,6 +17,7 @@ import { useApp } from "../store/context"; import { Callout } from "../ui/Callout"; import { useBotAlerts } from "./alerts"; import { useAttentionMark } from "./attention"; +import { useFolderNotices } from "./folders"; import { useOpenAtSignIn } from "./signIn"; import { TitleBar } from "./TitleBar"; import { useTray } from "./tray"; @@ -31,6 +32,7 @@ export function Workspace() { useAttentionMark(); useTray(); useBotAlerts(); + useFolderNotices(); useOpenAtSignIn(); let main: ReactNode; diff --git a/app/src/shell/folders.ts b/app/src/shell/folders.ts new file mode 100644 index 00000000..28bc214c --- /dev/null +++ b/app/src/shell/folders.ts @@ -0,0 +1,21 @@ +// Telling the owner when the folder of a deleted bot or crew could not go +// to the Recycle Bin (spec 7.6): the daemon moves it after answering, so +// the news comes as a notification. + +import { useEffect } from "react"; +import { t } from "../i18n"; +import { useApi } from "../store/context"; +import { notifyError } from "../ui/toast"; + +export function useFolderNotices(): void { + const api = useApi(); + useEffect( + () => + api.subscribe((event) => { + if (event.name === "folder.recycled" && event.params.error !== null) { + notifyError(t().shell.recycleFailed(event.params.path), event.params.error); + } + }), + [api], + ); +} diff --git a/app/src/ui/KeptFolder.tsx b/app/src/ui/KeptFolder.tsx new file mode 100644 index 00000000..e6ed6ecc --- /dev/null +++ b/app/src/ui/KeptFolder.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +/** + * In a confirmation: what happens to a folder on disk, and where it is. + * `recycle` adds the choice to send it to the Recycle Bin. + */ +export function KeptFolder({ + path, + children, + recycle, +}: { + path: string; + children: ReactNode; + recycle?: { label: string; checked: boolean; onChange(checked: boolean): void }; +}) { + return ( +
    +

    {children}

    +

    + {path} +

    + {recycle && ( + + )} +
    + ); +} diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index c7a302a7..05ce8536 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -127,6 +127,10 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -229,9 +233,10 @@ fn export_bindings() { let bot_deleted = out.name::(); out.method( method::CREWS_DELETE, - &out.name::(), + &out.name::(), &crew_deleted, ); + out.method(method::ARCHIVE_LIST, "undefined", &out.name::()); out.method(method::BOTS_LIST, &out.name::(), &bots); out.method(method::BOTS_CREATE, &out.name::(), &bot); out.method(method::BOTS_UPDATE, &out.name::(), &bot); @@ -253,7 +258,7 @@ fn export_bindings() { out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); out.method( method::BOTS_DELETE, - &out.name::(), + &out.name::(), &bot_deleted, ); out.method(method::BOTS_RESTART, &out.name::(), &bot); @@ -328,6 +333,12 @@ fn export_bindings() { " \"{}\": {bot_deleted};", notification::BOT_DELETED ); + let _ = writeln!( + out.text, + " \"{}\": {};", + notification::FOLDER_RECYCLED, + out.name::() + ); let state = out.name::(); let item = out.name::(); let delta = out.name::(); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index c487e107..ad3fc256 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -90,6 +90,7 @@ pub mod method { pub const CREWS_SET_LEAD: &str = "crews.setLead"; pub const CREWS_ARCHIVE: &str = "crews.archive"; pub const CREWS_DELETE: &str = "crews.delete"; + pub const ARCHIVE_LIST: &str = "archive.list"; pub const BOTS_LIST: &str = "bots.list"; pub const BOTS_CREATE: &str = "bots.create"; pub const BOTS_UPDATE: &str = "bots.update"; @@ -138,6 +139,7 @@ pub mod notification { pub const CREW_DELETED: &str = "crew.deleted"; pub const BOT_CHANGED: &str = "bot.changed"; pub const BOT_DELETED: &str = "bot.deleted"; + pub const FOLDER_RECYCLED: &str = "folder.recycled"; pub const BOT_STATE: &str = "bot.state"; pub const CHAT_ITEM: &str = "chat.item"; pub const CHAT_DELTA: &str = "chat.delta"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 169ca45f..f29684d7 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -179,3 +179,52 @@ pub struct BotDeleted { pub struct CrewDeleted { pub crew_id: CrewId, } + +/// What the owner archived (`archive.list`): out of the app, kept in the +/// database, and still there to delete (spec 7.6). `bots` has every +/// archived bot, those of archived crews too. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Archive { + pub crews: Vec, + pub bots: Vec, +} + +/// Params of `bots.delete` (spec 7.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsDeleteParams { + pub bot_id: BotId, + /// Also move the bot's folder to the Recycle Bin; it stays when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub recycle_folder: Option, +} + +/// Params of `crews.delete` (spec 7.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewsDeleteParams { + pub crew_id: CrewId, + /// Also move the crew's own folder, with each bot's folder and + /// `shared`, to the Recycle Bin; it stays when absent. A work folder + /// the owner chose is never moved. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub recycle_folder: Option, +} + +/// Params of the `folder.recycled` notification: how the move of a deleted +/// bot's or crew's folder to the Recycle Bin ended (spec 7.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct FolderRecycled { + /// Absolute path the folder had. + pub path: String, + /// Why the folder is still there; `null` when it is in the bin. + pub error: Option, +} diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index ad01857b..055cd827 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -36,7 +36,9 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity", "Win32_UI_Shell", "Win32_UI_Shell_Common"] } +# Named by the code that `windows::core::implement` writes. +windows-core = "0.62.2" [target."cfg(windows)".build-dependencies] embed-resource = "3.0.11" diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 20e3ac95..8a6dbb30 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -24,4 +24,5 @@ pub mod settings; pub mod state; pub mod supervisor; pub mod tools; +pub mod trash; pub mod workspace; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index b7c14979..b840d96f 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -18,6 +18,7 @@ use botloftd::service::tasks::TaskSettings; use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; +use botloftd::trash::RecycleBin; use botloftd::{approvals, autostart, keep_awake, logging, routines, secrets, server}; use clap::{Parser, Subcommand}; use tokio::net::TcpListener; @@ -129,6 +130,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a bots: BotSettings::from_config(&config), browser: BrowserSettings::from_config(&config), settings: LiveSettings::new(Some(config_path), &config), + trash: Arc::new(RecycleBin), }); // No bot process survived the last run, so nobody waits for these. approvals::expire_all(&daemon); diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 752556ac..ebc2f2c9 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -12,13 +12,13 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] pub use unix::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle, + register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; #[cfg(windows)] pub use windows::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle, + register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; /// A program the system starts for the owner (spec 14). diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 1b8e49ac..fe7bd35b 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -103,6 +103,14 @@ pub fn delete_task(_name: &str) -> io::Result { Err(no_tasks()) } +/// There is no Recycle Bin off Windows: the folder stays. +pub fn recycle(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "the Recycle Bin is only supported on Windows", + )) +} + /// Unix has no Windows sign-in to tell apart. pub fn sign_in_id() -> Option { None diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index e2645193..dddad3fc 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -6,6 +6,7 @@ mod env; mod job; mod owner; mod power; +mod recycle; mod sign_in; mod task; mod task_xml; @@ -15,6 +16,7 @@ pub use env::user_environment; pub use job::ProcessJob; pub use owner::owner_name; pub use power::KeepAwake; +pub use recycle::recycle; pub use sign_in::sign_in_id; pub use task::{delete_task, find_task, register_task, run_task, stop_task}; diff --git a/crates/botloftd/src/platform/windows/recycle.rs b/crates/botloftd/src/platform/windows/recycle.rs new file mode 100644 index 00000000..3027404c --- /dev/null +++ b/crates/botloftd/src/platform/windows/recycle.rs @@ -0,0 +1,218 @@ +//! Moving a folder to the Recycle Bin (spec 7.6), with the shell's +//! `IFileOperation`. +//! +//! When the bin cannot take something (too big for it, a path too long, a +//! drive without a bin) and the shell may not ask, it deletes for good. The +//! sink below refuses exactly that, so the folder stays where it is. + +use std::cell::Cell; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::rc::Rc; + +use windows::Win32::Foundation::E_ABORT; +use windows::Win32::System::Com::{ + CLSCTX_ALL, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, +}; +use windows::Win32::UI::Shell::{ + FOF_ALLOWUNDO, FOF_NO_UI, FOFX_EARLYFAILURE, FOFX_RECYCLEONDELETE, FileOperation, + IFileOperation, IFileOperationProgressSink, IFileOperationProgressSink_Impl, IShellItem, + SHCreateItemFromParsingName, TSF_DELETE_RECYCLE_IF_POSSIBLE, +}; +use windows::core::{HRESULT, PCWSTR, Ref, Result as ComResult, implement}; + +/// Moves `path` to the current user's Recycle Bin, or fails and leaves it +/// where it is: nothing is ever deleted for good. `Unsupported` means the +/// bin cannot take it; another error may pass, like a file still in use. +pub fn recycle(path: &Path) -> io::Result<()> { + let path = path.to_owned(); + // The shell's file operations need a single-threaded apartment, which + // a thread of the daemon's pool may not be: they get one of their own. + std::thread::spawn(move || on_this_thread(&path)) + .join() + .map_err(|_| io::Error::other("the move to the Recycle Bin crashed"))? +} + +fn on_this_thread(path: &Path) -> io::Result<()> { + if !path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "the folder is not there", + )); + } + // SAFETY: plain COM initialization for this thread, which is ours. + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }.ok()?; + let moved = delete_to_bin(path); + // SAFETY: pairs with the successful CoInitializeEx above; every COM + // object of `delete_to_bin` is released by now. + unsafe { CoUninitialize() }; + moved +} + +fn delete_to_bin(path: &Path) -> io::Result<()> { + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let refused = Rc::new(Cell::new(false)); + let sink: IFileOperationProgressSink = Sink { + refused: Rc::clone(&refused), + } + .into(); + // SAFETY: COM is initialized on this thread; `wide` is NUL-terminated + // and outlives the calls; the interfaces are used on this thread only. + let (performed, aborted) = unsafe { + let operation: IFileOperation = CoCreateInstance(&FileOperation, None, CLSCTX_ALL)?; + operation.SetOperationFlags( + FOF_NO_UI | FOF_ALLOWUNDO | FOFX_RECYCLEONDELETE | FOFX_EARLYFAILURE, + )?; + let item: IShellItem = SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None)?; + operation.DeleteItem(&item, &sink)?; + let performed = operation.PerformOperations(); + let aborted = operation.GetAnyOperationsAborted(); + (performed, aborted) + }; + if refused.get() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows cannot put this folder in the Recycle Bin", + )); + } + performed?; + if aborted.is_ok_and(|aborted| aborted.as_bool()) || path.exists() { + return Err(io::Error::other( + "Windows did not move the folder to the Recycle Bin", + )); + } + Ok(()) +} + +/// Stops the operation when an item would be deleted for good. +#[implement(IFileOperationProgressSink)] +struct Sink { + refused: Rc>, +} + +#[allow(non_snake_case)] +impl IFileOperationProgressSink_Impl for Sink_Impl { + fn PreDeleteItem(&self, dwflags: u32, _item: Ref) -> ComResult<()> { + // The shell sets this flag on what goes to the bin, and leaves it + // out on what it is about to destroy. + if dwflags & TSF_DELETE_RECYCLE_IF_POSSIBLE.0 as u32 == 0 { + self.refused.set(true); + return Err(E_ABORT.into()); + } + Ok(()) + } + + fn StartOperations(&self) -> ComResult<()> { + Ok(()) + } + + fn FinishOperations(&self, _result: HRESULT) -> ComResult<()> { + Ok(()) + } + + fn PreRenameItem(&self, _: u32, _: Ref, _: &PCWSTR) -> ComResult<()> { + Ok(()) + } + + fn PostRenameItem( + &self, + _: u32, + _: Ref, + _: &PCWSTR, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PreMoveItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + ) -> ComResult<()> { + Ok(()) + } + + fn PostMoveItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PreCopyItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + ) -> ComResult<()> { + Ok(()) + } + + fn PostCopyItem( + &self, + _: u32, + _: Ref, + _: Ref, + _: &PCWSTR, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PostDeleteItem( + &self, + _: u32, + _: Ref, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn PreNewItem(&self, _: u32, _: Ref, _: &PCWSTR) -> ComResult<()> { + Ok(()) + } + + fn PostNewItem( + &self, + _: u32, + _: Ref, + _: &PCWSTR, + _: &PCWSTR, + _: u32, + _: HRESULT, + _: Ref, + ) -> ComResult<()> { + Ok(()) + } + + fn UpdateProgress(&self, _total: u32, _so_far: u32) -> ComResult<()> { + Ok(()) + } + + fn ResetTimer(&self) -> ComResult<()> { + Ok(()) + } + + fn PauseTimer(&self) -> ComResult<()> { + Ok(()) + } + + fn ResumeTimer(&self) -> ComResult<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/botloftd/src/platform/windows/recycle/tests.rs b/crates/botloftd/src/platform/windows/recycle/tests.rs new file mode 100644 index 00000000..3e794e29 --- /dev/null +++ b/crates/botloftd/src/platform/windows/recycle/tests.rs @@ -0,0 +1,148 @@ +//! The real Recycle Bin: each test checks its folder is in it, then takes +//! it out again, so a run leaves nothing behind. + +use std::path::PathBuf; + +use super::*; +use crate::platform::windows::CurrentUser; + +/// What `canonicalize` puts before a path, which may then pass 260 +/// characters. +const VERBATIM: &str = r"\\?\"; + +/// Where the bin keeps what came from `original`: its `$I` record and +/// the `$R` item itself. `None` if the bin has no such thing. +fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { + let sid = CurrentUser::query().ok()?.sid_string().ok()?; + let drive = original.components().next()?.as_os_str().to_string_lossy(); + let bin = PathBuf::from(format!(r"{drive}\$Recycle.Bin\{sid}")); + let wanted = original.to_string_lossy().to_lowercase(); + for entry in std::fs::read_dir(bin).ok()?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(rest) = name.strip_prefix("$I") else { + continue; + }; + // Version, size, time, then the length of the path and the + // path itself in UTF-16 (the record of Windows 10 and later). + let Ok(record) = std::fs::read(entry.path()) else { + continue; + }; + let units: Vec = record + .get(28..) + .unwrap_or_default() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .take_while(|unit| *unit != 0) + .collect(); + if String::from_utf16_lossy(&units).to_lowercase() == wanted { + return Some(( + entry.path(), + entry.path().with_file_name(format!("$R{rest}")), + )); + } + } + None +} + +/// Where a test's folder `name` goes, under `dir`, in its long form: the +/// bin records the long path, and the temporary folder may be given in +/// the short one (`RUNNER~1`). +fn place(dir: &tempfile::TempDir, name: &str) -> PathBuf { + let long = std::fs::canonicalize(dir.path()).expect("long path"); + let plain = long.to_string_lossy(); + PathBuf::from(plain.strip_prefix(VERBATIM).unwrap_or(&plain)).join(name) +} + +/// Takes the test's own folder out of the bin, so a test run leaves +/// nothing behind. +fn purge(record: &Path, item: &Path) { + let _ = std::fs::remove_dir_all(item); + let _ = std::fs::remove_file(record); +} + +#[test] +fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { + let dir = tempfile::tempdir().expect("tempdir"); + let folder = place(&dir, "botloft-recycle-test"); + std::fs::create_dir_all(folder.join("attachments")).expect("folders"); + std::fs::write(folder.join("CLAUDE.md"), "what the bot learned").expect("file"); + std::fs::write(folder.join("attachments").join("plan.txt"), "plan").expect("file"); + + recycle(&folder).expect("recycle"); + + assert!(!folder.exists(), "the folder left its place"); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + assert_eq!( + std::fs::read_to_string(item.join("CLAUDE.md")).expect("kept"), + "what the bot learned" + ); + assert!(item.join("attachments").join("plan.txt").is_file()); + purge(&record, &item); +} + +#[test] +fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() { + let dir = tempfile::tempdir().expect("tempdir"); + let folder = place(&dir, "botloft-recycle-busy"); + std::fs::create_dir(&folder).expect("folder"); + let log = folder.join("session.log"); + std::fs::write(&log, "still writing").expect("file"); + // As a process that has not ended yet holds its files. + let held = std::fs::File::open(&log).expect("open"); + + let err = recycle(&folder).expect_err("a file is in use"); + assert_ne!(err.kind(), io::ErrorKind::Unsupported, "worth trying again"); + assert_eq!( + std::fs::read_to_string(&log).expect("kept"), + "still writing" + ); + + drop(held); + recycle(&folder).expect("recycle"); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + purge(&record, &item); +} + +#[test] +fn a_missing_folder_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let err = recycle(&dir.path().join("nothing-here")).expect_err("nothing to move"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); +} + +#[test] +fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() { + // Paths past 260 characters, as a bot's `node_modules` has. Windows + // 11 moves the folder whole; whatever a Windows does, nothing of it + // may be lost. + let dir = tempfile::tempdir().expect("tempdir"); + let folder = place(&dir, "botloft-recycle-long"); + std::fs::create_dir(&folder).expect("folder"); + let top = PathBuf::from(format!("{VERBATIM}{}", folder.display())); + let mut deep = top.clone(); + for _ in 0..8 { + deep.push("a-folder-name-that-is-forty-characters--"); + } + std::fs::create_dir_all(&deep).expect("deep folders"); + let file = deep.join("notes.txt"); + std::fs::write(&file, "deep").expect("deep file"); + + match recycle(&folder) { + // Moved whole: it must be in the bin, with the deep file. + Ok(()) => { + assert!(!folder.exists()); + let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin"); + let mut kept = std::fs::canonicalize(&item).expect("verbatim path"); + kept.extend(deep.strip_prefix(&top).expect("under the folder")); + let kept = std::fs::read_to_string(kept.join("notes.txt")).expect("deep file"); + assert_eq!(kept, "deep"); + purge(&record, &item); + } + // Refused: nothing was touched. + Err(_) => { + assert_eq!(std::fs::read_to_string(&file).expect("kept"), "deep"); + } + } +} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 9d37c7e0..4c707c0a 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,8 +8,8 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, attachments, bots, chat, crews, delete, deliveries, files, lead, messages, - models, modes, routines, screens, settings, tasks, + self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead, + messages, models, modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -29,6 +29,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(lead::set_lead(daemon, parse(params)?)), method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)), method::CREWS_DELETE => reply(delete::crew(daemon, parse(params)?)), + method::ARCHIVE_LIST => reply(archive::list(daemon)), method::BOTS_LIST => reply(bots::list(daemon, parse(params)?)), method::BOTS_CREATE => reply(bots::create(daemon, parse(params)?)), method::BOTS_UPDATE => reply(bots::update(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index 9b1c2605..78079f49 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -208,6 +208,9 @@ fn to_notification(event: &Event) -> String { Event::CrewDeleted(crew) => (notification::CREW_DELETED, serde_json::to_value(crew)), Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), Event::BotDeleted(bot) => (notification::BOT_DELETED, serde_json::to_value(bot)), + Event::FolderRecycled(folder) => { + (notification::FOLDER_RECYCLED, serde_json::to_value(folder)) + } Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)), Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)), Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)), diff --git a/crates/botloftd/src/service/archive.rs b/crates/botloftd/src/service/archive.rs new file mode 100644 index 00000000..f130158e --- /dev/null +++ b/crates/botloftd/src/service/archive.rs @@ -0,0 +1,38 @@ +//! `archive.list`: what the owner archived, for the app to show and delete +//! (spec 7.6). The other lists leave archived crews and bots out. + +use std::cmp::Reverse; +use std::collections::HashMap; + +use botloft_core::protocol::Archive; + +use super::{ApiResult, bots, crews}; +use crate::state::Daemon; + +/// Archived crews and bots, the most recently archived first. A bot of an +/// archived crew is listed too, so the app can count them. +pub fn list(daemon: &Daemon) -> ApiResult { + let store = daemon.store(); + let crews: HashMap<_, _> = store + .crews(true)? + .into_iter() + .map(|crew| (crew.id.clone(), crews::present(daemon, crew))) + .collect(); + let mut bots: Vec<_> = store + .bots(None, true)? + .into_iter() + .filter(|bot| bot.archived_at.is_some()) + .filter_map(|bot| { + let crew = crews.get(&bot.crew_id)?; + Some(bots::to_protocol(daemon, &store, crew, bot)) + }) + .collect(); + // Ids grow with time, so they settle what was archived together. + bots.sort_by_key(|bot| Reverse((bot.archived_at, bot.id.clone()))); + let mut crews: Vec<_> = crews + .into_values() + .filter(|crew| crew.archived_at.is_some()) + .collect(); + crews.sort_by_key(|crew| Reverse((crew.archived_at, crew.id.clone()))); + Ok(Archive { crews, bots }) +} diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs index 1efdad1e..a62a54ac 100644 --- a/crates/botloftd/src/service/delete.rs +++ b/crates/botloftd/src/service/delete.rs @@ -1,10 +1,15 @@ //! `bots.delete` and `crews.delete` (spec 7.6): removing a bot or a crew //! for good, active or archived. Archiving keeps everything in the -//! database; deleting keeps only the folders on disk (spec 5). +//! database; deleting keeps only the folders on disk (spec 5), or sends +//! them to the Recycle Bin when the owner asks. use botloft_core::ids::{BotId, MessageId}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use botloft_core::protocol::{ - BotDeleted, BotIdParams, Crew, CrewDeleted, CrewIdParams, Message, MessageKind, SenderKind, + BotDeleted, BotsDeleteParams, Crew, CrewDeleted, CrewsDeleteParams, FolderRecycled, Message, + MessageKind, SenderKind, }; use botloft_store::{BotRecord, Store, TaskFilter}; use tracing::{info, warn}; @@ -13,15 +18,18 @@ use super::messages::post; use super::tasks::UNFINISHED; use super::{ApiResult, bots, crews, lead}; use crate::state::{Daemon, Event}; +use crate::{trash, workspace}; /// Deletes the bot: its process stops at once and its conversation, -/// routines and tasks leave the database. Its folder stays. -pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult { +/// routines and tasks leave the database. Its folder stays, unless the +/// owner asked for it to go to the Recycle Bin. +pub fn bot(daemon: &Daemon, params: BotsDeleteParams) -> ApiResult { let store = daemon.store(); let record = bots::find(&store, ¶ms.bot_id)?; let crew = crews::find(&store, &record.crew_id)?; let notices = task_notices(&store, &crew, &record)?; let waiting = store.pending_approvals(&record.id)?; + let folder = daemon.paths.bot_workspace(&crew.slug, &record.slug); store.delete_bot(&record.id)?; stop(daemon, &record.id); @@ -41,12 +49,16 @@ pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult { daemon.approvals.forget(&waiting); release(daemon, &deleted.bot_id); info!(bot = %deleted.bot_id, "bot deleted"); + if params.recycle_folder == Some(true) { + recycle(daemon, &crew, folder); + } Ok(deleted) } /// Deletes the crew with every bot in it, archived ones too. Its folders -/// stay. -pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { +/// stay, unless the owner asked for the crew's own folder to go to the +/// Recycle Bin. +pub fn crew(daemon: &Daemon, params: CrewsDeleteParams) -> ApiResult { let store = daemon.store(); let crew = crews::find(&store, ¶ms.crew_id)?; let bots = store.bots(Some(&crew.id), true)?; @@ -58,7 +70,9 @@ pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { for bot in &bots { stop(daemon, &bot.id); } - let deleted = CrewDeleted { crew_id: crew.id }; + let deleted = CrewDeleted { + crew_id: crew.id.clone(), + }; daemon.emit(Event::CrewDeleted(deleted.clone())); drop(store); daemon.approvals.forget(&waiting); @@ -66,9 +80,39 @@ pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult { release(daemon, &bot.id); } info!(crew = %deleted.crew_id, bots = bots.len(), "crew deleted"); + if params.recycle_folder == Some(true) { + recycle(daemon, &crew, daemon.paths.crew_dir(&crew.slug)); + } Ok(deleted) } +/// Sends `folder`, which Botloft made for a deleted bot or crew, to the +/// Recycle Bin, and tells the apps how it went (spec 7.6). A work folder +/// the owner chose is theirs: a folder that holds it stays. +fn recycle(daemon: &Daemon, crew: &Crew, folder: PathBuf) { + let chosen = crew + .work_folder_chosen + .then(|| Path::new(&crew.work_folder)); + // Only ever a folder under the workspaces, never the root of them. + let refused = if !workspace::folder::contains(&daemon.paths.workspaces_root, &folder) + || workspace::folder::contains(&folder, &daemon.paths.workspaces_root) + { + Some("it is not one of Botloft's folders") + } else if chosen.is_some_and(|chosen| workspace::folder::contains(&folder, chosen)) { + Some("the work folder you chose for the crew is inside it") + } else { + None + }; + if let Some(reason) = refused { + daemon.emit(Event::FolderRecycled(FolderRecycled { + path: folder.to_string_lossy().into_owned(), + error: Some(reason.to_owned()), + })); + return; + } + trash::move_away(Arc::clone(&daemon.trash), daemon.events(), folder); +} + /// Kills the bot's process and closes its browser, profile included. fn stop(daemon: &Daemon, bot: &BotId) { daemon.supervisor.forget(bot); diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index a3fc2343..7fde3679 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -1,6 +1,7 @@ //! What the RPC methods do, independent of JSON-RPC. Each operation runs //! under the store lock, so checks and writes cannot interleave. +pub mod archive; pub mod attachments; pub mod bots; pub mod chat; diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 73742cfc..019369db 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -6,7 +6,8 @@ use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta, - ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, Routine, RoutineRun, + ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -24,6 +25,7 @@ use crate::secrets::TokenHash; use crate::service::tasks::TaskSettings; use crate::settings::LiveSettings; use crate::supervisor::{Supervisor, SupervisorSettings}; +use crate::trash::Trash; use crate::workspace::WorkspaceEnv; /// Something that changed and every connected app should hear about. @@ -33,6 +35,7 @@ pub enum Event { CrewDeleted(CrewDeleted), BotChanged(Bot), BotDeleted(BotDeleted), + FolderRecycled(FolderRecycled), BotState(BotStateChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), @@ -83,6 +86,8 @@ pub struct DaemonOptions { pub browser: BrowserSettings, /// What the owner changes in the app's Settings. pub settings: LiveSettings, + /// Where a deleted bot's folder goes when the owner asks (spec 7.6). + pub trash: Arc, } pub struct Daemon { @@ -97,6 +102,7 @@ pub struct Daemon { pub routines: Routines, pub browsers: Browsers, pub screens: Screens, + pub trash: Arc, /// Time for everything stored or compared with stored times. pub clock: Arc, store: Mutex, @@ -132,6 +138,7 @@ impl Daemon { Arc::clone(&options.clock), ), screens: Screens::default(), + trash: options.trash, clock: options.clock, paths: options.paths, port: options.port, @@ -169,6 +176,11 @@ impl Daemon { self.events.subscribe() } + /// A sender for work that outlives the call that started it. + pub(crate) fn events(&self) -> broadcast::Sender { + self.events.clone() + } + pub(crate) fn emit(&self, event: Event) { // No receivers just means no app is connected. let _ = self.events.send(event); diff --git a/crates/botloftd/src/trash.rs b/crates/botloftd/src/trash.rs new file mode 100644 index 00000000..93585d6f --- /dev/null +++ b/crates/botloftd/src/trash.rs @@ -0,0 +1,136 @@ +//! Where the folder of a deleted bot or crew goes when the owner asks +//! (spec 7.6): the Recycle Bin, behind a trait so tests move no folder +//! into a real one. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use botloft_core::protocol::FolderRecycled; +use tokio::sync::broadcast; +use tracing::{info, warn}; + +use crate::platform; +use crate::state::Event; + +/// How many times a folder that cannot move yet is tried, and how long +/// between tries: a process that was just killed still holds its files. +const TRIES: u32 = 20; +const RETRY_EVERY: Duration = Duration::from_millis(500); + +pub trait Trash: Send + Sync { + /// Moves the folder away, where the owner can get it back, or fails + /// and leaves it as it is. `Unsupported` means it can never go there. + fn put(&self, folder: &Path) -> io::Result<()>; +} + +/// The Windows Recycle Bin. +pub struct RecycleBin; + +impl Trash for RecycleBin { + fn put(&self, folder: &Path) -> io::Result<()> { + platform::recycle(folder) + } +} + +/// Moves `folder` to the trash off the async workers, trying again while +/// something still holds it, and tells the apps how it ended +/// (`folder.recycled`). +pub(crate) fn move_away(trash: Arc, events: broadcast::Sender, folder: PathBuf) { + let work = move || { + let error = put_with_retries(trash.as_ref(), &folder) + .err() + .map(|err| err.to_string()); + match &error { + None => info!("a deleted bot's folder went to the Recycle Bin"), + Some(error) => warn!("a folder did not go to the Recycle Bin: {error}"), + } + // No receivers just means no app is connected. + let _ = events.send(Event::FolderRecycled(FolderRecycled { + path: folder.to_string_lossy().into_owned(), + error, + })); + }; + match tokio::runtime::Handle::try_current() { + Ok(runtime) => drop(runtime.spawn_blocking(work)), + Err(_) => work(), + } +} + +fn put_with_retries(trash: &dyn Trash, folder: &Path) -> io::Result<()> { + let mut tries = 0; + loop { + tries += 1; + match trash.put(folder) { + Ok(()) => return Ok(()), + // Already gone: there is nothing left to move. + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::Unsupported || tries >= TRIES => { + return Err(err); + } + Err(_) => std::thread::sleep(RETRY_EVERY), + } + } +} + +/// A trash for tests: folders move into `bin`, and the next tries can be +/// made to fail. +#[derive(Clone)] +pub struct FakeTrash { + bin: PathBuf, + state: Arc>, +} + +#[derive(Default)] +struct FakeState { + moved: Vec, + /// Errors the next tries answer with, first to last. + failures: Vec, + tries: usize, +} + +impl FakeTrash { + pub fn new(bin: PathBuf) -> Self { + Self { + bin, + state: Arc::default(), + } + } + + /// The folders that were moved, in order, as they were named. + pub fn moved(&self) -> Vec { + self.lock().moved.clone() + } + + /// How many times a move was tried. + pub fn tries(&self) -> usize { + self.lock().tries + } + + /// The next tries fail with these, in order, before one may work. + pub fn fail_next(&self, failures: &[io::ErrorKind]) { + self.lock().failures = failures.to_vec(); + } + + fn lock(&self) -> std::sync::MutexGuard<'_, FakeState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl Trash for FakeTrash { + fn put(&self, folder: &Path) -> io::Result<()> { + let mut state = self.lock(); + state.tries += 1; + if !state.failures.is_empty() { + return Err(io::Error::new(state.failures.remove(0), "fake failure")); + } + if !folder.exists() { + return Err(io::Error::new(io::ErrorKind::NotFound, "no such folder")); + } + std::fs::create_dir_all(&self.bin)?; + std::fs::rename(folder, self.bin.join(state.moved.len().to_string()))?; + state.moved.push(folder.to_owned()); + Ok(()) + } +} diff --git a/crates/botloftd/src/workspace/folder.rs b/crates/botloftd/src/workspace/folder.rs index 6271246f..03dffbbe 100644 --- a/crates/botloftd/src/workspace/folder.rs +++ b/crates/botloftd/src/workspace/folder.rs @@ -62,7 +62,7 @@ fn parts(path: &Path) -> Vec { } /// Whether `outer` is `inner` or one of its parents. -fn contains(outer: &Path, inner: &Path) -> bool { +pub(crate) fn contains(outer: &Path, inner: &Path) -> bool { let (outer, inner) = (parts(outer), parts(inner)); inner.len() >= outer.len() && inner[..outer.len()] == outer[..] } diff --git a/crates/botloftd/tests/archive.rs b/crates/botloftd/tests/archive.rs new file mode 100644 index 00000000..c062ecfc --- /dev/null +++ b/crates/botloftd/tests/archive.rs @@ -0,0 +1,75 @@ +//! `archive.list` (spec 7.6): what the owner archived, which the other +//! lists leave out, until it is deleted. + +mod common; + +use common::TestDaemon; +use serde_json::{Value, json}; + +async fn archive(app: &mut common::Client) -> Value { + app.call("archive.list", json!(null)) + .await + .expect("archive") +} + +fn names(list: &Value) -> Vec<&str> { + list.as_array() + .expect("list") + .iter() + .map(|item| item["name"].as_str().expect("name")) + .collect() +} + +#[tokio::test] +async fn the_archive_lists_what_was_archived_until_it_is_deleted() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let ops = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let docs = app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("crew"); + let mut bots = Vec::new(); + for (crew, name) in [(&ops, "Scout"), (&ops, "Writer"), (&docs, "Editor")] { + let bot = json!({ "crewId": crew["id"], "name": name, "role": "", "instructions": "" }); + bots.push(app.call("bots.create", bot).await.expect("bot")); + } + let empty = archive(&mut app).await; + assert_eq!(empty, json!({ "crews": [], "bots": [] })); + + app.call("bots.archive", json!({ "botId": bots[0]["id"] })) + .await + .expect("archive a bot"); + app.call("crews.archive", json!({ "crewId": docs["id"] })) + .await + .expect("archive a crew"); + + let listed = archive(&mut app).await; + assert_eq!(names(&listed["crews"]), ["Docs"]); + // The archived crew's bot comes too, the latest archived first. + assert_eq!(names(&listed["bots"]), ["Editor", "Scout"]); + let scout = &listed["bots"][1]; + assert_eq!(scout["state"], "archived"); + assert!(scout["archivedAt"].as_i64().is_some()); + let folder = t.paths.bot_workspace("ops", "scout"); + assert_eq!(scout["workspace"], folder.to_string_lossy().as_ref()); + let shared = t.paths.shared_dir("docs"); + assert_eq!( + listed["crews"][0]["workFolder"], + shared.to_string_lossy().as_ref() + ); + + app.call("crews.delete", json!({ "crewId": docs["id"] })) + .await + .expect("delete the crew"); + app.call("bots.delete", json!({ "botId": bots[0]["id"] })) + .await + .expect("delete the bot"); + assert_eq!(archive(&mut app).await, empty); + // What was never archived is where it was. + let active = app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(names(&active), ["Writer"]); +} diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index b6da920b..fb3e8d32 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -31,6 +31,7 @@ use botloftd::service::tasks::TaskSettings; use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings}; +use botloftd::trash::FakeTrash; use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use tempfile::TempDir; @@ -48,6 +49,8 @@ pub struct TestDaemon { pub daemon: Arc, pub runtime: FakeRuntime, pub clock: Arc, + /// Where folders go instead of the Recycle Bin. + pub trash: FakeTrash, _dir: TempDir, } @@ -89,6 +92,7 @@ pub struct Parts { pub runtime: FakeRuntime, pub clock: Arc, pub paths: Paths, + pub trash: FakeTrash, pub dir: TempDir, } @@ -97,6 +101,7 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces")); let runtime = FakeRuntime::new(); let clock = Arc::new(ManualClock::new()); + let trash = FakeTrash::new(dir.path().join("bin")); let daemon = Daemon::new(DaemonOptions { paths: paths.clone(), port: 45710, @@ -115,12 +120,14 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { // Approvals time out fast enough for a test to wait for it. settings: LiveSettings::new(None, &Config::default()) .with_approval_wait(Duration::from_secs(3)), + trash: Arc::new(trash.clone()), }); Parts { daemon, runtime, clock, paths, + trash, dir, } } @@ -156,6 +163,7 @@ impl TestDaemon { daemon: parts.daemon, runtime: parts.runtime, clock: parts.clock, + trash: parts.trash, _dir: parts.dir, } } diff --git a/crates/botloftd/tests/delete_folders.rs b/crates/botloftd/tests/delete_folders.rs new file mode 100644 index 00000000..6837e88b --- /dev/null +++ b/crates/botloftd/tests/delete_folders.rs @@ -0,0 +1,172 @@ +//! Deleting a bot or a crew and sending its folder to the Recycle Bin +//! (spec 7.6). The test daemon's trash is a fake: folders move into a +//! folder of the test, never into a real bin. + +mod common; + +use std::io::ErrorKind; +use std::path::Path; + +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +async fn crew_with_scout(app: &mut Client, work_folder: Option<&Path>) -> (Value, Value) { + let mut crew = json!({ "name": "Ops" }); + if let Some(folder) = work_folder { + crew["workFolder"] = json!(folder.to_string_lossy()); + } + let crew = app.call("crews.create", crew).await.expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let scout = app.call("bots.create", bot).await.expect("bot"); + (crew, scout) +} + +fn text(path: &Path) -> Value { + json!(path.to_string_lossy()) +} + +#[tokio::test] +async fn a_deleted_bots_folder_goes_to_the_bin_when_asked() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (_, scout) = crew_with_scout(&mut app, None).await; + let workspace = t.paths.bot_workspace("ops", "scout"); + std::fs::write(workspace.join("CLAUDE.md"), "what the bot learned").expect("memory"); + + app.call( + "bots.delete", + json!({ "botId": scout["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let moved = app.notification("folder.recycled").await; + assert_eq!( + moved, + json!({ "path": text(&workspace), "error": Value::Null }) + ); + assert!(!workspace.exists()); + assert_eq!(t.trash.moved(), [workspace]); + // Only the bot's own folder went. + assert!(t.paths.shared_dir("ops").is_dir()); +} + +#[tokio::test] +async fn without_asking_the_folder_stays_and_nothing_is_said() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (crew, scout) = crew_with_scout(&mut app, None).await; + for params in [ + json!({ "botId": scout["id"] }), + json!({ "botId": scout["id"], "recycleFolder": false }), + ] { + // The second call finds no bot: the first one deleted it. + let _ = app.call("bots.delete", params).await; + } + app.call("crews.delete", json!({ "crewId": crew["id"] })) + .await + .expect("delete the crew"); + assert_eq!(t.trash.tries(), 0); + assert!(t.paths.bot_workspace("ops", "scout").is_dir()); + assert!(t.paths.shared_dir("ops").is_dir()); +} + +#[tokio::test] +async fn a_folder_still_in_use_is_tried_again() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (_, scout) = crew_with_scout(&mut app, None).await; + t.trash.fail_next(&[ErrorKind::PermissionDenied]); + + app.call( + "bots.delete", + json!({ "botId": scout["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let moved = app.notification("folder.recycled").await; + assert_eq!(moved["error"], Value::Null); + assert_eq!(t.trash.tries(), 2); + assert!(!t.paths.bot_workspace("ops", "scout").exists()); +} + +#[tokio::test] +async fn a_folder_the_bin_cannot_take_stays_and_the_app_is_told() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + let (_, scout) = crew_with_scout(&mut app, None).await; + t.trash.fail_next(&[ErrorKind::Unsupported]); + let workspace = t.paths.bot_workspace("ops", "scout"); + + // The bot is deleted all the same. + app.call( + "bots.delete", + json!({ "botId": scout["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let kept = app.notification("folder.recycled").await; + assert_eq!(kept["path"], text(&workspace)); + assert_eq!(kept["error"], "fake failure"); + assert_eq!(t.trash.tries(), 1, "no use trying again"); + assert!(workspace.is_dir()); + assert_eq!( + app.call("bots.list", json!({})).await.expect("bots"), + json!([]) + ); +} + +#[tokio::test] +async fn a_deleted_crews_own_folder_goes_whole() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + // The owner's own folder, outside Botloft's: it is never moved. + let project = t.paths.workspaces_root.with_file_name("project"); + let (crew, scout) = crew_with_scout(&mut app, Some(&project)).await; + app.call("bots.archive", json!({ "botId": scout["id"] })) + .await + .expect("archive"); + std::fs::write(project.join("site.html"), "the owner's work").expect("work"); + let folder = t.paths.crew_dir("ops"); + + app.call( + "crews.delete", + json!({ "crewId": crew["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let moved = app.notification("folder.recycled").await; + assert_eq!( + moved, + json!({ "path": text(&folder), "error": Value::Null }) + ); + assert!(!folder.exists(), "bots' folders and shared went with it"); + assert_eq!( + std::fs::read_to_string(project.join("site.html")).expect("kept"), + "the owner's work" + ); +} + +#[tokio::test] +async fn a_folder_that_holds_the_owners_work_folder_stays() { + let t = TestDaemon::start().await; + let mut app = t.session().await; + // The owner chose a work folder inside the crew's own folder. + let inside = t.paths.crew_dir("ops").join("project"); + let (crew, _) = crew_with_scout(&mut app, Some(&inside)).await; + let folder = t.paths.crew_dir("ops"); + + app.call( + "crews.delete", + json!({ "crewId": crew["id"], "recycleFolder": true }), + ) + .await + .expect("delete"); + let kept = app.notification("folder.recycled").await; + assert_eq!(kept["path"], text(&folder)); + assert_eq!( + kept["error"], + "the work folder you chose for the crew is inside it" + ); + assert_eq!(t.trash.tries(), 0); + assert!(inside.is_dir()); +} diff --git a/docs/spec.md b/docs/spec.md index 4b03193b..cd4005ab 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -117,7 +117,7 @@ Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer **Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. -**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica. +**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e, a menos que o dono peça a Lixeira na confirmação (abaixo), deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: a confirmação do app diz que elas ficam e mostra o caminho (15.1), e o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica. Se o dono marca a caixa da confirmação, a pasta que o Botloft criou vai para a Lixeira do Windows, de onde ainda dá para recuperar: o workspace do bot, ou a pasta inteira da crew (`\\`, com a `shared\` e os workspaces). A pasta de trabalho que o dono escolheu nunca é movida. Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** quando o nome de exibição muda. Slug: ASCII minúsculo com hífens, acentos transliterados (`Revisão` -> `revisao`), no máximo 32 caracteres, nunca um nome de dispositivo do Windows (`con`, `lpt1`...) e nunca `shared` para bot. Colisão ganha sufixo (`docs-2`), inclusive com slug de item arquivado ou pasta que já exista no disco. @@ -282,7 +282,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **Tasks em aberto** (`open` ou `expired`) somem com o bot, e o outro lado é avisado pelo daemon (`from Botloft`), se ainda está ativo: quem pediu uma task ao excluído lê que ela não vai ser feita; quem fazia uma task para o excluído lê que ninguém mais espera o resultado. Um `complete_task` dela responde que a task não existe. - **Excluir uma crew** exclui todos os bots dela, ativos e arquivados, com tudo acima, e a crew. Ninguém é avisado: não sobra ninguém. - **Avisos ao app:** `bot.deleted {botId, crewId}` e `crew.deleted {crewId}` (11.3). O app tira do store o bot (ou a crew com os bots) e o que era dele: rotinas, tasks, deliveries e o navegador. As outras coisas que mudam saem pelos avisos de sempre (`crew.changed` sem chefe, `message.created` dos avisos de task). -- **Próximos passos:** Excluir nos menus do app, com uma confirmação que diz o que sai e o que fica; nela, uma caixa para mandar a pasta para a Lixeira; e uma lista dos arquivados, que hoje o app não mostra. +- **No app** (15.1): Excluir fica no menu do bot e no menu da crew, depois de Arquivar, com uma confirmação que diz o que sai e o que fica. Os arquivados, que o resto do app não mostra, ficam em Configurações > Arquivados, cada um com o seu Excluir; a lista vem de `archive.list` (11.2). +- **Lixeira, se o dono pedir:** `recycleFolder: true` em `bots.delete` manda o workspace do bot para a Lixeira do Windows; em `crews.delete`, a pasta da crew inteira (`\\`: a `shared\` e o workspace de cada bot, também dos arquivados e dos excluídos antes). Só vai uma pasta que esteja dentro de `workspaces_root`, nunca a própria raiz, e nunca uma que contenha a pasta de trabalho escolhida pelo dono (5): nesse caso nada é movido. O método responde na hora, como sem a opção; a pasta vai depois, fora dos workers do servidor, porque o processo recém-morto ainda segura arquivos por um instante: até 20 tentativas, uma a cada 500 ms. Quando termina, o daemon avisa com `folder.recycled {path, error}`: `error: null` se a pasta está na Lixeira (ou já não existia), senão o motivo de ela ter ficado. Nada é apagado de vez: o que a Lixeira não pode receber fica onde está, sem nova tentativa (14). ## 8. Chat @@ -505,8 +506,9 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `crews.setLead` | `crewId, botId` (ou `null`, sem chefe) | `Crew`; o chefe antigo e o novo reiniciam quando nada estiver em andamento (10.2) | | `crews.setWorkFolder` | `crewId, workFolder` (caminho, ou `null` para voltar à `shared\`) | `Crew`; os bots reiniciam na pasta nova quando nada estiver em andamento (5) | | `crews.archive` | `crewId` | `Crew` | -| `crews.delete` | `crewId` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela (7.6) | +| `crews.delete` | `crewId, recycleFolder?` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela; com `recycleFolder`, a pasta dela vai depois para a Lixeira (7.6) | | `bots.list` | `crewId?` | `Bot[]` | +| `archive.list` | | `{crews, bots}`: o que está arquivado, que `crews.list` e `bots.list` deixam de fora, do arquivado mais recente ao mais antigo. `bots` traz todos os bots arquivados, também os das crews arquivadas (7.6) | | `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` | | `bots.update` | `botId, name?, role?, instructions?, color?` | `Bot` | | `bots.setPaused` | `botId, paused` | `Bot` | @@ -514,7 +516,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.setModel` | `botId, model` (`default`, `fable`, `opus`, `sonnet`, `haiku`) | `Bot`; o bot reinicia no novo modelo quando nada estiver em andamento (7.4) | | `bots.restart` | `botId, fresh?` | `Bot` | | `bots.archive` | `botId` | `Bot` | -| `bots.delete` | `botId` | `{botId, crewId}`; exclui o bot, ativo ou arquivado (7.6) | +| `bots.delete` | `botId, recycleFolder?` | `{botId, crewId}`; exclui o bot, ativo ou arquivado; com `recycleFolder`, a pasta dele vai depois para a Lixeira (7.6) | | `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | | `approvals.answer` | `approvalId, allow, note?, input?` (`input`: a sugestão de bot como o dono a deixou, 10.2) | `Approval` | | `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | @@ -534,7 +536,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `bot.deleted`, `crew.changed`, `crew.deleted` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). +`bot.state`, `bot.changed`, `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). ### 11.4 Erros @@ -595,7 +597,8 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Processos órfãos | Job Object por bot (seção 7.3) | | Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | -| Arquivo em uso | arquivar e excluir não apagam workspace (5). O perfil do navegador do bot é apagado depois que o navegador fecha, com novas tentativas por 5 s enquanto o Edge ainda segura os arquivos | +| Arquivo em uso | arquivar e excluir não apagam workspace (5). O perfil do navegador do bot é apagado depois que o navegador fecha, com novas tentativas por 5 s enquanto o Edge ainda segura os arquivos. A pasta que vai para a Lixeira (abaixo) é tentada de novo por 10 s enquanto o processo morto ainda a segura | +| Lixeira (testado no Windows 11 25H2, 2026-09-30) | `IFileOperation` do shell, numa thread própria em STA (o shell exige, e uma thread do daemon pode já estar em MTA, como a do Agendador), com `FOF_NO_UI`, `FOF_ALLOWUNDO`, `FOFX_RECYCLEONDELETE` e `FOFX_EARLYFAILURE`. Sem poder perguntar, o shell apaga de vez o que a Lixeira não pode receber; por isso um `IFileOperationProgressSink` aborta em `PreDeleteItem` todo item que chega sem `TSF_DELETE_RECYCLE_IF_POSSIBLE`, e a pasta fica. Visto: a pasta vai inteira e aparece no registro `$I` da Lixeira com o caminho original; uma pasta com caminhos de mais de 260 caracteres também vai inteira, com o arquivo do fundo; com um arquivo aberto por outro processo a operação falha, a pasta fica como estava e vai na tentativa seguinte. Os testes tiram da Lixeira o que puseram | | Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | | Porta ocupada | se 45710 estiver em uso por outro processo, sair com erro (sem porta alternativa no MVP) | @@ -642,9 +645,9 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. +- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. -- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. +- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. - **Perto do relógio:** com os bots continuando depois de fechar e "Mostrar o Botloft perto do relógio" ligada, o app põe um ícone na área de notificação (o ícone do app, com um ponto quando algo espera o dono, como o overlay da taskbar). A dica diz "Botloft: " e o que acontece: um bot que precisa do dono pelo nome, algo esperando, quantos bots trabalham ou nenhum. Um clique abre a janela; o menu tem essa mesma linha, "Abrir o Botloft", "Pausar todas as equipes" (ou "Retomar todas as equipes", com todas pausadas) e "Sair do Botloft", que fecha o app e deixa os bots trabalhando. Com o ícone, fechar a janela só a esconde; sem ele, o app fecha. - **Avisos:** quando um bot passa a precisar do dono (pedido de permissão, com a ação no idioma do dono, por exemplo "Rodar um comando", sem o comando; ou entrar na conta do Claude) e, se o dono quiser, quando um bot passa de trabalhando a disponível ("Scout terminou", com a equipe). Com o Botloft fora da frente (janela escondida, minimizada ou sem foco), é um aviso do Windows, com o som do Windows se "Tocar um som" estiver ligada; com ele na frente, só um som curto feito na hora (duas notas, sem arquivo). O que já estava assim quando o app conectou não avisa. Abrir o Botloft pelo aviso (o que traz a janela aberta, 15.2) até 10 min depois abre o bot do último aviso. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve). @@ -653,6 +656,7 @@ Layout, como um app de mensagens: - **Área principal com uma crew:** a timeline (messages entre os bots e do dono) e as tasks. Embaixo do nome, "Trabalha em ", que abre a pasta no Explorer; o menu da crew tem "Abrir pasta de trabalho" e "Mudar pasta de trabalho…", que abre o seletor de pastas do Windows e confirma antes de mudar, avisando que cada bot reinicia quando terminar o que está fazendo. - **Nova crew:** nome; "Para que é esta equipe?", o objetivo que vira as instruções do chefe, com a explicação de que a equipe começa com um Chefe que planeja o trabalho e sugere os bots de que precisa; "Pasta de trabalho", com "Escolher pasta…" (o seletor do Windows; sem escolha, "Uma pasta nova dentro do Botloft", a `shared\`); e "Modelo do Chefe". Criada, o app abre o chat do chefe. - **Chefe:** uma coroa ao lado do nome na barra lateral e nos cartões da crew, e "Chefe" no cabeçalho do bot, com a dica "Lidera : planeja o trabalho e sugere bots novos". O menu do bot tem "Tornar chefe da equipe" ou "Deixar de ser chefe". +- **Excluir** (7.6): "Excluir bot" é a última ação do menu do bot (no cabeçalho e no clique direito) e "Excluir equipe" a última do menu da crew, as duas em vermelho, depois de Arquivar. A confirmação leva o nome no título ("Excluir Scout?") e diz, em palavras simples, o que sai e o que fica: o bot para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte, e não dá para desfazer; se ele é o chefe, que a equipe fica sem chefe; e, num quadro à parte, que a pasta dele continua no computador, com o caminho para copiar. A da crew diz quantos bots param e saem com ela, e mostra a pasta de trabalho, lembrando que a pasta de cada bot também fica. No mesmo quadro, uma caixa desmarcada: "Mandar esta pasta para a Lixeira" (na crew, "Mandar as pastas da equipe para a Lixeira"). Marcada, o texto do quadro passa a dizer que a pasta vai para a Lixeira, de onde ainda dá para recuperar, e a crew mostra a pasta dela no Botloft em vez da `shared\`; se o dono escolheu a pasta de trabalho, o quadro diz que a pasta de cada bot vai e a escolhida fica onde está. O app manda `recycleFolder` só com a caixa marcada. Se a pasta não pôde ir (`folder.recycled` com `error`), um aviso diz que ela não foi para a Lixeira e continua lá, com o caminho e o motivo. Confirmado, o app tira o bot ou a crew da tela sem esperar o aviso do daemon; se falhar, um aviso diz que não foi possível e nada muda. Excluído o bot aberto, fica a página da crew; excluída a crew aberta, nenhuma fica escolhida (sem crews, a tela de boas-vindas). Uma message de um bot que saiu, arquivado ou excluído, aparece na conversa e na timeline como de "um bot que não está mais na equipe". - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. - **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou.