diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index c91780aa..b17b052d 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -75,6 +75,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { usePermissionStore } from '@/stores/permissionStore'; +import { togglePermissionAutoAccept } from './permissionAutoAccept'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -648,7 +649,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl type PermissionAutoAcceptButtonProps = { footerIconButtonClass: string; iconSizeClass: string; - permissionScopeSessionId: string | null; + isInteractive: boolean; permissionAutoAcceptEnabled: boolean; handlePermissionAutoAcceptToggle: () => void; withTooltip?: boolean; @@ -659,7 +660,7 @@ const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButto const { footerIconButtonClass, iconSizeClass, - permissionScopeSessionId, + isInteractive, permissionAutoAcceptEnabled, handlePermissionAutoAcceptToggle, withTooltip = false, @@ -679,7 +680,7 @@ const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButto className={cn( footerIconButtonClass, 'rounded-md hover:bg-transparent', - !permissionScopeSessionId && 'opacity-30', + !isInteractive && 'opacity-30', )} onMouseDown={(event) => { event.preventDefault(); @@ -1083,7 +1084,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); + const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => ( + s.newSessionDraft?.open ? s.newSessionDraft.permissionAutoAcceptEnabled === true : false + )); const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); + const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); @@ -4523,22 +4528,32 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId; const permissionAutoAcceptEnabled = usePermissionStore((state) => { if (!permissionScopeSessionId) { - return false; + return draftPermissionAutoAcceptEnabled; } return state.isSessionAutoAccepting(permissionScopeSessionId); }); + const isPermissionAutoAcceptInteractive = Boolean(permissionScopeSessionId || newSessionDraftOpen); const handlePermissionAutoAcceptToggle = React.useCallback(() => { - if (!permissionScopeSessionId) { - toast.error(t('chat.chatInput.toast.openSessionFirst')); - return; - } - - const nextEnabled = !permissionAutoAcceptEnabled; - setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => { - toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed')); + togglePermissionAutoAccept({ + permissionScopeSessionId, + newSessionDraftOpen, + draftPermissionAutoAcceptEnabled, + permissionAutoAcceptEnabled, + setDraftPermissionAutoAcceptEnabled, + setSessionAutoAccept, + onOpenSessionFirst: () => toast.error(t('chat.chatInput.toast.openSessionFirst')), + onToggleFailed: () => toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed')), }); - }, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept, t]); + }, [ + draftPermissionAutoAcceptEnabled, + newSessionDraftOpen, + permissionAutoAcceptEnabled, + permissionScopeSessionId, + setDraftPermissionAutoAcceptEnabled, + setSessionAutoAccept, + t, + ]); React.useEffect(() => { const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; @@ -5296,7 +5311,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo @@ -5364,7 +5379,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo void; + setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise; + onOpenSessionFirst: () => void; + onToggleFailed: () => void; +}; + +export const togglePermissionAutoAccept = (args: PermissionAutoAcceptToggleArgs): void => { + const { + permissionScopeSessionId, + newSessionDraftOpen, + draftPermissionAutoAcceptEnabled, + permissionAutoAcceptEnabled, + setDraftPermissionAutoAcceptEnabled, + setSessionAutoAccept, + onOpenSessionFirst, + onToggleFailed, + } = args; + + if (!permissionScopeSessionId) { + if (!newSessionDraftOpen) { + onOpenSessionFirst(); + return; + } + + setDraftPermissionAutoAcceptEnabled(!draftPermissionAutoAcceptEnabled); + return; + } + + const nextEnabled = !permissionAutoAcceptEnabled; + void setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(onToggleFailed); +}; diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts new file mode 100644 index 00000000..00fc232f --- /dev/null +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -0,0 +1,350 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { togglePermissionAutoAccept } from "../../components/chat/permissionAutoAccept" + +const storage = new Map() +const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = [] +const permissionAutoAcceptCalls: Array<[string, boolean]> = [] + +const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? []) + +mock.module("zustand", () => ({ + create: () => (initializer: (set: (patch: unknown | ((state: unknown) => unknown)) => void, get: () => unknown) => Record) => { + let state: Record + const get = () => state + const set = (patch: unknown | ((current: Record) => unknown)) => { + const next = typeof patch === "function" ? patch(state) : patch + state = next && typeof next === "object" ? { ...state, ...(next as Record) } : state + } + + state = initializer(set, get) + + const store = ((selector?: (current: Record) => unknown) => ( + typeof selector === "function" ? selector(state) : state + )) as unknown as { + getState: () => Record + setState: (patch: unknown | ((current: Record) => unknown)) => void + subscribe: () => () => void + } + + store.getState = () => state + store.setState = (patch) => set(patch) + store.subscribe = () => () => undefined + + return store + }, +})) + +const deferredStorage: Storage = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value) + }, + removeItem: (key: string) => { + storage.delete(key) + }, + clear: () => { + storage.clear() + }, + key: (index: number) => Array.from(storage.keys())[index] ?? null, + get length() { + return storage.size + }, +} + +mock.module("@/stores/utils/safeStorage", () => ({ + getDeferredSafeStorage: () => deferredStorage, +})) + +mock.module("@/lib/opencode/client", () => ({ + opencodeClient: { + getDirectory: () => null, + setDirectory: mock(() => undefined), + }, +})) + +mock.module("@/stores/permissionStore", () => ({ + usePermissionStore: { + getState: () => ({ + setSessionAutoAccept: mock(async (sessionId: string, enabled: boolean) => { + permissionAutoAcceptCalls.push([sessionId, enabled]) + }), + }), + }, +})) + +mock.module("@/stores/useConfigStore", () => ({ + useConfigStore: { + getState: () => ({ + currentAgentName: "agent-default", + agents: [], + activateDirectory: mock(async () => undefined), + applyDefaultModelAgentSelection: mock(() => undefined), + }), + }, +})) + +mock.module("@/stores/useProjectsStore", () => ({ + useProjectsStore: { + getState: () => ({ + projects: [], + activeProjectId: null, + getActiveProject: () => null, + }), + }, +})) + +mock.module("@/stores/useDirectoryStore", () => ({ + useDirectoryStore: { + getState: () => ({ + currentDirectory: null, + setDirectory: mock(() => undefined), + }), + }, +})) + +mock.module("@/stores/useGlobalSessionsStore", () => ({ + useGlobalSessionsStore: { + getState: () => ({ + activeSessions: [], + archivedSessions: [], + }), + }, + resolveGlobalSessionDirectory: () => null, +})) + +mock.module("@/stores/useSessionFoldersStore", () => ({ + useSessionFoldersStore: { + getState: () => ({ + addSessionToFolder: mock(() => undefined), + }), + }, +})) + +mock.module("@/stores/useCommandsStore", () => ({ + useCommandsStore: { + getState: () => ({ + commands: [], + }), + }, +})) + +mock.module("@/stores/useSkillsStore", () => ({ + useSkillsStore: { + getState: () => ({ + skills: [], + }), + }, +})) + +mock.module("@/components/ui", () => ({ + toast: { + error: () => undefined, + info: () => undefined, + success: () => undefined, + }, +})) + +mock.module("../selection-store", () => ({ + useSelectionStore: { + getState: () => ({ + saveSessionModelSelection: () => undefined, + saveSessionAgentSelection: () => undefined, + saveAgentModelForSession: () => undefined, + saveAgentModelVariantForSession: () => undefined, + getSessionAgentSelection: () => null, + getSessionModelSelection: () => null, + getAgentModelForSession: () => null, + getAgentModelVariantForSession: () => undefined, + }), + }, +})) + +mock.module("@/lib/runtime-switch", () => ({ + getRuntimeApiBaseUrl: () => "", + getRuntimeKey: () => "test-runtime", + initializeRuntimeEndpoint: () => undefined, + subscribeRuntimeEndpointChanged: () => () => undefined, + switchRuntimeEndpoint: () => undefined, +})) + +mock.module("@/lib/userSendAnimation", () => ({ + markPendingUserSendAnimation: () => undefined, +})) + +mock.module("../sync-context", () => ({ + setActiveSession: () => undefined, +})) + +mock.module("../notification-store", () => ({ + markSessionViewed: () => undefined, +})) + +mock.module("../session-navigation", () => ({ + setSessionOpener: () => undefined, +})) + +mock.module("../session-worktree-contract", () => ({ + getAttachedSessionDirectory: () => null, +})) + +mock.module("../session-worktree-store", () => ({ + useSessionWorktreeStore: { + getState: () => ({ + getAttachment: () => undefined, + setAttachment: () => undefined, + clearAttachment: () => undefined, + }), + }, +})) + +mock.module("../viewport-store", () => ({ + getViewportSessionMemory: () => null, + viewportSessionKey: (sessionId: string) => sessionId, + useViewportStore: { + getState: () => ({ + updateViewportAnchor: mock(() => undefined), + }), + setState: () => undefined, + }, +})) + +mock.module("../input-store", () => ({ + useInputStore: { + getState: () => ({ + clearAttachedFiles: () => undefined, + setPendingInputText: () => undefined, + addRestoredAttachment: () => undefined, + }), + }, +})) + +mock.module("../sync-refs", () => ({ + getDirectoryState: () => null, + getSyncSessions: () => [], + getSyncMessages: () => [], + getSyncParts: () => [], + getAllSyncSessions: () => [], +})) + +mock.module("../session-actions", () => ({ + createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => { + createSessionCalls.push({ title, directory, parentID, metadata }) + return { id: "ses_issue_2039", directory } + }), + deleteSession: mock(async () => true), + archiveSession: mock(async () => true), + updateSessionTitle: mock(async () => undefined), + shareSession: mock(async () => undefined), + unshareSession: mock(async () => undefined), + optimisticSend: mock(async () => undefined), + refetchSessionMessages: mock(async () => undefined), + revertToMessage: mock(async () => undefined), + unrevertSession: mock(async () => undefined), + forkFromMessage: mock(async () => undefined), + fetchMessagesForSession: mock(async () => undefined), +})) + +const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store") + +describe("issue 2039 draft auto-accept", () => { + test("toggles draft state before a session exists", () => { + const setDraftPermissionAutoAcceptEnabled = mock(() => undefined) + const setSessionAutoAccept = mock(async () => undefined) + const onOpenSessionFirst = mock(() => undefined) + const onToggleFailed = mock(() => undefined) + + togglePermissionAutoAccept({ + permissionScopeSessionId: null, + newSessionDraftOpen: true, + draftPermissionAutoAcceptEnabled: false, + permissionAutoAcceptEnabled: false, + setDraftPermissionAutoAcceptEnabled, + setSessionAutoAccept, + onOpenSessionFirst, + onToggleFailed, + }) + + expect(getMockCalls(setDraftPermissionAutoAcceptEnabled).length).toBe(1) + expect(getMockCalls(setDraftPermissionAutoAcceptEnabled)[0]).toEqual([true]) + expect(getMockCalls(setSessionAutoAccept).length).toBe(0) + expect(getMockCalls(onOpenSessionFirst).length).toBe(0) + expect(getMockCalls(onToggleFailed).length).toBe(0) + }) + + test("guards the toggle when no draft is open", () => { + const setDraftPermissionAutoAcceptEnabled = mock(() => undefined) + const setSessionAutoAccept = mock(async () => undefined) + const onOpenSessionFirst = mock(() => undefined) + const onToggleFailed = mock(() => undefined) + + togglePermissionAutoAccept({ + permissionScopeSessionId: null, + newSessionDraftOpen: false, + draftPermissionAutoAcceptEnabled: false, + permissionAutoAcceptEnabled: false, + setDraftPermissionAutoAcceptEnabled, + setSessionAutoAccept, + onOpenSessionFirst, + onToggleFailed, + }) + + expect(getMockCalls(setDraftPermissionAutoAcceptEnabled).length).toBe(0) + expect(getMockCalls(setSessionAutoAccept).length).toBe(0) + expect(getMockCalls(onOpenSessionFirst).length).toBe(1) + expect(getMockCalls(onToggleFailed).length).toBe(0) + }) + + beforeEach(() => { + storage.clear() + createSessionCalls.length = 0 + permissionAutoAcceptCalls.length = 0 + + useSessionUIStore.setState({ + currentSessionId: null, + currentSessionDirectory: null, + newSessionDraft: { + open: false, + directoryOverride: null, + parentID: null, + }, + }) + }) + + test("stores auto-accept in the draft and applies it when the session materializes", async () => { + useSessionUIStore.getState().openNewSessionDraft() + + expect(useSessionUIStore.getState().newSessionDraft.permissionAutoAcceptEnabled).toBe(false) + + useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true) + + expect(useSessionUIStore.getState().newSessionDraft.permissionAutoAcceptEnabled).toBe(true) + + const result = await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + }) + + expect(result?.sessionId).toBe("ses_issue_2039") + expect(createSessionCalls).toHaveLength(1) + expect(permissionAutoAcceptCalls).toEqual([["ses_issue_2039", true]]) + expect(useSessionUIStore.getState().currentSessionId).toBe("ses_issue_2039") + }) + + test("does not apply draft auto-accept after the draft is closed", async () => { + useSessionUIStore.getState().openNewSessionDraft() + useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true) + useSessionUIStore.getState().closeNewSessionDraft() + + expect(useSessionUIStore.getState().newSessionDraft.open).toBe(false) + expect(useSessionUIStore.getState().newSessionDraft.permissionAutoAcceptEnabled === undefined).toBe(true) + + const result = await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + }) + + expect(result).toBeNull() + expect(createSessionCalls).toHaveLength(0) + expect(permissionAutoAcceptCalls).toHaveLength(0) + }) +}) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 397fb484..c81a48d7 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -195,6 +195,7 @@ export type NewSessionDraftState = { open: boolean selectedProjectId?: string | null directoryOverride: string | null + permissionAutoAcceptEnabled?: boolean pendingWorktreeRequestId?: string | null bootstrapPendingDirectory?: string | null preserveDirectoryOverride?: boolean @@ -251,6 +252,7 @@ export type SessionUIState = { closeNewSessionDraft: () => void setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void setDraftPreserveDirectoryOverride: (value: boolean) => void + setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void acknowledgeSessionAbort: (sessionId: string) => void clearAbortPrompt: () => void armAbortPrompt: (durationMs?: number) => number | null @@ -446,6 +448,7 @@ export async function materializeOpenDraftSession(selection: { const store = useSessionUIStore.getState() const draft = store.newSessionDraft if (!draft?.open) return null + const draftPermissionAutoAcceptEnabled = draft.permissionAutoAcceptEnabled === true const trimmedAgent = typeof selection.agent === "string" && selection.agent.trim().length > 0 ? selection.agent.trim() @@ -485,6 +488,15 @@ export async function materializeOpenDraftSession(selection: { useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant) } + if (draftPermissionAutoAcceptEnabled) { + try { + const { usePermissionStore } = await import("@/stores/permissionStore") + await usePermissionStore.getState().setSessionAutoAccept(created.id, true) + } catch (error) { + console.warn("Failed to apply draft permission auto-accept to new session:", error) + } + } + store.initializeNewOpenChamberSession(created.id, configState.agents ?? []) store.setCurrentSession(created.id, createdDirectory) @@ -732,6 +744,7 @@ export const useSessionUIStore = create()((set, get) => ({ open: true, selectedProjectId: selectedProject?.id ?? null, directoryOverride: directory, + permissionAutoAcceptEnabled: options?.permissionAutoAcceptEnabled === true, pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), preserveDirectoryOverride: options?.preserveDirectoryOverride === true, @@ -827,6 +840,12 @@ export const useSessionUIStore = create()((set, get) => ({ return { newSessionDraft: { ...s.newSessionDraft, preserveDirectoryOverride: value } } }), + setDraftPermissionAutoAcceptEnabled: (enabled) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } } + }), + acknowledgeSessionAbort: (sessionId) => set((s) => { const flags = new Map(s.sessionAbortFlags)