diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index a3eed141..0e4ecd98 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -3,13 +3,12 @@ import { devtools, persist, createJSONStorage } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk/v2/client"; import { autoRespondsPermission, - normalizeDirectory, - sessionAcceptKey, type PermissionAutoAcceptMap, } from "./utils/permissionAutoAccept"; import { getSafeStorage } from "./utils/safeStorage"; -import { getAllSyncSessions } from "@/sync/sync-refs"; +import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; import { opencodeClient } from "@/lib/opencode/client"; +import { respondToPermission } from "@/sync/session-actions"; import { useSessionUIStore } from "@/sync/session-ui-store"; interface PermissionState { @@ -23,45 +22,120 @@ interface PermissionActions { type PermissionStore = PermissionState & PermissionActions; -const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { - const map = new Map(); - for (const session of sessions) { - map.set(session.id, session); +const coerceAutoAcceptValue = (value: unknown): boolean => { + if (typeof value === "boolean") { + return value; } - const result: string[] = []; - const seen = new Set(); - let current: string | undefined = sessionID; - while (current && !seen.has(current)) { - seen.add(current); - result.push(current); - current = map.get(current)?.parentID; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } } + + if (typeof value === "number") { + return value === 1; + } + + return false; +}; + +const isLegacyDirectoryAutoAcceptKey = (key: string): boolean => key.endsWith("/*"); + +const extractSessionIdFromLegacyKey = (key: string): string | null => { + const trimmed = key.trim(); + if (!trimmed) { + return null; + } + const lastSlash = trimmed.lastIndexOf("/"); + if (lastSlash === -1 || lastSlash === trimmed.length - 1) { + return trimmed; + } + return trimmed.slice(lastSlash + 1); +}; + +const resolveSessionScope = (sessionID: string, sessions: Session[]): Set => { + const map = new Map(); + const children = new Map(); + for (const session of sessions) { + map.set(session.id, session); + if (session.parentID) { + const list = children.get(session.parentID); + if (list) { + list.push(session.id); + } else { + children.set(session.parentID, [session.id]); + } + } + } + + if (!map.has(sessionID)) { + return new Set([sessionID]); + } + + const result = new Set(); + const seen = new Set(); + const queue = [sessionID]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + result.add(current); + const nextChildren = children.get(current); + if (!nextChildren || nextChildren.length === 0) { + continue; + } + for (const child of nextChildren) { + if (!seen.has(child)) { + queue.push(child); + } + } + } + return result; }; +const normalizeDirectoryCandidate = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const collectPendingFromSyncStores = (sessionScope: Set): Array<{ id: string; sessionID: string }> => { + try { + const stores = getSyncChildStores(); + const pending: Array<{ id: string; sessionID: string }> = []; + for (const store of stores.children.values()) { + const permissionMap = store.getState().permission ?? {}; + for (const [sessionId, entries] of Object.entries(permissionMap)) { + if (!sessionScope.has(sessionId)) continue; + for (const permission of entries ?? []) { + if (!permission?.id) continue; + pending.push({ id: permission.id, sessionID: permission.sessionID || sessionId }); + } + } + } + return pending; + } catch { + return []; + } +}; + const autoRespondsPermissionBySession = ( autoAccept: PermissionAutoAcceptMap, sessions: Session[], sessionID: string, ): boolean => { - const targetSession = sessions.find((session) => session.id === sessionID); - const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionID); - const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null); - if (!directory) { - for (const id of resolveLineage(sessionID, sessions)) { - if (id in autoAccept) { - return autoAccept[id] === true; - } - } - return false; - } - return autoRespondsPermission({ autoAccept, - sessions, sessionID, - directory, + sessions, }); }; @@ -88,31 +162,55 @@ export const usePermissionStore = create()( } const sessions = getAllSyncSessions(); - const targetSession = sessions.find((session) => session.id === sessionId); - const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId); - const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null); - const key = directory ? sessionAcceptKey(sessionId, directory) : sessionId; set((state) => { const autoAccept = { ...state.autoAccept }; - if (directory) { - delete autoAccept[sessionId]; - } - autoAccept[key] = enabled; + autoAccept[sessionId] = enabled; return { autoAccept }; }); - if (!enabled || !directory) { + if (!enabled) { return; } - const pending = await opencodeClient.listPendingPermissions({ directories: [directory] }); - const client = opencodeClient.getScopedSdkClient(directory); - const sessionLineage = new Set(resolveLineage(sessionId, sessions)); + const sessionScope = resolveSessionScope(sessionId, sessions); + const sessionDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId); + const directories = new Set(); + const currentDirectory = normalizeDirectoryCandidate(opencodeClient.getDirectory()); + if (currentDirectory) { + directories.add(currentDirectory); + } + const mappedSessionDirectory = normalizeDirectoryCandidate(sessionDirectory); + if (mappedSessionDirectory) { + directories.add(mappedSessionDirectory); + } + for (const scopedSessionId of sessionScope) { + const mapped = normalizeDirectoryCandidate(useSessionUIStore.getState().getDirectoryForSession(scopedSessionId)); + if (mapped) { + directories.add(mapped); + } + } + + const pendingFromStores = collectPendingFromSyncStores(sessionScope); + const pendingFromApi = await opencodeClient.listPendingPermissions({ directories: Array.from(directories) }); + const mergedPending = new Map(); + + for (const permission of pendingFromStores) { + mergedPending.set(permission.id, permission); + } + for (const permission of pendingFromApi) { + if (!permission?.id || !permission?.sessionID) { + continue; + } + if (!sessionScope.has(permission.sessionID)) { + continue; + } + mergedPending.set(permission.id, { id: permission.id, sessionID: permission.sessionID }); + } + await Promise.all( - pending - .filter((permission) => sessionLineage.has(permission.sessionID)) - .map((permission) => client.permission.reply({ requestID: permission.id, reply: "once" }).catch(() => undefined)), + Array.from(mergedPending.values()) + .map((permission) => respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)), ); }, }), @@ -126,12 +224,36 @@ export const usePermissionStore = create()( ...(persistedState as Partial), }; - const nextAutoAccept = Object.fromEntries( - Object.entries(merged.autoAccept || {}).map(([sessionId, enabled]) => [ - sessionId, - Boolean(enabled), - ]), - ); + const persisted = Object.entries(merged.autoAccept || {}); + const nextAutoAccept: PermissionAutoAcceptMap = {}; + + for (const [rawKey, rawEnabled] of persisted) { + if (rawKey.includes("/") || isLegacyDirectoryAutoAcceptKey(rawKey)) { + continue; + } + nextAutoAccept[rawKey] = coerceAutoAcceptValue(rawEnabled); + } + + for (const [rawKey, rawEnabled] of persisted) { + if (isLegacyDirectoryAutoAcceptKey(rawKey)) { + continue; + } + if (!rawKey.includes("/")) { + continue; + } + + const sessionId = extractSessionIdFromLegacyKey(rawKey); + if (!sessionId) { + continue; + } + if (Object.prototype.hasOwnProperty.call(nextAutoAccept, sessionId)) { + continue; + } + + const normalized = coerceAutoAcceptValue(rawEnabled); + const existing = nextAutoAccept[sessionId]; + nextAutoAccept[sessionId] = existing === true ? true : normalized; + } return { ...merged, diff --git a/packages/ui/src/stores/utils/permissionAutoAccept.ts b/packages/ui/src/stores/utils/permissionAutoAccept.ts index d7a625c1..1a4a67c1 100644 --- a/packages/ui/src/stores/utils/permissionAutoAccept.ts +++ b/packages/ui/src/stores/utils/permissionAutoAccept.ts @@ -2,86 +2,43 @@ import type { Session } from "@opencode-ai/sdk/v2/client"; export type PermissionAutoAcceptMap = Record; -const DIRECTORY_WILDCARD = "*"; - -const encodeBase64 = (value: string): string => { - try { - const bytes = new TextEncoder().encode(value); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary); - } catch { - return btoa(value); - } +const buildSessionMap = (sessions: Session[]): Map => { + const map = new Map(); + for (const session of sessions) { + map.set(session.id, session); + } + return map; }; -export const normalizeDirectory = (value: string | null | undefined): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const normalized = trimmed.replace(/\\/g, "/"); - if (normalized === "/") { - return "/"; - } - return normalized.length > 1 ? normalized.replace(/\/+$/g, "") : normalized; -}; - -export const directoryAcceptKey = (directory: string): string => `${encodeBase64(directory)}/${DIRECTORY_WILDCARD}`; - -export const sessionAcceptKey = (sessionID: string, directory: string): string => `${encodeBase64(directory)}/${sessionID}`; - const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { - const map = new Map(); - for (const session of sessions) { - map.set(session.id, session); - } + const map = buildSessionMap(sessions); + const result: string[] = []; + const seen = new Set(); + let current: string | undefined = sessionID; - const result: string[] = []; - const seen = new Set(); - let current: string | undefined = sessionID; - while (current && !seen.has(current)) { - seen.add(current); - result.push(current); - current = map.get(current)?.parentID; - } - return result; + while (current && !seen.has(current)) { + seen.add(current); + result.push(current); + current = map.get(current)?.parentID; + } + + return result; }; export const autoRespondsPermission = (input: { - autoAccept: PermissionAutoAcceptMap; - sessions: Session[]; - sessionID: string; - directory: string; + autoAccept: PermissionAutoAcceptMap; + sessions: Session[]; + sessionID: string; }): boolean => { - const { autoAccept, sessions, sessionID, directory } = input; + const { autoAccept, sessions, sessionID } = input; + const lineage = resolveLineage(sessionID, sessions); - for (const id of resolveLineage(sessionID, sessions)) { - const key = sessionAcceptKey(id, directory); - if (key in autoAccept) { - return autoAccept[key] === true; - } - - // Legacy fallback for pre-directory keys. - if (id in autoAccept) { - return autoAccept[id] === true; - } + for (const id of lineage) { + if (!Object.prototype.hasOwnProperty.call(autoAccept, id)) { + continue; } + return autoAccept[id] === true; + } - const directoryKey = directoryAcceptKey(directory); - if (directoryKey in autoAccept) { - return autoAccept[directoryKey] === true; - } - - return false; -}; - -export const isDirectoryAutoAccepting = (autoAccept: PermissionAutoAcceptMap, directory: string): boolean => { - const key = directoryAcceptKey(directory); - return autoAccept[key] === true; + return false; }; diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index c07198ee..a7908806 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -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() +const pendingPermissionToastIds = new Set() + +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([ + ...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 = {} + 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(() => { diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js index e8bb32c3..91a4cee7 100644 --- a/packages/web/server/lib/notifications/runtime.js +++ b/packages/web/server/lib/notifications/runtime.js @@ -365,10 +365,17 @@ export const createNotificationTriggerRuntime = (deps) => { } if (payload.type === 'permission.replied' && sessionId) { - const requestId = payload.properties?.requestID; + const requestId = payload.properties?.requestID ?? payload.properties?.requestId ?? payload.properties?.id; const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; const pendingNotification = pushPermissionDebounceTimers.get(sessionId); - if (requestKey && pendingNotification?.requestKey === requestKey) { + if (!pendingNotification) { + return; + } + + // Some runtimes may omit requestID on permission.replied. + // When request ID is missing, clear session debounce to avoid + // showing stale permission notifications for auto-approved prompts. + if (!requestKey || !pendingNotification.requestKey || pendingNotification.requestKey === requestKey) { clearTimeout(pendingNotification.timer); pushPermissionDebounceTimers.delete(sessionId); } @@ -376,7 +383,7 @@ export const createNotificationTriggerRuntime = (deps) => { } if (payload.type === 'permission.asked' && sessionId) { - const requestId = payload.properties?.id; + const requestId = payload.properties?.id ?? payload.properties?.requestID ?? payload.properties?.requestId; const permission = payload.properties?.permission; const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; if (requestKey && notifiedPermissionRequests.has(requestKey)) {