diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 57e82be4..6b4a5578 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod claude; mod daemon; mod open; +mod save; mod sign_in; #[cfg(unix)] mod terminal; @@ -118,6 +119,13 @@ async fn reveal_file(path: String) -> Result<(), String> { .await } +/// Saves a copy of a bot's file where the owner picks with the system's +/// Save dialog. Returns false when they cancel. +#[tauri::command] +async fn save_file_as(app: tauri::AppHandle, path: String) -> Result { + blocking(move || save::copy_as(&app, Path::new(&path))).await +} + /// Opens Botloft when the owner signs in to Windows, or stops doing so. #[tauri::command] async fn open_at_sign_in(on: bool) -> Result<(), String> { @@ -184,6 +192,7 @@ pub fn run() { open_path, open_file, reveal_file, + save_file_as, open_url, open_at_sign_in, launched_at_sign_in diff --git a/app/src-tauri/src/save.rs b/app/src-tauri/src/save.rs new file mode 100644 index 00000000..b6e0666f --- /dev/null +++ b/app/src-tauri/src/save.rs @@ -0,0 +1,47 @@ +//! Saving a copy of a bot's file where the owner picks (spec 15.2). The +//! system's Save dialog chooses the place, so a page can never write a file +//! anywhere the owner did not pick. + +use std::path::Path; + +use tauri::{AppHandle, Manager as _}; +use tauri_plugin_dialog::DialogExt as _; + +/// Asks where to save a copy of `path` and copies it there. `Ok(false)` +/// when the owner cancels. +pub fn copy_as(app: &AppHandle, path: &Path) -> Result { + if !path.is_absolute() || !path.is_file() { + return Err(format!("{} is not a file", path.display())); + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut dialog = app.dialog().file().set_file_name(&name); + if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) { + dialog = dialog.add_filter(extension.to_uppercase(), &[extension]); + } + if let Some(window) = app.get_webview_window("main") { + dialog = dialog.set_parent(&window); + } + let Some(chosen) = dialog.blocking_save_file() else { + return Ok(false); + }; + let target = chosen.into_path().map_err(|err| err.to_string())?; + // The system dialog already asked before replacing a file; copying a + // file onto itself would empty it. + if same_file(path, &target) { + return Ok(true); + } + std::fs::copy(path, &target) + .map(drop) + .map_err(|err| format!("could not save {}: {err}", target.display()))?; + Ok(true) +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index f5af5f08..0c6cac80 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -97,11 +97,19 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { writtenByBot: true, at: fake.now, }); - made.add(crew.scout, "papers.csv", { + const papers = made.add(crew.scout, "papers.csv", { text: "paper,pages,deep_read\nRetrieval at scale,14,yes\nAgents that plan,22,yes\nSmall evals,9,maybe\n", at: fake.now + MINUTE, }); - made.add(crew.scout, "week-39-report.docx", { text: "docx", at: fake.now + 2 * MINUTE }); + const report = made.add(crew.scout, "week-39-report.docx", { + text: "docx", + at: fake.now + 2 * MINUTE, + }); + const shared = chat.tool(crew.scout, "mcp__botloft__share_file", { + summary: "week-39-report.docx, papers.csv", + input: JSON.stringify({ files: [report.path, papers.path] }), + }); + chat.finish(shared, JSON.stringify({ shown: [report, papers] })); at(92); chat.reply(crew.scout, SUMMARY); const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", { diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index f7aa4754..feb305a5 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -2,7 +2,7 @@ import { X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; -import type { Bot, Crew } from "../../lib/protocol.gen"; +import type { Bot, BotFile, Crew } from "../../lib/protocol.gen"; import { useWindowVisible } from "../../shell/visibility"; import { type BotPanel, routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; @@ -74,6 +74,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setSince(now); }, [bot.id]); const [shown, setShown] = useState(null); + // A file shared in the chat, for when the panel's list leaves it out. + const [described, setDescribed] = useState(null); // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's file is not this bot's useEffect(() => setShown(null), [bot.id]); const filesOpen = side === "files"; @@ -90,12 +92,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { // A tool line in the chat points to its file: read the list first, so the // file is in it when the panel goes to it. // Stable, so the chat does not re-render when this view does. - const showFile = useStable(async (path: string) => { + const showFile = useStable(async (path: string, file?: BotFile) => { if (!filesOpen) { seen(); } setSide("files"); await files.refresh(); + setDescribed(file ?? null); setShown(path); }); const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; @@ -213,6 +216,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { data={files} since={since} path={shown} + described={described} onPath={setShown} onClose={toggleFiles} /> diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 2916d9c2..453e2310 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -12,6 +12,8 @@ import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; import { runParts, splitDraft } from "./rows"; +import { SharedFiles } from "./SharedFiles"; +import { isSharedFiles } from "./shared"; import { ToolLines } from "./ToolLines"; export interface Live { @@ -124,7 +126,11 @@ function BotRunView({ const head = part[0] as ChatItem; switch (head.body.kind) { case "tool": - return ; + return isSharedFiles(head) ? ( + + ) : ( + + ); case "reply": // It was already on screen as it was written. return ; diff --git a/app/src/features/chat/SharedFiles.tsx b/app/src/features/chat/SharedFiles.tsx new file mode 100644 index 00000000..f8afc26a --- /dev/null +++ b/app/src/features/chat/SharedFiles.tsx @@ -0,0 +1,118 @@ +// Files the bot shared with `share_file` (spec 10, 15.3): one card each, +// with the file's tile or picture, its name, kind and size, and buttons to +// preview it, open it and save a copy anywhere. + +import { Download, ExternalLink } from "lucide-react"; +import { useContext } from "react"; +import { useT } from "../../i18n"; +import { fileSize } from "../../lib/format"; +import type { BotFile, ChatItem } from "../../lib/protocol.gen"; +import { useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { useArrival } from "../../ui/motion"; +import { attempt } from "../../ui/toast"; +import { fileKind, kindIcon } from "../files/kinds"; +import { ShowFile } from "../files/showFile"; +import { useFileImage } from "./images"; +import { sharedFiles } from "./shared"; + +/** "PDF" from report.pdf; nothing for a name without one. */ +function extension(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toUpperCase() : ""; +} + +function Picture({ botId, file }: { botId: string; file: BotFile }) { + const source = useFileImage(botId, file); + if (source.kind !== "ready") { + return null; + } + return ( + {file.name} + ); +} + +function FileCard({ botId, file }: { botId: string; file: BotFile }) { + const t = useT().files; + const host = useHost(); + const showFile = useContext(ShowFile); + const kind = fileKind(file.mediaType, file.name); + const Icon = kindIcon(kind); + const what = [extension(file.name), fileSize(file.size)].filter(Boolean).join(" · "); + const about = ( + <> + + + + + + {file.name} + + {what} + + + ); + return ( +
+ +
+ {/* Where there is a files panel, the file itself opens its preview. */} + {showFile ? ( + + ) : ( +
{about}
+ )} + +
+
+ ); +} + +export function SharedFiles({ item, botId, bot }: { item: ChatItem; botId: string; bot: string }) { + const t = useT().files; + const arrival = useArrival(item.createdAt); + if (item.body.kind !== "tool") { + return null; + } + const files = sharedFiles(item.body); + if (files.length === 0) { + return null; + } + return ( +
    + {files.map((file) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/app/src/features/chat/images.ts b/app/src/features/chat/images.ts index 70db5f70..674b297d 100644 --- a/app/src/features/chat/images.ts +++ b/app/src/features/chat/images.ts @@ -1,10 +1,11 @@ // Images the owner attached, as data URLs for thumbnails. What the app sent // in this session is known already; the rest is read back from the bot's -// folder with `attachments.read` (spec 9.5). The CSP allows `data:` images -// only, so no blob URLs. +// folder with `attachments.read` (spec 9.5). Images a bot shares are read +// with `files.read` (spec 8.4). The CSP allows `data:` images only, so no +// blob URLs. import { useEffect, useState } from "react"; -import type { Attachment } from "../../lib/protocol.gen"; +import type { Attachment, BotFile } from "../../lib/protocol.gen"; import { useApi } from "../../store/context"; /** Types the webview shows safely as ``. */ @@ -23,10 +24,10 @@ export function dataUrl(mediaType: string, base64: string): string { return `data:${mediaType};base64,${base64}`; } -/** Remembers an image the app has the bytes of. */ -export function rememberImage(attachmentId: string, url: string): void { - cache.delete(attachmentId); - cache.set(attachmentId, url); +/** Remembers an image the app has the bytes of, by attachment id or file. */ +export function rememberImage(key: string, url: string): void { + cache.delete(key); + cache.set(key, url); while (cache.size > CACHE_MAX) { const oldest = cache.keys().next().value; if (oldest === undefined) { @@ -76,3 +77,39 @@ export function useImage(attachment: Attachment): ImageSource { return source; } + +/** The thumbnail of a file a bot shared, read again when the file changes. */ +export function useFileImage(botId: string, file: BotFile): ImageSource { + const api = useApi(); + const shown = isImage(file.mediaType) && file.size <= THUMBNAIL_MAX_BYTES; + const key = `file:${file.path}:${file.modifiedAt}`; + const [source, setSource] = useState(() => { + const known = cache.get(key); + if (known) { + return { kind: "ready", url: known }; + } + return shown ? { kind: "loading" } : { kind: "none" }; + }); + + useEffect(() => { + if (!shown || cache.has(key)) { + return; + } + let alive = true; + api.call("files.read", { botId, path: file.path }).then( + (read) => { + const url = dataUrl(read.mediaType, read.data); + rememberImage(key, url); + if (alive) { + setSource({ kind: "ready", url }); + } + }, + () => alive && setSource({ kind: "missing" }), + ); + return () => { + alive = false; + }; + }, [api, botId, file.path, key, shown]); + + return source; +} diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index 91b2bbc8..5d3e9efa 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -3,6 +3,7 @@ // line starts every day. import type { ChatItem, Message, NoticeItem } from "../../lib/protocol.gen"; +import { isSharedFiles } from "./shared"; export type Row = | { kind: "day"; key: string; at: number } @@ -48,12 +49,20 @@ export function chatRows(items: ChatItem[]): Row[] { return rows; } -/** Splits a run into replies, approvals and turns alone, and tool calls together. */ +/** Tool calls that run together as lines; shared files are cards of their own. */ +function isLine(item: ChatItem | undefined): boolean { + return item?.body.kind === "tool" && !isSharedFiles(item); +} + +/** + * Splits a run into replies, approvals, shared files and turns alone, and + * tool calls together. + */ export function runParts(items: ChatItem[]): ChatItem[][] { const parts: ChatItem[][] = []; for (const item of items) { const last = parts.at(-1); - if (item.body.kind === "tool" && last?.[0]?.body.kind === "tool") { + if (last && isLine(item) && isLine(last[0])) { last.push(item); } else { parts.push([item]); diff --git a/app/src/features/chat/shared.ts b/app/src/features/chat/shared.ts new file mode 100644 index 00000000..e41ac874 --- /dev/null +++ b/app/src/features/chat/shared.ts @@ -0,0 +1,39 @@ +// What a `share_file` call shared (spec 10): the daemon describes each file +// in the tool's answer, as the files panel lists it. + +import type { BotFile, ChatItem, ToolItem } from "../../lib/protocol.gen"; +import { toolKey } from "./toolNames"; + +function isBotFile(value: unknown): value is BotFile { + const file = value as Partial | null; + return ( + typeof file?.path === "string" && + typeof file.name === "string" && + typeof file.mediaType === "string" && + typeof file.size === "number" + ); +} + +/** The files the daemon described in the tool's answer. */ +export function sharedFiles(tool: ToolItem): BotFile[] { + try { + const answer: unknown = JSON.parse(tool.output ?? ""); + const shown = (answer as { shown?: unknown } | null)?.shown; + return Array.isArray(shown) ? shown.filter(isBotFile) : []; + } catch { + return []; + } +} + +/** + * A `share_file` call that worked shows as file cards, not as a tool line. + * One whose answer cannot be read stays a tool line, where it can be opened. + */ +export function isSharedFiles(item: ChatItem): boolean { + return ( + item.body.kind === "tool" && + toolKey(item.body.name) === "share_file" && + item.body.status === "done" && + sharedFiles(item.body).length > 0 + ); +} diff --git a/app/src/features/chat/sharedFiles.test.tsx b/app/src/features/chat/sharedFiles.test.tsx new file mode 100644 index 00000000..e77e1610 --- /dev/null +++ b/app/src/features/chat/sharedFiles.test.tsx @@ -0,0 +1,99 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import type { BotFile } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { runParts } from "./rows"; + +afterEach(cleanup); + +/** A crew "Ops" with @scout, idle. */ +function crew() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + return { fake, scout }; +} + +/** Scout shares `files` with `share_file`, as the daemon answers it. */ +function share(fake: FakeBotloft, botId: string, files: BotFile[], failed = false) { + const call = fake.chat.tool(botId, "mcp__botloft__share_file", { + summary: files.map((file) => file.name).join(", "), + input: JSON.stringify({ files: files.map((file) => file.path) }), + }); + const answer = failed + ? "There is no file at C:\\Work\\gone.pdf" + : JSON.stringify({ shown: files }); + return fake.chat.finish(call, answer, failed); +} + +async function openScout(fake: FakeBotloft) { + const rendered = renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return rendered; +} + +const cards = () => screen.findByRole("list", { name: "Files shared by Scout" }); + +describe("shared files", () => { + test("show as cards that open, save a copy and preview", async () => { + const { fake, scout } = crew(); + const report = fake.files.add(scout.id, "report.pdf", { text: "%PDF-1.7", folder: "final" }); + const notes = fake.files.add(scout.id, "notes.md", { text: "# Notes" }); + share(fake, scout.id, [report, notes]); + const { host } = await openScout(fake); + + const list = await cards(); + const items = within(list).getAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(items[0]?.textContent).toContain("report.pdf"); + expect(items[0]?.textContent).toContain("PDF · 8 B"); + // A card, not a tool line. + expect(screen.queryByRole("button", { name: /Share a file/ })).toBeNull(); + + const first = within(items[0] as HTMLElement); + fireEvent.click(first.getByRole("button", { name: "Save as…" })); + fireEvent.click(first.getByRole("button", { name: "Open" })); + await waitFor(() => expect(host.savedFiles).toEqual([report.path])); + expect(host.openedFiles).toEqual([report.path]); + + fireEvent.click(within(items[1] as HTMLElement).getByRole("button", { name: /^Preview/ })); + const panel = await screen.findByRole("complementary", { name: "Files from Scout" }); + expect(await within(panel).findByRole("heading", { name: "Notes" })).toBeDefined(); + }); + + test("a file the panel's list leaves out still previews from the card", async () => { + const { fake, scout } = crew(); + // The list skips files older than the bot; the card still knows it. + const old = fake.files.add(scout.id, "old.md", { text: "# Older than the bot", listed: false }); + share(fake, scout.id, [old]); + await openScout(fake); + + const list = await cards(); + fireEvent.click(within(list).getByRole("button", { name: /^Preview/ })); + const panel = await screen.findByRole("complementary", { name: "Files from Scout" }); + expect(await within(panel).findByRole("heading", { name: "Older than the bot" })).toBeDefined(); + }); + + test("a share that failed stays a tool line with its error", async () => { + const { fake, scout } = crew(); + const gone = fake.files.add(scout.id, "gone.pdf"); + share(fake, scout.id, [gone], true); + await openScout(fake); + expect(await screen.findByRole("button", { name: /Share a file/ })).toBeDefined(); + expect(screen.queryByRole("list", { name: "Files shared by Scout" })).toBeNull(); + }); + + test("shared files stand apart from the tool lines around them", () => { + const { fake, scout } = crew(); + const file = fake.files.add(scout.id, "a.csv", { text: "a" }); + const read = fake.chat.finish(fake.chat.tool(scout.id, "Read"), "ok"); + const shared = share(fake, scout.id, [file]); + const grep = fake.chat.finish(fake.chat.tool(scout.id, "Grep"), "ok"); + const write = fake.chat.finish(fake.chat.tool(scout.id, "Write"), "ok"); + expect(runParts([read, shared, grep, write]).map((part) => part.length)).toEqual([1, 1, 2]); + }); +}); diff --git a/app/src/features/files/FilesPanel.tsx b/app/src/features/files/FilesPanel.tsx index 32226198..e4aee84c 100644 --- a/app/src/features/files/FilesPanel.tsx +++ b/app/src/features/files/FilesPanel.tsx @@ -19,6 +19,7 @@ export function FilesPanel({ data, since, path, + described, onPath, onClose, }: { @@ -28,11 +29,15 @@ export function FilesPanel({ since: number; /** The file shown, or null for the list. */ path: string | null; + /** The file at `path` as the chat described it, if the list leaves it out. */ + described?: BotFile | null; onPath(path: string | null): void; onClose(): void; }) { const t = useT().files; - const chosen = data.files.find((file) => file.path === path); + const chosen = + data.files.find((file) => file.path === path) ?? + (described?.path === path ? described : undefined); return ( diff --git a/app/src/features/files/showFile.ts b/app/src/features/files/showFile.ts index 25c0dae1..66a04537 100644 --- a/app/src/features/files/showFile.ts +++ b/app/src/features/files/showFile.ts @@ -1,6 +1,8 @@ -// How a tool line in the chat asks for its file to be shown in the files -// panel (spec 15.1). Absent where there is no panel, so no button shows. +// How the chat asks for a file to be shown in the files panel (spec 15.1). +// Absent where there is no panel, so no button shows. A file the chat +// already describes comes along, for one the panel's list leaves out. import { createContext } from "react"; +import type { BotFile } from "../../lib/protocol.gen"; -export const ShowFile = createContext<((path: string) => void) | null>(null); +export const ShowFile = createContext<((path: string, file?: BotFile) => void) | null>(null); diff --git a/app/src/i18n/en/files.ts b/app/src/i18n/en/files.ts index 12c6a0e4..9a26ca6a 100644 --- a/app/src/i18n/en/files.ts +++ b/app/src/i18n/en/files.ts @@ -18,9 +18,14 @@ export const files = { back: "All files", open: "Open", reveal: "Show in folder", + shared: (bot: string) => `Files shared by ${bot}`, + save: "Save as…", + saveOne: (name: string) => `Save a copy of ${name}`, + previewOne: "Preview", failed: { open: "Could not open the file", reveal: "Could not show the file", + save: "Could not save the file", }, preview: { loading: "Loading…", diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index 71d9f3d6..26e03557 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -33,6 +33,7 @@ export const tools = { my_tasks: "See its tasks", suggest_bot: "Suggest a bot", schedule_routine: "Set up a routine", + share_file: "Share a file", browser: "Use a site", browser_help: "Ask for your hand", browser_open: "Open a page", diff --git a/app/src/i18n/es/files.ts b/app/src/i18n/es/files.ts index df10fb94..0aa5ec32 100644 --- a/app/src/i18n/es/files.ts +++ b/app/src/i18n/es/files.ts @@ -20,9 +20,14 @@ export const files: Messages["files"] = { back: "Todos los archivos", open: "Abrir", reveal: "Mostrar en la carpeta", + shared: (bot) => `Archivos compartidos por ${bot}`, + save: "Guardar como…", + saveOne: (name) => `Guardar una copia de ${name}`, + previewOne: "Ver", failed: { open: "No se pudo abrir el archivo", reveal: "No se pudo mostrar el archivo", + save: "No se pudo guardar el archivo", }, preview: { loading: "Cargando…", diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index 24bef187..04388b93 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -34,6 +34,7 @@ export const tools: Messages["tools"] = { my_tasks: "Ver sus tareas", suggest_bot: "Sugerir un bot", schedule_routine: "Crear una rutina", + share_file: "Compartir un archivo", browser: "Usar un sitio", browser_help: "Pedir tu ayuda", browser_open: "Abrir una página", diff --git a/app/src/i18n/pt-BR/files.ts b/app/src/i18n/pt-BR/files.ts index e719d9cf..06ebe4e6 100644 --- a/app/src/i18n/pt-BR/files.ts +++ b/app/src/i18n/pt-BR/files.ts @@ -20,9 +20,14 @@ export const files: Messages["files"] = { back: "Todos os arquivos", open: "Abrir", reveal: "Mostrar na pasta", + shared: (bot) => `Arquivos compartilhados por ${bot}`, + save: "Salvar como…", + saveOne: (name) => `Salvar uma cópia de ${name}`, + previewOne: "Ver", failed: { open: "Não foi possível abrir o arquivo", reveal: "Não foi possível mostrar o arquivo", + save: "Não foi possível salvar o arquivo", }, preview: { loading: "Carregando…", diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index a52333ab..9ce0f8c7 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -33,6 +33,7 @@ export const tools: Messages["tools"] = { my_tasks: "Ver as próprias tarefas", suggest_bot: "Sugerir um bot", schedule_routine: "Criar uma rotina", + share_file: "Compartilhar um arquivo", browser: "Usar um site", browser_help: "Pedir sua ajuda", browser_open: "Abrir uma página", diff --git a/app/src/lib/fakeFiles.ts b/app/src/lib/fakeFiles.ts index b491fb75..f8b1edd4 100644 --- a/app/src/lib/fakeFiles.ts +++ b/app/src/lib/fakeFiles.ts @@ -17,17 +17,26 @@ const TYPES: Record = { export class FakeFiles { private readonly files = new Map(); private readonly contents = new Map(); + /** Readable but not listed, like a file older than the bot (spec 8.4). */ + private readonly unlisted = new Set(); constructor(private readonly fake: FakeBotloft) {} /** * A file the bot made. `text` is what its preview shows; `folder` is - * relative to the work folder. + * relative to the work folder. `listed: false` leaves it out of the + * list, as the daemon does with a file older than the bot. */ add( botId: BotId, name: string, - options: { text?: string; folder?: string; writtenByBot?: boolean; at?: number } = {}, + options: { + text?: string; + folder?: string; + writtenByBot?: boolean; + at?: number; + listed?: boolean; + } = {}, ): BotFile { this.fake.bot(botId); const folder = options.folder ?? ""; @@ -43,6 +52,9 @@ export class FakeFiles { }; const list = (this.files.get(botId) ?? []).filter((other) => other.path !== file.path); this.files.set(botId, [file, ...list]); + if (options.listed === false) { + this.unlisted.add(file.path); + } this.contents.set(file.path, { mediaType: file.mediaType, data: encodeBytes(new TextEncoder().encode(text)), @@ -54,7 +66,9 @@ export class FakeFiles { return { "files.list": ({ botId }) => { this.fake.bot(botId, false); - return [...(this.files.get(botId) ?? [])].sort((a, b) => b.modifiedAt - a.modifiedAt); + return (this.files.get(botId) ?? []) + .filter((file) => !this.unlisted.has(file.path)) + .sort((a, b) => b.modifiedAt - a.modifiedAt); }, "files.read": ({ botId, path }) => { const known = (this.files.get(botId) ?? []).some((file) => file.path === path); diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index ee283fb6..70dc0ab5 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -26,6 +26,9 @@ export class FakeHost implements Host { /** Files opened with their program, and files shown in their folder. */ readonly openedFiles: string[] = []; readonly revealed: string[] = []; + /** Files saved as a copy, and whether the Save dialog picks a place. */ + readonly savedFiles: string[] = []; + saveChosen = true; /** What the folder picker gives back; null is a cancel. */ nextFolder: string | null = null; /** Where each folder picker started. */ @@ -169,6 +172,11 @@ export class FakeHost implements Host { return Promise.resolve(); } + saveFileAs(path: string): Promise { + this.savedFiles.push(path); + return Promise.resolve(this.saveChosen); + } + pickFolder(_title: string, start?: string): Promise { this.pickerStarts.push(start ?? null); return Promise.resolve(this.nextFolder); diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index c4375c0e..ec0c6e41 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -119,6 +119,11 @@ export interface Host { openFile(path: string): Promise; /** Shows a file in its folder, selected. */ revealFile(path: string): Promise; + /** + * Saves a copy of a file where the owner picks with the system's Save + * dialog. Resolves false if they cancel. + */ + saveFileAs(path: string): Promise; /** * Asks the owner for a folder with Windows' folder picker, starting at * `start` if given. Resolves null if they cancel. diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index dfaedb2d..7ebe42af 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -100,6 +100,7 @@ export function tauriHost(): Host { openPath: (path) => invoke("open_path", { path }), openFile: (path) => invoke("open_file", { path }), revealFile: (path) => invoke("reveal_file", { path }), + saveFileAs: (path) => invoke("save_file_as", { path }), pickFolder: async (title, start) => { const picked = await open({ directory: true, diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 26ab3567..6c37c7ba 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -109,6 +109,14 @@ pub fn tool_summary(name: &str, input: &Value) -> String { field(input, "task").map(str::to_owned) } "mcp__botloft__browser_open" => field(input, "url").map(str::to_owned), + "mcp__botloft__share_file" => input.get("files").and_then(Value::as_array).map(|files| { + let names: Vec = files + .iter() + .filter_map(Value::as_str) + .map(file_name) + .collect(); + names.join(", ") + }), // Element refs ("e12") mean nothing to the owner, and a direction // needs words: the app says those (spec 21.8). "mcp__botloft__browser_press" => field(input, "key").map(str::to_owned), @@ -239,6 +247,13 @@ mod tests { "notebook jupyter" ); assert_eq!(tool_summary("SomethingNew", &json!({ "a": 1 })), ""); + assert_eq!( + tool_summary( + "mcp__botloft__share_file", + &json!({ "files": [r"C:\work\report.pdf", "out/chart.png"] }) + ), + "report.pdf, chart.png" + ); assert_eq!( tool_summary( "mcp__botloft__browser_open", diff --git a/crates/botloftd/src/service/files/mod.rs b/crates/botloftd/src/service/files/mod.rs index 684d5fd6..6aa85140 100644 --- a/crates/botloftd/src/service/files/mod.rs +++ b/crates/botloftd/src/service/files/mod.rs @@ -121,14 +121,18 @@ fn file_name(path: &Path) -> String { .map_or_else(String::new, |name| name.to_string_lossy().into_owned()) } -/// `files.read`: the bytes of a file `files.list` can show. A path outside -/// the bot's folders that the bot did not write is refused, so the panel -/// cannot be used to read anything else on the computer. -pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { - let (_, places) = places(daemon, ¶ms.bot_id)?; - let asked = PathBuf::from(¶ms.path); - let gone = || ApiError::NotFound(format!("{} is no longer there", params.path)); - let path = asked.canonicalize().map_err(|_| gone())?; +/// Why a path is not one of the bot's files. +enum Refused { + /// Nothing is there, or it is not a file. + Gone, + /// Outside the bot's folders, and the bot did not write it. + Outside, +} + +/// The real path of one of the bot's files: inside one of its two folders, +/// or written by its own calls. `asked` must be absolute. +fn reach(places: &Places, asked: &Path) -> Result<(PathBuf, std::fs::Metadata), Refused> { + let path = asked.canonicalize().map_err(|_| Refused::Gone)?; let inside = [&places.work, &places.workspace] .into_iter() .filter_map(|root| root.canonicalize().ok()) @@ -139,15 +143,28 @@ pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { .filter_map(|file| Path::new(file).canonicalize().ok()) .any(|file| file == path); if !asked.is_absolute() || !(inside || written) { - return Err(ApiError::NotFound(format!( - "{} is not one of this bot's files", - params.path - ))); + return Err(Refused::Outside); } - let meta = std::fs::metadata(&path).map_err(|_| gone())?; + let meta = std::fs::metadata(&path).map_err(|_| Refused::Gone)?; if !meta.is_file() { - return Err(gone()); + return Err(Refused::Gone); } + Ok((path, meta)) +} + +/// `files.read`: the bytes of a file `files.list` can show. A path outside +/// the bot's folders that the bot did not write is refused, so the panel +/// cannot be used to read anything else on the computer. +pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { + let (_, places) = places(daemon, ¶ms.bot_id)?; + let gone = || ApiError::NotFound(format!("{} is no longer there", params.path)); + let (path, meta) = + reach(&places, Path::new(¶ms.path)).map_err(|refused| match refused { + Refused::Gone => gone(), + Refused::Outside => { + ApiError::NotFound(format!("{} is not one of this bot's files", params.path)) + } + })?; if meta.len() > PREVIEW_MAX_BYTES { return Err(ApiError::validation(format!( "the file is larger than {} MB", @@ -160,3 +177,30 @@ pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { data: BASE64.encode(bytes), }) } + +/// A file the bot shows the owner in the chat (`share_file`, spec 10): one +/// `files.read` can read, so the owner can preview and save it. A relative +/// path is in the bot's own folder, where it runs. +pub fn shared(daemon: &Daemon, bot_id: &BotId, path: &str) -> ApiResult { + let (_, places) = places(daemon, bot_id)?; + let asked = std::path::absolute(places.workspace.join(path.trim())) + .map_err(|_| ApiError::NotFound(format!("{path} is not a file path")))?; + let (_, meta) = reach(&places, &asked).map_err(|refused| { + ApiError::NotFound(match refused { + Refused::Gone => format!("There is no file at {}", asked.display()), + Refused::Outside => format!( + "{} is outside the work folder and your own folder: copy it into the work \ + folder first, then share the copy", + asked.display() + ), + }) + })?; + let written = keys(&places.written).contains(&key(&asked)); + let found = Found { + size: meta.len(), + modified_at: millis(&meta), + path: asked, + }; + let folders = [places.work.as_path(), places.workspace.as_path()]; + Ok(describe(found, written, &folders)) +} diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 6afe4f5d..481e0d30 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -16,6 +16,7 @@ use tracing::warn; use super::Failure; use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE}; +use super::share::{self, SHARE_FILE}; use crate::service::tasks::{self, BotMessage}; use crate::service::{ApiError, bots, lead}; use crate::state::Daemon; @@ -36,6 +37,7 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result parse(arguments).and_then(|args| send(daemon, bot, args)), COMPLETE_TASK => parse(arguments).and_then(|args| complete(daemon, bot, args)), MY_TASKS => parse(arguments).and_then(|args| my_tasks(daemon, bot, args)), + SHARE_FILE => parse(arguments).and_then(|args| share::share(daemon, bot, args)), other => return Err(invalid(&format!("Unknown tool: {other}"))), }; Ok(tool_result(outcome)) diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 60c1079f..b96e8cc6 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -24,7 +24,8 @@ pub fn tools() -> Value { if let Value::Array(list) = &mut tools { // Before permission_prompt, which stays last. let at = list.len() - 1; - list.splice(at..at, super::browser_catalog::tools()); + let more = std::iter::once(super::share::tool()).chain(super::browser_catalog::tools()); + list.splice(at..at, more); } tools } diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index 4ab23ce2..5c4984ef 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -16,6 +16,7 @@ mod calls; mod catalog; mod era; mod routine; +mod share; mod suggest; use std::sync::Arc; @@ -40,7 +41,8 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \ - suggest new bots. schedule_routine asks the owner for work at set times. The browser_ tools drive your own web browser, which the owner can watch \ + suggest new bots. schedule_routine asks the owner for work at set times. share_file shows the \ + owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \ live and take over when you ask with browser_ask_owner. The owner writes to you directly; messages from other bots and from Botloft start with \ [botloft]."; diff --git a/crates/botloftd/src/tools/share.rs b/crates/botloftd/src/tools/share.rs new file mode 100644 index 00000000..96e1b05e --- /dev/null +++ b/crates/botloftd/src/tools/share.rs @@ -0,0 +1,67 @@ +//! `share_file` (spec 10): the bot shows the owner files it made, as cards +//! in the chat they can open and save. The card is the tool's own chat +//! item; this only checks the files and describes them. + +use botloft_core::ids::BotId; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::explain; +use crate::service::files; +use crate::state::Daemon; + +pub const SHARE_FILE: &str = "share_file"; +/// Files in one card group, as many as the owner can attach to a message. +pub const SHARE_MAX: usize = 10; + +pub(super) fn tool() -> Value { + json!({ + "name": SHARE_FILE, + "title": "Share a file", + "description": "Shows the owner files you made, or that they ask you for, as cards in \ + your chat: they can preview each one, open it and save a copy anywhere. Use it \ + whenever you finish a file for the owner, and when they ask for one again, instead of \ + writing its path in your reply. Only files in the crew's work folder or your own \ + folder, or files you wrote, can be shared.", + "inputSchema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": SHARE_MAX, + "description": "Paths of the files: absolute, or relative to your own \ + folder. Write them with forward slashes (C:/folder/file.md).", + }, + }, + "required": ["files"], + "additionalProperties": false, + }, + "annotations": { "readOnlyHint": true }, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ShareArgs { + files: Vec, +} + +/// Every file must be one the owner can read; otherwise none is shown and +/// the bot reads which one and why. +pub(super) fn share(daemon: &Daemon, bot: &BotId, args: ShareArgs) -> Result { + if args.files.is_empty() || args.files.len() > SHARE_MAX { + return Err(format!("files takes 1 to {SHARE_MAX} paths")); + } + let shown = args + .files + .iter() + .map(|path| files::shared(daemon, bot, path).map_err(explain)) + .collect::, _>>()?; + Ok(json!({ + "shown": shown, + "note": "The owner sees each file as a card in your chat, with buttons to open and save \ + it. You don't need to repeat the paths in your reply.", + })) +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index d7319e41..442fa86c 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -121,6 +121,10 @@ app; the other bots of your crew send you messages too. their chat as markdown: keep them short and clear, and say what you did. - Files the owner sends are saved in `attachments/` in this folder; the message lists them. Images also come inline. +- When you finish a file for the owner (a report, a spreadsheet, an image), + call `share_file` with its path: they get a card in the chat to preview, + open and save it. Do the same when they ask again for a file you made. + Don't write the path in your reply instead. - When you need a permission, the owner gets a request in the chat with Allow and Deny. If they deny it, find another way or ask them. - The owner may not be technical. Every time you run a command, fill in its diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 26c7608d..86f026a6 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -73,6 +73,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "my_tasks", "suggest_bot", "schedule_routine", + "share_file", "browser_open", "browser_look", "browser_click", @@ -128,7 +129,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(18) + Some(19) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/crates/botloftd/tests/share_file.rs b/crates/botloftd/tests/share_file.rs new file mode 100644 index 00000000..5222d99d --- /dev/null +++ b/crates/botloftd/tests/share_file.rs @@ -0,0 +1,96 @@ +//! `share_file` (spec 10): the bot shows the owner its files as cards in +//! the chat, and only files the app may read. + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use common::TestDaemon; +use common::mcp::Mcp; +use serde_json::{Value, json}; + +fn put(path: &Path, text: &str) { + fs::create_dir_all(path.parent().expect("parent")).expect("dirs"); + fs::write(path, text).expect("write"); +} + +/// A running bot, its MCP client and its two folders: its own and the crew's. +async fn one_bot() -> (TestDaemon, Mcp, PathBuf, PathBuf) { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let bot = app.call("bots.create", bot).await.expect("bot"); + let process = t.process_of(&bot).await; + let client = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + let workspace = PathBuf::from(bot["workspace"].as_str().expect("workspace")); + let work = workspace.parent().expect("crew folder").join("shared"); + (t, client, workspace, work) +} + +#[tokio::test] +async fn a_bot_shares_files_from_its_folders() { + let (_t, mut client, workspace, work) = one_bot().await; + put(&work.join("report.pdf"), "pdf!"); + put(&workspace.join("out/chart.png"), "png"); + + let shared = client + .tool( + "share_file", + json!({ "files": [work.join("report.pdf"), "out/chart.png"] }), + ) + .await + .expect("shared"); + let shown = shared["shown"].as_array().expect("shown"); + assert_eq!(shown.len(), 2); + assert_eq!(shown[0]["name"], "report.pdf"); + assert_eq!(shown[0]["mediaType"], "application/pdf"); + assert_eq!(shown[0]["size"], 4); + assert_eq!(shown[0]["folder"], ""); + // A relative path is in the bot's own folder, where it runs. + assert_eq!(shown[1]["name"], "chart.png"); + assert_eq!(shown[1]["folder"], "out"); + let chart = PathBuf::from(shown[1]["path"].as_str().expect("path")); + assert!(chart.is_absolute(), "{}", chart.display()); + assert!(chart.ends_with(Path::new("out").join("chart.png"))); +} + +#[tokio::test] +async fn nothing_outside_its_folders_is_shared() { + let (_t, mut client, _workspace, work) = one_bot().await; + put(&work.join("ok.txt"), "ok"); + let elsewhere = tempfile::tempdir().expect("tempdir"); + let private = elsewhere.path().join("private.txt"); + put(&private, "private"); + + let outside = client + .tool( + "share_file", + json!({ "files": [work.join("ok.txt"), private] }), + ) + .await + .expect_err("outside"); + assert!( + outside.contains("copy it into the work folder"), + "{outside}" + ); + + for (files, says) in [ + (json!([work.join("missing.txt")]), "There is no file"), + (json!([work]), "There is no file"), + (json!([]), "1 to 10"), + ] { + let err = client + .tool("share_file", json!({ "files": files })) + .await + .expect_err("refused"); + assert!(err.contains(says), "{err}"); + } + let unknown: Value = json!({ "files": ["ok.txt"], "caption": "x" }); + let err = client.tool("share_file", unknown).await.expect_err("args"); + assert!(err.contains("invalid arguments"), "{err}"); +} diff --git a/docs/spec.md b/docs/spec.md index 680dd66f..0a2383df 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -131,7 +131,7 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha attachments\\ arquivos que o dono mandou no chat (9.5) .claude\ settings.json regra de negação para os segredos e o que o Claude Code não carrega no bot, 7.5 (gerado pelo daemon, sobrescrito a cada start) - rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1), que agendar é rotina (20) e, no chefe, como liderar (gerado pelo daemon) + rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1), que agendar é rotina (20), que arquivo pronto para o dono vai no chat com `share_file` (10) e, no chefe, como liderar (gerado pelo daemon) .botloft\ mcp.json config MCP do bot (gerado pelo daemon) ``` @@ -369,6 +369,8 @@ O app mostra os arquivos que o bot fez num painel ao lado do chat (15.1). O daem `files.list {botId}` devolve até 200 `BotFile` (`path`, `name`, `folder` relativa à pasta de trabalho ou à do bot, `mediaType` pela extensão, `size`, `modifiedAt`, `writtenByBot`), do mais novo para o mais antigo. `files.read {botId, path}` devolve `{mediaType, data}` (base64) de um arquivo que a lista poderia mostrar: dentro de uma das duas pastas ou escrito pelo bot. Qualquer outro caminho, relativo ou que suba de pasta dá `not_found`, para o painel não ler o resto do computador. Acima de 20 MiB dá `validation`. Nenhum caminho nem conteúdo vai para o log em `info` ou acima (8.5). +**No chat:** o bot mostra ao dono um arquivo que fez, ou que o dono pediu de novo, com a tool `share_file` (10). Só vale arquivo que `files.read` leria (dentro de uma das duas pastas ou escrito pelo bot), para o cartão poder mostrar e salvar o que aponta; um caminho relativo é da pasta do bot, onde ele roda. Não há item novo: o item `tool` da chamada guarda na `output` a resposta da tool, com um `BotFile` por arquivo em `shown`, e o app desenha cartões a partir dela (15.1). Um arquivo de antes do bot, que a lista deixa de fora, continua compartilhável: o cartão leva o `BotFile` ao painel. + ### 8.5 Privacidade Itens do chat são dado pessoal como o corpo das messages: nunca vão para o log em nível `info` ou acima. O log de `debug` registra só tipos de evento e ids. @@ -527,6 +529,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `my_tasks` | `role?` (`assigned`, `requested`) | tasks `open` e `expired`: id, de, para, status, prazo relativo, hops e o pedido original | | `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | | `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) | +| `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | | `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo | | `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono | @@ -791,7 +794,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. 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. +- **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 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. ### 15.2 Comandos Tauri @@ -814,6 +817,7 @@ Layout, como um app de mensagens: | seletor de pastas | não é comando próprio: o app usa o `tauri-plugin-dialog` (`open({directory: true})`, permissão `dialog:allow-open`) para a pasta de trabalho da crew (5) | | `open_file` | abre um arquivo do bot com o programa que o Windows usa para ele (`explorer.exe `). Só extensões de documento, imagem, som e vídeo (`pdf`, `png`, `md`, `docx`, `xlsx`, `mp4`...); qualquer outra, como `.exe`, `.bat`, `.ps1` ou `.lnk`, é recusada, porque o Explorer a executaria | | `reveal_file` | abre o Explorer na pasta do arquivo, com ele marcado (`/select,`). Nada é executado. No macOS, `open -R`; no Linux, que não tem um jeito comum de marcar o arquivo, abre a pasta dele | +| `save_file_as` | salva uma cópia de um arquivo do bot onde o dono escolher: o próprio comando abre o diálogo Salvar como do sistema (`tauri-plugin-dialog`, pelo Rust, com o nome do arquivo e um filtro pela extensão) e copia; responde `false` se o dono cancelar. Quem escolhe o destino é sempre o diálogo, então a página não grava arquivo em lugar nenhum que o dono não tenha escolhido. Copiar um arquivo sobre ele mesmo não faz nada | | `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | | overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito. Com "Marcar o ícone quando um bot responder" (15.1), o mesmo ponto aparece enquanto algum bot está não lido | @@ -857,6 +861,7 @@ No chat: - Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar. - Pedido para seguir com um plano (10.1) é um cartão com o plano em markdown, um campo para o que deve mudar e os botões Aprovar plano e Pedir mudanças. Respondido, vira uma linha que abre o plano de novo. - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho. +- Arquivos que o bot compartilha (`share_file`) aparecem como cartões na fala dele, com Abrir e Salvar como… (15.1). Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/app-icon.svg`, de onde `pnpm tauri icon app-icon.svg` gera os PNG, o ICO e o ICNS; as pastas `android` e `ios` que ele cria são apagadas) é o mascote laranja, na cor que mostra o desenho como foi feito, sobre um quadrado preto, montado com os mesmos contornos e camadas de `mascotArt.ts`. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação, sem fundo e com um contorno fino e discreto (escuro no tema claro, claro no escuro) para as cores claras não sumirem. O avatar é a primeira pose da folha de poses da chama, vetorizada (`mascotArt.ts`): o corpo com as labaredas, uma gotinha de fogo solta acima da ponta, camadas de luz e sombra recortadas pelo contorno e olhos pretos com brilhos e reflexo avermelhado embaixo, tudo retingido para a cor do bot (matiz, saturação e luminosidade), então todo bot tem a mesma luz e sombra. A referência da retintura é a primeira cor da paleta (`#FF7A59`): um bot laranja fica igual ao desenho, e as cores claras da paleta mantêm a profundidade em vez de desbotar. A gotinha passa um pouco acima do quadrado do avatar, como as faíscas. O mascote do próprio Botloft (barra de título, mensagens do daemon) é o do ícone: laranja sobre o quadrado preto. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. O mascote se mexe conforme o que o bot faz (ver Movimento), sem substituir isso. @@ -1033,6 +1038,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Dono no controle com o Claude Code real | 21.10 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedido para abrir uma página de login local e chamar o dono, o bot carregou `browser_ask_owner` pelo `ToolSearch` sozinho, com a tarefa na língua do dono. Pelo cartão, o painel abriu já nas mãos do dono; clique, digitação, troca de campo e Enter chegaram ao Edge, e a página de login entrou na conta. Devolvido, a tool respondeu com a página nova e o bot listou os pedidos. A senha digitada não apareceu no log, no banco nem na conversa do Claude Code (o bot leu só o que a página mostrava). Numa página comprida, a roda do mouse sobre a tela ao vivo rolou a página do bot e não o painel | feito | | Abas nas mãos do dono com o Claude Code real | 21.10 | **A verificar**: com um bot no meio de uma tarefa num site, o dono assume, abre uma aba nova em outro endereço e devolve. O bot deve ler o aviso de que a aba mudou (no erro da tool que ia agir, ou no `browser_look`), ler a aba nova e seguir a tarefa nela ou abrir de novo a página de que precisa, sem agir às cegas. Recarregar sem assumir: o bot lê "The owner reloaded the page." e continua | manual (PR) | | Cursor do bot nas telas | 22.3 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual), com o app num Edge sem janela: pedidas quatro páginas (floricultura, cafeteria, livraria, academia), o cursor seguiu o título, cada cartão, cada item de plano e cada linha de tabela enquanto o bot escrevia, e a prancheta rolou junto. Visto aqui: num `iframe` de outra origem, o script às vezes lê a página com `innerHeight` 0 e só depois recebe o tamanho (por isso a espera do `resize`); a versão escondida fala antes de ir para a frente (por isso vale a marca do quadro à mostra). O conteúdo das páginas não foi para o log | feito | +| Bot compartilha arquivos com `share_file` | 10, 8.4 | Ferramenta nossa, sem comportamento novo do Claude Code: a tool vem liberada por `--allowedTools mcp__botloft` e é carregada pelo `ToolSearch` como as outras. **Testado com 2.1.284** (daemon de dev, Haiku, modo Aceitar edições), só com a regra de 5.1 e a descrição da tool: pedido um relatório salvo na pasta de trabalho, o bot o escreveu e chamou `share_file` sozinho; pedido "me manda o relatório de novo", chamou de novo sem escrever o caminho na resposta. A primeira chamada veio com o caminho do Windows com `\` sem escape, que o próprio Claude Code recusa como JSON inválido; o bot repetiu certo. Por isso a descrição do campo pede barras normais. O conteúdo não foi para o log | feito | | Esforço em `-p` | 7.4 | Confirmado (`cli-reference`, `model-config`, `headless`): `--effort` aceita `low`, `medium`, `high`, `xhigh`, `max` (e `ultracode`, não usado), vale só para a sessão e não é guardado; os níveis dependem do modelo (Fable, Opus e Sonnet atuais têm os cinco; Opus 4.6 e Sonnet 4.6 não têm `xhigh`); sem escolha, o padrão é `high`, menos em Opus 5.5 e Sonnet 5.5 (`medium`) e Opus 4.7 (`xhigh`); `/effort ` funciona em `-p` e vale só para a sessão; as chaves `effortLevel` e `modelSettings` das settings não aceitam `max`; trocar o esforço no meio da sessão mantém o cache em Opus 5.5, Sonnet 5.5 e Fable 5.1 e o invalida nos outros (`prompt-caching`). **Testado com 2.1.284** (`--setting-sources project,local`, plano Max): `--effort max` e `xhigh` com `--model sonnet` são aplicados; um valor desconhecido só avisa no stderr e usa o padrão; `--resume` sem a flag volta ao padrão do modelo; sem a flag, Sonnet 5.5 e Opus 5.5 aplicam `medium` e Fable 5.1 `high`; Haiku 4.5 não tem níveis (o `initialize` do SDK lista `supportedEffortLevels` para os outros e nada para ele) e ignora a flag sem erro. `/effort low` mandado como mensagem troca na hora, com uma resposta sintética e sem custo, e o pedido de controle `apply_flag_settings {effortLevel}` também; o daemon reinicia o processo em vez disso. O que acontece com um nível que o modelo não tem (`xhigh` num modelo antigo) não foi visto: os apelidos do Botloft apontam para modelos com os cinco | feito | | Pedidos de controle no stdin | 9.2 | `getContextUsage()` é documentado no Agent SDK (`agent-sdk/typescript`), com `totalTokens`, `maxTokens`, `rawMaxTokens`, `percentage`, `autoCompactThreshold` e `isAutoCompactEnabled`; o formato no fio e `get_settings` **não são documentados**. **Testado com 2.1.284**: `{"type":"control_request","request_id","request":{"subtype":"get_context_usage"}}` é respondido com `control_response` (`subtype: "success"`, o mesmo `request_id`, `response`) em até 2,5 s, antes do primeiro turno, entre turnos e durante um turno, sem `system/init` nem `result`; `get_settings` responde `{effective, sources, applied: {model, effort, ...}}`, com `effort: null` em Haiku; um `subtype` desconhecido responde `subtype: "error"`. Numa sessão retomada, a primeira resposta levou uma vez mais de 15 s. **Pelo daemon de dev** (Haiku e Sonnet): o modelo e o esforço do modelo chegaram ao app antes do primeiro turno, e `--model sonnet --effort low` apareceu na linha de comando do bot depois de `bots.setEffort` | feito | | Uso de contexto no `stream-json` | 8.6 | Parcialmente documentado: `usage` e `modelUsage` no `result` (`agent-sdk/typescript`), janela de 1 milhão em Fable, Sonnet 5+ e Opus 4.7+ e compactação automática em cerca de 967 mil (`model-config`). **Visto com 2.1.284**: cada `assistant` traz `message.usage` (`input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`), e a soma dos três primeiros bate com o `totalTokens` do `get_context_usage` a menos de 1%; `result.modelUsage[modelo]` traz `contextWindow` (200000 em Haiku 4.5, 1000000 em Sonnet 5.5) e `maxOutputTokens`; `result.usage.iterations[]` traz cada pedido do turno; `autoCompactThreshold` é a janela menos 33 mil (167000, 967000; 67000 com `--autocompact 100k`, que também encolhe `maxTokens`); `/context` como mensagem devolve um `assistant` sintético com `context_usage` e abre um turno, por isso o daemon usa o pedido de controle. `total_cost_usd` é o total do processo, não do turno. **Pelo daemon de dev**: o app recebeu o tamanho ao subir o bot, a cada pedido ao modelo e no fim do turno | feito |