mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 14:07:13 +02:00
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:
@@ -1199,6 +1199,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
permissionKind: event.permissionKind,
|
permissionKind: event.permissionKind,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
detail: event.detail,
|
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 { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react';
|
||||||
|
|
||||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||||
|
import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView';
|
||||||
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
|
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
|
||||||
|
|
||||||
/* ── ApprovalBanner ────────────────────────────────────────── */
|
/* ── ApprovalBanner ────────────────────────────────────────── */
|
||||||
@@ -47,9 +48,11 @@ export function ApprovalBanner({
|
|||||||
{approval.permissionKind && <span>Permission: <span className="text-zinc-300">{approval.permissionKind}</span></span>}
|
{approval.permissionKind && <span>Permission: <span className="text-zinc-300">{approval.permissionKind}</span></span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approval.detail && (
|
{approval.permissionDetail
|
||||||
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
|
? <PermissionDetailView detail={approval.permissionDetail} />
|
||||||
)}
|
: approval.detail && (
|
||||||
|
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,7 +137,9 @@ export function QueuedApprovalsList({ approvals }: { approvals: PendingApprovalR
|
|||||||
key={approval.id}
|
key={approval.id}
|
||||||
>
|
>
|
||||||
<ShieldAlert className="size-3 shrink-0 text-zinc-600" />
|
<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">
|
<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}
|
{kindLabel}
|
||||||
</span>
|
</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)}…`;
|
||||||
|
}
|
||||||
@@ -181,6 +181,30 @@ export interface AgentActivityEvent {
|
|||||||
toolName?: string;
|
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 {
|
export interface ApprovalRequestedEvent {
|
||||||
type: 'approval-requested';
|
type: 'approval-requested';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -193,6 +217,7 @@ export interface ApprovalRequestedEvent {
|
|||||||
permissionKind?: string;
|
permissionKind?: string;
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
permissionDetail?: PermissionDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandErrorEvent {
|
export interface CommandErrorEvent {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PermissionDetail } from '@shared/contracts/sidecar';
|
||||||
|
|
||||||
export type ApprovalCheckpointKind = 'tool-call' | 'final-response';
|
export type ApprovalCheckpointKind = 'tool-call' | 'final-response';
|
||||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||||
export type ApprovalDecision = Exclude<ApprovalStatus, 'pending'>;
|
export type ApprovalDecision = Exclude<ApprovalStatus, 'pending'>;
|
||||||
@@ -35,6 +37,7 @@ export interface PendingApprovalRecord {
|
|||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
messages?: PendingApprovalMessageRecord[];
|
messages?: PendingApprovalMessageRecord[];
|
||||||
|
permissionDetail?: PermissionDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingApprovalState {
|
export interface PendingApprovalState {
|
||||||
@@ -290,6 +293,7 @@ export function normalizePendingApproval(
|
|||||||
title,
|
title,
|
||||||
detail: normalizeOptionalString(approval?.detail),
|
detail: normalizeOptionalString(approval?.detail),
|
||||||
messages: normalizePendingApprovalMessages(approval?.messages),
|
messages: normalizePendingApprovalMessages(approval?.messages),
|
||||||
|
permissionDetail: approval?.permissionDetail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user