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 <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
934af638fe
commit
0495bc85a0
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PermissionToastActionsProps {
|
||||||
|
sessionTitle: string;
|
||||||
|
permissionBody: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onOnce: () => Promise<void> | void;
|
||||||
|
onAlways: () => Promise<void> | void;
|
||||||
|
onDeny: () => Promise<void> | 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<PermissionToastActionsProps> = ({
|
||||||
|
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> | void) => {
|
||||||
|
if (isBusy || disabled) return;
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1.5 min-w-0 space-y-0.5">
|
||||||
|
<p className="typography-meta text-muted-foreground" title={sessionTitle}>
|
||||||
|
Session:{' '}
|
||||||
|
<span className="inline-block max-w-[280px] align-bottom truncate text-foreground">
|
||||||
|
{sessionPreview}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="typography-meta text-muted-foreground" title={permissionBody}>
|
||||||
|
Permission:{' '}
|
||||||
|
<span className="inline-block max-w-[280px] align-bottom truncate">
|
||||||
|
{permissionPreview}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(onOnce)}
|
||||||
|
disabled={disabled || isBusy}
|
||||||
|
aria-label={`Approve once${actionContext}`}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(var(--status-success) / 0.1)',
|
||||||
|
color: 'var(--status-success)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Once
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(onAlways)}
|
||||||
|
disabled={disabled || isBusy}
|
||||||
|
aria-label={`Approve always${actionContext}`}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(var(--muted) / 0.5)',
|
||||||
|
color: 'var(--muted-foreground)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Always
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(onDeny)}
|
||||||
|
disabled={disabled || isBusy}
|
||||||
|
aria-label={`Deny permission${actionContext}`}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 typography-meta font-medium rounded transition-colors h-6",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(var(--status-error) / 0.1)',
|
||||||
|
color: 'var(--status-error)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import { useContextStore } from '@/stores/contextStore';
|
|||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { isDesktopLocalOriginActive } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||||
import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus';
|
import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus';
|
||||||
|
import { PermissionToastActions } from '@/components/chat/PermissionToastActions';
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -34,6 +35,139 @@ const readStringProp = (obj: unknown, keys: string[]): string | null => {
|
|||||||
return 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<string, unknown>;
|
||||||
|
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<string, unknown>
|
||||||
|
: {};
|
||||||
|
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, unknown>): 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<string, unknown>) => void;
|
type MessageTracker = (messageId: string, event?: string, extraData?: Record<string, unknown>) => void;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -212,7 +346,11 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const request of pending) {
|
for (const request of pending) {
|
||||||
addPermission(request as unknown as PermissionRequest);
|
const normalizedRequest = normalizePermissionRequest(request);
|
||||||
|
if (!normalizedRequest) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addPermission(normalizedRequest);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignored
|
// ignored
|
||||||
@@ -1537,12 +1675,11 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'permission.asked': {
|
case 'permission.asked': {
|
||||||
if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
|
const request = normalizePermissionRequest(props);
|
||||||
|
if (!request) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = props as unknown as PermissionRequest;
|
|
||||||
|
|
||||||
addPermission(request);
|
addPermission(request);
|
||||||
|
|
||||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||||
@@ -1593,20 +1730,58 @@ export const useEventStream = () => {
|
|||||||
const sessionTitle =
|
const sessionTitle =
|
||||||
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
|
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
|
||||||
'Session';
|
'Session';
|
||||||
|
const permissionBody = buildPermissionToastBody(request);
|
||||||
|
|
||||||
import('sonner').then(({ toast }) => {
|
import('sonner').then(({ toast }) => {
|
||||||
toast.warning('Permission required', {
|
const isMobile = useUIStore.getState().isMobile;
|
||||||
id: toastKey,
|
|
||||||
description: sessionTitle,
|
if (isMobile) {
|
||||||
duration: 30000,
|
toast.warning('Permission required', {
|
||||||
action: {
|
id: toastKey,
|
||||||
label: 'Open',
|
description: sessionTitle,
|
||||||
onClick: () => {
|
duration: 30000,
|
||||||
useUIStore.getState().setActiveMainTab('chat');
|
action: {
|
||||||
void useSessionStore.getState().setCurrentSession(request.sessionID);
|
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);
|
}, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user