From 0495bc85a096869b1b6a3697e1a820e25049d56e Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Sat, 28 Feb 2026 15:50:59 -0300 Subject: [PATCH] feat(chat): show session and permission preview in desktop permission toasts (#559) * feat(chat): add compact permission toast action component * feat(chat): enrich permission toast with session and request preview * fix(chat): guard permission payload parsing for toasts --------- Co-authored-by: Bohdan Triapitsyn --- .../chat/PermissionToastActions.tsx | 131 +++++++++++ packages/ui/src/hooks/useEventStream.ts | 205 ++++++++++++++++-- 2 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/components/chat/PermissionToastActions.tsx diff --git a/packages/ui/src/components/chat/PermissionToastActions.tsx b/packages/ui/src/components/chat/PermissionToastActions.tsx new file mode 100644 index 00000000..af61d175 --- /dev/null +++ b/packages/ui/src/components/chat/PermissionToastActions.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface PermissionToastActionsProps { + sessionTitle: string; + permissionBody: string; + disabled?: boolean; + onOnce: () => Promise | void; + onAlways: () => Promise | void; + onDeny: () => Promise | void; +} + +const truncateToastText = (value: string, maxLength: number): string => { + const normalized = value.trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`; +}; + +export const PermissionToastActions: React.FC = ({ + sessionTitle, + permissionBody, + disabled = false, + onOnce, + onAlways, + onDeny, +}) => { + const [isBusy, setIsBusy] = React.useState(false); + const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : ''; + const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session'; + const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable'; + + const handleAction = async (action: () => Promise | void) => { + if (isBusy || disabled) return; + setIsBusy(true); + try { + await action(); + } finally { + setIsBusy(false); + } + }; + + return ( +
+
+

+ Session:{' '} + + {sessionPreview} + +

+

+ Permission:{' '} + + {permissionPreview} + +

+
+ +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 002a3a54..2d1829eb 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -18,6 +18,7 @@ import { useContextStore } from '@/stores/contextStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { isDesktopLocalOriginActive } from '@/lib/desktop'; import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus'; +import { PermissionToastActions } from '@/components/chat/PermissionToastActions'; interface EventData { type: string; @@ -34,6 +35,139 @@ const readStringProp = (obj: unknown, keys: string[]): string | null => { return null; }; +const readStringArrayProp = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +}; + +const normalizePermissionRequest = (value: unknown): PermissionRequest | null => { + if (!value || typeof value !== 'object') { + return null; + } + + const record = value as Record; + const id = readStringProp(record, ['id']); + const sessionID = readStringProp(record, ['sessionID']); + if (!id || !sessionID) { + return null; + } + + const permission = typeof record.permission === 'string' ? record.permission : ''; + const patterns = readStringArrayProp(record.patterns); + const metadata = typeof record.metadata === 'object' && record.metadata !== null + ? record.metadata as Record + : {}; + const always = readStringArrayProp(record.always); + + const toolValue = record.tool; + const tool = (toolValue && typeof toolValue === 'object') + ? { + messageID: readStringProp(toolValue, ['messageID']) ?? '', + callID: readStringProp(toolValue, ['callID']) ?? '', + } + : undefined; + + return { + id, + sessionID, + permission, + patterns, + metadata, + always, + tool: tool && tool.messageID.length > 0 && tool.callID.length > 0 ? tool : undefined, + }; +}; + +const readPermissionMetadataPreview = (metadata: Record): string => { + const preferredKeys = [ + 'command', + 'cmd', + 'script', + 'path', + 'filePath', + 'filepath', + 'file_path', + 'directory', + 'working_directory', + 'cwd', + 'url', + 'uri', + 'endpoint', + 'description', + 'action', + 'operation', + ]; + + for (let i = 0; i < preferredKeys.length; i++) { + const value = metadata[preferredKeys[i]]; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + continue; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (Array.isArray(value)) { + const joined = value + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .slice(0, 3) + .join(', ') + .trim(); + if (joined.length > 0) { + return joined; + } + } + } + + const metadataEntries = Object.entries(metadata); + if (metadataEntries.length === 0) { + return ''; + } + + try { + return JSON.stringify(metadata); + } catch { + return ''; + } +}; + +const buildPermissionToastBody = (request: PermissionRequest): string => { + const patterns = Array.isArray(request.patterns) ? request.patterns : []; + const patternSummary = patterns + .filter((pattern): pattern is string => typeof pattern === 'string' && pattern.trim().length > 0) + .join(', ') + .trim(); + + const metadata = typeof request.metadata === 'object' && request.metadata !== null ? request.metadata : {}; + const metadataSummary = readPermissionMetadataPreview(metadata); + + if (patternSummary.length > 0 && metadataSummary.length > 0) { + return `${patternSummary} | ${metadataSummary}`; + } + + if (patternSummary.length > 0) { + return patternSummary; + } + + if (metadataSummary.length > 0) { + return metadataSummary; + } + + const fallback = typeof request.permission === 'string' ? request.permission.trim() : ''; + return fallback.length > 0 ? fallback : 'Permission details unavailable'; +}; + type MessageTracker = (messageId: string, event?: string, extraData?: Record) => void; declare global { @@ -212,7 +346,11 @@ export const useEventStream = () => { } for (const request of pending) { - addPermission(request as unknown as PermissionRequest); + const normalizedRequest = normalizePermissionRequest(request); + if (!normalizedRequest) { + continue; + } + addPermission(normalizedRequest); } } catch { // ignored @@ -1537,12 +1675,11 @@ export const useEventStream = () => { } case 'permission.asked': { - if (!('sessionID' in props) || typeof props.sessionID !== 'string') { + const request = normalizePermissionRequest(props); + if (!request) { break; } - const request = props as unknown as PermissionRequest; - addPermission(request); const runtimeAPIs = getRegisteredRuntimeAPIs(); @@ -1593,20 +1730,58 @@ export const useEventStream = () => { const sessionTitle = useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || 'Session'; + const permissionBody = buildPermissionToastBody(request); import('sonner').then(({ toast }) => { - toast.warning('Permission required', { - id: toastKey, - description: sessionTitle, - duration: 30000, - action: { - label: 'Open', - onClick: () => { - useUIStore.getState().setActiveMainTab('chat'); - void useSessionStore.getState().setCurrentSession(request.sessionID); + const isMobile = useUIStore.getState().isMobile; + + if (isMobile) { + toast.warning('Permission required', { + id: toastKey, + description: sessionTitle, + duration: 30000, + action: { + label: 'Open', + onClick: () => { + useUIStore.getState().setActiveMainTab('chat'); + void useSessionStore.getState().setCurrentSession(request.sessionID); + }, }, - }, - }); + }); + } else { + toast.warning('Permission required', { + id: toastKey, + description: React.createElement(PermissionToastActions, { + sessionTitle, + permissionBody, + onOnce: async () => { + try { + await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'once'); + toast.dismiss(toastKey); + } catch (error) { + console.error('Failed to respond to permission:', error); + } + }, + onAlways: async () => { + try { + await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'always'); + toast.dismiss(toastKey); + } catch (error) { + console.error('Failed to respond to permission:', error); + } + }, + onDeny: async () => { + try { + await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'reject'); + toast.dismiss(toastKey); + } catch (error) { + console.error('Failed to respond to permission:', error); + } + }, + }), + duration: 30000, + }); + } }); }, 0);