fix(ui): drain yolo permissions outside shell render (#424)

## Summary
- Split out from #422 as the YOLO-only permission fix.
- Moves YOLO auto-accept draining out of `InstanceShell` render effects
and into the permission queue flow.
- Keeps behavior unchanged: YOLO remains per-session and replies with
`once` only.
- Keeps in-flight cleanup for auto-accept attempts so duplicate sends
are guarded outside UI render timing.

## Why
In YOLO mode, the app could still appear to wait on a permission or
block the UI, even though auto-accept was enabled and the permission was
already queued.

The auto-accept drain was tied to an `InstanceShell` render effect, so
the reply path depended on a specific UI shell rendering. Draining from
permission sync/enqueue and from the YOLO toggle makes auto-accept run
from the permission queue itself instead of UI render timing.

## Validation
- `git diff --check`
- `npm run typecheck --workspace @codenomad/ui`
This commit is contained in:
Pascal André 2026-05-10 17:48:04 +02:00 committed by GitHub
parent 6d120b0d84
commit 8976d9985b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 86 additions and 47 deletions

View file

@ -36,7 +36,7 @@ import { serverApi } from "../../lib/api-client"
import { loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n"
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
@ -58,13 +58,7 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
import { useDrawerResize } from "./shell/useDrawerResize"
import { useSessionCache } from "./shell/useSessionCache"
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
import { getPermissionSessionId } from "../../types/permission"
import {
canAutoRespondPermission,
finishAutoRespondPermission,
getPermissionAutoAcceptInFlightVersion,
isPermissionAutoAcceptEnabled,
} from "../../stores/permission-auto-accept"
import { isPermissionAutoAcceptEnabled } from "../../stores/permission-auto-accept"
const log = getLogger("session")
const OPEN_SESSION_SEARCH_EVENT = "codenomad:open-session-search"
@ -269,8 +263,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return permissions + questions > 0
})
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
const activePromptInputApi = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
@ -284,25 +276,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}))
}
createEffect(() => {
getPermissionAutoAcceptInFlightVersion()
for (const permission of permissionQueue()) {
const sessionId = getPermissionSessionId(permission)
if (!sessionId) continue
if (!permission?.id) continue
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
.catch((error) => {
log.error("Failed to auto-accept permission", error)
})
.finally(() => {
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
})
}
})
const yoloModeEnabled = createMemo(() => {
const session = activeSessionForInstance()
if (!session) return false

View file

@ -13,7 +13,8 @@ import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status"
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances"
import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept"
interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string
@ -63,7 +64,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
color="warning"
size="small"
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
onChange={() => togglePermissionAutoAcceptForSession(props.instanceId, session.id)}
/>
</div>
</div>

View file

@ -32,6 +32,7 @@ import { setSessionPendingPermission, setSessionPendingQuestion } from "./sessio
import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge"
import { clearAutoAcceptPermission, drainAutoAcceptPermissions, isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "./permission-auto-accept"
import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
@ -207,6 +208,7 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
}
drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission)
} catch (error) {
log.warn("Failed to sync pending permissions", { instanceId, error })
}
@ -615,6 +617,10 @@ function getPermissionQueueLength(instanceId: string): number {
return getPermissionQueue(instanceId).length
}
function hasPendingPermission(instanceId: string, permissionId: string): boolean {
return getPermissionQueue(instanceId).some((permission) => permission.id === permissionId)
}
function getQuestionQueue(instanceId: string): QuestionRequest[] {
const queue = questionQueues().get(instanceId)
if (!queue) {
@ -796,6 +802,8 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
}
byPermissionId.set(permission.id, slug)
}
drainAutoAcceptPermissions(instanceId, [permission], sendPermissionResponse, hasPendingPermission)
}
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
@ -822,8 +830,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
return next
})
const updatedQueue = getPermissionQueue(instanceId)
recomputeActiveInterruption(instanceId)
const removed = removedPermission
@ -832,13 +838,27 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId)
const removedSessionId = getPermissionSessionId(removed)
if (removedSessionId) {
clearAutoAcceptPermission(instanceId, removedSessionId, permissionId)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
}
}
function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void {
const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId)
togglePermissionAutoAccept(instanceId, sessionId)
if (!willEnable) return
drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission)
}
function clearPermissionQueue(instanceId: string): void {
for (const permission of getPermissionQueue(instanceId)) {
const sessionId = getPermissionSessionId(permission)
if (sessionId) {
clearAutoAcceptPermission(instanceId, sessionId, permission.id)
}
}
setPermissionQueues((prev) => {
const next = new Map(prev)
next.delete(instanceId)
@ -1130,6 +1150,7 @@ export {
getPermissionQueueLength,
addPermissionToQueue,
removePermissionFromQueue,
togglePermissionAutoAcceptForSession,
clearPermissionQueue,
sendPermissionResponse,
setActivePermissionIdForInstance,

View file

@ -1,7 +1,15 @@
import { createSignal } from "solid-js"
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
import { getPermissionSessionId } from "../types/permission"
import { getLogger } from "../lib/logger"
const STORAGE_KEY = "codenomad:permission-auto-accept:v1"
const log = getLogger("api")
type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise<void>
type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean
function makeKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
@ -34,7 +42,6 @@ function persist(next: Map<string, boolean>) {
}
const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState())
const [inFlightVersion, setInFlightVersion] = createSignal(0)
const inFlight = new Set<string>()
@ -54,28 +61,65 @@ export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: st
persist(next)
return next
})
if (!enabled) {
clearAutoAcceptSession(instanceId, sessionId)
}
}
export function togglePermissionAutoAccept(instanceId: string, sessionId: string) {
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
}
export function canAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
const key = makeKey(instanceId, sessionId)
if (!autoAcceptState().get(key)) return false
const requestKey = `${key}:${requestId}`
if (inFlight.has(requestKey)) return false
inFlight.add(requestKey)
return true
function makeRequestKey(instanceId: string, sessionId: string, requestId: string) {
return `${makeKey(instanceId, sessionId)}:${requestId}`
}
export function getPermissionAutoAcceptInFlightVersion() {
return inFlightVersion()
export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) {
const requestKey = makeRequestKey(instanceId, sessionId, requestId)
inFlight.delete(requestKey)
}
export function finishAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
if (!inFlight.delete(`${makeKey(instanceId, sessionId)}:${requestId}`)) {
return
export function clearAutoAcceptSession(instanceId: string, sessionId: string) {
const prefix = `${makeKey(instanceId, sessionId)}:`
for (const requestKey of Array.from(inFlight)) {
if (requestKey.startsWith(prefix)) {
inFlight.delete(requestKey)
}
}
}
export function drainAutoAcceptPermission(
instanceId: string,
permission: PermissionRequestLike,
responder: AutoAcceptResponder,
isPending: PendingPermissionChecker,
) {
const sessionId = getPermissionSessionId(permission)
if (!sessionId || !permission?.id) return
if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return
if (!isPending(instanceId, permission.id)) return
const requestKey = makeRequestKey(instanceId, sessionId, permission.id)
if (inFlight.has(requestKey)) return
inFlight.add(requestKey)
void responder(instanceId, sessionId, permission.id, "once")
.catch((error) => {
log.error("Failed to auto-accept permission", error)
})
.finally(() => {
inFlight.delete(requestKey)
})
}
export function drainAutoAcceptPermissions(
instanceId: string,
permissions: PermissionRequestLike[],
responder: AutoAcceptResponder,
isPending: PendingPermissionChecker,
) {
for (const permission of permissions) {
drainAutoAcceptPermission(instanceId, permission, responder, isPending)
}
setInFlightVersion((value) => value + 1)
}