fix(mobile): recover pending permission prompts after reconnect and resume (#497)

This commit is contained in:
Nelson Pires
2026-02-24 20:15:19 +02:00
committed by GitHub
parent fc06a9c499
commit 4c69bccf56
2 changed files with 97 additions and 42 deletions
+34 -15
View File
@@ -45,6 +45,7 @@ declare global {
const TEXT_SHRINK_TOLERANCE = 50; const TEXT_SHRINK_TOLERANCE = 50;
const RESYNC_DEBOUNCE_MS = 750; const RESYNC_DEBOUNCE_MS = 750;
const QUESTION_RECONCILE_COOLDOWN_MS = 1500; const QUESTION_RECONCILE_COOLDOWN_MS = 1500;
const PERMISSION_RECONCILE_COOLDOWN_MS = 1500;
const textLengthCache = new WeakMap<Part[], number>(); const textLengthCache = new WeakMap<Part[], number>();
const computeTextLength = (parts: Part[] | undefined | null): number => { const computeTextLength = (parts: Part[] | undefined | null): number => {
@@ -196,13 +197,17 @@ export const useEventStream = () => {
void bootstrapPendingQuestions(); void bootstrapPendingQuestions();
}, [bootstrapPendingQuestions]); }, [bootstrapPendingQuestions]);
React.useEffect(() => { const bootstrapPendingPermissions = React.useCallback(async () => {
let cancelled = false;
const bootstrapPendingPermissions = async () => {
try { try {
const pending = await opencodeClient.listPendingPermissions(); const projects = useProjectsStore.getState().projects;
if (cancelled || pending.length === 0) { const projectDirs = projects.map((project) => project.path);
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory);
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
const pending = await opencodeClient.listPendingPermissions({ directories });
if (pending.length === 0) {
return; return;
} }
@@ -212,15 +217,27 @@ export const useEventStream = () => {
} catch { } catch {
// ignored // ignored
} }
}; }, [addPermission, effectiveDirectory]);
const lastPermissionRefreshAtRef = React.useRef(0);
const requestPendingPermissionsRefresh = React.useCallback((force = false) => {
const now = Date.now();
if (!force && now - lastPermissionRefreshAtRef.current < PERMISSION_RECONCILE_COOLDOWN_MS) {
return;
}
lastPermissionRefreshAtRef.current = now;
void bootstrapPendingPermissions(); void bootstrapPendingPermissions();
requestPendingQuestionsRefresh(true); }, [bootstrapPendingPermissions]);
return () => { const requestPendingPermissionsRefreshRef = React.useRef(requestPendingPermissionsRefresh);
cancelled = true; React.useEffect(() => {
}; requestPendingPermissionsRefreshRef.current = requestPendingPermissionsRefresh;
}, [addPermission, requestPendingQuestionsRefresh]); }, [requestPendingPermissionsRefresh]);
React.useEffect(() => {
requestPendingPermissionsRefresh(true);
requestPendingQuestionsRefresh(true);
}, [requestPendingPermissionsRefresh, requestPendingQuestionsRefresh]);
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => { const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
@@ -1875,9 +1892,7 @@ export const useEventStream = () => {
checkConnection(); checkConnection();
triggerSessionStatusPoll(); triggerSessionStatusPoll();
// Always refresh session status on connect to detect any requestPendingPermissionsRefreshRef.current(shouldRefresh);
// already-running sessions (e.g., started via CLI before UI opened)
// Removed: void refreshSessionStatus();
if (shouldRefresh) { if (shouldRefresh) {
void stableBootstrapState('sse_reconnected'); void stableBootstrapState('sse_reconnected');
@@ -2045,6 +2060,7 @@ export const useEventStream = () => {
scheduleSoftResync(sessionId, 'visibility_restore', getMessageLimit()); scheduleSoftResync(sessionId, 'visibility_restore', getMessageLimit());
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
} }
requestPendingPermissionsRefreshRef.current(false);
// Removed: void refreshSessionStatus(); // Removed: void refreshSessionStatus();
triggerSessionStatusPoll(); triggerSessionStatusPoll();
@@ -2073,6 +2089,7 @@ export const useEventStream = () => {
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
scheduleSoftResync(sessionId, 'window_focus', getMessageLimit()); scheduleSoftResync(sessionId, 'window_focus', getMessageLimit());
} }
requestPendingPermissionsRefreshRef.current(false);
// Removed: void refreshSessionStatus(); // Removed: void refreshSessionStatus();
triggerSessionStatusPoll(); triggerSessionStatusPoll();
@@ -2085,6 +2102,7 @@ export const useEventStream = () => {
const handleOnline = () => { const handleOnline = () => {
onlineStatusRef.current = true; onlineStatusRef.current = true;
maybeBootstrapIfStale('network_restored'); maybeBootstrapIfStale('network_restored');
requestPendingPermissionsRefreshRef.current(false);
if (pendingResumeRef.current || !unsubscribeRef.current) { if (pendingResumeRef.current || !unsubscribeRef.current) {
triggerSessionStatusPoll(); triggerSessionStatusPoll();
publishStatus('connecting', 'Network restored'); publishStatus('connecting', 'Network restored');
@@ -2115,6 +2133,7 @@ export const useEventStream = () => {
void scheduleSoftResync(sessionId, 'page_show', getMessageLimit()); void scheduleSoftResync(sessionId, 'page_show', getMessageLimit());
requestSessionMetadataRefresh(sessionId); requestSessionMetadataRefresh(sessionId);
} }
requestPendingPermissionsRefreshRef.current(false);
// Removed: void refreshSessionStatus(); // Removed: void refreshSessionStatus();
triggerSessionStatusPoll(); triggerSessionStatusPoll();
startStream({ resetAttempts: true }); startStream({ resetAttempts: true });
+39 -3
View File
@@ -992,14 +992,50 @@ class OpencodeService {
return result.data || false; return result.data || false;
} }
async listPendingPermissions(): Promise<PermissionRequest[]> { async listPendingPermissions(options?: { directories?: Array<string | null | undefined> }): Promise<PermissionRequest[]> {
const fetches: Array<Promise<PermissionRequest[]>> = [];
const fetchForDirectory = async (directory?: string | null): Promise<PermissionRequest[]> => {
try { try {
// Permission requests are global across sessions; do not scope by directory. const trimmed = typeof directory === 'string' ? directory.trim() : '';
const result = await this.client.permission.list(); const result = await this.client.permission.list(trimmed ? { directory: trimmed } : undefined);
return (result.data || []) as unknown as PermissionRequest[]; return (result.data || []) as unknown as PermissionRequest[];
} catch { } catch {
return []; return [];
} }
};
// Try unscoped first (server may return global pending items).
fetches.push(fetchForDirectory(null));
const uniqueDirectories = new Set<string>();
for (const entry of options?.directories ?? []) {
const normalized = this.normalizeCandidatePath(entry ?? null);
if (normalized) {
uniqueDirectories.add(normalized);
}
}
for (const directory of uniqueDirectories) {
fetches.push(fetchForDirectory(directory));
}
const results = await Promise.all(fetches);
const merged: PermissionRequest[] = [];
const seenIds = new Set<string>();
for (const list of results) {
for (const item of list) {
if (!item || typeof item !== 'object') continue;
const id = (item as { id?: unknown }).id;
if (typeof id !== 'string' || id.length === 0) continue;
if (seenIds.has(id)) continue;
seenIds.add(id);
merged.push(item);
}
}
return merged;
} }
// Questions ("ask" tool) // Questions ("ask" tool)