Explain Auto-Approve once per session

This commit is contained in:
Devika Verma
2026-08-22 07:24:47 +05:30
parent 9ac59aafe3
commit 76e9174ef9
5 changed files with 81 additions and 1 deletions
+25
View File
@@ -117,6 +117,18 @@ function readLastSessions(): Record<string, LastSession> {
}
}
// Shown once the first time Auto-Approve is active in a session (spec §1.5). Says what the
// mode buys, what it can never override, and — deliberately unhedged — where it can be
// wrong. It reduces interruptions, not risk, and the last line has to leave that clear.
const AUTO_APPROVE_NOTICE = [
"Your session model lets routine actions through without asking; anything it isn't sure " +
"about still comes to you — whatever the rules settle outright, it never sees.",
"It reduces interruptions from lower-risk actions, not the risk from what it can't tell: " +
"what a script will do, or whether an instruction came from you or from a web page or " +
"email. Shell commands aren't sandboxed — they reach anything you can. These are model " +
"judgments, not guarantees — a false allow executes unchecked.",
].join("\n\n");
function rememberLastSession(agent: string, sessionId: string, workspace: string | null) {
if (!agent || !sessionId) return;
try {
@@ -187,6 +199,10 @@ export function App() {
const [streaming, setStreamingState] = useState("");
// Ref mirror of `streaming`: the WS handler closure is built once per socket and can't read
// fresh state — the interrupted/error flush below needs the live buffer at event time.
// Auto-Approve banner: once per session, however the mode was entered (picked in the
// composer, applied by a plan approval, or restored with the session). Keyed on the
// session id, so toggling out and back stays quiet but a new session explains itself.
const autoApproveNoticeFor = useRef("");
const streamingRef = useRef("");
const setStreaming = (value: string | ((s: string) => string)) => {
streamingRef.current = typeof value === "function" ? value(streamingRef.current) : value;
@@ -870,6 +886,15 @@ export function App() {
atBottomRef.current = true;
setFollowing(true);
}, [sessionId]);
useEffect(() => {
if (mode !== "auto-approve" || autoApproveNoticeFor.current === sessionId) return;
autoApproveNoticeFor.current = sessionId;
setItems((p) => [
...p,
{ kind: "notice", tone: "info", title: "Auto-Approve is on.", text: AUTO_APPROVE_NOTICE },
]);
}, [mode, sessionId]);
useEffect(() => {
if (atBottomRef.current) scrollToBottom();
}, [items, streaming]);
@@ -318,3 +318,31 @@ describe("reviewer deny card (§8.4)", () => {
expect(screen.queryByTestId("reviewer-allow-anyway")).toBeNull();
});
});
// The Auto-Approve banner (spec §1.5): a titled notice is prose, not a status line, so it
// renders as a heading plus paragraphs rather than one centred grey row.
describe("mode notice", () => {
const BANNER: Item[] = [
{
kind: "notice",
tone: "info",
title: "Auto-Approve is on.",
text: "First paragraph about what it does.\n\nSecond paragraph about what it can't tell.",
},
];
it("renders the title and one paragraph per blank-line break", () => {
render(<Transcript items={BANNER} running={false} />);
const block = screen.getByTestId("mode-notice");
expect(block.textContent).toContain("Auto-Approve is on.");
expect(block.querySelectorAll("p")).toHaveLength(2);
// Prose layout, not the centred one-liner used for "Context compacted".
expect(block.className).toContain("notice-block");
});
it("leaves untitled status notices as plain one-liners", () => {
render(<Transcript items={[{ kind: "notice", tone: "info", text: "Context compacted" }]} running={false} />);
expect(screen.queryByTestId("mode-notice")).toBeNull();
expect(screen.getByText("Context compacted").className).not.toContain("notice-block");
});
});
@@ -524,6 +524,21 @@ export function Transcript({ items, running, streamingText, onRetry, onUndoMemor
</div>
);
case "notice":
// A titled notice is prose (the Auto-Approve banner), not a status line.
if (item.title) {
return (
<div
className={"notice notice-block " + (item.tone === "warn" ? "warn" : "")}
key={bi}
data-testid="mode-notice"
>
<div className="notice-title">{item.title}</div>
{item.text.split("\n\n").map((para, i) => (
<p key={i}>{para}</p>
))}
</div>
);
}
return (
<div className={"notice " + (item.tone === "warn" ? "warn" : "")} key={bi}>
{item.text}
+9
View File
@@ -367,6 +367,15 @@ body {
.notice { font-size: 13px; color: var(--muted); text-align: center; }
.notice.warn { color: var(--accent); }
/* Mode banner: prose, so it reads left-aligned in a card rather than as a centred
status line. Same `notice` item, switched on by a title. */
.notice-block {
text-align: left; max-width: 62ch; margin: 12px auto; padding: 11px 13px;
border: 1px solid var(--line); border-radius: 9px; background: var(--paper);
display: flex; flex-direction: column; gap: 7px; line-height: 1.5;
}
.notice-block .notice-title { font-weight: 600; color: var(--ink); }
.notice-block p { margin: 0; }
/* approval */
.approval {
+4 -1
View File
@@ -152,7 +152,10 @@ export type Item =
questions?: GroupedQuestion[];
resolved?: string;
}
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean }
// `title` switches the one-line status notice to a block: a heading plus
// blank-line-separated paragraphs, left-aligned. Used for the Auto-Approve banner,
// which is prose rather than a status line.
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean; title?: string }
// MEMORY-SPEC §5.1: the save notice, inline in the conversation where the user is
// already looking (a corner toast vanished before it could be read or undone —
// owner-hit 2026-07-28). Stays put. `previous` is set when an existing memory was