diff --git a/app/src/features/bots/BotPanels.tsx b/app/src/features/bots/BotPanels.tsx new file mode 100644 index 00000000..6579dbc2 --- /dev/null +++ b/app/src/features/bots/BotPanels.tsx @@ -0,0 +1,83 @@ +// What sits around a bot's chat besides the chat (spec 15.1): the notices +// over it, about a paused crew or a bot that needs the owner, and the panel +// with the bot's details. + +import { X } from "lucide-react"; +import { useT } from "../../i18n"; +import type { Bot, Crew } from "../../lib/protocol.gen"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { SidePanel } from "../../ui/SidePanel"; +import { SignInButton } from "../onboarding/SignIn"; +import { AllowRules } from "./AllowRules"; +import type { stateView } from "./BotStateBadge"; + +export function Notices({ + bot, + crew, + view, +}: { + bot: Bot; + crew: Crew; + view: ReturnType; +}) { + const t = useT(); + const notices = []; + if (crew.paused && !bot.paused) { + notices.push( + + {t.bots.notices.crewPausedBody} + , + ); + } + // An approval shows in the chat itself; the rest needs a word up here. + if ((view.tone === "warn" || view.tone === "danger") && bot.state !== "needs_approval") { + notices.push( + + {view.hint} + {bot.state === "auth_error" && } + , + ); + } + if (notices.length === 0) { + return null; + } + return
{notices}
; +} + +export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) { + const words = useT().bots.details; + return ( + +
+

{words.title(bot.name)}

+
+
+
+
{words.role}
+
{bot.role || words.noRole}
+
+
+
{words.folder}
+
+ {bot.workspace} +
+
+
+
{words.process}
+
+ {bot.generation === null ? words.notStarted : words.generation(bot.generation)} +
+
+
+
{words.instructions}
+
+ {bot.instructions || words.noInstructions} +
+
+ +
+
+ ); +} diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 382a76f8..d373ec5f 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -1,15 +1,11 @@ -import { X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, BotFile, Crew } from "../../lib/protocol.gen"; import { useWindowVisible } from "../../shell/visibility"; -import { type BotPanel, routinesOf } from "../../store/app"; +import { routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; -import { Button } from "../../ui/Button"; -import { Callout } from "../../ui/Callout"; import { PanelClosing, PanelRestored } from "../../ui/panelMotion"; -import { SidePanel } from "../../ui/SidePanel"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { useStable } from "../../ui/useStable"; import { BrowserPanel } from "../browser/BrowserPanel"; @@ -18,19 +14,20 @@ import { ChatView } from "../chat/ChatView"; import { FilesPanel } from "../files/FilesPanel"; import { ShowFile } from "../files/showFile"; import { useBotFiles } from "../files/useBotFiles"; -import { SignInButton } from "../onboarding/SignIn"; import { BotRoutines } from "../routines/RoutineList"; import { ScreensPanel } from "../screens/ScreensPanel"; import { ShowScreen } from "../screens/showScreen"; import { useScreens } from "../screens/useScreens"; -import { AllowRules } from "./AllowRules"; +import { Dock } from "../terminal/ComputerDock"; +import { ShowTerminal } from "../terminal/showTerminal"; +import { TerminalPanel } from "../terminal/TerminalPanel"; import { BotHeader } from "./BotHeader"; +import { Details, Notices } from "./BotPanels"; import { stateView } from "./BotStateBadge"; import { useFollowBot } from "./useFollowBot"; +import { usePanel } from "./usePanel"; type Pane = "chat" | "routines"; -/** What the panel beside the chat shows. */ -type Side = BotPanel | null; /** * A bot's conversation and its routines, with its details in a side panel @@ -38,30 +35,9 @@ type Side = BotPanel | null; */ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); - // Kept per bot in the store, not here: the panel the owner left open is - // back when they come back to the bot, and one they closed stays closed - // (spec 15.1). - const side = useApp((state) => state.panels[bot.id] ?? null); - const keepPanel = useApp((state) => state.setPanel); - // The panel that came back is there at once; one opened after it slides. - const [restored, setRestored] = useState(side !== null); - const setSide = (panel: Side) => { - setRestored(false); - keepPanel(bot.id, panel); - }; - // The panel on screen: the one open, or the last one while it slides - // closed (spec 15.1). - const [leaving, setLeaving] = useState(null); - useEffect(() => { - if (side !== null) { - setLeaving(side); - } - }, [side]); - const beside = side ?? leaving; - const closing = useMemo( - () => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null), - [side, leaving], - ); + // Kept per bot in the store: the panel the owner left open is back when + // they come back to the bot, and one they closed stays closed (spec 15.1). + const { side, setSide, beside, restored, closing } = usePanel(bot.id); const [pane, setPane] = useState("chat"); const files = useBotFiles(bot, side === "files"); // What the owner has seen: files newer than this are new to them. @@ -152,6 +128,25 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { open: (panel) => (panel === "browser" ? showBrowser() : showScreen(null)), close: (panel) => side === panel && setSide(null), }); + const showTerminal = useStable(() => { + if (filesOpen) { + seen(); + } + setSide("terminal"); + }); + // The dock of the bot's computer: browser, terminal and files. + const dock = { + open: side, + pick: (place: "browser" | "terminal" | "files") => { + if (place === "browser") { + showBrowser(); + } else if (place === "terminal") { + showTerminal(); + } else if (!filesOpen) { + toggleFiles(); + } + }, + }; const view = stateView(bot, crew.paused, t); const tabs: Tab[] = [ { id: "chat", label: t.routines.chatTab }, @@ -188,7 +183,9 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { - + + + @@ -196,105 +193,38 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { )} - - - {beside === "details" &&
setSide(null)} />} - {beside === "browser" && ( - setSide(null)} /> - )} - {beside === "screens" && ( - setSide(null)} - /> - )} - {beside === "files" && ( - - )} - - + + + + {beside === "details" &&
setSide(null)} />} + {beside === "browser" && ( + setSide(null)} /> + )} + {beside === "screens" && ( + setSide(null)} + /> + )} + {beside === "files" && ( + + )} + {beside === "terminal" && setSide(null)} />} + + + ); } - -function Notices({ - bot, - crew, - view, -}: { - bot: Bot; - crew: Crew; - view: ReturnType; -}) { - const t = useT(); - const notices = []; - if (crew.paused && !bot.paused) { - notices.push( - - {t.bots.notices.crewPausedBody} - , - ); - } - // An approval shows in the chat itself; the rest needs a word up here. - if ((view.tone === "warn" || view.tone === "danger") && bot.state !== "needs_approval") { - notices.push( - - {view.hint} - {bot.state === "auth_error" && } - , - ); - } - if (notices.length === 0) { - return null; - } - return
{notices}
; -} - -function Details({ bot, onClose }: { bot: Bot; onClose(): void }) { - const words = useT().bots.details; - return ( - -
-

{words.title(bot.name)}

-
-
-
-
{words.role}
-
{bot.role || words.noRole}
-
-
-
{words.folder}
-
- {bot.workspace} -
-
-
-
{words.process}
-
- {bot.generation === null ? words.notStarted : words.generation(bot.generation)} -
-
-
-
{words.instructions}
-
- {bot.instructions || words.noInstructions} -
-
- -
-
- ); -} diff --git a/app/src/features/bots/usePanel.ts b/app/src/features/bots/usePanel.ts new file mode 100644 index 00000000..6bcac6d1 --- /dev/null +++ b/app/src/features/bots/usePanel.ts @@ -0,0 +1,33 @@ +// The panel beside a bot's chat (spec 15.1): which one is open, kept per +// bot in the store so it comes back with the bot, and the one sliding +// closed while it leaves. + +import { useEffect, useMemo, useState } from "react"; +import type { BotPanel } from "../../store/app"; +import { useApp } from "../../store/context"; + +export type Side = BotPanel | null; + +export function usePanel(botId: string) { + const side = useApp((state) => state.panels[botId] ?? null); + const keepPanel = useApp((state) => state.setPanel); + // The panel that came back is there at once; one opened after it slides. + const [restored, setRestored] = useState(side !== null); + const setSide = (panel: Side) => { + setRestored(false); + keepPanel(botId, panel); + }; + // The panel on screen: the one open, or the last one while it slides + // closed. + const [leaving, setLeaving] = useState(null); + useEffect(() => { + if (side !== null) { + setLeaving(side); + } + }, [side]); + const closing = useMemo( + () => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null), + [side, leaving], + ); + return { side, setSide, beside: side ?? leaving, restored, closing }; +} diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index 6513c94a..96123989 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -16,6 +16,7 @@ import { Callout } from "../../ui/Callout"; import { EmptyState } from "../../ui/EmptyState"; import { SidePanel } from "../../ui/SidePanel"; import { BotAvatar } from "../bots/BotAvatar"; +import { ComputerDock } from "../terminal/ComputerDock"; import { AddressBar } from "./AddressBar"; import { AskCallout, ControlPill, HeldNotes, TakeNotes } from "./HandsBars"; import { HandsLayer } from "./HandsLayer"; @@ -36,6 +37,8 @@ const STAGE = 14; const STAGE_BOTTOM = 30; /** Kept under the page, for the pill and what the bot just did. */ const UNDER = 56; +/** The dock of the bot's computer, on the desk under the page. */ +const DOCK = 56; export function BrowserPanel({ bot, @@ -65,7 +68,7 @@ export function BrowserPanel({ const [inside, body] = useRoom(); const room = inside && { width: inside.width - 2 * PAD - 2 * STAGE, - height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER, + height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER - DOCK, }; useFitPage(bot, room, watched); // A new tab is blank: the owner says where it goes. @@ -141,7 +144,12 @@ export function BrowserPanel({ )} ) : status === "closed" && !frame ? ( - !windowed && + !windowed && ( + <> + + + + ) ) : ( <> {live && ask && !hands.held && } @@ -189,6 +197,7 @@ export function BrowserPanel({ )} + {(hands.held || (live && !ask)) && ( diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 988a0426..d0e786a5 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -74,7 +74,7 @@ describe("browser panel", () => { test("gives the page the shape of the room the panel has, and shows it as large as fits", async () => { const { fake, scout } = crew(); fake.browser.open(scout.id, "https://example.com/", "Example"); - // The panel under its address row, as the app measures it: 508 by 680 + // The panel under its address row, as the app measures it: 508 by 624 // for the page, once what goes around and under it is set aside. vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(560); vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(804); @@ -82,9 +82,9 @@ describe("browser panel", () => { fireEvent.click(toggle()); const resized = () => fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params); - await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 508, height: 680 }])); + await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 508, height: 624 }])); // As wide as ever, and as tall as the room's shape asks. - expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1713 }); + expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1572 }); act(() => { fake.browser.frame(scout.id, "AAAA"); @@ -92,7 +92,7 @@ describe("browser panel", () => { // The bot's cursor lands where it acted on the taller page. const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" }); act(() => { - fake.browser.act(scout.id, "click", { x: 640, y: 1713, label: "More" }); + fake.browser.act(scout.id, "click", { x: 640, y: 1572, label: "More" }); }); const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement; expect(cursor.style.top).toBe("100%"); diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 7ee1178b..66e52bd2 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -31,6 +31,7 @@ import { useArrival } from "../../ui/motion"; import { isBrowserTool, ShowBrowser } from "../browser/showBrowser"; import { ShowFile } from "../files/showFile"; import { isScreenFile, ShowScreen } from "../screens/showScreen"; +import { ShowTerminal } from "../terminal/showTerminal"; import { commandOf, isCommand } from "./command"; import { toolDetail, toolKey, toolTitle } from "./toolNames"; @@ -94,6 +95,7 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { const showFile = useContext(ShowFile); const showBrowser = useContext(ShowBrowser); const showScreen = useContext(ShowScreen); + const showTerminal = useContext(ShowTerminal); const arrival = useArrival(createdAt); const title = toolTitle(tool.name, t.tools); // A command reads in the bot's own words, when it gave them; the command @@ -150,6 +152,17 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { )} + {showTerminal && command && ( + + )} {showBrowser && isBrowserTool(tool.name) && ( + ); + })} + + ); +} diff --git a/app/src/features/terminal/TerminalPanel.tsx b/app/src/features/terminal/TerminalPanel.tsx new file mode 100644 index 00000000..77ba4527 --- /dev/null +++ b/app/src/features/terminal/TerminalPanel.tsx @@ -0,0 +1,110 @@ +// The bot's terminal beside its chat (spec 15.1): the commands it ran, as +// a terminal window on the bot's desk, live, each with what the bot said it +// is for, what it printed and whether it failed. Read from the chat: the +// commands are its `Bash` and `PowerShell` calls. + +import { LoaderCircle, X } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { useT } from "../../i18n"; +import type { Bot, ToolItem } from "../../lib/protocol.gen"; +import { Button } from "../../ui/Button"; +import { SidePanel } from "../../ui/SidePanel"; +import { commandOf, isCommand } from "../chat/command"; +import { useChat } from "../chat/useChat"; +import { ComputerDock } from "./ComputerDock"; + +function Command({ tool }: { tool: ToolItem }) { + const t = useT().terminal; + const command = commandOf(tool.input); + return ( +
  • + {tool.explanation &&

    # {tool.explanation}

    } +

    + + ❯{" "} + + {command.text} +

    + {tool.status === "running" ? ( +

    + + {t.running} +

    + ) : ( + tool.output && ( +
    +            {tool.output}
    +          
    + ) + )} +
  • + ); +} + +export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) { + const t = useT().terminal; + const chat = useChat(bot.id); + const commands = chat.items.flatMap((item) => + item.body.kind === "tool" && isCommand(item.body.name) + ? [{ id: item.id, tool: item.body }] + : [], + ); + const screen = useRef(null); + const last = commands.at(-1); + // Follows the newest command, as a terminal does. + // biome-ignore lint/correctness/useExhaustiveDependencies: a new or finished command scrolls + useEffect(() => { + const element = screen.current; + if (element) { + element.scrollTop = element.scrollHeight; + } + }, [last?.id, last?.tool.status]); + + return ( + +
    +

    {t.heading}

    +
    +
    +
    +
    +
    + {[0, 1, 2].map((dot) => ( + + ))} + {t.title(bot.name)} +
    +
    + {chat.loading && commands.length === 0 ? ( +

    {t.loading}

    + ) : commands.length === 0 ? ( +

    {t.empty(bot.name)}

    + ) : ( +
      + {commands.map(({ id, tool }) => ( + + ))} +
    + )} +
    +
    + +
    +
    +
    + ); +} diff --git a/app/src/features/terminal/showTerminal.ts b/app/src/features/terminal/showTerminal.ts new file mode 100644 index 00000000..ae3c37e4 --- /dev/null +++ b/app/src/features/terminal/showTerminal.ts @@ -0,0 +1,6 @@ +// How the chat asks for the bot's terminal (spec 15.1). Absent where there +// is no panel, so no button shows. + +import { createContext } from "react"; + +export const ShowTerminal = createContext<(() => void) | null>(null); diff --git a/app/src/features/terminal/terminal.test.tsx b/app/src/features/terminal/terminal.test.tsx new file mode 100644 index 00000000..1e1c303e --- /dev/null +++ b/app/src/features/terminal/terminal.test.tsx @@ -0,0 +1,73 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function setup() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "busy"); + const list = fake.chat.tool(scout.id, "PowerShell", { + summary: "Get-ChildItem", + explanation: "See what is in the folder", + input: JSON.stringify({ command: "Get-ChildItem" }), + }); + fake.chat.finish(list, "report.md\nnotes.md"); + const tests = fake.chat.tool(scout.id, "Bash", { + summary: "npm test", + input: JSON.stringify({ command: "npm test" }), + }); + fake.chat.finish(tests, "1 test failed", true); + fake.chat.turn(scout.id); + fake.setBotState(scout.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + // The two calls fold into one line: open it. + fireEvent.click(screen.getByRole("button", { name: /2 commands/, expanded: false })); + return { fake, scout }; +} + +const panel = (name: string) => screen.getByRole("complementary", { name }); +const dock = (name: string) => + within(screen.getByRole("navigation", { name: "Computer" })).getByRole("button", { name }); + +describe("the bot's terminal", () => { + test("a command in the chat opens it, with each command, what it is for and what it printed", async () => { + const { fake, scout } = await setup(); + fireEvent.click(screen.getByRole("button", { name: "Show in terminal: npm test" })); + const log = within(panel("Scout's terminal")).getByRole("log"); + expect(await within(log).findByText("# See what is in the folder")).toBeDefined(); + expect(log.textContent).toContain("Get-ChildItem"); + expect(log.textContent).toContain("report.md"); + expect(within(log).getByText("1 test failed").className).toContain("terminal-failed"); + + // A new command shows up live, running. + act(() => { + fake.chat.tool(scout.id, "Bash", { + summary: "git status", + input: JSON.stringify({ command: "git status" }), + }); + }); + expect(within(log).getByText("git status")).toBeDefined(); + expect(within(log).getByText("running…")).toBeDefined(); + }); + + test("the dock moves between the browser, the terminal and the files", async () => { + await setup(); + fireEvent.click(screen.getByRole("button", { name: "Show in terminal: npm test" })); + expect(dock("Terminal").getAttribute("aria-current")).toBe("page"); + + fireEvent.click(dock("Browser")); + expect(panel("Scout's browser")).toBeDefined(); + fireEvent.click(dock("Files")); + expect(panel("Files from Scout")).toBeDefined(); + expect(dock("Files").getAttribute("aria-current")).toBe("page"); + fireEvent.click(dock("Terminal")); + expect(panel("Scout's terminal")).toBeDefined(); + }); +}); diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index 767eca5a..db179839 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -16,6 +16,7 @@ import { screens } from "./screens"; import { search } from "./search"; import { setup } from "./setup"; import { shell } from "./shell"; +import { terminal } from "./terminal"; import { tools } from "./tools"; import { updates } from "./updates"; @@ -37,6 +38,7 @@ export const en = { screens, search, setup, + terminal, tools, }; diff --git a/app/src/i18n/en/terminal.ts b/app/src/i18n/en/terminal.ts new file mode 100644 index 00000000..a6e11590 --- /dev/null +++ b/app/src/i18n/en/terminal.ts @@ -0,0 +1,20 @@ +// The bot's computer (spec 15.1): its terminal, with the commands it ran, +// and the dock that moves between its browser, terminal and files. + +export const terminal = { + heading: "Terminal", + panel: (bot: string) => `${bot}'s terminal`, + /** The title of the terminal window. */ + title: (bot: string) => `${bot} — commands`, + close: "Close", + showInPanel: "Show in terminal", + running: "running…", + loading: "Loading the commands…", + empty: (bot: string) => `${bot} has not run any command yet. What it runs shows up here, live.`, + dock: { + label: "Computer", + browser: "Browser", + terminal: "Terminal", + files: "Files", + }, +}; diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index fc5dd780..bd6fc998 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -17,6 +17,7 @@ import { screens } from "./screens"; import { search } from "./search"; import { setup } from "./setup"; import { shell } from "./shell"; +import { terminal } from "./terminal"; import { tools } from "./tools"; import { updates } from "./updates"; @@ -38,5 +39,6 @@ export const es: Messages = { screens, search, setup, + terminal, tools, }; diff --git a/app/src/i18n/es/terminal.ts b/app/src/i18n/es/terminal.ts new file mode 100644 index 00000000..1eba798e --- /dev/null +++ b/app/src/i18n/es/terminal.ts @@ -0,0 +1,18 @@ +import type { Messages } from "../en"; + +export const terminal: Messages["terminal"] = { + heading: "Terminal", + panel: (bot) => `Terminal de ${bot}`, + title: (bot) => `${bot} — comandos`, + close: "Cerrar", + showInPanel: "Ver en la terminal", + running: "ejecutando…", + loading: "Cargando los comandos…", + empty: (bot) => `${bot} aún no ejecutó ningún comando. Lo que ejecute aparece aquí, en vivo.`, + dock: { + label: "Computadora", + browser: "Navegador", + terminal: "Terminal", + files: "Archivos", + }, +}; diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index e435e227..55100555 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -17,6 +17,7 @@ import { screens } from "./screens"; import { search } from "./search"; import { setup } from "./setup"; import { shell } from "./shell"; +import { terminal } from "./terminal"; import { tools } from "./tools"; import { updates } from "./updates"; @@ -38,5 +39,6 @@ export const ptBR: Messages = { screens, search, setup, + terminal, tools, }; diff --git a/app/src/i18n/pt-BR/terminal.ts b/app/src/i18n/pt-BR/terminal.ts new file mode 100644 index 00000000..47c9b57a --- /dev/null +++ b/app/src/i18n/pt-BR/terminal.ts @@ -0,0 +1,18 @@ +import type { Messages } from "../en"; + +export const terminal: Messages["terminal"] = { + heading: "Terminal", + panel: (bot) => `Terminal de ${bot}`, + title: (bot) => `${bot} — comandos`, + close: "Fechar", + showInPanel: "Ver no terminal", + running: "rodando…", + loading: "Carregando os comandos…", + empty: (bot) => `${bot} ainda não rodou nenhum comando. O que ele rodar aparece aqui, ao vivo.`, + dock: { + label: "Computador", + browser: "Navegador", + terminal: "Terminal", + files: "Arquivos", + }, +}; diff --git a/app/src/index.css b/app/src/index.css index c25f9d53..dcc6b26f 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -20,6 +20,7 @@ @import "./activity.css"; @import "./calls.css"; @import "./browser-stage.css"; +@import "./terminal.css"; @import "./motion-less.css"; @import "./paused.css"; @import "./offscreen.css"; @@ -59,6 +60,14 @@ --scrim: rgb(0 0 0 / 0.5); /* Windows' close button on hover, the same in both themes. */ --close: #c42b1c; + /* The bot's terminal (terminal.css): dark in both themes. Words read at + 4.5:1 or more on --terminal. */ + --terminal: #131416; + --terminal-ink: #e8e8e2; + --terminal-soft: #c6c6bf; + --terminal-muted: #9c9c94; + --terminal-ok: #6fd49a; + --terminal-danger: #ff8a7e; } [data-theme="dark"] { diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 88b7b6f0..cfd4bf3e 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -28,7 +28,7 @@ import { withQuestion } from "./questions"; import { loadMarked, loadSeen, loadSeenSince, seenActions, withReply } from "./seen"; /** A panel beside a bot's chat (spec 15.1). */ -export type BotPanel = "details" | "files" | "browser" | "screens"; +export type BotPanel = "details" | "files" | "browser" | "screens" | "terminal"; export interface AppState { connection: ConnectionState; diff --git a/app/src/terminal.css b/app/src/terminal.css new file mode 100644 index 00000000..b286b6c7 --- /dev/null +++ b/app/src/terminal.css @@ -0,0 +1,46 @@ +/* The bot's terminal and the dock of its computer (spec 15.1). The + terminal is dark in both themes, as terminals are; its colors are the + --terminal tokens in index.css. */ + +.terminal-window { + background: var(--terminal); + color: var(--terminal-ink); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.2), + 0 10px 24px rgb(0 0 0 / 0.28); +} + +.terminal-bar { + background: color-mix(in oklab, var(--terminal) 88%, white); + color: var(--terminal-muted); +} + +.terminal-light { + width: 9px; + height: 9px; + border-radius: 999px; + background: color-mix(in oklab, var(--terminal-muted) 55%, transparent); +} + +.terminal-prompt { + color: var(--terminal-ok); +} + +.terminal-comment { + color: var(--terminal-muted); +} + +.terminal-output { + color: var(--terminal-soft); +} + +.terminal-failed { + color: var(--terminal-danger); +} + +/* The dock sits on the desk, frosted, as a dock does. */ +.computer-dock { + background: color-mix(in oklab, var(--panel) 55%, transparent); + backdrop-filter: blur(8px); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--panel) 70%, transparent); +} diff --git a/docs/spec.md b/docs/spec.md index 09854bda..b4c37b12 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -818,6 +818,8 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As Layout, como um app de mensagens: +- **Terminal do bot:** os comandos que o bot rodou (as chamadas `Bash` e `PowerShell` do chat), numa janela de terminal sobre a mesa na cor do bot (21.8), ao vivo: o que ele disse que o comando faz, como comentário (`# ...`); o comando depois de `❯`; e o que ele imprimiu, em vermelho se falhou, ou "rodando…" enquanto roda. Lê a página mais nova do chat do bot e segue os itens novos, rolando até o último. O terminal é escuro nos dois temas (tokens `--terminal*`). Abre pelo botão "Ver no terminal" na linha de um comando no chat, ou pela doca. +- **Doca do computador do bot:** Navegador, Terminal e Arquivos como ícones numa faixa fosca, embaixo da página na mesa do navegador e do terminal, e no pé do painel de arquivos (e embaixo do navegador vazio). O lugar aberto fica marcado; um clique troca o painel. A doca ocupa o seu lugar na conta do tamanho da página (21.8). - **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto; um bot parado e livre, `idle`, não mostra estado, porque é o normal) e a prévia da última atividade (`lastActivity`) com a hora. O que alguém escreveu (resposta, mensagem, pergunta) aparece em texto simples, sem as marcas de Markdown que o chat desenha (`**negrito**`, títulos, listas, links); um comando ou caminho de ferramenta fica como veio. Um bot que respondeu desde a última vez que o dono olhou o chat dele fica marcado como não lido, como num app de mensagens: nome em negrito, prévia e hora em destaque e um ponto na cor de destaque no fim da linha, até o dono abrir o chat. Vale toda resposta (`lastReplyAt`, e cada item `reply` que chega), mesmo com outra coisa depois dela na conversa. Com o chat aberto e a janela à vista, o que chega já conta como visto; com a janela escondida, conta quando ela volta. O app guarda no `localStorage` quando o dono olhou cada bot (`botloft.seen`) e, na primeira vez, a hora de começar a marcar (`botloft.seenSince`): respostas de antes contam como vistas, para uma atualização não marcar todos os bots de uma vez. O dono também marca um bot como não lido pelo menu dele ("Marcar como não lida"; num bot marcado, "Marcar como lida"), para lembrar de voltar à conversa: a marca fica no `localStorage` (`botloft.unread`) até ele abrir o chat, e marcar o chat aberto o fecha e volta à página da crew. Ao lado do nome de cada crew, um número na cor de destaque diz quantos bots dela estão não lidos. 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, marcar como não lida ou lida, 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. Um botão na barra de título, ao lado do nome do Botloft, esconde a barra lateral (ou Ctrl+B), para numa tela pequena sobrarem só o chat e o painel do bot: o resto se alarga de uma vez e ela desliza para a esquerda por cima dele (só `transform`, para a conversa não ser refeita a cada quadro). A escolha fica no `localStorage` (`botloft.sidebar`). Escondida, um ponto no botão avisa quando algo espera o dono (o mesmo que marca o ícone na barra de tarefas, 15.2) ou, parado e na cor de destaque, quando algum bot tem resposta não lida. Na tela de boas-vindas, antes da primeira crew, ela não se esconde. Uma seta ao lado do nome de cada crew a recolhe: ficam só o nome, o número de não lidos e, se um bot dela espera o dono, um ponto na cor de aviso; a conversa aberta continua à vista. Um botão no topo recolhe ou expande todas. As crews recolhidas ficam no `localStorage` (`botloft.collapsedCrews`). - **Página das crews:** clicar em "Equipes", no topo da barra lateral, abre no meio da janela todas as crews como cartões: nome, número de bots, os rostos dos primeiros seis (parados, para a página não redesenhar cada chama), quantos bots trabalham e quantos esperam o dono, ou "Pausada". Um cartão abre a crew. É também o que aparece quando nenhuma crew está escolhida (a crew aberta foi arquivada ou excluída), e ela continua aberta depois de uma reconexão; só a primeira carga abre a primeira crew. - **Á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. @@ -837,7 +839,7 @@ Layout, como um app de mensagens: - **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. - **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Quando o pedido tem `always` (10.1), entre Permitir e Negar fica um terceiro botão que diz o que cobre: "Permitir sempre este comando", "Permitir sempre em example.com", "Permitir sempre neste arquivo" ou só "Permitir sempre"; a dica dele avisa que o bot não vai pedir aquilo de novo e que dá para desfazer nos detalhes. Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. No fim dele, "Permitido sem perguntar" lista as regras do bot (10.1): a ação no idioma do dono ("Rodar um comando") e, embaixo, o comando, o site ou o arquivo, cada uma com um X que faz o bot voltar a perguntar. A lista é lida ao abrir o painel e segue `bot.rules`; vazia, diz que "Permitir sempre" coloca as coisas ali. 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 e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. 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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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. Um `share_file` que deu certo (8.4) não vira linha: cada arquivo vira um cartão no chat, com o quadrado do tipo (ou a miniatura de uma imagem de até 8 MB, lida com `files.read`), nome, extensão e tamanho, "Abrir" e "Salvar como…" (`save_file_as`, 15.2). Um clique no arquivo abre a prévia no painel. Num chat estreito, o botão de salvar fica só com o ícone. Se a resposta da tool não puder ser lida, ou se a chamada falhou, fica a linha da ferramenta, com o erro. 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; o chat toma a largura nova de uma vez, e fechando o painel sai deslizando por cima dele. Só um `transform` se move, para a conversa não ser refeita a cada quadro (a 180 Hz, cada quadro tem 5,5 ms). 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. +- **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 e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. 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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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. Um `share_file` que deu certo (8.4) não vira linha: cada arquivo vira um cartão no chat, com o quadrado do tipo (ou a miniatura de uma imagem de até 8 MB, lida com `files.read`), nome, extensão e tamanho, "Abrir" e "Salvar como…" (`save_file_as`, 15.2). Um clique no arquivo abre a prévia no painel. Num chat estreito, o botão de salvar fica só com o ícone. Se a resposta da tool não puder ser lida, ou se a chamada falhou, fica a linha da ferramenta, com o erro. Detalhes, arquivos, o navegador (21.8), as telas (22.5) e o terminal (abaixo) 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; o chat toma a largura nova de uma vez, e fechando o painel sai deslizando por cima dele. Só um `transform` se move, para a conversa não ser refeita a cada quadro (a 180 Hz, cada quadro tem 5,5 ms). 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. ### 15.2 Comandos Tauri @@ -1386,7 +1388,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] - No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo"; com o navegador descansando (21.2), "Em descanso", e a dica diz que ele não gasta o computador e volta na hora em que o bot ou o dono precisar. A tela mostra o quadro tirado ao descansar. Com o navegador aberto e em uso e o painel fechado, o botão ganha um ponto que pulsa; descansando, não. O painel abre sozinho quando o navegador do bot sobe, quando acorda do descanso para o bot e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). Quando o navegador entra em descanso, o painel fecha na hora, deslizando para a direita, e o chat volta a ocupar o espaço; aberto de novo pelo dono, mostra "Em descanso". Nas mãos do dono, ou com um pedido de mão aberto, ele fica. As duas coisas seguem a escolha "Abrir o navegador e as telas quando um bot começar a usar" (15.1): desligada, o painel só abre e fecha pelo botão. - O painel divide o lugar com detalhes e arquivos (15.1) e é redimensionável como eles (`botloft.panel.browser`, 560 px de início), com um botão para ocupar todo o espaço que o chat pode ceder. - Em cima, as abas: uma por página aberta, na ordem em que abriram, com o título e, embaixo dele, o site (ou o nome do arquivo); a ativa em destaque, "Nova aba" numa aba em branco, e **+** no fim. Não cabendo, a fila rola para o lado. Sem o controle elas só mostram o que o bot abriu, e a dica diz para assumir o controle; no controle, um clique troca de aba e **+** abre uma nova, já com o cursor na barra do endereço (21.10). -- Depois, a barra do endereço: **Recarregar** (a seta circular), que vale a qualquer momento (21.10); o endereço, com o cadeado ou a roda de carregando; e "Abrir no meu navegador" (`open_url`, 15.2). O endereço é só leitura e selecionável; no controle vira um campo: Enter leva a aba até o que o dono digitou (`browser.open`), Esc desfaz. Embaixo, a tela ao vivo ocupa o espaço que sobra no painel: o app mede o painel abaixo da barra do endereço, tira a mesa em volta (14 px dos lados e em cima, 30 px embaixo) e o lugar da pílula e da legenda (56 px) e manda o resto ao daemon (`browser.resize`, 300 ms depois da última mudança, e de novo ao voltar a assistir); cada quadro aparece no maior tamanho que cabe, na proporção da página. Arrastar a borda do painel, alargá-lo ou mudar o tamanho da janela muda a página junto. O que entra e sai do painel (o pedido de ajuda e as linhas embaixo da pílula, 21.10) não entra na conta: a página nunca muda de tamanho por causa disso, só a imagem encolhe um pouco para caber. Sobre a tela, o cursor do bot (a seta, na cor dele) indo até cada ponto de `browser.action`, um anel no clique e uma legenda curta do que ele fez ("Clicou em Entrar", "Escreveu em E-mail", "Abriu exemplo.com"). +- Depois, a barra do endereço: **Recarregar** (a seta circular), que vale a qualquer momento (21.10); o endereço, com o cadeado ou a roda de carregando; e "Abrir no meu navegador" (`open_url`, 15.2). O endereço é só leitura e selecionável; no controle vira um campo: Enter leva a aba até o que o dono digitou (`browser.open`), Esc desfaz. Embaixo, a tela ao vivo ocupa o espaço que sobra no painel: o app mede o painel abaixo da barra do endereço, tira a mesa em volta (14 px dos lados e em cima, 30 px embaixo) o lugar da doca (56 px, 15.1) e o da pílula e da legenda (56 px) e manda o resto ao daemon (`browser.resize`, 300 ms depois da última mudança, e de novo ao voltar a assistir); cada quadro aparece no maior tamanho que cabe, na proporção da página. Arrastar a borda do painel, alargá-lo ou mudar o tamanho da janela muda a página junto. O que entra e sai do painel (o pedido de ajuda e as linhas embaixo da pílula, 21.10) não entra na conta: a página nunca muda de tamanho por causa disso, só a imagem encolhe um pouco para caber. Sobre a tela, o cursor do bot (a seta, na cor dele) indo até cada ponto de `browser.action`, um anel no clique e uma legenda curta do que ele fez ("Clicou em Entrar", "Escreveu em E-mail", "Abriu exemplo.com"). - A tela fica sobre uma mesa na cor do bot, para o painel ler como o computador dele: um degradê da cor misturada com o fundo do painel (suave no tema claro, fundo no escuro), com a página como uma janela com sombra. Com o navegador fechado, a mesa perde a cor junto com o último quadro. - Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado". Em `failed`, o motivo em "Details". - No chat, a linha de uma tool `browser_*` ganha o ícone do globo e o botão "Ver no navegador", que abre o painel.