Files
openchamber/packages/ui/src/components/chat/PermissionCard.tsx
T
aptdnfapt 9887b25864 Add permission asking UI and subAgent session navigation footer (#101)
* feat: add support for OpenCode permission.asked event

Implement UI handling for the new permission.asked event from OpenCode's
PermissionNext system, allowing tools configured with "ask" in opencode.json
to prompt user for approval.

Changes:
- Add permission.asked event handler in useEventStream
- Add patterns[] and always[] fields to Permission type
- Display patterns being requested in PermissionCard
- Show what "Always Allow" will auto-approve

This enables the permission asking feature where tools can require user
approval based on opencode.json configuration.

* feat: add button to open subAgent session from task tool

When an agent uses the task tool to create a subagent session,
a new "Open subAgent session" button is now displayed next to
the task tool output. Clicking this button navigates to the
child session in the chat view.

The button shows only when a sessionId is available in the task
metadata and uses the external link icon to indicate it opens
a separate session context.
2026-01-03 23:07:08 +02:00

549 lines
22 KiB
TypeScript

import React from 'react';
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { Permission, PermissionResponse } from '@/types/permission';
import { useSessionStore } from '@/stores/useSessionStore';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { DiffPreview, WritePreview } from './DiffPreview';
interface PermissionCardProps {
permission: Permission;
onResponse?: (response: 'once' | 'always' | 'reject') => void;
}
const getToolIcon = (toolName: string) => {
const iconClass = "h-3 w-3";
const tool = toolName.toLowerCase();
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
return <RiPencilAiLine className={iconClass} />;
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
return <RiFileEditLine className={iconClass} />;
}
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
return <RiTerminalBoxLine className={iconClass} />;
}
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
return <RiGlobalLine className={iconClass} />;
}
return <RiToolsLine className={iconClass} />;
};
const getToolDisplayName = (toolName: string): string => {
const tool = toolName.toLowerCase();
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
return 'edit';
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
return 'write';
}
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
return 'bash';
}
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
return 'webfetch';
}
return toolName;
};
export const PermissionCard: React.FC<PermissionCardProps> = ({
permission,
onResponse
}) => {
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const { respondToPermission } = useSessionStore();
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const handleResponse = async (response: PermissionResponse) => {
setIsResponding(true);
try {
await respondToPermission(permission.sessionID, permission.id, response);
setHasResponded(true);
onResponse?.(response);
} catch { /* ignored */ } finally {
setIsResponding(false);
}
};
if (hasResponded) {
return null;
}
const toolName = permission.type || 'Unknown Tool';
const tool = toolName.toLowerCase();
const getMeta = (key: string, fallback: string = ''): string => {
const val = permission.metadata[key];
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : fallback);
};
const getMetaNum = (key: string): number | undefined => {
const val = permission.metadata[key];
return typeof val === 'number' ? val : undefined;
};
const getMetaBool = (key: string): boolean => {
const val = permission.metadata[key];
return Boolean(val);
};
const displayToolName = getToolDisplayName(toolName);
const renderToolContent = () => {
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
const command = getMeta('command') || getMeta('cmd') || getMeta('script');
const description = getMeta('description');
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
const timeout = getMetaNum('timeout');
const commandInTitle = permission.title === command;
return (
<>
{description && (
<div className="typography-meta text-muted-foreground mb-2">{description}</div>
)}
{workingDir && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Working Directory:</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
</div>
)}
{timeout && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">Timeout:</span> {timeout}ms
</div>
)}
{}
{command && !commandInTitle && (
<div>
<SyntaxHighlighter
language="bash"
style={syntaxTheme}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
overflow: 'visible'
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}
}}
wrapLongLines={true}
>
{command}
</SyntaxHighlighter>
</div>
)}
</>
);
}
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
const changes = getMeta('changes') || getMeta('diff');
const replaceAll = getMetaBool('replace_all') || getMetaBool('replaceAll');
return (
<>
{replaceAll && (
<div className="typography-meta text-muted-foreground mb-2">
<span className="font-semibold">⚠️ Replace All Occurrences</span>
</div>
)}
{changes && (
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
<DiffPreview diff={changes} syntaxTheme={syntaxTheme} filePath={filePath} />
</ScrollableOverlay>
)}
</>
);
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
const content = getMeta('content') || getMeta('text') || getMeta('data');
if (content) {
return (
<ScrollableOverlay outerClassName="max-h-[60vh]" className="tool-output-surface p-1 rounded-xl border border-border/20 bg-transparent">
<WritePreview content={content} syntaxTheme={syntaxTheme} filePath={filePath} />
</ScrollableOverlay>
);
}
return null;
}
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
const url = getMeta('url') || getMeta('uri') || getMeta('endpoint');
const method = getMeta('method') || 'GET';
const headers = permission.metadata.headers && typeof permission.metadata.headers === 'object' ? (permission.metadata.headers as Record<string, unknown>) : undefined;
const body = getMeta('body') || getMeta('data') || getMeta('payload');
const timeout = getMetaNum('timeout');
const format = getMeta('format') || getMeta('responseType');
return (
<>
{url && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Request:</div>
<div className="flex items-center gap-2">
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
{method}
</span>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded flex-1 break-all">
{url}
</code>
</div>
</div>
)}
{headers && Object.keys(headers).length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Headers:</div>
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
<SyntaxHighlighter
language="json"
style={syntaxTheme}
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem'
}}
wrapLongLines={true}
>
{JSON.stringify(headers, null, 2)}
</SyntaxHighlighter>
</ScrollableOverlay>
</div>
)}
{body && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Body:</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<SyntaxHighlighter
language={typeof body === 'object' ? 'json' : 'text'}
style={syntaxTheme}
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem'
}}
wrapLongLines={true}
>
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
</SyntaxHighlighter>
</ScrollableOverlay>
</div>
)}
{(timeout || format) && (
<div className="typography-meta text-muted-foreground">
{timeout && <span>Timeout: {timeout}ms</span>}
{timeout && format && <span> • </span>}
{format && <span>Response format: {format}</span>}
</div>
)}
</>
);
}
const genericContent = getMeta('command') || getMeta('content') || getMeta('action') || getMeta('operation');
const description = getMeta('description');
return (
<>
{description && (
<div className="typography-meta text-muted-foreground mb-2">{description}</div>
)}
{genericContent && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Action:</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{String(genericContent)}
</pre>
</ScrollableOverlay>
</div>
)}
{}
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
<div>
<div className="typography-meta text-muted-foreground mb-1">Details:</div>
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
{JSON.stringify(permission.metadata, null, 2)}
</pre>
</ScrollableOverlay>
</div>
)}
</>
);
};
return (
<div className="group w-full pt-0 pb-2">
<div className="chat-column">
<div className="-mt-1 border border-border/30 rounded-xl bg-muted/10">
{}
<div className="px-2 py-1.5 border-b border-border/20 bg-muted/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<RiQuestionLine className="h-3.5 w-3.5 text-yellow-500" />
<span className="typography-meta font-medium text-muted-foreground">
Permission Required
</span>
</div>
<div className="flex items-center gap-1.5">
{getToolIcon(toolName)}
<span className="typography-meta text-muted-foreground font-medium">{displayToolName}</span>
</div>
</div>
</div>
{}
<div className="px-2 py-2">
{/* Show patterns being requested */}
{(permission.patterns as string[]) && (permission.patterns as string[]).length > 0 && (
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Patterns:</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{(permission.patterns as string[]).join(", ")}
</code>
</div>
)}
{!((permission.patterns as string[]) && (permission.patterns as string[]).length > 0) &&
(permission.pattern as string | string[]) &&
<div className="mb-2">
<div className="typography-meta text-muted-foreground mb-1">Pattern:</div>
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
{Array.isArray(permission.pattern) ? permission.pattern.join(", ") : permission.pattern}
</code>
</div>
}
{(() => {
let primaryContent = '';
let primaryLanguage = 'text';
let shouldHighlight = false;
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
primaryContent = getMeta('command') || getMeta('cmd') || getMeta('script');
primaryLanguage = 'bash';
shouldHighlight = true;
}
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool' || tool === 'write' || tool === 'create' || tool === 'file_write') {
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
shouldHighlight = false;
}
else if (tool === 'webfetch' || tool === 'fetch') {
primaryContent = getMeta('url') || getMeta('uri') || getMeta('endpoint');
shouldHighlight = false;
}
const titleMatchesContent = permission.title === primaryContent;
if (titleMatchesContent && primaryContent && shouldHighlight) {
return (
<div className="mb-3">
<SyntaxHighlighter
language={primaryLanguage}
style={syntaxTheme}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: 'var(--text-meta)',
lineHeight: '1.25rem',
background: 'rgb(var(--muted) / 0.3)',
borderRadius: '0.25rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
overflow: 'visible'
}}
codeTagProps={{
style: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}
}}
wrapLongLines={true}
>
{primaryContent}
</SyntaxHighlighter>
</div>
);
}
if (titleMatchesContent && primaryContent && !shouldHighlight) {
return (
<div className="mb-3">
<code className="typography-ui-label px-2 py-1 bg-muted/30 rounded block break-all">
{primaryContent}
</code>
</div>
);
}
if (permission.title) {
return (
<div className={cn(
"typography-ui-label text-foreground mb-3",
(shouldHighlight || primaryContent) && "font-mono"
)}>
{permission.title}
</div>
);
}
return null;
})()}
{}
{renderToolContent()}
</div>
{}
<div className="px-2 pb-1.5 pt-1 flex items-center gap-1.5 border-t border-border/20">
<button
onClick={() => handleResponse('once')}
disabled={isResponding}
className={cn(
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
"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)';
}}
>
<RiCheckLine className="h-3 w-3" />
Allow Once
</button>
{(permission.always as string[]) && (permission.always as string[]).length > 0 ? (
<button
onClick={() => handleResponse('always')}
disabled={isResponding}
className={cn(
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
"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)';
}}
>
<RiTimeLine className="h-3 w-3" />
{(() => {
const always = (permission.always as string[]) || (permission.metadata.always as string[]) || [];
if (always.length === 0) return "Always Allow";
const displayPatterns = always.slice(0, 2);
const text = displayPatterns.join(", ");
const hasMore = always.length > 2;
return hasMore ? `Always: ${text}...` : `Always: ${text}`;
})()}
</button>
) : (
<button
onClick={() => handleResponse('always')}
disabled={isResponding}
className={cn(
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
"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)';
}}
>
<RiTimeLine className="h-3 w-3" />
Always Allow
</button>
)}
<button
onClick={() => handleResponse('reject')}
disabled={isResponding}
className={cn(
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
"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)';
}}
>
<RiCloseLine className="h-3 w-3" />
Deny
</button>
{isResponding && (
<div className="ml-auto typography-meta text-muted-foreground">
<div className="animate-spin h-3 w-3 border border-primary border-t-transparent rounded-full" />
</div>
)}
</div>
</div>
</div>
</div>
);
};