Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
- Código, identificadores, comentários, commits e README em **inglês**. Docs em `docs/` podem ser em português.
- Arquivos com no máximo **~300 linhas**. Passou disso, divida por responsabilidade.
- Erros: `thiserror` nos crates de biblioteca, `anyhow` só no binário.
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`, `qst_`). Tempo em ms Unix.
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`, `qst_`, `dsk_`). Tempo em ms Unix.
- Tudo que é específico de Windows fica em `crates/botloftd/src/platform/`. O resto do daemon não chama Win32 direto.
- A UI depende só da interface `BotloftApi` (`app/src/lib/api.ts`). Testes de componente usam `FakeBotloft`.
- Nenhum texto de interface escrito direto no componente: tudo vai em `app/src/i18n/{en,pt-BR,es}/<área>.ts` e é lido com `useT()` (ou `t()` fora do React). Texto novo entra nos três idiomas no mesmo commit; o TypeScript acusa o que faltar (spec 15.6). Palavras simples, sem "daemon" nem outro jargão.
Expand Down
2 changes: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { seedCalls } from "./seedCalls";
import { seedChats } from "./seedChats";
import { seedChief } from "./seedChief";
import { seedContext } from "./seedContext";
import { seedDesktop } from "./seedDesktop";
import { seedHands } from "./seedHands";
import { activeTab, drawPage } from "./seedPage";
import { seedQuestions } from "./seedQuestions";
Expand Down Expand Up @@ -132,6 +133,7 @@ function seed(fake: FakeBotloft): void {
fake.browser.paint(analyst.id, (size) => drawPage(activeTab(fake, analyst.id), size));
fake.browser.rest(analyst.id);
seedScreens(fake, research.id);
seedDesktop(fake, { scout: scout.id, analyst: analyst.id });
seedHands(fake, research.id);
seedCalls(fake, { writer: writer.id, reviewer: reviewer.id });
const ops = fake.addCrew("Ops");
Expand Down
22 changes: 22 additions & 0 deletions app/src/dev/seedDesktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Dev only: the preview's desktop (spec 24). Analyst asks to see a
// spreadsheet in Excel, and Scout may already see Notepad.

import type { FakeBotloft } from "../lib/fake";
import type { BotId } from "../lib/protocol.gen";

const EXCEL = "C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE";

export function seedDesktop(fake: FakeBotloft, bots: { scout: BotId; analyst: BotId }) {
fake.desktop.grant(bots.scout, "C:\\Windows\\notepad.exe", "Notepad");
fake.chat.ask(
bots.analyst,
"mcp__botloft__desktop",
"Microsoft Excel",
JSON.stringify({
app: "Microsoft Excel",
path: EXCEL,
level: "see",
why: "To read the flour prices in your spreadsheet",
}),
);
}
2 changes: 2 additions & 0 deletions app/src/features/bots/BotPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { DesktopGrants } from "../desktop/DesktopGrants";
import { SignInButton } from "../onboarding/SignIn";
import { AllowRules } from "./AllowRules";
import type { stateView } from "./BotStateBadge";
Expand Down Expand Up @@ -77,6 +78,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
</dd>
</div>
<AllowRules bot={bot} />
<DesktopGrants bot={bot} />
</dl>
</SidePanel>
);
Expand Down
3 changes: 3 additions & 0 deletions app/src/features/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard";
import { Details } from "../../ui/Details";
import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
import { DESKTOP_TOOL, DesktopCard } from "../desktop/DesktopCard";
import {
CHANGE_ROUTINE_TOOL,
DELETE_ROUTINE_TOOL,
Expand Down Expand Up @@ -74,6 +75,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
return <RoutineDeleteCard approval={approval} bot={bot} />;
case SITE_TOOL:
return <SiteCard approval={approval} bot={bot} />;
case DESKTOP_TOOL:
return <DesktopCard approval={approval} bot={bot} />;
case HELP_TOOL:
return <HelpCard approval={approval} bot={bot} />;
default:
Expand Down
11 changes: 9 additions & 2 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ListTodo,
LoaderCircle,
type LucideIcon,
Monitor,
Send,
SquareTerminal,
UserPlus,
Expand Down Expand Up @@ -60,9 +61,15 @@ const ICONS: Record<string, LucideIcon> = {
browser_ask_owner: Hand,
};

/** The browser tools share the globe (spec 21.4). */
/** The browser tools share the globe (spec 21.4), the desktop's the screen (24.6). */
function iconOf(label: string): LucideIcon {
return ICONS[label] ?? (label.startsWith("browser_") ? Globe : Wrench);
if (ICONS[label]) {
return ICONS[label];
}
if (label.startsWith("browser_")) {
return Globe;
}
return label.startsWith("desktop_") ? Monitor : Wrench;
}

/** JSON indented for reading; anything else as it came. */
Expand Down
100 changes: 100 additions & 0 deletions app/src/features/desktop/DesktopCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// A bot asking to see an app on the owner's desktop (spec 24.2): the app,
// why the bot wants it, what seeing it means, and Allow or Deny. Once
// answered it shrinks to one line.

import { Ban, Check, Monitor, TimerOff } from "lucide-react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard";
import { useAnswer } from "../chat/useAnswer";

/** The request the daemon opens from the desktop tools. */
export const DESKTOP_TOOL = "mcp__botloft__desktop";

interface Asked {
path: string | null;
why: string | null;
}

function askedOf(input: string): Asked {
try {
const parsed = JSON.parse(input) as { path?: unknown; why?: unknown };
const text = (value: unknown) => (typeof value === "string" && value.trim() ? value : null);
return { path: text(parsed.path), why: text(parsed.why) };
} catch {
return { path: null, why: null };
}
}

function Answered({ approval, bot, app }: { approval: ApprovalItem; bot: Bot; app: string }) {
const words = useT().desktop.card;
switch (approval.status) {
case "allowed":
return <SettledLine icon={Check} tone="ok" text={words.allowed(bot.name, app)} />;
case "denied":
return (
<SettledLine
icon={Ban}
tone="danger"
text={words.denied(bot.name, app)}
note={approval.note}
/>
);
default:
return <SettledLine icon={TimerOff} tone="quiet" text={words.expired(app)} />;
}
}

export function DesktopCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
const t = useT();
const words = t.desktop.card;
const { note, setNote, busy, answer } = useAnswer(approval, {
allow: t.chat.approval.allowFailed,
deny: t.chat.approval.denyFailed,
});
const app = approval.summary;
if (approval.status !== "pending") {
return <Answered approval={approval} bot={bot} app={app} />;
}
const { path, why } = askedOf(approval.input);

return (
<RequestCard
label={words.asks(bot.name, app)}
icon={Monitor}
tone="warn"
title={
<span>
{words.wants(bot.name)} <span className="font-semibold">{app}</span>
</span>
}
>
{why && (
<p className="mt-2 text-sm" data-selectable>
<span className="text-muted">{words.because(bot.name)}</span> {why}
</p>
)}
{path && (
<p className="mt-1.5 break-all font-mono text-ink-soft text-xs" data-selectable>
{path}
</p>
)}
<p className="mt-1.5 text-muted text-xs">{words.means(bot.name)}</p>
<NoteInput
label={t.chat.approval.noteLabel(bot.name)}
value={note}
onChange={setNote}
placeholder={t.chat.approval.notePlaceholder}
/>
<div className="mt-2.5 flex gap-2">
<Button variant="primary" icon={Check} disabled={busy} onClick={() => answer(true)}>
{t.chat.approval.allow}
</Button>
<Button variant="danger" icon={Ban} disabled={busy} onClick={() => answer(false)}>
{t.chat.approval.deny}
</Button>
</div>
</RequestCard>
);
}
91 changes: 91 additions & 0 deletions app/src/features/desktop/DesktopGrants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// What the bot may see and use on the owner's desktop (spec 24.10), in its
// details: each app the owner let it see, with a button that takes it back.

