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.
This commit is contained in:
aptdnfapt
2026-01-03 23:07:08 +02:00
committed by GitHub
parent d971836962
commit 9887b25864
4 changed files with 151 additions and 29 deletions
@@ -329,7 +329,26 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
{}
<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 = '';
@@ -441,27 +460,58 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
Allow Once
</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>
{(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')}
@@ -1,7 +1,7 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
@@ -9,6 +9,7 @@ import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@ope
import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -240,7 +241,9 @@ const TaskToolSummary: React.FC<{
hasPrevTool: boolean;
hasNextTool: boolean;
output?: string;
}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output }) => {
sessionId?: string;
}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId }) => {
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const completedEntries = React.useMemo(() => {
return entries.filter((entry) => entry.state?.status === 'completed');
}, [entries]);
@@ -251,7 +254,14 @@ const TaskToolSummary: React.FC<{
const hasOutput = trimmedOutput.length > 0;
const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
if (completedEntries.length === 0 && !hasOutput) {
const handleOpenSession = (event: React.MouseEvent) => {
event.stopPropagation();
if (sessionId) {
setCurrentSession(sessionId);
}
};
if (completedEntries.length === 0 && !hasOutput && !sessionId) {
return null;
}
@@ -292,8 +302,20 @@ const TaskToolSummary: React.FC<{
</ToolScrollableSection>
) : null}
{sessionId && (
<button
type="button"
className="flex items-center gap-2 typography-meta text-primary hover:text-primary/80 w-full"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleOpenSession}
>
<RiExternalLinkLine className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta text-primary font-medium">Open subAgent session</span>
</button>
)}
{hasOutput ? (
<div className={cn('space-y-1', completedEntries.length > 0 && 'pt-1')}
<div className={cn('space-y-1', (completedEntries.length > 0 || sessionId) && 'pt-1')}
>
<button
type="button"
@@ -311,7 +333,6 @@ const TaskToolSummary: React.FC<{
)}
<span className="typography-meta text-foreground/80 font-medium">Output</span>
</button>
{isOutputExpanded ? (
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
<div className="w-full min-w-0">
@@ -895,6 +916,14 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start;
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end;
const taskSessionId = React.useMemo<string | undefined>(() => {
if (!isTaskTool) {
return undefined;
}
const candidate = metadata as { sessionId?: string } | undefined;
return typeof candidate?.sessionId === 'string' ? candidate.sessionId : undefined;
}, [isTaskTool, metadata]);
const taskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
return [];
@@ -1023,13 +1052,14 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
</div>
{}
{isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized) ? (
{isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized || taskSessionId) ? (
<TaskToolSummary
entries={taskSummaryEntries}
isExpanded={isExpanded}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
output={typeof stateWithData.output === 'string' ? stateWithData.output : undefined}
sessionId={taskSessionId}
/>
) : null}
+37 -1
View File
@@ -955,8 +955,44 @@ export const useEventStream = () => {
}
break;
case 'permission.replied':
case 'permission.asked':
// New permission system from OpenCode's PermissionNext
if ('sessionID' in props && props.sessionID === currentSessionId) {
const askedProps = props as {
id: string;
permission: string;
sessionID: string;
patterns?: string[];
always?: string[];
metadata: Record<string, unknown>;
tool?: {
messageID: string;
callID: string;
};
};
// Convert new permission.asked event format to Permission type
const permission = {
id: askedProps.id,
type: askedProps.permission,
pattern: askedProps.patterns, // Map patterns to pattern field for compatibility
sessionID: askedProps.sessionID,
messageID: askedProps.tool?.messageID || askedProps.sessionID,
callID: askedProps.tool?.callID,
title: `${askedProps.permission} permission required`,
metadata: {
...askedProps.metadata,
always: askedProps.always, // Store always in metadata for UI access
patterns: askedProps.patterns,
},
time: { created: Date.now() },
} as unknown as Permission;
addPermission(permission);
}
break;
case 'permission.replied':
// Permission was responded to - UI will update via permissionStore
break;
case 'todo.updated': {
+6
View File
@@ -2,6 +2,8 @@ export interface Permission {
id: string;
type: string;
pattern?: string | string[];
patterns?: string[]; // New system: array of specific patterns requesting approval
always?: string[]; // New system: what will be auto-approved on "always" click
sessionID: string;
messageID: string;
callID?: string;
@@ -10,6 +12,10 @@ export interface Permission {
time: {
created: number;
};
tool?: {
messageID: string;
callID: string;
};
}
export interface PermissionEvent {