Skip to content
Open
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
14 changes: 14 additions & 0 deletions electron/chat/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,20 @@ describe('Claude chat adapter', () => {
});
});

// The mode is otherwise known only from a turn's init message, and the chat
// view reads it to decide whether a mode can be picked.
it('reports the bypass mode as soon as it connects, before any turn', async () => {
const h = harness([], undefined, { skipPermissions: true });
await h.chat.start();
expect(h.chat.state.permissionMode).toBe('bypassPermissions');
});

it('reports no mode before a turn when it does not bypass', async () => {
const h = harness([], undefined, { permissionMode: 'acceptEdits' });
await h.chat.start();
expect(h.chat.state.permissionMode).toBeUndefined();
});

it('runs the mode the user picked for this chat, and only then overrides their settings', async () => {
const h = harness([], undefined, { permissionMode: 'acceptEdits' });
await h.chat.start();
Expand Down
4 changes: 4 additions & 0 deletions electron/chat/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export class ClaudeChat implements AgentChat {
if (this.isClosed())
throw new Error(this.state.error ?? 'Claude disconnected while connecting.');
this.noteUnadoptedSettingsMode(settingsMode);
// Claude reports its permission mode only in each turn's init message, so
// until the first prompt the state would not say this session bypasses
// permissions. It is known from how the session was launched.
if (this.opts.skipPermissions) this.state.permissionMode = 'bypassPermissions';
this.state.status = 'ready';
this.publish();
void this.refreshContextUsage();
Expand Down
4 changes: 3 additions & 1 deletion src/components/AgentChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ export function AgentChatView(props: {
...callbacks,
onReview: props.onReview ? reviewFile : undefined,
permissionMode: state()?.permissionMode ?? props.task.chatPermissionMode,
permissionsDisabled: props.task.skipPermissions,
// The session's own mode, not the task flag: the flag can now change while a
// chat runs, and the session keeps the permissions it was started with.
permissionsDisabled: state()?.permissionMode === 'bypassPermissions',
onPermissionMode: provider() === 'claude' ? selectPermissionMode : undefined,
agentName: agentName(),
connection: connected,
Expand Down
34 changes: 34 additions & 0 deletions src/components/TaskTitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getTaskAttentionState,
toggleTaskFocusMode,
clearTaskLandingReview,
setTaskSkipPermissions,
getPrChecks,
getVerifyCommand,
isTaskCanvasVisible,
Expand All @@ -31,6 +32,7 @@ import { getTaskDockerBadgeLabel } from '../lib/docker';
import { displayTaskNameFromPrompt, shouldUsePromptDerivedTaskName } from '../lib/clean-task-name';
import type { Task } from '../store/types';
import { isLandedTaskState } from '../store/landing';
import { resolveSkipPermissionsArgs } from '../../electron/shared/skip-permissions';

// Kinds without an entry stay silent: a configured-but-never-run command on
// every task would be noise, and cancelled runs carry no signal.
Expand All @@ -56,6 +58,23 @@ interface TaskTitleBarProps {
export function TaskTitleBar(props: TaskTitleBarProps) {
const dockerBadgeLabel = () => getTaskDockerBadgeLabel(props.task.dockerSource);
const isLandedTask = () => isLandedTaskState(props.task.landingState);
// Offered when any of the task's agents takes a skip-permissions flag, resolved
// by command so a definition restored without its flags still qualifies.
// Not for coordinators: sub-task propagation is fixed when the coordinator
// registers, so a change there would only partly apply until the next launch.
const offersSkipPermissionsToggle = () =>
!props.task.coordinatorMode &&
!isLandedTask() &&
props.task.agentIds.some((id) => {
const def = store.agents[id]?.def;
return !!def && resolveSkipPermissionsArgs(def).length > 0;
});
const skipPermissionsOn = () => props.task.skipPermissions === true;
const skipPermissionsTitle = () =>
(skipPermissionsOn()
? "Skips the agent's permission prompts. Click to turn off."
: "Uses the agent's own permission settings. Click to skip its prompts.") +
' Takes effect the next time the agent starts.';
const landingBadge = () => {
switch (props.task.landingState) {
case 'landed_pending_review':
Expand Down Expand Up @@ -239,6 +258,21 @@ export function TaskTitleBar(props: TaskTitleBarProps) {
</span>
)}
</Show>
<Show when={offersSkipPermissionsToggle()}>
<button
style={{
...badgeStyle(skipPermissionsOn() ? theme.warning : theme.fgMuted),
cursor: 'pointer',
}}
title={skipPermissionsTitle()}
onClick={(e) => {
e.stopPropagation();
setTaskSkipPermissions(props.task.id, !skipPermissionsOn());
}}
>
{skipPermissionsOn() ? 'skip confirms' : 'confirms on'}
</button>
</Show>
</div>
<div class="task-title-actions">
<Show when={props.task.gitIsolation === 'worktree' && !isLandedTask()}>
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
setInitialPrompt,
clearPrefillPrompt,
clearTaskLandingReview,
setTaskSkipPermissions,
setPrefillPrompt,
reorderTask,
reorderTaskVisually,
Expand Down
32 changes: 32 additions & 0 deletions src/store/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ import {
markTaskMcpError,
retryTaskMcpStartup,
clearTaskLandingReview,
setTaskSkipPermissions,
toggleAITerminalLayout,
reorderTaskVisually,
createAgentRecord,
Expand Down Expand Up @@ -1930,3 +1931,34 @@ describe('mergeTask / pushTask preconditions', () => {
expect(mockInvoke).not.toHaveBeenCalled();
});
});

describe('setTaskSkipPermissions', () => {
beforeEach(() => {
mockTasks['skip-task'] = { agentIds: [], shellAgentIds: [], skipPermissions: false };
});

it('turns it on for an existing task and saves', () => {
setTaskSkipPermissions('skip-task', true);
expect(mockTasks['skip-task'].skipPermissions).toBe(true);
expect(mockSaveState).toHaveBeenCalledTimes(1);
});

it('turns it back off and saves', () => {
mockTasks['skip-task'].skipPermissions = true;
setTaskSkipPermissions('skip-task', false);
expect(mockTasks['skip-task'].skipPermissions).toBe(false);
expect(mockSaveState).toHaveBeenCalledTimes(1);
});

// An absent flag, from a task created before it existed, reads as off.
it.each([false, undefined])('does not save when it is already off (%s)', (current) => {
mockTasks['skip-task'].skipPermissions = current;
setTaskSkipPermissions('skip-task', false);
expect(mockSaveState).not.toHaveBeenCalled();
});

it('ignores an unknown task', () => {
setTaskSkipPermissions('nope', true);
expect(mockSaveState).not.toHaveBeenCalled();
});
});
11 changes: 11 additions & 0 deletions src/store/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,17 @@ export function clearStagedNotification(taskId: string): void {
setStore('tasks', taskId, 'stagedNotification', undefined);
}

/** Change whether an existing task's agent launches with its skip-permissions
* flag. Takes effect the next time the agent starts; a running agent keeps the
* permissions it was launched with. */
export function setTaskSkipPermissions(taskId: string, enabled: boolean): void {
const task = store.tasks[taskId];
if (!task) return;
if ((task.skipPermissions ?? false) === enabled) return;
setStore('tasks', taskId, 'skipPermissions', enabled);
void saveState();
}

export function clearTaskLandingReview(taskId: string): void {
const task = store.tasks[taskId];
if (!task) return;
Expand Down
Loading