fix(sync): scope auto-approve to session tree and restore request UX

This commit is contained in:
Bohdan Triapitsyn
2026-04-16 19:16:32 +03:00
parent ddc1039d1c
commit 758c0dc4b8
4 changed files with 388 additions and 139 deletions
+179 -16
View File
@@ -26,7 +26,7 @@ import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize
import { syncDebug } from "./debug"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { autoRespondsPermission, normalizeDirectory } from "@/stores/utils/permissionAutoAccept"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import type { State } from "./types"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
@@ -235,6 +235,26 @@ async function repairSessionParts(
// Used to determine if user is currently viewing the session when a notification arrives.
let _activeDirectory = ""
let _activeSession = ""
const pendingQuestionToastIds = new Set<string>()
const pendingPermissionToastIds = new Set<string>()
const getQuestionToastKey = (sessionID?: string, requestID?: string) => {
if (!sessionID || !requestID) return null
return `${sessionID}:${requestID}`
}
const getPermissionToastKey = (sessionID?: string, requestID?: string) => {
if (!sessionID || !requestID) return null
return `${sessionID}:${requestID}`
}
const openSessionFromToast = (sessionID: string, directory: string) => {
void import("./session-ui-store")
.then(({ useSessionUIStore }) => {
useSessionUIStore.getState().setCurrentSession(sessionID, directory)
})
.catch(() => undefined)
}
export function setActiveSession(directory: string, sessionId: string) {
_activeDirectory = directory
@@ -789,6 +809,30 @@ async function resyncDirectoryAfterReconnect(
for (const sessionId of Object.keys(grouped)) {
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
for (const [sessionId, questions] of Object.entries(grouped)) {
const knownIds = new Set((before.question[sessionId] ?? []).map((item) => item.id))
const isViewed = isViewedInCurrentSession(directory, sessionId)
if (isViewed) continue
for (const question of questions) {
if (knownIds.has(question.id)) continue
const toastKey = getQuestionToastKey(sessionId, question.id)
if (!toastKey || pendingQuestionToastIds.has(toastKey)) continue
pendingQuestionToastIds.add(toastKey)
const firstQuestion = question.questions?.[0]
const title = firstQuestion?.header?.trim() || "Input needed"
const description = firstQuestion?.question?.trim() || "Agent is waiting for your response"
toast.info(title, {
id: `question-${toastKey}`,
description,
action: {
label: "Open session",
onClick: () => openSessionFromToast(sessionId, directory),
},
})
}
}
store.setState((state: DirectoryStore) => {
const merged = { ...state.question }
for (const [sessionId, questions] of Object.entries(grouped)) {
@@ -807,6 +851,73 @@ async function resyncDirectoryAfterReconnect(
// Non-fatal: question resync best-effort
}
// Re-fetch pending permissions on reconnect — same rationale as questions.
try {
const before = store.getState()
const knownSessionIds = new Set<string>([
...before.session.map((session) => session.id),
...Object.keys(before.message ?? {}),
...Object.keys(before.session_status ?? {}),
...Object.keys(before.question ?? {}),
...Object.keys(before.permission ?? {}),
])
const beforeSignatures = new Map(
candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.permission[sessionId])]),
)
const pendingPermissions = await opencodeClient.listPendingPermissions({ directories: [directory] })
const grouped: Record<string, PermissionRequest[]> = {}
for (const permission of pendingPermissions) {
if (!permission?.id || !permission.sessionID) continue
if (!knownSessionIds.has(permission.sessionID)) continue
const list = grouped[permission.sessionID]
if (list) list.push(permission)
else grouped[permission.sessionID] = [permission]
}
for (const sessionId of Object.keys(grouped)) {
grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
for (const [sessionId, permissions] of Object.entries(grouped)) {
const knownIds = new Set((before.permission[sessionId] ?? []).map((item) => item.id))
const isViewed = isViewedInCurrentSession(directory, sessionId)
if (isViewed) continue
for (const permission of permissions) {
if (knownIds.has(permission.id)) continue
const toastKey = getPermissionToastKey(sessionId, permission.id)
if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue
pendingPermissionToastIds.add(toastKey)
const description = typeof permission.permission === "string" && permission.permission.trim().length > 0
? permission.permission
: "Agent needs your approval"
toast.info("Permission needed", {
id: `permission-${toastKey}`,
description,
action: {
label: "Open session",
onClick: () => openSessionFromToast(sessionId, directory),
},
})
}
}
store.setState((state: DirectoryStore) => {
const merged = { ...state.permission }
for (const [sessionId, permissions] of Object.entries(grouped)) {
merged[sessionId] = permissions
}
for (const sessionId of candidateSessionIds) {
if (grouped[sessionId]) continue
const beforeSignature = beforeSignatures.get(sessionId) ?? ""
const currentSignature = requestSignature(state.permission[sessionId])
if (currentSignature !== beforeSignature) continue
delete merged[sessionId]
}
return { permission: merged }
})
} catch {
// Non-fatal: permission resync best-effort
}
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
}
@@ -885,6 +996,72 @@ function handleEvent(
childStores.mark(resolvedDirectory)
if (payload.type === "permission.asked") {
const permission = payload.properties as PermissionRequest
const permissionStore = usePermissionStore.getState()
if (permissionStore.isSessionAutoAccepting(permission.sessionID)) {
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)
return
}
const toastKey = getPermissionToastKey(permission.sessionID, permission.id)
const isViewed = isViewedInCurrentSession(resolvedDirectory, permission.sessionID)
if (!isViewed && toastKey && !pendingPermissionToastIds.has(toastKey)) {
pendingPermissionToastIds.add(toastKey)
const description = typeof permission.permission === "string" && permission.permission.trim().length > 0
? permission.permission
: "Agent needs your approval"
toast.info("Permission needed", {
id: `permission-${toastKey}`,
description,
action: {
label: "Open session",
onClick: () => openSessionFromToast(permission.sessionID, resolvedDirectory),
},
})
}
}
if (payload.type === "permission.replied") {
const props = payload.properties as { sessionID?: string; requestID?: string }
const toastKey = getPermissionToastKey(props.sessionID, props.requestID)
if (toastKey) {
pendingPermissionToastIds.delete(toastKey)
toast.dismiss(`permission-${toastKey}`)
}
}
if (payload.type === "question.asked") {
const question = payload.properties as QuestionRequest
const sessionID = question.sessionID
const toastKey = getQuestionToastKey(sessionID, question.id)
const isViewed = isViewedInCurrentSession(resolvedDirectory, sessionID)
if (!isViewed && toastKey && !pendingQuestionToastIds.has(toastKey)) {
pendingQuestionToastIds.add(toastKey)
const firstQuestion = question.questions?.[0]
const title = firstQuestion?.header?.trim() || "Input needed"
const description = firstQuestion?.question?.trim() || "Agent is waiting for your response"
toast.info(title, {
id: `question-${toastKey}`,
description,
action: {
label: "Open session",
onClick: () => openSessionFromToast(sessionID, resolvedDirectory),
},
})
}
}
if (payload.type === "question.replied" || payload.type === "question.rejected") {
const props = payload.properties as { sessionID?: string; requestID?: string }
const toastKey = getQuestionToastKey(props.sessionID, props.requestID)
if (toastKey) {
pendingQuestionToastIds.delete(toastKey)
toast.dismiss(`question-${toastKey}`)
}
}
// Notification dispatch for session turn-complete and error events.
// These are NOT handled by the event reducer — only the notification store.
if (payload.type === "session.idle" || payload.type === "session.error") {
@@ -996,20 +1173,6 @@ function handleEvent(
}
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
if (payload.type === "permission.asked") {
const nd = normalizeDirectory(resolvedDirectory)
if (!nd) {
return
}
const permission = payload.properties as PermissionRequest
const sessions = store.getState().session
const autoAccept = usePermissionStore.getState().autoAccept
if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: nd })) {
void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)
}
}
}
// ---------------------------------------------------------------------------
@@ -1151,7 +1314,7 @@ export function SyncProvider(props: {
},
})
return cleanup
}, [props.sdk, childStores, routingIndex])
}, [props.sdk, props.directory, childStores, routingIndex])
// Ensure current directory's child store exists
useEffect(() => {