import { Monitor, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, DesktopGrant } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";

/** The bot's grants, read when shown and kept current by `bot.desktop`. */
function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null {
const api = useApi();
const [grants, setGrants] = useState<DesktopGrant[] | null>(null);
useEffect(() => {
let alive = true;
setGrants(null);
const stop = api.subscribe((event) => {
if (event.name === "bot.desktop" && event.params.botId === botId) {
setGrants(event.params.grants);
}
});
api.call("desktop.grants", { botId }).then(
(listed) => alive && setGrants((current) => current ?? listed),
// An older daemon has no desktop: the list stays empty.
() => alive && setGrants([]),
);
return () => {
alive = false;
stop();
};
}, [api, botId]);
return grants;
}

export function DesktopGrants({ bot }: { bot: Bot }) {
const words = useT().desktop.grants;
const api = useApi();
const grants = useDesktopGrants(bot.id);
return (
<div>
<dt className="text-muted text-xs">{words.title}</dt>
<dd className="mt-1">
{grants !== null && grants.length === 0 && (
<p className="text-ink-soft text-xs leading-relaxed">{words.none(bot.name)}</p>
)}
{grants !== null && grants.length > 0 && (
<ul className="flex flex-col gap-1.5">
{grants.map((grant) => {
const app = grant.scope === "desktop" ? words.whole : (grant.appName ?? "");
return (
<li
key={grant.id}
className="flex items-start gap-2 rounded-lg border border-line bg-sunken py-1.5 pr-1 pl-2.5"
>
<Monitor aria-hidden size={14} className="mt-0.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1">
<span className="block text-xs">{app}</span>
<span className="block text-ink-soft text-xs">
{grant.level === "act" ? words.act : words.see}
</span>
{grant.appPath && (
<span
className="block break-all font-mono text-muted text-xs"
data-selectable
>
{grant.appPath}
</span>
)}
</span>
<Button
variant="ghost"
size="sm"
icon={X}
label={words.remove(app)}
onClick={() =>
attempt(words.removeFailed, () =>
api.call("desktop.revoke", { grantId: grant.id }),
)
}
/>
</li>
);
})}
</ul>
)}
</dd>
</div>
);
}
79 changes: 79 additions & 0 deletions app/src/features/desktop/desktop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { act, cleanup, fireEvent, screen, waitFor, 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);

