Spaces:
Paused
Paused
File size: 7,342 Bytes
0b9dc2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | import type { PermissionContext, PermissionRule } from '@agentscope-ai/agentscope/permission';
import { Ban, CircleHelp, FolderOpen, ShieldCheck, ShieldX } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { PanelEmpty } from '@/components/panel/PanelEmpty';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTranslation } from '@/i18n/useI18n';
/** The behavior keys we render sections for (excludes ``passthrough``). */
type RuleBehavior = 'allow' | 'deny' | 'ask';
interface PermissionPanelProps {
/**
* The permission context to render. Pass ``null`` when no data is
* available yet (renders an empty state).
*/
permissionContext: PermissionContext | null;
}
/** i18n key suffix for each behavior group title. */
const BEHAVIOR_META: Record<RuleBehavior, { i18nKey: string; icon: typeof ShieldCheck }> = {
allow: { i18nKey: 'panel.permission.allow', icon: ShieldCheck },
deny: { i18nKey: 'panel.permission.deny', icon: Ban },
ask: { i18nKey: 'panel.permission.ask', icon: CircleHelp },
};
/**
* A monospace value (a path or rule pattern) that truncates to fit its
* row. When (and only when) the text is actually clipped, hovering
* reveals the full value in a tooltip anchored to the left.
*
* @param value - The text to display.
* @returns The truncating value element, with a tooltip when clipped.
*/
function TruncatedCode({ value }: { value: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => setTruncated(el.scrollWidth > el.clientWidth);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, [value]);
return (
<Tooltip>
<TooltipTrigger asChild>
<span ref={ref} className="min-w-0 flex-1 truncate text-left font-mono text-xs">
{value}
</span>
</TooltipTrigger>
{truncated ? (
<TooltipContent side="left" className="max-w-sm font-mono break-all">
{value}
</TooltipContent>
) : null}
</Tooltip>
);
}
/**
* A single tool's rule card: a header naming the tool plus one row per
* matching rule (pattern on the left, source on the right).
*
* @param toolName - The tool these rules apply to.
* @param rules - The rules configured for this tool under one behavior.
* @returns The tool card element.
*/
function ToolRuleCard({ toolName, rules }: { toolName: string; rules: PermissionRule[] }) {
const { t } = useTranslation();
return (
<div className="rounded-md border">
<div className="flex items-center gap-x-2 border-b px-2 py-1.5 text-sm font-medium">
{toolName}
<Badge variant="secondary" className="ml-auto">
{rules.length}
</Badge>
</div>
<ul className="flex flex-col">
{rules.map((rule, index) => (
<li
key={`${rule.rule_content ?? '*'}-${index}`}
className="flex items-center justify-between gap-x-2 px-2 py-1.5 text-xs not-last:border-b"
>
{rule.rule_content ? (
<TruncatedCode value={rule.rule_content} />
) : (
<span className="min-w-0 flex-1 text-muted-foreground">
{t('panel.permission.anyInvocation')}
</span>
)}
<Badge variant="outline" className="shrink-0">
{rule.source}
</Badge>
</li>
))}
</ul>
</div>
);
}
/**
* One behavior group (allow / deny / ask): a titled section that lists
* a {@link ToolRuleCard} per tool that has rules under this behavior.
*
* @param behavior - The behavior category for this section.
* @param ruleMap - Rules keyed by tool name for this behavior.
* @returns The section element, or ``null`` when there are no rules.
*/
function BehaviorGroup({
behavior,
ruleMap,
}: {
behavior: RuleBehavior;
ruleMap: Record<string, PermissionRule[]> | undefined;
}) {
const { t } = useTranslation();
const entries = Object.entries(ruleMap ?? {}).filter(([, rules]) => rules.length > 0);
if (entries.length === 0) return null;
const meta = BEHAVIOR_META[behavior];
const Icon = meta.icon;
return (
<div className="flex flex-col gap-y-1.5">
<div className="flex items-center gap-x-1.5 text-xs font-medium text-muted-foreground">
<Icon className="size-3.5" />
{t(meta.i18nKey)}
</div>
{entries.map(([toolName, rules]) => (
<ToolRuleCard key={toolName} toolName={toolName} rules={rules} />
))}
</div>
);
}
/**
* Pure content body for the Permission dock panel. Shows the active
* permission mode, the working directories in scope, and the configured
* rules grouped by behavior (deny / ask / allow) and then by tool. Data
* arrives via props so it owns no data fetching.
*
* Renders without its own header/border — the surrounding `Panel`
* chrome (from `PanelDock`) provides those.
*
* @param permissionContext - The permission context, or ``null``.
* @returns The permission panel body.
*/
export function PermissionPanel({ permissionContext }: PermissionPanelProps) {
const { t } = useTranslation();
const workingDirs = Object.values(permissionContext?.working_directories ?? {});
const hasRules =
Object.keys(permissionContext?.allow_rules ?? {}).length > 0 ||
Object.keys(permissionContext?.deny_rules ?? {}).length > 0 ||
Object.keys(permissionContext?.ask_rules ?? {}).length > 0;
return (
<div className="flex flex-col flex-1 min-h-0 gap-y-3">
<span className="text-muted-foreground text-sm">
{t('panel.permission.description')}
</span>
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto gap-y-4">
{/* Working directories — always shown, with an empty state. */}
<div className="flex flex-col gap-y-1.5">
<div className="flex items-center gap-x-1.5 text-xs font-medium text-muted-foreground">
<FolderOpen className="size-3.5" />
{t('panel.permission.workingDirectories')}
</div>
{workingDirs.length === 0 ? (
<p className="text-muted-foreground text-xs px-1 py-2">
{t('panel.permission.noWorkingDirectories')}
</p>
) : (
<ul className="flex flex-col rounded-md border">
{workingDirs.map((dir) => (
<li
key={dir.path}
className="flex items-center justify-between gap-x-2 px-2 py-1.5 text-xs not-last:border-b"
>
<TruncatedCode value={dir.path} />
<Badge variant="outline" className="shrink-0">
{dir.source}
</Badge>
</li>
))}
</ul>
)}
</div>
{/* Rules grouped by behavior, then by tool. */}
{hasRules ? (
<>
<BehaviorGroup behavior="deny" ruleMap={permissionContext?.deny_rules} />
<BehaviorGroup behavior="ask" ruleMap={permissionContext?.ask_rules} />
<BehaviorGroup behavior="allow" ruleMap={permissionContext?.allow_rules} />
</>
) : (
<PanelEmpty
icon={ShieldX}
title={t('panel.permission.emptyTitle')}
description={t('panel.permission.emptyDescription')}
/>
)}
</div>
</div>
);
}
|