Skip to content

Commit 6a8340d

Browse files
committed
feat(chat): pick each bot's permission mode in its chat
Like Claude Code's mode picker, below the chat: Auto, Manual, Accept edits and Plan, each described by what the bot does ("Scout decides what needs your OK"), and Bypass permissions behind a confirmation that says the bot is then not held to its folder. A bot that asks nothing is marked in red in the picker and its header. The daemon stores the mode (migration 0004) and starts the bot with it. Claude Code reads --permission-mode only at start, so a new mode restarts the process with --resume once nothing is in progress, and never cuts a turn short. When an approved plan moves the bot out of plan mode, the system/init of the next turn says so and the stored mode follows. A bot in plan mode asks to go ahead with ExitPlanMode: the chat shows the whole plan (kept up to 32 KB) with Approve plan and Keep planning, which sends back what should change. Spec: 7.4, 10.1, 11.2, 12, 13 (what bypassing means on Windows), 15.1, 15.3 and two rows in section 19 to check with the real Claude Code.
1 parent 25c3078 commit 6a8340d

40 files changed

Lines changed: 1054 additions & 65 deletions

‎app/src/dev/preview.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,19 @@ function seed(fake: FakeBotloft): void {
8686
"Checks facts and tone before anything ships",
8787
);
8888
const analyst = fake.addBot(research.id, "Analyst", "Crunches the numbers behind each claim");
89+
const planner = fake.addBot(research.id, "Planner", "Plans changes before touching the code");
90+
void fake.call("bots.setPermissionMode", { botId: planner.id, mode: "plan" });
8991
fake.setBotState(scout.id, "idle", 3);
9092
fake.setBotState(writer.id, "busy", 2);
9193
fake.setBotState(reviewer.id, "needs_approval", 5);
9294
fake.setBotState(analyst.id, "rate_limited", 1);
95+
fake.setBotState(planner.id, "needs_approval", 2);
9396
seedChats(fake, {
9497
scout: scout.id,
9598
writer: writer.id,
9699
reviewer: reviewer.id,
97100
analyst: analyst.id,
101+
planner: planner.id,
98102
});
99103
const ops = fake.addCrew("Ops");
100104
const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays");

‎app/src/dev/seedChats.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Dev only: a morning's work between four research bots, for the preview.
1+
// Dev only: a morning's work between five research bots, for the preview.
22

33
import type { FakeBotloft } from "../lib/fake";
44
import type { BotId } from "../lib/protocol.gen";
@@ -23,11 +23,22 @@ const SUMMARY = `Done. The summary is in \`shared/summaries/week-39.md\`.
2323
2424
Your chart matches the numbers in the first paper. I asked @writer to turn this into the weekly report.`;
2525

26+
const PLAN = `## Cache the weekly report
27+
28+
The report is rebuilt on every visit, and Monday is when everyone opens it.
29+
30+
1. Build the report once when the week closes and save it in \`shared/cache/\`.
31+
2. Serve the saved copy; rebuild it only when a source file changes.
32+
3. Add a test that the saved copy matches a fresh build.
33+
34+
**Not changing:** the report's layout or its sources.`;
35+
2636
interface Crew {
2737
scout: BotId;
2838
writer: BotId;
2939
reviewer: BotId;
3040
analyst: BotId;
41+
planner: BotId;
3142
}
3243

