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
9 changes: 7 additions & 2 deletions .design-sync/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ Tailwind and TypeScript). Nothing there reimplements a component.
inputs and summaries follow the daemon (`crates/botloft-core/src/chat.rs` `tool_summary`,
`crates/botloftd/src/tools/browser_sites.rs`): a site request is `{site, url}` with the site as
its summary.
- `BrowserPanel`: `BotloftProvider` `browsers` opens each bot's browser in `FakeBrowser` and
paints it with the app's dev page drawer (`app/src/dev/seedPage.ts`). It must happen in the
provider's `useState` initializer, before the store connects: the fake answers the store's
first `browser.list` synchronously, so an answer taken before the browsers opened arrives after
their `browser.changed` and wins (the panel then says "Browser closed" over the page).
- Type emission covers the whole package with the repo as root; `.design-sync` is renamed to `ds`
in `dist/types/emit` (ts-morph skips dot folders), and `paths` points bare imports at
`app/node_modules` (`@types` first, or React resolves to its JS). `vite/client.d.ts` is added
Expand All @@ -71,7 +76,7 @@ Tailwind and TypeScript). Nothing there reimplements a component.
- The icon set follows the app's imports: an icon the app stops using disappears from `icons`.
- Bot colors in previews and conventions are sample values, not tokens.
- Scope (owner's choices on 2026-10-02): the base pieces, the bot badges, and the app screens the
explainer video needs (sidebar, crew cards, a bot's turn, messages, requests). Not in it yet:
the whole crew view, the composer, the browser and screens panels, routines, settings.
explainer video needs (sidebar, crew cards, a bot's turn, messages, requests, the live browser).
Not in it yet: the whole crew view, the composer, the screens panel, routines, settings.
- `scene.ts` mirrors protocol shapes and daemon summaries by hand; a protocol change
(`protocol.gen.ts`) or a new request kind needs it updated, or the tsc step fails.
9 changes: 7 additions & 2 deletions .design-sync/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
"BotRun": "/** One turn of the bot, in order: items from Botloft.chat.reply / tool / askCommand / askSite / suggestBot / askPlan / turn(bot, ...). Needs BotloftProvider around it. */ items: any[]; /** A bot from Botloft.makeBot(...). */ bot: any; /** The turn in progress: the reply being written (draft) or the working dots (working). Only a live run moves the avatar. */ live?: { draft: string; working: boolean };",
"InboundRow": "/** A message from Botloft.makeMessage({ from, to, body }). From \"owner\" it is your bubble on the right; from a bot or \"system\" it sits on the left with the sender's avatar. The sender must be among BotloftProvider bots. */ message: any; /** The bot whose chat this is: the message's recipient. */ /** A bot from Botloft.makeBot(...). */ bot: any;",
"CrewBots": "/** A crew from Botloft.makeCrew(...). */ crew: any; /** Its bots, from Botloft.makeBot({ crew, ... }); the Chief (chief: true) gets the crown. Pass the same ones to BotloftProvider. */ bots: any[]; onNewBot: () => void;",
"Sidebar": "/** No props: it lists the crews and bots given to BotloftProvider, with each bot's state and last activity, and the account at the bottom. Give it a fixed height. */ className?: never;",
"BotloftProvider": "/** Crews from Botloft.makeCrew(...). */ crews?: any[]; /** Bots from Botloft.makeBot(...). A new array updates them, e.g. to change a state over time. */ bots?: any[]; /** The bot whose chat is open: highlighted in the sidebar. */ selectedBotId?: string; /** Called when Allow or Deny is clicked on a request card. */ onAnswer?: (answer: { approvalId: string; allow: boolean; note?: string }) => void; children: React.ReactNode;"
"Sidebar": "/** No props: it lists the crews and bots given to BotloftProvider, with each bot's state and last activity, and the account at the bottom. Put it as the direct first child of the app window's flex row (fixed height); wrapped in anything else it shrinks to its content. */ className?: never;",
"BotloftProvider": "/** Crews from Botloft.makeCrew(...). */ crews?: any[]; /** Bots from Botloft.makeBot(...). A new array updates them, e.g. to change a state over time. */ bots?: any[]; /** Open browsers for BrowserPanel: [{ bot, url, title, tabs?: [{ url, title }], action?: { kind: \"open\" | \"click\" | \"type\" | \"scroll\" | \"back\", x?, y?, label? } }]. A new action object moves the bot's cursor on the page again. */ browsers?: any[]; /** The bot whose chat is open: highlighted in the sidebar. */ selectedBotId?: string; /** Called when Allow or Deny is clicked on a request card. */ onAnswer?: (answer: { approvalId: string; allow: boolean; note?: string }) => void; children: React.ReactNode;",
"BrowserPanel": "/** The bot whose browser this is; its page comes from BotloftProvider browsers (it shows \"closed\" without one). Place it as the last child of the app window's flex row, right of the chat column: it is a side panel the owner can widen. */ bot: any; /** Counts up each time the owner asks to take the browser into their own hands. */ take?: number; onClose: () => void;"
},
"overrides": {
"Dialog": {
Expand Down Expand Up @@ -67,6 +68,10 @@
"BotloftProvider": {
"cardMode": "single",
"viewport": "1000x560"
},
"BrowserPanel": {
"cardMode": "single",
"viewport": "1440x620"
}
},
"readmeHeader": ".design-sync/conventions.md"
Expand Down
23 changes: 17 additions & 6 deletions .design-sync/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,22 @@ const scout = makeBot({ crew, name: "Scout", state: "busy", role: "Finds sources
`Toaster` + `notifyError(what, error)` for failures away from a form.
- Fields: `TextField`, `TextArea`, `SelectField` (labeled), `Select` (custom dropdown),
`Choices` (2-6 options side by side), `Switch`, `Tabs`, `Menu`, `Details`, `SidePanel`.
- App screens: `Sidebar` (crews and bots, 288px wide, give it a height), `CrewBots` (a crew's bot
cards), `BotRun` (one turn of a bot in its chat, inside a `<ul>`), `InboundRow` (a message in a
chat, inside a `<ul>`), `ApprovalCard` (a request: a website, a command, a suggested bot, a plan).
The app's window is the `Sidebar` on the left and the open bot's chat on the right, on `bg-canvas`.
- App screens: `Sidebar` (crews and bots, 288px wide), `CrewBots` (a crew's bot cards), `BotRun`
(one turn of a bot in its chat, inside a `<ul>`), `InboundRow` (a message in a chat, inside a
`<ul>`), `ApprovalCard` (a request: a website, a command, a suggested bot, a plan),
`BrowserPanel` (a bot's browser, live: tabs, address bar, the page and the bot's cursor).
`BotloftProvider`'s `onAnswer` hears Allow and Deny on request cards.
- **The app window:** one row, `className="flex bg-canvas text-ink"` with a fixed height
(`style={{ height: 820 }}`): the `Sidebar` as its **direct first child**, then the chat column
(`flex min-w-0 flex-1 flex-col`), then, when a bot's browser is open, `<BrowserPanel bot={...}
onClose={...} />` as the **last direct child**. The Sidebar stretches to the row's full height,
with the account pinned at the bottom-left; wrapped in any other element it shrinks to its
content and looks cut short. Never wrap the Sidebar or the BrowserPanel. Give the window at
least 1400px of width when the browser is open, so the chat keeps its room.
- **A browser:** pass `browsers={[{ bot: scout, url, title, tabs: [{ url, title }], action: { kind:
"click", x, y, label } }]}` to `BotloftProvider`. The page is drawn for you (title, address,
text lines); `action` moves the bot's cursor (`open`, `click`, `type`, `scroll`, `back`), and a
new `browsers` array with another `url` or `action` makes the bot browse on.

Read `components/<group>/<Name>/<Name>.prompt.md` for each one's props.

Expand All @@ -105,8 +116,8 @@ function AppWindow() {
return (
<BotloftProvider crews={[crew]} bots={[chief, scoutNow]} selectedBotId={scout.id}
onAnswer={(answer) => setAllowed(answer.allow)}>
<div className="flex bg-canvas text-ink" style={{ height: 560 }}>
<Sidebar />
<div className="flex bg-canvas text-ink" style={{ height: 820 }}>
<Sidebar /> {/* direct child of the row: it runs the full height */}
<ul className="flex min-w-0 flex-1 flex-col gap-5 p-5">
<BotRun bot={scoutNow} items={[chat.reply(scout, "I need to open this paper."), card]}
live={{ draft: "", working: allowed }} />
Expand Down
3 changes: 2 additions & 1 deletion .design-sync/pkg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { BotStateBadge } from "../../app/src/features/bots/BotStateBadge";
export { ChiefBadge } from "../../app/src/features/bots/ChiefBadge";
export { ApprovalCard } from "../../app/src/features/chat/ApprovalCard";
export { BotRun } from "../../app/src/features/chat/BotRun";
export { BrowserPanel } from "../../app/src/features/browser/BrowserPanel";
export { InboundRow } from "../../app/src/features/chat/InboundRow";
export { CrewBots } from "../../app/src/features/crews/CrewBots";
export { Sidebar } from "../../app/src/features/crews/Sidebar";
Expand All @@ -29,5 +30,5 @@ export { Switch } from "../../app/src/ui/Switch";
export { type Tab, Tabs } from "../../app/src/ui/Tabs";
export { notifyError, Toaster } from "../../app/src/ui/toast";
export * as icons from "./icons.gen";
export { BotloftProvider, type BotloftProviderProps } from "./provider";
export { BotloftProvider, type BotloftProviderProps, type BrowserSeed } from "./provider";
export { AVATAR_PALETTE, chat, makeBot, makeCrew, makeMessage } from "./scene";
59 changes: 57 additions & 2 deletions .design-sync/pkg/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,37 @@
// instead of a running Botloft. Crews and bots passed in become the fake
// daemon's, so the sidebar, the crew view and the chat read them as the app
// would; a new array updates them, which lets a design animate states.
// A bot's open browser is drawn by the same page painter as the app's dev
// preview (app/src/dev/seedPage.ts) and streamed to BrowserPanel as frames.

import { type ReactNode, useEffect, useRef, useState } from "react";
import type { ApprovalsAnswerParams, Bot, BotId, Crew } from "../../app/src/lib/protocol.gen";
import { activeTab, drawPage } from "../../app/src/dev/seedPage";
import { FakeBotloft } from "../../app/src/lib/fake";
import { FakeHost } from "../../app/src/lib/fakeHost";
import type {
ApprovalsAnswerParams,
Bot,
BotId,
BrowserActionKind,
Crew,
} from "../../app/src/lib/protocol.gen";
import { DaemonProvider, HostProvider, useApp, useAppStore } from "../../app/src/store/context";

/** A bot's browser, open on `url`; earlier tabs stay behind it. */
export interface BrowserSeed {
bot: Bot;
url: string;
title: string;
tabs?: { url: string; title: string }[];
/** What the bot just did on the page, shown by its cursor; a new object plays it again. */
action?: { kind: BrowserActionKind; x?: number; y?: number; label?: string };
}

export interface BotloftProviderProps {
crews?: Crew[];
bots?: Bot[];
/** Open browsers, shown live by BrowserPanel. */
browsers?: BrowserSeed[];
/** The bot whose chat is open: highlighted in the sidebar. */
selectedBotId?: BotId | null;
/** Called when Allow or Deny is clicked on a request card. */
Expand All @@ -32,13 +53,42 @@ function Selection({ botId }: { botId: BotId | null | undefined }) {
return null;
}

export function BotloftProvider({ crews = [], bots = [], selectedBotId, onAnswer, children }: BotloftProviderProps) {
/** Opens (or moves) the bot's browser, draws its page and plays its action. */
function showBrowser(fake: FakeBotloft, browser: BrowserSeed): void {
const id = browser.bot.id;
if (fake.browser.state(id).status === "open") {
fake.browser.open(id, browser.url, browser.title);
} else {
for (const tab of browser.tabs ?? []) {
fake.browser.openTab(id, tab.url, tab.title);
}
// The page the bot is on: the last tab, the active one.
fake.browser.openTab(id, browser.url, browser.title);
fake.browser.open(id, browser.url, browser.title);
}
fake.browser.paint(id, (size) => drawPage(activeTab(fake, id), size));
if (browser.action) {
fake.browser.act(id, browser.action.kind, browser.action);
}
}

export function BotloftProvider({
crews = [],
bots = [],
browsers = [],
selectedBotId,
onAnswer,
children,
}: BotloftProviderProps) {
const answer = useRef(onAnswer);
answer.current = onAnswer;
const [fake] = useState(() => {
const api = new FakeBotloft();
for (const crew of crews) api.crews.set(crew.id, crew);
for (const bot of bots) api.bots.set(bot.id, bot);
// Before the store connects: the fake answers its first `browser.list`
// at once, and an answer from before the browsers opened would win.
for (const browser of browsers) showBrowser(api, browser);
const call = api.call.bind(api) as FakeBotloft["call"];
// A request card's answer goes to the design, not to a daemon.
api.call = ((method: string, ...params: unknown[]) => {
Expand Down Expand Up @@ -69,6 +119,11 @@ export function BotloftProvider({ crews = [], bots = [], selectedBotId, onAnswer
fake.changedBot(bot);
}
}, [fake, bots]);
useEffect(() => {
for (const browser of browsers) {
showBrowser(fake, browser);
}
}, [fake, browsers]);

return (
<HostProvider host={host}>
Expand Down
60 changes: 60 additions & 0 deletions .design-sync/previews/BrowserPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
BotloftProvider,
BotRun,
BrowserPanel,
chat,
makeBot,
makeCrew,
setLocaleChoice,
Sidebar,
} from "@botloft/ui";

// The app follows the browser's language; the cards are in English.
setLocaleChoice("en");

const crew = makeCrew({ name: "Research" });
const chief = makeBot({ crew, name: "Chief", chief: true, state: "idle" });
const scout = makeBot({
crew,
name: "Scout",
state: "busy",
activity: { kind: "tool", text: "arxiv.org/abs/2609.01234", tool: "mcp__botloft__browser_open", minutesAgo: 0 },
});
const writer = makeBot({ crew, name: "Writer", state: "idle" });

// The app's window with Scout's browser open beside its chat: the sidebar,
// the chat column, then the panel, all direct children of one flex row.
export function BesideTheChat() {
return (
<BotloftProvider
crews={[crew]}
bots={[chief, scout, writer]}
selectedBotId={scout.id}
browsers={[
{
bot: scout,
url: "https://arxiv.org/abs/2609.01234",
title: "Small language models on phones: a benchmark",
tabs: [{ url: "https://news.example/on-device-ai", title: "On-device AI this week" }],
action: { kind: "scroll", x: 520, y: 360, label: "Abstract" },
},
]}
>
<div className="flex border border-line bg-canvas text-ink" style={{ height: 600, width: 1400 }}>
<Sidebar />
<ul className="flex min-w-0 flex-1 flex-col gap-5 p-5">
<BotRun
bot={scout}
items={[
chat.reply(scout, "One paper looks key. I need to open it.", 1),
chat.askSite(scout, "https://arxiv.org/abs/2609.01234", { status: "allowed", minutesAgo: 1 }),
chat.tool(scout, { name: "mcp__botloft__browser_open", summary: "https://arxiv.org/abs/2609.01234" }),
]}
live={{ draft: "", working: true }}
/>
</ul>
<BrowserPanel bot={scout} onClose={() => {}} />
</div>
</BotloftProvider>
);
}
Loading