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:
@@ -20,6 +20,21 @@ const activeAgentId = ref<string | null>(null)
|
|||||||
const activeAnchorRect = ref<DOMRect | null>(null)
|
const activeAnchorRect = ref<DOMRect | null>(null)
|
||||||
const openInRecording = ref(false)
|
const openInRecording = ref(false)
|
||||||
const promptBarRef = ref<InstanceType<typeof PromptBar> | null>(null)
|
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(() =>
|
const isRecordingActive = computed(() =>
|
||||||
promptBarRef.value?.isRecording ?? false
|
promptBarRef.value?.isRecording ?? false
|
||||||
@@ -177,6 +192,8 @@ function connectStatusWs() {
|
|||||||
|
|
||||||
case 'permissionRequest':
|
case 'permissionRequest':
|
||||||
s.awaitingPermission = true
|
s.awaitingPermission = true
|
||||||
|
// Auto-open PromptBar if not already open for this agent
|
||||||
|
autoOpenForAgent(agent.id)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'reading':
|
case 'reading':
|
||||||
@@ -334,6 +351,7 @@ onBeforeUnmount(() => {
|
|||||||
<FloatBubble
|
<FloatBubble
|
||||||
v-for="agent in enabledAgents"
|
v-for="agent in enabledAgents"
|
||||||
:key="agent.id"
|
:key="agent.id"
|
||||||
|
:ref="(el: any) => setBubbleRef(agent.id, el)"
|
||||||
:agent="agent"
|
:agent="agent"
|
||||||
:status="agentStatuses[agent.id]"
|
:status="agentStatuses[agent.id]"
|
||||||
:recording="(activeAgentId === agent.id && isRecordingActive) || (floatingAgentId === agent.id && isFloatingRecording)"
|
:recording="(activeAgentId === agent.id && isRecordingActive) || (floatingAgentId === agent.id && isFloatingRecording)"
|
||||||
|
|||||||
@@ -395,13 +395,28 @@ function addPermissionCard(msg: any) {
|
|||||||
if (messages.some(m => m.intervention?.requestId === rid)) return
|
if (messages.some(m => m.intervention?.requestId === rid)) return
|
||||||
|
|
||||||
const input = msg.tool_input || {}
|
const input = msg.tool_input || {}
|
||||||
|
const toolName = msg.tool_name || 'Unknown'
|
||||||
|
|
||||||
|
// Build a summary line for the content field (fallback)
|
||||||
let detail = ''
|
let detail = ''
|
||||||
if (msg.tool_name === 'Bash') {
|
if (toolName === 'Bash') {
|
||||||
detail = input.command || ''
|
detail = input.command || ''
|
||||||
} else if (msg.tool_name === 'Edit' || msg.tool_name === 'Write') {
|
} else if (toolName === 'Edit') {
|
||||||
detail = input.file_path || ''
|
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 {
|
} else {
|
||||||
detail = JSON.stringify(input).slice(0, 200)
|
detail = JSON.stringify(input).slice(0, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -412,7 +427,7 @@ function addPermissionCard(msg: any) {
|
|||||||
intervention: {
|
intervention: {
|
||||||
type: 'permission',
|
type: 'permission',
|
||||||
requestId: rid,
|
requestId: rid,
|
||||||
toolName: msg.tool_name,
|
toolName,
|
||||||
toolInput: input,
|
toolInput: input,
|
||||||
resolved: false
|
resolved: false
|
||||||
}
|
}
|
||||||
@@ -420,6 +435,36 @@ function addPermissionCard(msg: any) {
|
|||||||
scrollToBottom()
|
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) {
|
function addQuestionCard(msg: any) {
|
||||||
const input = msg.tool_input || {}
|
const input = msg.tool_input || {}
|
||||||
const questions = input.questions || []
|
const questions = input.questions || []
|
||||||
@@ -788,18 +833,78 @@ onBeforeUnmount(() => {
|
|||||||
<div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
|
<div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
|
||||||
<!-- Permission card -->
|
<!-- Permission card -->
|
||||||
<template v-if="item.msg!.intervention.type === 'permission'">
|
<template v-if="item.msg!.intervention.type === 'permission'">
|
||||||
<div class="intv-header">
|
<div class="perm-header">
|
||||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<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"/>
|
<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="9" x2="12" y2="13"/>
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="intv-title">Permission: {{ item.msg!.intervention.toolName }}</span>
|
|
||||||
</div>
|
</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">
|
<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--allow" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'allow')">
|
||||||
<button class="intv-btn intv-btn--deny" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'deny')">Deny</button>
|
<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>
|
||||||
<div v-else class="intv-resolved" :class="item.msg!.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
|
<div v-else class="intv-resolved" :class="item.msg!.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
|
||||||
{{ item.msg!.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
|
{{ item.msg!.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
|
||||||
@@ -1276,9 +1381,173 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intervention--permission {
|
.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);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
animation: permission-pulse 2s ease-in-out infinite;
|
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 {
|
.intervention--question {
|
||||||
@@ -1314,7 +1583,10 @@ onBeforeUnmount(() => {
|
|||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 60px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intv-actions {
|
.intv-actions {
|
||||||
@@ -1324,13 +1596,16 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intv-btn {
|
.intv-btn {
|
||||||
padding: 4px 14px;
|
padding: 5px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intv-btn--allow {
|
.intv-btn--allow {
|
||||||
|
|||||||
Reference in New Issue
Block a user