Skip to content

Commit 354e8bf

Browse files
authored
Merge pull request #20 from httpsphl/feat/crew-chief
A chief for every crew, who suggests new bots
2 parents be5f01c + b45ac4c commit 354e8bf

65 files changed

Lines changed: 1772 additions & 184 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src/dev/preview.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FakeHost } from "../lib/fakeHost";
1111
import type { Host } from "../lib/host";
1212
import { PROTOCOL_VERSION } from "../lib/protocol.gen";
1313
import { seedChats } from "./seedChats";
14+
import { seedChief } from "./seedChief";
1415

1516
export function previewProps(search: string): { host: Host; connect: Connect } {
1617
const params = new URLSearchParams(search);
@@ -114,4 +115,5 @@ function seed(fake: FakeBotloft): void {
114115
void fake.call("crews.setPaused", { crewId: ops.id, paused: true });
115116
fake.setBotState(deploy.id, "offline");
116117
fake.setBotState(watcher.id, "offline");
118+
seedChief(fake);
117119
}

‎app/src/dev/seedChief.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Dev only: a crew led by its chief, who asks the owner for a new bot.
2+
3+
import type { FakeBotloft } from "../lib/fake";
4+
import { SUGGEST_TOOL } from "../lib/fakeChat";
5+
6+
const PLAN = `Here's how I'd split it:
7+
8+
1. **Pages and copy**: I write the menu, hours and "about us" text myself.
9+
2. **Look and feel**: this needs a designer. The crew has none, so I'm suggesting one.
10+
3. **Build**: once the design is ready, @writer and I turn it into the site in the work folder.
11+
12+
I'll hand out the first tasks as soon as you approve the designer.`;
13+
14+
const DESIGNER = {
15+
name: "Designer",
16+
role: "Designs the pages: layout, colors and photos",
17+
instructions:
18+
"Design the bakery's site: home, menu, hours and contact. Put the mockups in design/ in the work folder, one image per page, and tell @chief when each is ready. Keep it warm and simple; the owner likes the colors of the shop's sign.",
19+
model: "sonnet",
20+
reason: "Nobody in the crew designs; the site needs a look before anyone builds it.",
21+
};
22+
23+
export function seedChief(fake: FakeBotloft): void {
24+
void fake
25+
.call("crews.create", {
26+
name: "Bakery site",
27+
workFolder: "D:\\Projects\\Bakery site",
28+
lead: {
29+
name: "Chief",
30+
role: "Leads the crew: plans the work, suggests new bots and hands out tasks",
31+
instructions: "Build and keep up the website of my bakery.",
32+
},
33+
})
34+
.then((crew) => {
35+
const chief = crew.leadBotId;
36+
if (!chief) {
37+
return;
38+
}
39+
const writer = fake.addBot(crew.id, "Writer", "Writes the pages' text");
40+
fake.setBotState(writer.id, "idle", 1);
41+
fake.bot(chief).modelInUse = "claude-opus-5-5";
42+
const { chat } = fake;
43+
void fake.call("messages.send", {
44+
botId: chief,
45+
body: "Let's build the website for my bakery. What do you need?",
46+
});
47+
chat.finish(
48+
chat.tool(chief, "mcp__botloft__crew_roster", {}),
49+
'{"bots":[{"handle":"writer"}]}',
50+
);
51+
chat.reply(chief, PLAN);
52+
const input = JSON.stringify(DESIGNER);
53+
chat.tool(chief, SUGGEST_TOOL, { summary: "Designer", input });
54+
chat.ask(chief, SUGGEST_TOOL, "Designer", input);
55+
fake.setBotState(chief, "needs_approval", 1);
56+
});
57+
}

‎app/src/features/bots/BotHeader.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Archive,
3+
Crown,
34
Ellipsis,
45
FolderOpen,
56
PanelRight,
@@ -21,6 +22,7 @@ import { attempt } from "../../ui/toast";
2122
import { BotAvatar } from "./BotAvatar";
2223
import { BotDialog } from "./BotDialog";
2324
import { BotStateBadge } from "./BotStateBadge";
25+
import { ChiefBadge, isChief } from "./ChiefBadge";
2426

2527
type Open = "edit" | "archive" | "fresh" | null;
2628

@@ -41,6 +43,8 @@ export function BotHeader({
4143
const api = useApi();
4244
const host = useHost();
4345
const putBot = useApp((state) => state.putBot);
46+
const putCrew = useApp((state) => state.putCrew);
47+
const chief = isChief(bot, crew);
4448
const [open, setOpen] = useState<Open>(null);
4549
const close = () => setOpen(null);
4650
const stopped = bot.paused || crew.paused;
@@ -49,6 +53,10 @@ export function BotHeader({
4953
attempt(paused ? words.failed.pause : words.failed.resume, async () =>
5054
putBot(await api.call("bots.setPaused", { botId: bot.id, paused })),
5155
);
56+
const setChief = (on: boolean) =>
57+
attempt(words.failed.chief, async () =>
58+
putCrew(await api.call("crews.setLead", { crewId: crew.id, botId: on ? bot.id : null })),
59+
);
5260
const restart = (fresh: boolean) =>
5361
attempt(words.failed.restart, async () =>
5462
putBot(await api.call("bots.restart", { botId: bot.id, fresh })),
@@ -66,6 +74,7 @@ export function BotHeader({
6674
</div>
6775
<div className="mt-0.5 flex items-center gap-3 text-sm">
6876
<BotStateBadge bot={bot} crewPaused={crew.paused} />
77+
{chief && <ChiefBadge crew={crew} />}
6978
{bot.permissionMode === "bypass_permissions" && (
7079
<span
7180
title={t.chat.mode.badgeHint}
@@ -102,6 +111,11 @@ export function BotHeader({
102111
icon={Ellipsis}
103112
items={[
104113
{ label: words.edit, icon: Pencil, onSelect: () => setOpen("edit") },
114+
{
115+
label: chief ? words.stopChief : words.makeChief,
116+
icon: Crown,
117+
onSelect: () => setChief(!chief),
118+
},
105119
{
106120
label: words.restartFresh,
107121
icon: RefreshCcw,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Marks the bot that leads its crew (spec 10.2): a crown, with "Chief" in
2+
// full where there is room.
3+
4+
import { Crown } from "lucide-react";
5+
import { useT } from "../../i18n";
6+
import type { Bot, Crew } from "../../lib/protocol.gen";
7+
8+
export const isChief = (bot: Bot, crew: Crew) => crew.leadBotId === bot.id;
9+
10+
export function ChiefBadge({ crew, compact = false }: { crew: Crew; compact?: boolean }) {
11+
const c = useT().bots.chief;
12+
if (compact) {
13+
return (
14+
<span title={c.hint(crew.name)} className="inline-flex shrink-0 text-accent">
15+
<Crown aria-hidden size={13} />
16+
<span className="sr-only">{c.badge}</span>
17+
</span>
18+
);
19+
}
20+
return (
21+
<span
22+
title={c.hint(crew.name)}
23+
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-accent/10 px-2 py-0.5 font-medium text-accent text-xs"
24+
>
25+
<Crown aria-hidden size={12} />
26+
{c.badge}
27+
</span>
28+
);
29+
}

‎app/src/features/chat/ApprovalCard.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useT } from "../../i18n";
77
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
88
import { Button } from "../../ui/Button";
99
import { PLAN_TOOL, PlanCard } from "./PlanCard";
10+
import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard";
1011
import { pretty, toolLabel } from "./ToolLines";
1112
import { useAnswer } from "./useAnswer";
1213

@@ -44,11 +45,14 @@ function Answered({ approval }: { approval: ApprovalItem }) {
4445
}
4546

4647
export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
47-
return approval.toolName === PLAN_TOOL ? (
48-
<PlanCard approval={approval} bot={bot} />
49-
) : (
50-
<ToolApproval approval={approval} bot={bot} />
51-
);
48+
switch (approval.toolName) {
49+
case PLAN_TOOL:
50+
return <PlanCard approval={approval} bot={bot} />;
51+
case SUGGEST_TOOL:
52+
return <SuggestionCard approval={approval} bot={bot} />;
53+
default:
54+
return <ToolApproval approval={approval} bot={bot} />;
55+
}
5256
}
5357

5458
function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {

‎app/src/features/chat/ModePicker.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const MODES: { mode: PermissionMode; icon: LucideIcon }[] = [
3131
export function ModePicker({ bot, onLater }: { bot: Bot; onLater(text: string): void }) {
3232
const api = useApi();
3333
const putBot = useApp((state) => state.putBot);
34+
const chief = useApp((state) => state.crews[bot.crewId]?.leadBotId === bot.id);
3435
const m = useT().chat.mode;
3536
const [open, setOpen] = useState(false);
3637
const [confirming, setConfirming] = useState(false);
@@ -123,6 +124,7 @@ export function ModePicker({ bot, onLater }: { bot: Bot; onLater(text: string):
123124
>
124125
<p>{m.bypassBody(bot.name)}</p>
125126
<p className="mt-2">{m.bypassRisk}</p>
127+
{chief && <p className="mt-2">{m.bypassChief(bot.name)}</p>}
126128
<p className="mt-2 font-medium text-ink">{m.bypassAdvice}</p>
127129
</Confirm>
128130
)}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// The crew's chief suggesting a new bot (spec 10.2). The owner can change
2+
// the name, role, model and instructions before creating it, or say no
3+
// with a note for the chief. Once answered it shrinks to one line.
4+
5+
import { Ban, Check, ChevronRight, TimerOff, UserPlus } from "lucide-react";
6+
import { useId, useState } from "react";
7+
import { useT } from "../../i18n";
8+
import type { ApprovalItem, Bot, BotModel } from "../../lib/protocol.gen";
9+
import { Button } from "../../ui/Button";
10+
import { SelectField, TextArea, TextField } from "../../ui/Field";
11+
import { useAnswer } from "./useAnswer";
12+
13+
export const SUGGEST_TOOL = "mcp__botloft__suggest_bot";
14+
15+
const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];
16+
17+
interface Suggestion {
18+
name: string;
19+
role: string;
20+
instructions: string;
21+
model?: BotModel;
22+
reason: string;
23+
}
24+
25+
/** The suggestion in the request's input; empty fields if it is cut short. */
26+
export function suggestionOf(input: string): Suggestion {
27+
try {
28+
const parsed = JSON.parse(input) as Partial<Suggestion>;
29+
return {
30+
name: parsed.name ?? "",
31+
role: parsed.role ?? "",
32+
instructions: parsed.instructions ?? "",
33+
...(parsed.model && { model: parsed.model }),
34+
reason: parsed.reason ?? "",
35+
};
36+
} catch {
37+
return { name: "", role: "", instructions: input, reason: "" };
38+
}
39+
}
40+
41+
export function SuggestionCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
42+
const t = useT();
43+
const s = t.chat.suggestion;
44+
const asked = suggestionOf(approval.input);
45+
const [edited, setEdited] = useState<Suggestion>(asked);
46+
const { note, setNote, busy, answer } = useAnswer(approval, {
47+
allow: s.createFailed,
48+
deny: s.declineFailed,
49+
});
50+
const noteId = useId();
51+
if (approval.status !== "pending") {
52+
return <Answered approval={approval} />;
53+
}
54+
const changed = JSON.stringify(edited) !== JSON.stringify(asked);
55+
const set = (field: keyof Suggestion) => (value: string) =>
56+
setEdited((current) => ({ ...current, [field]: value }));
57+
58+
return (
59+
<section
60+
aria-label={s.title(bot.name)}
61+
className="my-1 overflow-hidden rounded-2xl border border-line bg-panel shadow-sm"
62+
>
63+
<p className="flex items-center gap-2.5 border-line border-b px-4 py-3 font-semibold text-sm">
64+
<span className="grid h-7 w-7 place-items-center rounded-full bg-accent/12 text-accent">
65+
<UserPlus aria-hidden size={15} />
66+
</span>
67+
{s.title(bot.name)}
68+
</p>
69+
<div className="flex flex-col gap-3 px-4 py-3">
70+
{asked.reason && (
71+
<p className="text-ink-soft text-sm" data-selectable>
72+
<span className="font-medium text-ink">{s.why}: </span>
73+
{asked.reason}
74+
</p>
75+
)}
76+
<div className="grid grid-cols-[1fr_12rem] gap-3">
77+
<TextField
78+
label={s.name}
79+
value={edited.name}
80+
onChange={(event) => set("name")(event.target.value)}
81+
/>
82+
<SelectField
83+
label={s.model}
84+
value={edited.model ?? "default"}
85+
onChange={(event) => set("model")(event.target.value)}
86+
options={MODELS.map((value) => ({ value, label: t.chat.model.names[value] }))}
87+
/>
88+
</div>
89+
<TextField
90+
label={s.role}
91+
value={edited.role}
92+
onChange={(event) => set("role")(event.target.value)}
93+
/>
94+
<TextArea
95+
label={s.instructions}
96+
value={edited.instructions}
97+
rows={5}
98+
onChange={(event) => set("instructions")(event.target.value)}
99+
/>
100+
<p className="text-muted text-xs">{s.startsNow(bot.name)}</p>
101+
</div>
102+
<div className="border-line border-t bg-canvas/40 px-4 py-3">
103+
<label htmlFor={noteId} className="sr-only">
104+
{s.noteLabel(bot.name)}
105+
</label>
106+
<textarea
107+
id={noteId}
108+
rows={2}
109+
value={note}
110+
onChange={(event) => setNote(event.target.value)}
111+
placeholder={s.notePlaceholder(bot.name)}
112+
className="block w-full resize-none rounded-xl border border-line-strong bg-panel px-3 py-2 text-sm outline-none placeholder:text-muted focus:border-muted"
113+
/>
114+
<div className="mt-2.5 flex flex-wrap gap-2">
115+
<Button
116+
variant="primary"
117+
icon={Check}
118+
disabled={busy || !edited.name.trim()}
119+
onClick={() => answer(true, changed ? JSON.stringify(edited) : undefined)}
120+
>
121+
{s.create}
122+
</Button>
123+
<Button icon={Ban} disabled={busy} onClick={() => answer(false)}>
124+
{s.decline}
125+
</Button>
126+
</div>
127+
</div>
128+
</section>
129+
);
130+
}
131+
132+
/** One line once answered; what was suggested stays a click away. */
133+
function Answered({ approval }: { approval: ApprovalItem }) {
134+
const t = useT();
135+
const s = t.chat.suggestion;
136+
const suggestion = suggestionOf(approval.input);
137+
const name = suggestion.name || s.name;
138+
const [Icon, text, tone] =
139+
approval.status === "allowed"
140+
? [Check, s.created(name), "text-ok"]
141+
: approval.status === "denied"
142+
? [Ban, s.declined(name), "text-danger"]
143+
: [TimerOff, s.expired(name), "text-quiet"];
144+
return (
145+
<details className="group">
146+
<summary className="flex min-w-0 cursor-default items-center gap-2 rounded-lg px-1.5 py-1 text-sm hover:bg-sunken">
147+
<Icon aria-hidden size={14} className={`shrink-0 ${tone}`} />
148+
<span className="shrink-0 font-medium">{text}</span>
149+
{approval.note && (
150+
<span className="truncate text-muted text-xs">{`“${approval.note}”`}</span>
151+
)}
152+
<ChevronRight
153+
aria-hidden
154+
size={13}
155+
className="shrink-0 text-muted transition-transform group-open:rotate-90"
156+
/>
157+
</summary>
158+
<dl
159+
className="mt-1.5 ml-6 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-xl border border-line bg-panel px-4 py-3 text-sm"
160+
data-selectable
161+
>
162+
<dt className="text-muted">{s.role}</dt>
163+
<dd>{suggestion.role}</dd>
164+
<dt className="text-muted">{s.model}</dt>
165+
<dd>{t.chat.model.names[suggestion.model ?? "default"]}</dd>
166+
<dt className="text-muted">{s.instructions}</dt>
167+
<dd className="whitespace-pre-wrap">{suggestion.instructions}</dd>
168+
</dl>
169+
</details>
170+
);
171+
}

‎app/src/features/chat/ToolLines.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type LucideIcon,
1515
Send,
1616
SquareTerminal,
17+
UserPlus,
1718
Users,
1819
Wrench,
1920
X,
@@ -39,6 +40,7 @@ const ICONS: Record<string, LucideIcon> = {
3940
Task: Users,
4041
Agent: Users,
4142
send_message: Send,
43+
suggest_bot: UserPlus,
4244
};
4345

4446
/** `mcp__botloft__send_message` reads as `send_message`. */

0 commit comments

Comments
 (0)