feat: surface structured permission details in approval UI

Wire the sidecar's new permissionDetail payload through the frontend:
- Add PermissionDetail interface to sidecar contract
- Add permissionDetail field to PendingApprovalRecord and thread
  through normalization and AryxAppService mapping
- Create PermissionDetailView component with kind-specific rendering:
  shell (command block + warning), write (filename + diff), read (path),
  mcp (server badge + args), url (prominent URL), memory (subject/fact),
  custom-tool (description + args), hook (message + args)
- ApprovalBanner shows structured detail when available, falls back to
  generic text for backward compatibility
- QueuedApprovalsList shows brief kind-specific summaries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-26 16:59:15 +01:00
co-authored by Copilot
parent 13c543e2fd
commit 4c01d8b1a7
5 changed files with 334 additions and 4 deletions
+1
View File
@@ -1199,6 +1199,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
permissionKind: event.permissionKind,
title: event.title,
detail: event.detail,
permissionDetail: event.permissionDetail,
};
}
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView';
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
/* ── ApprovalBanner ────────────────────────────────────────── */
@@ -47,9 +48,11 @@ export function ApprovalBanner({
{approval.permissionKind && <span>Permission: <span className="text-zinc-300">{approval.permissionKind}</span></span>}
</div>
{approval.detail && (
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
)}
{approval.permissionDetail
? <PermissionDetailView detail={approval.permissionDetail} />
: approval.detail && (
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
)}
</div>
</div>
@@ -134,7 +137,9 @@ export function QueuedApprovalsList({ approvals }: { approvals: PendingApprovalR
key={approval.id}
>
<ShieldAlert className="size-3 shrink-0 text-zinc-600" />
<span className="min-w-0 flex-1 truncate text-[11px] text-zinc-400">{approval.title}</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-zinc-400">
{(approval.permissionDetail && permissionDetailSummary(approval.permissionDetail)) || approval.title}
</span>
<span className="shrink-0 rounded-full bg-zinc-800 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-zinc-500">
{kindLabel}
</span>
@@ -0,0 +1,295 @@
import { useState } from 'react';
import {
AlertTriangle,
BookOpen,
ChevronDown,
ExternalLink,
FileEdit,
FileText,
Globe,
Server,
Terminal,
} from 'lucide-react';
import type { PermissionDetail } from '@shared/contracts/sidecar';
export function PermissionDetailView({ detail }: { detail: PermissionDetail }) {
switch (detail.kind) {
case 'shell':
return <ShellDetail detail={detail} />;
case 'write':
return <WriteDetail detail={detail} />;
case 'read':
return <ReadDetail detail={detail} />;
case 'mcp':
return <McpDetail detail={detail} />;
case 'url':
return <UrlDetail detail={detail} />;
case 'memory':
return <MemoryDetail detail={detail} />;
case 'custom-tool':
return <CustomToolDetail detail={detail} />;
case 'hook':
return <HookDetail detail={detail} />;
default:
return null;
}
}
export function permissionDetailSummary(detail: PermissionDetail): string | undefined {
switch (detail.kind) {
case 'shell':
return detail.command ? truncate(detail.command, 80) : undefined;
case 'write':
return detail.fileName;
case 'read':
return detail.path;
case 'url':
return detail.url;
case 'mcp':
return detail.serverName
? `${detail.serverName}${detail.toolTitle ?? ''}`
: detail.toolTitle;
case 'memory':
return detail.subject;
case 'custom-tool':
return detail.toolDescription ? truncate(detail.toolDescription, 80) : undefined;
case 'hook':
return detail.hookMessage ? truncate(detail.hookMessage, 80) : undefined;
default:
return undefined;
}
}
/* ── Kind-specific renderers ────────────────────────────────── */
function ShellDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
{detail.intention && <IntentionLine text={detail.intention} />}
{detail.warning && (
<div className="flex items-start gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5 text-[11px] text-red-300">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span>{detail.warning}</span>
</div>
)}
{detail.command && <CommandBlock text={detail.command} />}
{detail.possiblePaths && detail.possiblePaths.length > 0 && (
<MetaList label="Paths" items={detail.possiblePaths} />
)}
{detail.possibleUrls && detail.possibleUrls.length > 0 && (
<MetaList label="URLs" items={detail.possibleUrls} />
)}
</div>
);
}
function WriteDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
{detail.intention && <IntentionLine text={detail.intention} />}
{detail.fileName && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-300">
<FileEdit className="size-3 shrink-0 text-zinc-500" />
<code className="font-mono">{detail.fileName}</code>
</div>
)}
{detail.diff && <DiffBlock text={detail.diff} />}
{!detail.diff && detail.newFileContents && (
<CollapsibleCode label="New file contents" text={detail.newFileContents} />
)}
</div>
);
}
function ReadDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
{detail.intention && <IntentionLine text={detail.intention} />}
{detail.path && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-300">
<FileText className="size-3 shrink-0 text-zinc-500" />
<code className="font-mono">{detail.path}</code>
</div>
)}
</div>
);
}
function McpDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[11px]">
{detail.serverName && (
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-500/15 px-2 py-0.5 text-indigo-300">
<Server className="size-2.5" />
{detail.serverName}
</span>
)}
{detail.toolTitle && <span className="text-zinc-300">{detail.toolTitle}</span>}
{detail.readOnly && (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-400">
read-only
</span>
)}
</div>
{detail.args && Object.keys(detail.args).length > 0 && (
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
)}
</div>
);
}
function UrlDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
{detail.intention && <IntentionLine text={detail.intention} />}
{detail.url && (
<div className="flex items-center gap-1.5 rounded-md bg-zinc-800/60 px-2.5 py-1.5 text-[11px] text-blue-300">
<Globe className="size-3 shrink-0" />
<code className="min-w-0 flex-1 break-all font-mono">{detail.url}</code>
<ExternalLink className="size-3 shrink-0 text-zinc-500" />
</div>
)}
</div>
);
}
function MemoryDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-1.5">
{detail.subject && (
<div className="flex items-center gap-1.5 text-[11px]">
<BookOpen className="size-3 shrink-0 text-zinc-500" />
<span className="font-medium text-zinc-300">{detail.subject}</span>
</div>
)}
{detail.fact && (
<p className="rounded-md bg-zinc-800/60 px-2.5 py-1.5 text-[11px] leading-relaxed text-zinc-300">
{detail.fact}
</p>
)}
{detail.citations && (
<p className="text-[10px] text-zinc-500">
Source: <span className="text-zinc-400">{detail.citations}</span>
</p>
)}
</div>
);
}
function CustomToolDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
{detail.toolDescription && (
<p className="text-[11px] text-zinc-400">{detail.toolDescription}</p>
)}
{detail.args && Object.keys(detail.args).length > 0 && (
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
)}
</div>
);
}
function HookDetail({ detail }: { detail: PermissionDetail }) {
return (
<div className="mt-2.5 space-y-2">
{detail.hookMessage && (
<div className="flex items-start gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1.5 text-[11px] text-amber-200">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span>{detail.hookMessage}</span>
</div>
)}
{detail.args && Object.keys(detail.args).length > 0 && (
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
)}
</div>
);
}
/* ── Shared primitives ──────────────────────────────────────── */
function IntentionLine({ text }: { text: string }) {
return <p className="text-[11px] italic text-zinc-400">{text}</p>;
}
function CommandBlock({ text }: { text: string }) {
return (
<pre className="overflow-x-auto rounded-md bg-zinc-900/80 px-3 py-2 font-mono text-[11px] leading-relaxed text-emerald-300">
{text}
</pre>
);
}
function DiffBlock({ text }: { text: string }) {
const lines = text.split('\n');
return (
<CollapsibleCode label="Diff" text={text} defaultExpanded>
<pre className="max-h-48 overflow-auto rounded-md bg-zinc-900/80 px-3 py-2 font-mono text-[10px] leading-relaxed">
{lines.map((line, i) => {
let color = 'text-zinc-400';
if (line.startsWith('+')) color = 'text-emerald-400';
else if (line.startsWith('-')) color = 'text-red-400';
else if (line.startsWith('@@')) color = 'text-blue-400';
return (
<div className={color} key={i}>
{line}
</div>
);
})}
</pre>
</CollapsibleCode>
);
}
function CollapsibleCode({
label,
text,
children,
defaultExpanded = false,
}: {
label: string;
text: string;
children?: React.ReactNode;
defaultExpanded?: boolean;
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<div className="rounded-md border border-zinc-800/60 bg-zinc-900/40">
<button
aria-expanded={expanded}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[10px] font-medium text-zinc-500 hover:text-zinc-400"
onClick={() => setExpanded(!expanded)}
type="button"
>
<ChevronDown
className={`size-2.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
{label}
</button>
{expanded && (
<div className="border-t border-zinc-800/40 px-2.5 py-1.5">
{children ?? (
<pre className="max-h-48 overflow-auto font-mono text-[10px] leading-relaxed text-zinc-300">
{text}
</pre>
)}
</div>
)}
</div>
);
}
function MetaList({ label, items }: { label: string; items: string[] }) {
return (
<div className="text-[10px] text-zinc-500">
<span className="font-medium">{label}:</span>{' '}
<span className="text-zinc-400">{items.join(', ')}</span>
</div>
);
}
function truncate(text: string, maxLength: number): string {
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}`;
}
+25
View File
@@ -181,6 +181,30 @@ export interface AgentActivityEvent {
toolName?: string;
}
export interface PermissionDetail {
kind: string;
intention?: string;
command?: string;
warning?: string;
possiblePaths?: string[];
possibleUrls?: string[];
hasWriteFileRedirection?: boolean;
fileName?: string;
diff?: string;
newFileContents?: string;
path?: string;
serverName?: string;
toolTitle?: string;
args?: Record<string, unknown>;
readOnly?: boolean;
url?: string;
subject?: string;
fact?: string;
citations?: string;
toolDescription?: string;
hookMessage?: string;
}
export interface ApprovalRequestedEvent {
type: 'approval-requested';
requestId: string;
@@ -193,6 +217,7 @@ export interface ApprovalRequestedEvent {
permissionKind?: string;
title: string;
detail?: string;
permissionDetail?: PermissionDetail;
}
export interface CommandErrorEvent {
+4
View File
@@ -1,3 +1,5 @@
import type { PermissionDetail } from '@shared/contracts/sidecar';
export type ApprovalCheckpointKind = 'tool-call' | 'final-response';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
export type ApprovalDecision = Exclude<ApprovalStatus, 'pending'>;
@@ -35,6 +37,7 @@ export interface PendingApprovalRecord {
title: string;
detail?: string;
messages?: PendingApprovalMessageRecord[];
permissionDetail?: PermissionDetail;
}
export interface PendingApprovalState {
@@ -290,6 +293,7 @@ export function normalizePendingApproval(
title,
detail: normalizeOptionalString(approval?.detail),
messages: normalizePendingApprovalMessages(approval?.messages),
permissionDetail: approval?.permissionDetail,
};
}