{voice?.enabled ? (
diff --git a/apps/web/src/components/sessions/SessionToolApprovalsPicker.client.test.tsx b/apps/web/src/components/sessions/SessionToolApprovalsPicker.client.test.tsx
new file mode 100644
index 0000000000..2dc60467b7
--- /dev/null
+++ b/apps/web/src/components/sessions/SessionToolApprovalsPicker.client.test.tsx
@@ -0,0 +1,173 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+const auto = vi.hoisted(() => ({
+ state: undefined as
+ | { available: boolean; enabled: boolean; suspended: boolean }
+ | undefined,
+ isSaving: false,
+ setEnabled: vi.fn(),
+}));
+
+vi.mock('@/hooks/useSessionAutoToolApprovals', () => ({
+ useSessionAutoToolApprovals: () => auto,
+}));
+
+import {
+ SessionToolApprovalsPicker,
+ ToolApprovalsPicker,
+} from './SessionToolApprovalsPicker';
+
+const chip = () =>
+ screen.getByRole('button', { name: /^Tool approvals for this session/ });
+const openMenu = () => fireEvent.click(chip());
+const option = (name: RegExp) => screen.getByRole('option', { name });
+
+describe('ToolApprovalsPicker', () => {
+ it('names the current mode on the chip and lists both modes with what they do', () => {
+ render(
);
+ expect(chip()).toHaveTextContent('Run');
+ openMenu();
+ expect(screen.getByText('Tool approvals')).toBeInTheDocument();
+ expect(option(/^Run/)).toHaveAttribute('aria-selected', 'true');
+ expect(option(/^Auto/)).toHaveAttribute('aria-selected', 'false');
+ // Run is honest about the per-tool choices that still apply.
+ expect(
+ screen.getByText(
+ 'Tools run without a check, unless set to Always ask or Disable.',
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Roomote checks each tool call and asks before risky ones.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('changes the mode from the list and closes it', () => {
+ const onModeChange = vi.fn();
+ const { rerender } = render(
+
,
+ );
+ openMenu();
+ fireEvent.click(option(/^Auto/));
+ expect(onModeChange).toHaveBeenLastCalledWith('auto');
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+
+ rerender(
);
+ expect(chip()).toHaveTextContent('Auto');
+ openMenu();
+ // Picking the mode already in effect changes nothing.
+ fireEvent.click(option(/^Auto/));
+ expect(onModeChange).toHaveBeenCalledTimes(1);
+ openMenu();
+ fireEvent.click(option(/^Run/));
+ expect(onModeChange).toHaveBeenLastCalledWith('run');
+ });
+
+ it('keeps explaining Auto while it cannot be chosen, and can always leave it', () => {
+ const onModeChange = vi.fn();
+ const { rerender } = render(
+
,
+ );
+ openMenu();
+ expect(option(/^Auto/)).toBeDisabled();
+ expect(option(/^Auto/)).toHaveTextContent(
+ 'Roomote checks each tool call and asks before risky ones. Not available yet.',
+ );
+ fireEvent.click(option(/^Run/));
+
+ rerender(
+
,
+ );
+ openMenu();
+ expect(option(/^Run/)).toBeEnabled();
+ fireEvent.click(option(/^Run/));
+ expect(onModeChange).toHaveBeenLastCalledWith('run');
+ });
+
+ it('says when Auto paused itself and offers to resume it', () => {
+ const onResume = vi.fn();
+ const { rerender } = render(
+
,
+ );
+ expect(chip()).toHaveTextContent('Auto paused');
+ openMenu();
+ expect(
+ screen.getByText(
+ 'Auto couldn’t check tool calls, so tools will ask before running.',
+ ),
+ ).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Resume Auto' }));
+ expect(onResume).toHaveBeenCalledOnce();
+
+ // Nothing to resume with while calls still cannot be checked.
+ rerender(
+
,
+ );
+ openMenu();
+ expect(
+ screen.queryByRole('button', { name: 'Resume Auto' }),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe('SessionToolApprovalsPicker', () => {
+ beforeEach(() => {
+ auto.state = { available: true, enabled: false, suspended: false };
+ auto.isSaving = false;
+ auto.setEnabled.mockClear();
+ });
+
+ it('renders nothing until the mode is offered for the session', () => {
+ auto.state = undefined;
+ const { container } = render(
+
,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('starts in Run and turns Auto on and off for this session', () => {
+ const { rerender } = render(
+
,
+ );
+ expect(chip()).toHaveTextContent('Run');
+ openMenu();
+ fireEvent.click(option(/^Auto/));
+ expect(auto.setEnabled).toHaveBeenLastCalledWith(true);
+
+ auto.state = { available: true, enabled: true, suspended: true };
+ rerender(
);
+ expect(chip()).toHaveTextContent('Auto paused');
+ openMenu();
+ fireEvent.click(screen.getByRole('button', { name: 'Resume Auto' }));
+ expect(auto.setEnabled).toHaveBeenLastCalledWith(true);
+ openMenu();
+ fireEvent.click(option(/^Run/));
+ expect(auto.setEnabled).toHaveBeenLastCalledWith(false);
+ });
+
+ it('holds the chip while a change is saving', () => {
+ auto.isSaving = true;
+ render(
);
+ expect(chip()).toBeDisabled();
+ });
+});
diff --git a/apps/web/src/components/sessions/SessionToolApprovalsPicker.tsx b/apps/web/src/components/sessions/SessionToolApprovalsPicker.tsx
new file mode 100644
index 0000000000..18334a4fcb
--- /dev/null
+++ b/apps/web/src/components/sessions/SessionToolApprovalsPicker.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import { useState } from 'react';
+
+import { cn } from '@/lib/utils';
+import { useSessionAutoToolApprovals } from '@/hooks/useSessionAutoToolApprovals';
+
+import {
+ BasicTooltip,
+ Button,
+ buttonVariants,
+ Check,
+ ChevronDown,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Scale,
+} from '@/components/system';
+
+type ToolApprovalsMode = 'run' | 'auto';
+
+const MODES: {
+ mode: ToolApprovalsMode;
+ label: string;
+ description: string;
+}[] = [
+ {
+ mode: 'run',
+ label: 'Run',
+ description:
+ 'Tools run without a check, unless set to Always ask or Disable.',
+ },
+ {
+ mode: 'auto',
+ label: 'Auto',
+ description: 'Roomote checks each tool call and asks before risky ones.',
+ },
+];
+
+const COPY = {
+ title: 'Tool approvals',
+ tooltip: 'Tool approvals for this session',
+ unavailable: 'Not available yet.',
+ pausedLabel: 'Auto paused',
+ paused: 'Auto couldn’t check tool calls, so tools will ask before running.',
+ resume: 'Resume Auto',
+};
+
+/**
+ * The composer's tool approvals chip: what happens, in this session, to the
+ * tools nobody has made a choice about. It opens a short list of modes. The
+ * mode is the session owner's choice and every session starts in Run.
+ */
+export function ToolApprovalsPicker({
+ mode,
+ available = true,
+ paused = false,
+ disabled,
+ size = 'compact',
+ onModeChange,
+ onResume,
+}: {
+ mode: ToolApprovalsMode;
+ /** Whether Auto can be chosen; leaving it always works. */
+ available?: boolean;
+ /** Auto stopped because calls could not be checked. */
+ paused?: boolean;
+ disabled?: boolean;
+ size?: 'compact' | 'base';
+ onModeChange: (mode: ToolApprovalsMode) => void;
+ onResume?: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const autoPaused = mode === 'auto' && paused;
+ const label = autoPaused
+ ? COPY.pausedLabel
+ : MODES.find((entry) => entry.mode === mode)!.label;
+
+ return (
+
+
+
+
+
+
+
+
+ {COPY.title}
+
+
+ {MODES.map((entry) => {
+ const selected = entry.mode === mode;
+ const locked = entry.mode === 'auto' && !available && !selected;
+ return (
+
+ );
+ })}
+
+ {autoPaused ? (
+
+
{COPY.paused}
+ {available && onResume ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+/** The tool approvals chip for one running session, shown to its owner. */
+export function SessionToolApprovalsPicker({
+ sessionId,
+ disabled,
+}: {
+ /** The unified session, which approvals are keyed on. */
+ sessionId: string;
+ disabled?: boolean;
+}) {
+ const { state, isSaving, setEnabled } =
+ useSessionAutoToolApprovals(sessionId);
+ if (!state) return null;
+
+ return (
+
setEnabled(mode === 'auto')}
+ onResume={() => setEnabled(true)}
+ />
+ );
+}
diff --git a/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx b/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx
index 934d5372dc..895bc8d136 100644
--- a/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx
+++ b/apps/web/src/components/settings/IntegrationToolAutoApprovalsNightlySetting.tsx
@@ -22,9 +22,10 @@ export function IntegrationToolAutoApprovalsNightlySetting() {
onCheckedChange={setEnabled}
/>
- Expose the Auto-approval decisions card in Settings → Agent Guidance.
- Jev is required for Auto to make decisions, and saved per-tool choices
- continue to take priority.
+ Add the tool approvals mode to each session’s message box, where its
+ owner can choose Auto, and the Auto-approval decisions card to
+ Settings → Agent Guidance. Jev is required for Auto to make decisions,
+ and saved per-tool choices continue to take priority.
diff --git a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx
index 6ceb896c15..0ba7c6c605 100644
--- a/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx
+++ b/apps/web/src/components/settings/IntegrationToolAutoModeSetting.client.test.tsx
@@ -45,33 +45,19 @@ describe('IntegrationToolAutoModeSetting', () => {
state.setAuto.mockClear();
});
- it('shows the approval switch and a link to integration tool controls', () => {
+ it('has no deployment-wide switch: Auto is turned on per session', () => {
render(