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: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { seedContext } from "./seedContext";
import { seedDesktop } from "./seedDesktop";
import { seedHands } from "./seedHands";
import { activeTab, drawPage } from "./seedPage";
import { seedPlan } from "./seedPlan";
import { seedQuestions } from "./seedQuestions";
import { seedRoutines } from "./seedRoutines";
import { seedScreens } from "./seedScreens";
Expand Down Expand Up @@ -127,6 +128,7 @@ function seed(fake: FakeBotloft): void {
});
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
seedQuestions(fake, { scout: scout.id, analyst: analyst.id });
seedPlan(fake, writer.id);
seedBrowser(fake, scout.id);
// Analyst hit the limit in the middle of a search: its browser rests.
fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats");
Expand Down
26 changes: 26 additions & 0 deletions app/src/dev/seedPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Dev only: a bot that shows its plan before long work (spec 10.5).

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

export function seedPlan(fake: FakeBotloft, bot: BotId): void {
fake.bot(bot).planFirst = true;
void fake.call("messages.send", {
botId: bot,
body: "Rewrite the whole sourdough guide for beginners, with the new photos.",
});
fake.chat.tool(bot, "mcp__botloft__show_plan", { status: "done" });
const plan = {
summary: "A beginner's sourdough guide, rewritten from the start, with the new photos",
steps: [
"Read the current guide and the notes in the work folder",
"Write a shorter guide in five parts, from starter to first loaf",
"Put the new photos next to the steps they show",
"Ask Revisão to check facts and tone",
],
alternative: "Only fix the hardest part, feeding the starter, and leave the rest",
// Long enough to look at it in the preview.
starts_at: Date.now() + 90_000,
};
fake.chat.ask(bot, "mcp__botloft__show_plan", plan.summary, JSON.stringify(plan));
}
10 changes: 10 additions & 0 deletions app/src/features/bots/BotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function BotDialog(props: Props) {
const [model, setModel] = useState<BotModel>(editing?.model ?? "default");
// One command prefix per line, for a bot whose agent cannot ask (spec 30).
const [commands, setCommands] = useState((editing?.allowedCommands ?? []).join("\n"));
const [planFirst, setPlanFirst] = useState(editing?.planFirst ?? false);
// Which agent runs the bot is chosen once, when it is made (spec 30).
const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY);
const preferred = useApp((state) => state.settings?.defaultAgent ?? "claude");
Expand All @@ -65,6 +66,7 @@ export function BotDialog(props: Props) {
...(role !== editing.role && { role }),
...(instructions !== editing.instructions && { instructions }),
...(color !== undefined && color !== editing.color && { color }),
...(planFirst !== editing.planFirst && { planFirst }),
...(editing.agent !== "claude" &&
commands !== editing.allowedCommands.join("\n") && {
allowedCommands: commands.split("\n"),
Expand Down Expand Up @@ -184,6 +186,14 @@ export function BotDialog(props: Props) {
hint={t.chat.model.cost}
/>
)}
{editing && (
<Toggle
label={t.bots.dialog.planFirst.title}
hint={t.bots.dialog.planFirst.hint(editing.name)}
checked={planFirst}
onChange={setPlanFirst}
/>
)}
<fieldset className="flex flex-col gap-1.5">
<legend className="mb-1.5 font-medium text-ink-soft text-sm">
{t.bots.dialog.color}
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 @@ -21,6 +21,7 @@ import { BotChangeCard, CHANGE_BOT_TOOL } from "./BotChangeCard";
import { CREW_ACCESS_TOOL, CrewAccessCard } from "./CrewAccessCard";
import { commandOf, isCommand } from "./command";
import { PLAN_TOOL, PlanCard } from "./PlanCard";
import { START_PLAN_TOOL, StartPlanCard } from "./StartPlanCard";
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
import { pretty } from "./ToolLines";
import { toolAction } from "./toolNames";
Expand Down Expand Up @@ -67,6 +68,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
switch (approval.toolName) {
case PLAN_TOOL:
return <PlanCard approval={approval} bot={bot} />;
case START_PLAN_TOOL:
return <StartPlanCard approval={approval} bot={bot} />;
case SUGGEST_TOOL:
return <SuggestionCard approval={approval} bot={bot} />;
case ROUTINE_TOOL:
Expand Down
197 changes: 197 additions & 0 deletions app/src/features/chat/StartPlanCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// The plan a bot shows before long work (spec 10.5): it starts by itself
// after a few seconds unless the owner starts it now, picks the smaller
// way, changes it or cancels it.

import { Ban, Check, ListChecks, PencilLine, Play, Shrink, TimerReset } from "lucide-react";
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard";
import { attempt } from "../../ui/toast";

export const START_PLAN_TOOL = "mcp__botloft__show_plan";

export interface StartPlan {
summary: string;
steps: string[];
alternative: string | null;
/** When it starts by itself, in Unix ms. */
startsAt: number;
/** Set once the owner picked the smaller way. */
choice: "alternative" | null;
}

/** The plan in the request's input; `null` if it was cut or is not one. */
export function startPlanOf(input: string): StartPlan | null {
try {
const parsed = JSON.parse(input) as Record<string, unknown>;
const steps = parsed.steps;
if (typeof parsed.summary !== "string" || !Array.isArray(steps)) {
return null;
}
return {
summary: parsed.summary,
steps: steps.filter((step): step is string => typeof step === "string"),
alternative: typeof parsed.alternative === "string" ? parsed.alternative : null,
startsAt: typeof parsed.starts_at === "number" ? parsed.starts_at : 0,
choice: parsed.choice === "alternative" ? "alternative" : null,
};
} catch {
return null;
}
}

/** Whole seconds until `at`, counted down every second. */
function useSecondsUntil(at: number): number {
const left = () => Math.max(0, Math.ceil((at - Date.now()) / 1000));
const [seconds, setSeconds] = useState(left);
useEffect(() => {
if (seconds === 0) {
return;
}
const timer = window.setTimeout(() => setSeconds(left()), 1000);
return () => window.clearTimeout(timer);
});
return seconds;
}

function Plan({ plan }: { plan: StartPlan }) {
const p = useT().chat.startPlan;
return (
<div className="flex flex-col gap-2 px-4 py-3 text-sm" data-selectable>
<p className="font-medium">{plan.summary}</p>
<ol className="list-decimal space-y-1 pl-5 text-ink-soft">
{plan.steps.map((step, index) => (
// Steps can repeat; their place is what tells them apart.
// biome-ignore lint/suspicious/noArrayIndexKey: fixed list
<li key={index}>{step}</li>
))}
</ol>
{plan.alternative && (
<p className="rounded-lg bg-sunken px-3 py-2 text-ink-soft">
<span className="font-medium text-ink">{p.alternative}: </span>
{plan.alternative}
</p>
)}
</div>
);
}

export function StartPlanCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
const p = useT().chat.startPlan;
const api = useApi();
const plan = startPlanOf(approval.input);
const seconds = useSecondsUntil(plan?.startsAt ?? 0);
const [changing, setChanging] = useState(false);
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
if (!plan) {
return null;
}
if (approval.status !== "pending") {
return <Answered approval={approval} plan={plan} />;
}

const answer = async (allow: boolean, extra: { note?: string; input?: string } = {}) => {
setBusy(true);
await attempt(p.failed, () =>
api.call("approvals.answer", { approvalId: approval.approvalId, allow, ...extra }),
);
setBusy(false);
};
const change = () => {
const trimmed = note.trim();
if (trimmed) {
void answer(false, { note: trimmed });
}
};

return (
<SectionCard
label={p.ready(bot.name)}
icon={ListChecks}
tone="work"
footer={
<>
{changing && (
<div className="mb-2.5">
<NoteArea
label={p.noteLabel(bot.name)}
value={note}
onChange={setNote}
onEnter={change}
placeholder={p.notePlaceholder}
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{changing ? (
<Button
variant="primary"
icon={PencilLine}
disabled={busy || !note.trim()}
onClick={change}
>
{p.sendChange}
</Button>
) : (
<>
<Button variant="primary" icon={Play} disabled={busy} onClick={() => answer(true)}>
{p.startNow}
</Button>
{plan.alternative && (
<Button
icon={Shrink}
disabled={busy}
onClick={() => answer(true, { input: '{"choice":"alternative"}' })}
>
{p.onlyThis}
</Button>
)}
<Button icon={PencilLine} disabled={busy} onClick={() => setChanging(true)}>
{p.change}
</Button>
</>
)}
<Button icon={Ban} disabled={busy} onClick={() => answer(false)}>
{p.cancel}
</Button>
{!changing && (
<span className="ml-auto text-muted text-xs tabular-nums" aria-live="off">
{seconds > 0 ? p.startsIn(seconds) : p.starting}
</span>
)}
</div>
</>
}
>
<Plan plan={plan} />
</SectionCard>
);
}

/** One line once settled; the plan stays a click away. */
function Answered({ approval, plan }: { approval: ApprovalItem; plan: StartPlan }) {
const p = useT().chat.startPlan;
const [icon, text, tone] =
approval.status === "allowed"
? plan.choice === "alternative"
? ([Shrink, p.didAlternative, "ok"] as const)
: ([Check, p.started, "ok"] as const)
: approval.status === "denied"
? approval.note
? ([PencilLine, p.changed, "work"] as const)
: ([Ban, p.cancelled, "quiet"] as const)
: ([TimerReset, p.startedAlone, "ok"] as const);
return (
<SettledLine
icon={icon}
tone={tone}
text={text}
note={approval.note}
details={<Plan plan={plan} />}
/>
);
}
1 change: 1 addition & 0 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const ICONS: Record<string, LucideIcon> = {
WebSearch: Globe,
TodoWrite: ListChecks,
ExitPlanMode: ListTodo,
show_plan: ListChecks,
Task: Users,
Agent: Users,
send_message: Send,
Expand Down
13 changes: 9 additions & 4 deletions app/src/features/chat/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ export type Row =
| { kind: "run"; key: string; items: ChatItem[] }
| { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem };

/** The bot asking the owner (spec 23.2): its question card says it all. */
const ASK_TOOL = "mcp__botloft__ask_owner";
/**
* Tools whose card says it all: the bot asking the owner (spec 23.2) and
* the plan it shows before long work (spec 10.5).
*/
const CARD_TOOLS = new Set(["mcp__botloft__ask_owner", "mcp__botloft__show_plan"]);

/** A call to ask the owner that worked; the card stands for it. */
/** A call that worked and has its own card, which stands for it. */
function isAskLine(item: ChatItem): boolean {
return item.body.kind === "tool" && item.body.name === ASK_TOOL && item.body.status !== "failed";
return (
item.body.kind === "tool" && CARD_TOOLS.has(item.body.name) && item.body.status !== "failed"
);
}

function dayOf(ms: number): string {
Expand Down
Loading
Loading