3344
const MINUTE = 60 * 1000;
@@ -124,5 +135,16 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void {
124135
text: "The account reached its usage limit. Messages wait until it resets.",
125136
});
126137
chat.turn(crew.analyst, "rate_limit");
138+
139+
at(8);
140+
void fake.call("messages.send", {
141+
botId: crew.planner,
142+
body: "The report pages are slow on Mondays. Plan a fix, but don't change anything yet.",
143+
});
144+
at(7);
145+
chat.finish(chat.tool(crew.planner, "Grep", { summary: "buildReport" }), "3 matches");
146+
const plan = JSON.stringify({ plan: PLAN });
147+
chat.tool(crew.planner, "ExitPlanMode", { summary: "Cache the weekly report", input: plan });
148+
chat.ask(crew.planner, "ExitPlanMode", "Cache the weekly report", plan);
127149
fake.now = end;
128150
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Play,
99
RefreshCcw,
1010
RotateCw,
11+
ShieldOff,
1112
} from "lucide-react";
1213
import { useState } from "react";
1314
import { useT } from "../../i18n";
@@ -65,6 +66,15 @@ export function BotHeader({
6566
</div>
6667
<div className="mt-0.5 flex items-center gap-3 text-sm">
6768
<BotStateBadge bot={bot} crewPaused={crew.paused} />
69+
{bot.permissionMode === "bypass_permissions" && (
70+
<span
71+
title={t.chat.mode.badgeHint}
72+
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-danger/10 px-2 py-0.5 font-medium text-danger text-xs"
73+
>
74+
<ShieldOff aria-hidden size={12} />
75+
{t.chat.mode.badge}
76+
</span>
77+
)}
6878
<span className="truncate text-muted">{bot.role || words.noRole}</span>
6979
</div>
7080
</div>

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

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22
// and Deny. Once answered it shrinks to one line.
33

44
import { Ban, Check, Hand, TimerOff } from "lucide-react";
5-
import { useId, useState } from "react";
5+
import { useId } from "react";
66
import { useT } from "../../i18n";
77
import type { ApprovalItem, Bot } from "../../lib/protocol.gen";
8-
import { useApi } from "../../store/context";
98
import { Button } from "../../ui/Button";
10-
import { attempt } from "../../ui/toast";
9+
import { PLAN_TOOL, PlanCard } from "./PlanCard";
1110
import { pretty, toolLabel } from "./ToolLines";
11+
import { useAnswer } from "./useAnswer";
1212

1313
function Answered({ approval }: { approval: ApprovalItem }) {
1414
const t = useT();
1515
const label = toolLabel(approval.toolName);
16-
const line = "flex min-w-0 items-center gap-2 px-1.5 py-1 text-sm";
16+
const line = "flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-sm";
1717
switch (approval.status) {
1818
case "allowed":
1919
return (
@@ -44,48 +44,47 @@ function Answered({ approval }: { approval: ApprovalItem }) {
4444
}
4545

4646
export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
47-
const api = useApi();
47+
return approval.toolName === PLAN_TOOL ? (
48+
<PlanCard approval={approval} bot={bot} />
49+
) : (
50+
<ToolApproval approval={approval} bot={bot} />
51+
);
52+
}
53+
54+
function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) {
4855
const t = useT();
49-
const [note, setNote] = useState("");
50-
const [busy, setBusy] = useState(false);
56+
const { note, setNote, busy, answer } = useAnswer(approval, {
57+
allow: t.chat.approval.allowFailed,
58+
deny: t.chat.approval.denyFailed,
59+
});
5160
const noteId = useId();
5261
if (approval.status !== "pending") {
5362
return <Answered approval={approval} />;
5463
}
5564
const label = toolLabel(approval.toolName);
56-
const answer = async (allow: boolean) => {
57-
setBusy(true);
58-
const trimmed = note.trim();
59-
await attempt(allow ? t.chat.approval.allowFailed : t.chat.approval.denyFailed, () =>
60-
api.call("approvals.answer", {
61-
approvalId: approval.approvalId,
62-
allow,
63-
...(allow || !trimmed ? {} : { note: trimmed }),
64-
}),
65-
);
66-
setBusy(false);
67-
};
6865

6966
return (
7067
<section
7168
aria-label={t.chat.approval.asks(bot.name, label)}
72-
className="my-1 max-w-2xl rounded-md border border-warn/60 border-l-4 bg-panel px-3.5 py-3"
69+
className="my-1 max-w-2xl rounded-2xl border border-warn/40 bg-panel px-4 py-3.5 shadow-sm"
7370
>
74-
<p className="flex items-center gap-2 font-semibold text-sm">
75-
<Hand aria-hidden size={15} className="text-warn" />
71+
<p className="flex items-center gap-2.5 font-semibold text-sm">
72+
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-warn/12 text-warn">
73+
<Hand aria-hidden size={15} />
74+
</span>
7675
{t.chat.approval.wants(bot.name, label)}
7776
</p>
7877
{approval.summary && (
79-
<p className="mt-1.5 break-words font-mono text-ink-soft text-xs" data-selectable>
78+
<p className="mt-2 break-words font-mono text-ink-soft text-xs" data-selectable>
8079
{approval.summary}
8180
</p>
8281
)}
83-
<details className="mt-1.5 text-xs">
82+
<details className="mt-2 text-xs">
8483
<summary className="cursor-default text-muted hover:text-ink">
8584
{t.chat.approval.fullInput}
8685
</summary>
8786
<pre
88-
className="mt-1 max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-[3px] border border-line bg-sunken px-2.5 py-1.5 font-mono"
87+
className="mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono"
8988
data-selectable
9089
>
9190
{pretty(approval.input)}
@@ -99,7 +98,7 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
9998
value={note}
10099
onChange={(event) => setNote(event.target.value)}
101100
placeholder={t.chat.approval.notePlaceholder}
102-
className="mt-3 h-8 w-full rounded-[3px] border border-line-strong bg-canvas px-2.5 text-sm outline-none placeholder:text-muted focus:border-accent"
101+
className="mt-3 h-9 w-full rounded-xl border border-line-strong bg-canvas px-3 text-sm outline-none placeholder:text-muted focus:border-muted"
103102
/>
104103
<div className="mt-2.5 flex gap-2">
105104
<Button variant="primary" icon={Check} disabled={busy} onClick={() => answer(true)}>

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

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ClipboardEvent,
77
type FormEvent,
88
type KeyboardEvent,
9+
useEffect,
910
useId,
1011
useLayoutEffect,
1112
useRef,
@@ -17,16 +18,17 @@ import { fileSize } from "../../lib/format";
1718
import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
1819
import { useApi } from "../../store/context";
1920
import { rememberImage } from "./images";
21+
import { ModePicker } from "./ModePicker";
2022
import type { Files, PendingFile } from "./useFiles";
2123

2224
const MAX_HEIGHT_PX = 240;
2325

2426
function Chip({ file, onRemove }: { file: PendingFile; onRemove(): void }) {
2527
const t = useT();
2628
return (
27-
<li className="relative flex h-14 items-center gap-2 rounded-md border border-line bg-canvas pr-7 pl-1.5">
29+
<li className="relative flex h-14 items-center gap-2 rounded-xl border border-line bg-canvas pr-7 pl-1.5">
2830
{file.preview ? (
29-
<img src={file.preview} alt="" className="h-11 w-11 rounded-[3px] object-cover" />
31+
<img src={file.preview} alt="" className="h-11 w-11 rounded-lg object-cover" />
3032
) : (
3133
<FileIcon aria-hidden size={20} className="mx-1 text-muted" />
3234
)}
@@ -64,12 +66,21 @@ export function ChatComposer({
6466
const t = useT();
6567
const [text, setText] = useState("");
6668
const [busy, setBusy] = useState(false);
69+
/** A new mode that waits for the bot to finish what it is doing. */
70+
const [later, setLater] = useState<string | null>(null);
6771
const field = useRef<HTMLTextAreaElement>(null);
6872
const picker = useRef<HTMLInputElement>(null);
6973
const fieldId = useId();
7074
const tooLong = text.length > FIELD_LIMITS.message;
7175
const empty = !text.trim() && files.files.length === 0;
7276

77+
const working = bot.state === "busy" || bot.state === "needs_approval";
78+
useEffect(() => {
79+
if (!working) {
80+
setLater(null);
81+
}
82+
}, [working]);
83+
7384
// Grows with the text up to a limit, then scrolls.
7485
useLayoutEffect(() => {
7586
const element = field.current;
@@ -130,14 +141,18 @@ export function ChatComposer({
130141
};
131142

132143
return (
133-
<form onSubmit={send} className="shrink-0 px-5 pt-2 pb-4">
134-
{stopped && <p className="mb-1.5 text-muted text-xs">{t.chat.composer.paused(bot.name)}</p>}
135-
<div className="rounded-lg border border-line-strong bg-panel focus-within:border-accent">
144+
<form onSubmit={send} className="shrink-0 px-5 pt-2 pb-5">
145+
{stopped && (
146+
<p className="mb-1.5 px-2 text-muted text-xs">{t.chat.composer.paused(bot.name)}</p>
147+
)}
148+
{later && (
149+
<p role="status" className="mb-1.5 px-2 text-muted text-xs">
150+
{later}
151+
</p>
152+
)}
153+
<div className="rounded-2xl border border-line-strong bg-panel shadow-sm transition-colors focus-within:border-muted">
136154
{files.files.length > 0 && (
137-
<ul
138-
aria-label={t.chat.composer.filesToSend}
139-
className="flex flex-wrap gap-2 px-2.5 pt-2.5"
140-
>
155+
<ul aria-label={t.chat.composer.filesToSend} className="flex flex-wrap gap-2 px-3 pt-3">
141156
{files.files.map((file) => (
142157
<Chip key={file.key} file={file} onRemove={() => files.remove(file.key)} />
143158
))}
@@ -155,15 +170,15 @@ export function ChatComposer({
155170
onKeyDown={onKeyDown}
156171
onPaste={onPaste}
157172
placeholder={t.chat.composer.placeholder(bot.name)}
158-
className="block max-h-60 w-full resize-none bg-transparent px-3.5 pt-3 pb-1 leading-relaxed outline-none placeholder:text-muted"
173+
className="block max-h-60 w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 leading-relaxed outline-none placeholder:text-muted"
159174
/>
160-
<div className="flex items-center gap-2 px-2 pb-2">
175+
<div className="flex items-center gap-1 px-2.5 pb-2.5">
161176
<button
162177
type="button"
163178
aria-label={t.chat.composer.attach}
164179
title={t.chat.composer.attachHint}
165180
onClick={() => picker.current?.click()}
166-
className="grid h-8 w-8 place-items-center rounded-[3px] text-muted hover:bg-sunken hover:text-ink"
181+
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-muted hover:bg-sunken hover:text-ink"
167182
>
168183
<Paperclip aria-hidden size={16} />
169184
</button>
@@ -178,16 +193,19 @@ export function ChatComposer({
178193
event.target.value = "";
179194
}}
180195
/>
181-
<span className={`flex-1 text-xs ${tooLong ? "text-danger" : "text-muted"}`}>
196+
<ModePicker bot={bot} onLater={setLater} />
197+
<span
198+
className={`min-w-0 flex-1 truncate px-1 text-right text-xs ${tooLong ? "text-danger" : "text-muted"}`}
199+
>
182200
{tooLong
183201
? t.chat.composer.tooLong(text.length, FIELD_LIMITS.message)
184-
: t.chat.composer.keys}
202+
: text && t.chat.composer.keys}
185203
</span>
186204
<button
187205
type="submit"
188206
aria-label={t.chat.composer.send}
189207
disabled={empty || tooLong || busy}
190-
className="grid h-8 w-8 place-items-center rounded-full bg-ink text-canvas hover:bg-ink-soft disabled:opacity-35"
208+
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent text-canvas transition-opacity hover:opacity-90 disabled:bg-line-strong disabled:opacity-60"
191209
>
192210
<ArrowUp aria-hidden size={16} strokeWidth={2.25} />
193211
</button>

0 commit comments

Comments
 (0)