const NOTEPAD = "C:\\Windows\\notepad.exe";

async function openScout() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "idle");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
await screen.findByRole("list", { name: "Messages" });
return { fake, scout };
}

const details = () => screen.getByRole("complementary", { name: "About Scout" });

describe("the desktop", () => {
test("a request names the app, says why and what seeing means, and allowing answers it", async () => {
const { fake, scout } = await openScout();
let approvalId = "";
act(() => {
const item = fake.chat.ask(
scout.id,
"mcp__botloft__desktop",
"Notepad",
JSON.stringify({
app: "Notepad",
path: NOTEPAD,
level: "see",
why: "To read the shopping list",
}),
);
approvalId = item.body.kind === "approval" ? item.body.approvalId : "";
});
const card = screen.getByRole("region", { name: "Scout asks to see Notepad" });
expect(within(card).getByText("To read the shopping list")).toBeDefined();
expect(within(card).getByText(NOTEPAD)).toBeDefined();
expect(within(card).getByText(/Never your passwords/)).toBeDefined();
// No "Always allow" here: allowing is for good.
expect(within(card).queryByRole("button", { name: /Always allow/ })).toBeNull();
fireEvent.click(within(card).getByRole("button", { name: "Allow" }));
await waitFor(() =>
expect(fake.calls).toContainEqual({
method: "approvals.answer",
params: { approvalId, allow: true },
}),
);
expect(await screen.findByText("Scout can see Notepad")).toBeDefined();
});

test("the bot's details list what it may see on the desktop, and it can be taken back", async () => {
const { fake, scout } = await openScout();
fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
expect(
await within(details()).findByText(
"Scout cannot see any app on your computer. It asks in the chat the first time it needs one.",
),
).toBeDefined();
act(() => {
fake.desktop.grant(scout.id, NOTEPAD, "Notepad");
});
expect(await within(details()).findByText("Notepad")).toBeDefined();
expect(within(details()).getByText("Can see")).toBeDefined();
fireEvent.click(within(details()).getByRole("button", { name: "Take back Notepad" }));
expect(
await within(details()).findByText(
"Scout cannot see any app on your computer. It asks in the chat the first time it needs one.",
),
).toBeDefined();
expect(fake.desktop.grants).toHaveLength(0);
});
});
Loading
Loading