feat: Auto-open PromptBar on permission request and improve permission card UI

PromptBar now auto-opens when a permission request arrives and it's hidden.
Permission cards show rich contextual info: tool badge, description, code
blocks for Bash, diff preview for Edit, file paths for Write, and icons
on Allow/Deny buttons.
This commit is contained in:
2026-02-16 01:40:08 -06:00
parent e2fc281210
commit 6633a61ee4
2 changed files with 306 additions and 13 deletions

View File

@@ -20,6 +20,21 @@ const activeAgentId = ref<string | null>(null)
const activeAnchorRect = ref<DOMRect | null>(null)
const openInRecording = ref(false)
const promptBarRef = ref<InstanceType<typeof PromptBar> | null>(null)
const bubbleRefs = new Map<string, Element>()
function setBubbleRef(agentId: string, el: any) {
if (el?.$el) bubbleRefs.set(agentId, el.$el)
else if (el) bubbleRefs.set(agentId, el)
}
function autoOpenForAgent(agentId: string) {
if (activeAgentId.value === agentId) return // Already open
const bubbleEl = bubbleRefs.get(agentId)
if (!bubbleEl) return
activeAnchorRect.value = bubbleEl.getBoundingClientRect()
openInRecording.value = false
activeAgentId.value = agentId
}
const isRecordingActive = computed(() =>
promptBarRef.value?.isRecording ?? false
@@ -177,6 +192,8 @@ function connectStatusWs() {
case 'permissionRequest':
s.awaitingPermission = true
// Auto-open PromptBar if not already open for this agent
autoOpenForAgent(agent.id)
break
case 'reading':
@@ -334,6 +351,7 @@ onBeforeUnmount(() => {
<FloatBubble
v-for="agent in enabledAgents"
:key="agent.id"
:ref="(el: any) => setBubbleRef(agent.id, el)"
:agent="agent"
:status="agentStatuses[agent.id]"
:recording="(activeAgentId === agent.id && isRecordingActive) || (floatingAgentId === agent.id && isFloatingRecording)"

View File

@@ -395,13 +395,28 @@ function addPermissionCard(msg: any) {
if (messages.some(m => m.intervention?.requestId === rid)) return
const input = msg.tool_input || {}
const toolName = msg.tool_name || 'Unknown'
// Build a summary line for the content field (fallback)
let detail = ''
if (msg.tool_name === 'Bash') {
if (toolName === 'Bash') {
detail = input.command || ''
} else if (msg.tool_name === 'Edit' || msg.tool_name === 'Write') {
} else if (toolName === 'Edit') {
detail = input.file_path || ''
} else if (toolName === 'Write') {
detail = input.file_path || ''
} else if (toolName === 'Grep') {
detail = `${input.pattern || ''} in ${input.path || '.'}`
} else if (toolName === 'Glob') {
detail = input.pattern || ''
} else if (toolName === 'WebFetch') {
detail = input.url || ''
} else if (toolName === 'WebSearch') {
detail = input.query || ''
} else if (toolName.startsWith('mcp__')) {
detail = JSON.stringify(input).slice(0, 300)
} else {
detail = JSON.stringify(input).slice(0, 200)
detail = JSON.stringify(input).slice(0, 300)
}
messages.push({
@@ -412,7 +427,7 @@ function addPermissionCard(msg: any) {
intervention: {
type: 'permission',
requestId: rid,
toolName: msg.tool_name,
toolName,
toolInput: input,
resolved: false
}
@@ -420,6 +435,36 @@ function addPermissionCard(msg: any) {
scrollToBottom()
}
// ── Permission card helpers ──
function permToolMeta(toolName: string): ToolMeta {
return TOOL_CATEGORIES[toolName] || getToolMeta(toolName)
}
function permToolDescription(toolName: string): string {
const descs: Record<string, string> = {
Bash: 'Execute shell command',
Edit: 'Modify file content',
Write: 'Create or overwrite file',
Read: 'Read file contents',
Glob: 'Search for files',
Grep: 'Search file contents',
WebFetch: 'Fetch URL content',
WebSearch: 'Web search',
Task: 'Launch sub-agent',
NotebookEdit: 'Edit Jupyter notebook',
}
if (descs[toolName]) return descs[toolName]
if (toolName.startsWith('mcp__')) return 'MCP tool call'
return 'Tool execution'
}
function truncateMiddle(str: string, maxLen: number): string {
if (!str || str.length <= maxLen) return str
const half = Math.floor((maxLen - 3) / 2)
return str.slice(0, half) + '...' + str.slice(-half)
}
function addQuestionCard(msg: any) {
const input = msg.tool_input || {}
const questions = input.questions || []
@@ -788,18 +833,78 @@ onBeforeUnmount(() => {
<div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
<!-- Permission card -->
<template v-if="item.msg!.intervention.type === 'permission'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<div class="perm-header">
<span
class="perm-tool-badge"
:style="{ '--badge-color': permToolMeta(item.msg!.intervention.toolName!).color }"
>
{{ permToolMeta(item.msg!.intervention.toolName!).icon }}
</span>
<div class="perm-header-text">
<span class="perm-tool-name">{{ item.msg!.intervention.toolName }}</span>
<span class="perm-tool-desc">{{ permToolDescription(item.msg!.intervention.toolName!) }}</span>
</div>
<svg class="perm-warn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="intv-title">Permission: {{ item.msg!.intervention.toolName }}</span>
</div>
<div class="intv-detail">{{ item.msg!.content }}</div>
<!-- Bash: show description + command -->
<template v-if="item.msg!.intervention.toolName === 'Bash'">
<div v-if="(item.msg!.intervention.toolInput as any)?.description" class="perm-description">
{{ (item.msg!.intervention.toolInput as any).description }}
</div>
<div class="perm-code-block">
<span class="perm-code-prefix">$</span>
<code class="perm-code">{{ (item.msg!.intervention.toolInput as any)?.command || '' }}</code>
</div>
</template>
<!-- Edit: show file + diff preview -->
<template v-else-if="item.msg!.intervention.toolName === 'Edit'">
<div class="perm-file-path">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>{{ truncateMiddle((item.msg!.intervention.toolInput as any)?.file_path || '', 60) }}</span>
</div>
<div v-if="(item.msg!.intervention.toolInput as any)?.old_string" class="perm-diff">
<div class="perm-diff-remove">{{ truncateMiddle((item.msg!.intervention.toolInput as any).old_string, 120) }}</div>
<div class="perm-diff-add">{{ truncateMiddle((item.msg!.intervention.toolInput as any).new_string || '', 120) }}</div>
</div>
</template>
<!-- Write: show file + content preview -->
<template v-else-if="item.msg!.intervention.toolName === 'Write'">
<div class="perm-file-path">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>{{ truncateMiddle((item.msg!.intervention.toolInput as any)?.file_path || '', 60) }}</span>
</div>
<div v-if="(item.msg!.intervention.toolInput as any)?.content" class="perm-code-block perm-code-block--sm">
<code class="perm-code">{{ truncateMiddle((item.msg!.intervention.toolInput as any).content, 200) }}</code>
</div>
</template>
<!-- Other tools: show key params -->
<template v-else>
<div class="perm-detail-rich">{{ item.msg!.content }}</div>
</template>
<div v-if="!item.msg!.intervention.resolved" class="intv-actions">
<button class="intv-btn intv-btn--allow" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'allow')">Allow</button>
<button class="intv-btn intv-btn--deny" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'deny')">Deny</button>
<button class="intv-btn intv-btn--allow" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'allow')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
Allow
</button>
<button class="intv-btn intv-btn--deny" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'deny')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Deny
</button>
</div>
<div v-else class="intv-resolved" :class="item.msg!.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
{{ item.msg!.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
@@ -1276,9 +1381,173 @@ onBeforeUnmount(() => {
}
.intervention--permission {
background: rgba(239, 68, 68, 0.08);
background: rgba(239, 68, 68, 0.06);
border: 1px solid rgba(239, 68, 68, 0.2);
animation: permission-pulse 2s ease-in-out infinite;
gap: 8px;
}
/* Permission card header */
.perm-header {
display: flex;
align-items: center;
gap: 8px;
}
.perm-tool-badge {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
background: color-mix(in srgb, var(--badge-color) 18%, transparent);
color: var(--badge-color);
border: 1px solid color-mix(in srgb, var(--badge-color) 25%, transparent);
}
.perm-header-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.perm-tool-name {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.perm-tool-desc {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
}
.perm-warn-icon {
flex-shrink: 0;
color: rgba(239, 68, 68, 0.5);
}
/* Permission description */
.perm-description {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.4;
}
/* Permission code block */
.perm-code-block {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 6px 10px;
display: flex;
align-items: flex-start;
gap: 6px;
max-height: 120px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.perm-code-block--sm {
max-height: 80px;
}
.perm-code-prefix {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
color: rgba(52, 211, 153, 0.6);
flex-shrink: 0;
line-height: 1.5;
user-select: none;
}
.perm-code {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
}
/* Permission file path */
.perm-file-path {
display: flex;
align-items: center;
gap: 5px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
color: rgba(34, 211, 238, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.perm-file-path svg {
flex-shrink: 0;
color: rgba(34, 211, 238, 0.5);
}
/* Permission diff preview */
.perm-diff {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
overflow: hidden;
max-height: 100px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.perm-diff-remove,
.perm-diff-add {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 10px;
padding: 4px 8px;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
}
.perm-diff-remove {
background: rgba(239, 68, 68, 0.08);
color: rgba(248, 113, 113, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.perm-diff-remove::before {
content: '- ';
opacity: 0.5;
}
.perm-diff-add {
background: rgba(16, 185, 129, 0.08);
color: rgba(52, 211, 153, 0.8);
}
.perm-diff-add::before {
content: '+ ';
opacity: 0.5;
}
/* Rich detail for other tools */
.perm-detail-rich {
font-size: 11px;
font-family: 'SF Mono', monospace;
color: rgba(255, 255, 255, 0.55);
white-space: pre-wrap;
word-break: break-all;
max-height: 80px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.intervention--question {
@@ -1314,7 +1583,10 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: pre-wrap;
word-break: break-all;
max-height: 60px;
overflow-y: auto;
}
.intv-actions {
@@ -1324,13 +1596,16 @@ onBeforeUnmount(() => {
}
.intv-btn {
padding: 4px 14px;
padding: 5px 14px;
border-radius: 6px;
border: none;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 5px;
}
.intv-btn--allow {