fix(mobile): recover pending permission prompts after reconnect and resume (#497)
This commit is contained in:
@@ -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,31 +197,47 @@ export const useEventStream = () => {
|
|||||||
void bootstrapPendingQuestions();
|
void bootstrapPendingQuestions();
|
||||||
}, [bootstrapPendingQuestions]);
|
}, [bootstrapPendingQuestions]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const bootstrapPendingPermissions = React.useCallback(async () => {
|
||||||
let cancelled = false;
|
try {
|
||||||
|
const projects = useProjectsStore.getState().projects;
|
||||||
|
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 bootstrapPendingPermissions = async () => {
|
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
|
||||||
try {
|
const pending = await opencodeClient.listPendingPermissions({ directories });
|
||||||
const pending = await opencodeClient.listPendingPermissions();
|
if (pending.length === 0) {
|
||||||
if (cancelled || pending.length === 0) {
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const request of pending) {
|
|
||||||
addPermission(request as unknown as PermissionRequest);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
for (const request of pending) {
|
||||||
|
addPermission(request as unknown as PermissionRequest);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 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;
|
||||||
@@ -1754,7 +1771,7 @@ export const useEventStream = () => {
|
|||||||
trackMessage,
|
trackMessage,
|
||||||
reportMessage,
|
reportMessage,
|
||||||
requestPendingQuestionsRefresh,
|
requestPendingQuestionsRefresh,
|
||||||
|
|
||||||
updateSession,
|
updateSession,
|
||||||
removeSessionFromStore,
|
removeSessionFromStore,
|
||||||
bootstrapState,
|
bootstrapState,
|
||||||
@@ -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');
|
||||||
@@ -1885,14 +1900,14 @@ export const useEventStream = () => {
|
|||||||
const sessionId = currentSessionIdRef.current;
|
const sessionId = currentSessionIdRef.current;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit())
|
scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit())
|
||||||
.then(() => requestSessionMetadataRefresh(sessionId))
|
.then(() => requestSessionMetadataRefresh(sessionId))
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (streamDebugEnabled()) {
|
if (streamDebugEnabled()) {
|
||||||
@@ -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,18 +2089,20 @@ 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();
|
||||||
|
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 });
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
try {
|
const fetches: Array<Promise<PermissionRequest[]>> = [];
|
||||||
// Permission requests are global across sessions; do not scope by directory.
|
|
||||||
const result = await this.client.permission.list();
|
const fetchForDirectory = async (directory?: string | null): Promise<PermissionRequest[]> => {
|
||||||
return (result.data || []) as unknown as PermissionRequest[];
|
try {
|
||||||
} catch {
|
const trimmed = typeof directory === 'string' ? directory.trim() : '';
|
||||||
return [];
|
const result = await this.client.permission.list(trimmed ? { directory: trimmed } : undefined);
|
||||||
|
return (result.data || []) as unknown as PermissionRequest[];
|
||||||
|
} catch {
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user