fix: scope lifecycle notifications to active session, remove mock mode
- Only show hookHistory/lifecycleEvent when viewing the agent's current live session, preventing notification leaking to historical sessions - Reset notifications on SessionStart (like hookHistory already does) - Remove mock/demo animation mode from SessionLifecycleStatus - Delete dead useHooksApproval composable (never imported)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick, TransitionGroup } from 'vue'
|
||||||
import type {
|
import type {
|
||||||
ParsedConversation,
|
ParsedConversation,
|
||||||
ParsedUserMessage,
|
ParsedUserMessage,
|
||||||
@@ -16,6 +16,7 @@ import AssistantMessageBubble from './AssistantMessageBubble.vue'
|
|||||||
import ProgressEvent from './ProgressEvent.vue'
|
import ProgressEvent from './ProgressEvent.vue'
|
||||||
import SystemMessage from './SystemMessage.vue'
|
import SystemMessage from './SystemMessage.vue'
|
||||||
import TurnEndDivider from './TurnEndDivider.vue'
|
import TurnEndDivider from './TurnEndDivider.vue'
|
||||||
|
import CompactBoundaryDivider from './CompactBoundaryDivider.vue'
|
||||||
import UserInput from './UserInput.vue'
|
import UserInput from './UserInput.vue'
|
||||||
import SessionLifecycleStatus from './SessionLifecycleStatus.vue'
|
import SessionLifecycleStatus from './SessionLifecycleStatus.vue'
|
||||||
import ResumeTerminalButton from './ResumeTerminalButton.vue'
|
import ResumeTerminalButton from './ResumeTerminalButton.vue'
|
||||||
@@ -49,17 +50,17 @@ const props = defineProps<{
|
|||||||
// ── Agent status display ──
|
// ── Agent status display ──
|
||||||
const sessionStore = useSessionState()
|
const sessionStore = useSessionState()
|
||||||
|
|
||||||
const STATUS_DISPLAY: Record<AgentStatus, { color: string; label: string }> = {
|
const STATUS_DISPLAY: Record<AgentStatus, { color: string; label: string; icon: string; filled?: boolean; processing?: boolean }> = {
|
||||||
idle: { color: '#6b7280', label: 'Available' },
|
idle: { color: '#6b7280', label: 'Available', icon: 'M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0', filled: true },
|
||||||
thinking: { color: '#60a5fa', label: 'Thinking...' },
|
thinking: { color: '#60a5fa', label: 'Thinking...', icon: 'M12 4v2m0 12v2m-8-8H2m20 0h-2m-2.9-5.1l-1.4 1.4m-9.4 9.4l-1.4 1.4m0-12.8l1.4 1.4m9.4 9.4l1.4 1.4', processing: true },
|
||||||
reading: { color: '#22d3ee', label: 'Reading' },
|
reading: { color: '#22d3ee', label: 'Reading', icon: 'M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2zm20 0h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z' },
|
||||||
writing: { color: '#4ade80', label: 'Writing' },
|
writing: { color: '#4ade80', label: 'Writing', icon: 'M17 3l4 4L7 21H3v-4z', processing: true },
|
||||||
toolUse: { color: '#fbbf24', label: 'Using' },
|
toolUse: { color: '#fbbf24', label: 'Using', icon: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z', processing: true },
|
||||||
permissionRequest: { color: '#fb923c', label: 'Waiting approval' },
|
permissionRequest: { color: '#fb923c', label: 'Waiting approval', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', processing: true },
|
||||||
interrupted: { color: '#f87171', label: 'Interrupted' },
|
interrupted: { color: '#f87171', label: 'Interrupted', icon: 'M10 4h4v8h-4zm0 12h4v4h-4z', filled: true },
|
||||||
error: { color: '#f87171', label: 'Error' },
|
error: { color: '#f87171', label: 'Error', icon: 'M12 2L2 22h20zm0 7v6m0 2v2' },
|
||||||
sessionStart: { color: '#60a5fa', label: 'Starting' },
|
sessionStart: { color: '#60a5fa', label: 'Starting', icon: 'M5 3l14 9-14 9z', filled: true, processing: true },
|
||||||
sessionEnd: { color: '#6b7280', label: 'Session ended' },
|
sessionEnd: { color: '#6b7280', label: 'Session ended', icon: 'M18 6L6 18M6 6l12 12' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentStatus = computed(() => {
|
const agentStatus = computed(() => {
|
||||||
@@ -70,26 +71,43 @@ const agentStatus = computed(() => {
|
|||||||
const display = STATUS_DISPLAY[state.status] || STATUS_DISPLAY.idle
|
const display = STATUS_DISPLAY[state.status] || STATUS_DISPLAY.idle
|
||||||
const toolSuffix = state.currentTool ? ` ${state.currentTool.name}` : ''
|
const toolSuffix = state.currentTool ? ` ${state.currentTool.name}` : ''
|
||||||
return {
|
return {
|
||||||
...display,
|
color: display.color,
|
||||||
label: display.label + toolSuffix,
|
icon: display.icon,
|
||||||
|
filled: !!display.filled,
|
||||||
|
processing: !!display.processing,
|
||||||
|
tooltip: display.label + toolSuffix,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Lifecycle hook event (for SessionLifecycleStatus) ──
|
// ── Lifecycle hook event (for SessionLifecycleStatus) ──
|
||||||
|
// Only show live hook data when viewing the agent's current active session
|
||||||
|
const isLiveSession = computed(() => {
|
||||||
|
const agent = props.selectedAgent
|
||||||
|
if (!agent) return false
|
||||||
|
const agentState = sessionStore.agents[agent]
|
||||||
|
if (!agentState?.sessionId) return false
|
||||||
|
// If no session selected, assume live
|
||||||
|
if (!props.selectedSessionId) return true
|
||||||
|
return props.selectedSessionId === agentState.sessionId
|
||||||
|
})
|
||||||
|
|
||||||
const lifecycleEvent = computed(() => {
|
const lifecycleEvent = computed(() => {
|
||||||
|
if (!isLiveSession.value) return null
|
||||||
const agent = props.selectedAgent
|
const agent = props.selectedAgent
|
||||||
if (!agent) return null
|
if (!agent) return null
|
||||||
return sessionStore.agents[agent]?.lastHookEvent ?? null
|
return sessionStore.agents[agent]?.lastHookEvent ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const lifecycleDetail = computed(() => {
|
const lifecycleDetail = computed(() => {
|
||||||
|
if (!isLiveSession.value) return ''
|
||||||
const agent = props.selectedAgent
|
const agent = props.selectedAgent
|
||||||
if (!agent) return ''
|
if (!agent) return ''
|
||||||
return sessionStore.agents[agent]?.lastHookDetail ?? ''
|
return sessionStore.agents[agent]?.lastHookDetail ?? ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const hookHistory = computed(() => {
|
const hookHistory = computed(() => {
|
||||||
|
if (!isLiveSession.value) return []
|
||||||
const agent = props.selectedAgent
|
const agent = props.selectedAgent
|
||||||
if (!agent) return []
|
if (!agent) return []
|
||||||
return sessionStore.agents[agent]?.hookHistory ?? []
|
return sessionStore.agents[agent]?.hookHistory ?? []
|
||||||
@@ -97,6 +115,20 @@ const hookHistory = computed(() => {
|
|||||||
|
|
||||||
// ── Derived display values ──
|
// ── Derived display values ──
|
||||||
const permissionMode = computed(() => props.hookPermissionMode || '')
|
const permissionMode = computed(() => props.hookPermissionMode || '')
|
||||||
|
|
||||||
|
const PERMISSION_ICONS: Record<string, { icon: string; color: string; label: string; filled?: boolean }> = {
|
||||||
|
default: { icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', color: '#6b7280', label: 'Default — asks before risky actions' },
|
||||||
|
acceptEdits: { icon: 'M17 3l4 4L7 21H3v-4z', color: '#4ade80', label: 'Accept Edits — auto-approves file changes' },
|
||||||
|
plan: { icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8zm11-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6z', color: '#60a5fa', label: 'Plan — read-only, proposes before acting' },
|
||||||
|
bypassPermissions: { icon: 'M13 2L3 14h9l-1 8 10-12h-9z', color: '#f87171', label: 'Bypass — auto-approves everything', filled: true },
|
||||||
|
dontAsk: { icon: 'M18 6L6 18M6 6l12 12', color: '#fb923c', label: 'Don\'t Ask — denies unless pre-approved' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionIcon = computed(() => {
|
||||||
|
const mode = permissionMode.value
|
||||||
|
if (!mode) return null
|
||||||
|
return PERMISSION_ICONS[mode] || { icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', color: '#94a3b8', label: mode }
|
||||||
|
})
|
||||||
const fullCwd = computed(() => props.conversation.metadata.cwd || '')
|
const fullCwd = computed(() => props.conversation.metadata.cwd || '')
|
||||||
const displayCwd = computed(() => {
|
const displayCwd = computed(() => {
|
||||||
const cwd = fullCwd.value
|
const cwd = fullCwd.value
|
||||||
@@ -374,22 +406,44 @@ watch(userUuids, (newUuids) => {
|
|||||||
collapsedSections.value = new Set(newUuids.slice(0, -1))
|
collapsedSections.value = new Set(newUuids.slice(0, -1))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pre-filtered visible messages for TransitionGroup (no v-if needed in template)
|
||||||
|
const visibleMessages = computed(() => {
|
||||||
|
const filtered = props.conversation.messages.filter(msg => !isCollapsedChild(msg))
|
||||||
|
return filtered.map((msg, i) => ({
|
||||||
|
msg,
|
||||||
|
isAssistantContinuation: msg.kind === 'assistant' && i > 0 && filtered[i - 1].kind === 'assistant'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ selectMode, toggleSelectMode, allCollapsed, collapseAllExceptLast })
|
defineExpose({ selectMode, toggleSelectMode, allCollapsed, collapseAllExceptLast })
|
||||||
|
|
||||||
// Track messages that just resolved from optimistic → real
|
// Track messages that just resolved from optimistic → real
|
||||||
// These skip the bounce animation and get a smooth transition instead
|
// These skip the bounce animation and get a smooth transition instead
|
||||||
const resolvedUuids = ref(new Set<string>())
|
const resolvedUuids = ref(new Set<string>())
|
||||||
|
|
||||||
// Force scroll to the absolute bottom of the container
|
// Force scroll to the absolute bottom of the container.
|
||||||
|
// Multiple passes: rAF covers content-visibility reflow,
|
||||||
|
// delayed pass covers CSS transition height settle (350ms outer + 300ms inner).
|
||||||
|
let scrollRafId = 0
|
||||||
|
let scrollTimerId = 0
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
if (!scrollContainer.value) return
|
if (!scrollContainer.value) return
|
||||||
const el = scrollContainer.value
|
const el = scrollContainer.value
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = el.scrollHeight
|
||||||
// Second pass: content-visibility: auto can cause reflow after initial paint,
|
|
||||||
// so re-scroll after the browser finishes layout
|
cancelAnimationFrame(scrollRafId)
|
||||||
requestAnimationFrame(() => {
|
clearTimeout(scrollTimerId)
|
||||||
|
|
||||||
|
scrollRafId = requestAnimationFrame(() => {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = el.scrollHeight
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
// Final pass after transitions settle
|
||||||
|
scrollTimerId = window.setTimeout(() => {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}, 350)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom when a new transcript is loaded
|
// Scroll to bottom when a new transcript is loaded
|
||||||
@@ -402,9 +456,12 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auto-scroll to bottom when messages change (every WS update)
|
// Auto-scroll to bottom on every WS update.
|
||||||
|
// Watch the array reference (not .length) because parseJsonl creates a new array
|
||||||
|
// each time — even when messages.length stays the same (e.g. assistant content
|
||||||
|
// appended to an existing message, tool results resolved, etc.)
|
||||||
watch(
|
watch(
|
||||||
() => props.conversation.messages.length,
|
() => props.conversation.messages,
|
||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -593,16 +650,14 @@ function formatDuration(start: string, end: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="scrollContainer" class="messages-scroll">
|
<div ref="scrollContainer" class="messages-scroll">
|
||||||
<template
|
<TransitionGroup name="msg">
|
||||||
v-for="(msg, idx) in conversation.messages"
|
|
||||||
:key="msg.uuid"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="!isCollapsedChild(msg)"
|
v-for="{ msg, isAssistantContinuation } in visibleMessages"
|
||||||
|
:key="msg.uuid"
|
||||||
:class="['message-wrapper', {
|
:class="['message-wrapper', {
|
||||||
resolved: resolvedUuids.has(msg.uuid),
|
resolved: resolvedUuids.has(msg.uuid),
|
||||||
selected: selectedUuids.has(msg.uuid),
|
selected: selectedUuids.has(msg.uuid),
|
||||||
'assistant-continuation': msg.kind === 'assistant' && idx > 0 && conversation.messages[idx - 1].kind === 'assistant' && !isCollapsedChild(conversation.messages[idx - 1])
|
'assistant-continuation': isAssistantContinuation
|
||||||
}]"
|
}]"
|
||||||
>
|
>
|
||||||
<!-- Select checkbox -->
|
<!-- Select checkbox -->
|
||||||
@@ -629,7 +684,7 @@ function formatDuration(start: string, end: string): string {
|
|||||||
<AssistantMessageBubble
|
<AssistantMessageBubble
|
||||||
v-else-if="msg.kind === 'assistant'"
|
v-else-if="msg.kind === 'assistant'"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
:show-header="!(idx > 0 && conversation.messages[idx - 1].kind === 'assistant')"
|
:show-header="!isAssistantContinuation"
|
||||||
/>
|
/>
|
||||||
<ProgressEvent
|
<ProgressEvent
|
||||||
v-else-if="msg.kind === 'progress'"
|
v-else-if="msg.kind === 'progress'"
|
||||||
@@ -639,13 +694,17 @@ function formatDuration(start: string, end: string): string {
|
|||||||
v-else-if="msg.kind === 'system' && msg.subtype === 'turn_end'"
|
v-else-if="msg.kind === 'system' && msg.subtype === 'turn_end'"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
/>
|
/>
|
||||||
|
<CompactBoundaryDivider
|
||||||
|
v-else-if="msg.kind === 'system' && msg.subtype === 'compact_boundary'"
|
||||||
|
:message="msg"
|
||||||
|
/>
|
||||||
<SystemMessage
|
<SystemMessage
|
||||||
v-else-if="msg.kind === 'system'"
|
v-else-if="msg.kind === 'system'"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selection floating bar -->
|
<!-- Selection floating bar -->
|
||||||
@@ -694,7 +753,22 @@ function formatDuration(start: string, end: string): string {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<span v-if="permissionMode" class="meta-badge mode">{{ permissionMode }}</span>
|
<span v-if="agentStatus" class="agent-status-icon" :class="{ processing: agentStatus.processing }">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24"
|
||||||
|
:fill="agentStatus.filled ? agentStatus.color : 'none'"
|
||||||
|
:stroke="agentStatus.filled ? 'none' : agentStatus.color"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
><path :d="agentStatus.icon" /></svg>
|
||||||
|
<span class="agent-status-tooltip">{{ agentStatus.tooltip }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="permissionIcon" class="perm-icon-wrap">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24"
|
||||||
|
:fill="permissionIcon.filled ? permissionIcon.color : 'none'"
|
||||||
|
:stroke="permissionIcon.filled ? 'none' : permissionIcon.color"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
><path :d="permissionIcon.icon" /></svg>
|
||||||
|
<span class="perm-tooltip">{{ permissionIcon.label }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="displayCwd" class="meta-badge origin" tabindex="0">{{ fullCwd }}</span>
|
<span v-if="displayCwd" class="meta-badge origin" tabindex="0">{{ fullCwd }}</span>
|
||||||
<span class="meta-count">{{ conversation.messages.length }} msgs</span>
|
<span class="meta-count">{{ conversation.messages.length }} msgs</span>
|
||||||
<button class="new-session-status-btn" @click="emit('createSession')" title="New session">
|
<button class="new-session-status-btn" @click="emit('createSession')" title="New session">
|
||||||
@@ -839,6 +913,87 @@ function formatDuration(start: string, end: string): string {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Agent status icon ── */
|
||||||
|
.agent-status-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-icon.processing svg {
|
||||||
|
animation: agent-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes agent-pulse {
|
||||||
|
0%, 100% { opacity: 0.45; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 5px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 3px 7px;
|
||||||
|
background: rgba(10, 10, 18, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status-icon:hover .agent-status-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Permission mode icon ── */
|
||||||
|
.perm-icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 5px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 3px 7px;
|
||||||
|
background: rgba(10, 10, 18, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-icon-wrap:hover .perm-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.new-session-status-btn {
|
.new-session-status-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1001,7 +1156,6 @@ function formatDuration(start: string, end: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
animation: fadeIn 0.15s ease-out;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -1010,6 +1164,7 @@ function formatDuration(start: string, end: string): string {
|
|||||||
contain-intrinsic-size: auto 120px;
|
contain-intrinsic-size: auto 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.message-wrapper.selected {
|
.message-wrapper.selected {
|
||||||
background: rgba(99, 102, 241, 0.06);
|
background: rgba(99, 102, 241, 0.06);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1145,11 +1300,6 @@ function formatDuration(start: string, end: string): string {
|
|||||||
transform: translateX(-50%) translateY(8px);
|
transform: translateX(-50%) translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes resolveIn {
|
@keyframes resolveIn {
|
||||||
from { opacity: 0.7; }
|
from { opacity: 0.7; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
@@ -1386,6 +1536,29 @@ function formatDuration(start: string, end: string): string {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Non-scoped: TransitionGroup classes must reach child components -->
|
||||||
|
<style>
|
||||||
|
.msg-enter-active {
|
||||||
|
transition: opacity 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
/* Override content-visibility during transition so browser doesn't skip frames */
|
||||||
|
content-visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-leave-active {
|
||||||
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||||
|
content-visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
type LifecycleEvent =
|
type LifecycleEvent =
|
||||||
| 'SessionStart' | 'UserPromptSubmit'
|
| 'SessionStart' | 'UserPromptSubmit'
|
||||||
@@ -13,66 +13,52 @@ type Category = 'session' | 'user' | 'tool' | 'agent' | 'system'
|
|||||||
interface LifecycleInfo {
|
interface LifecycleInfo {
|
||||||
color: string
|
color: string
|
||||||
label: string
|
label: string
|
||||||
|
icon: string
|
||||||
category: Category
|
category: Category
|
||||||
processing?: boolean
|
processing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SVG path data for each icon (14x14 viewBox)
|
||||||
|
const ICON = {
|
||||||
|
play: 'M4 2 L12 7 L4 12Z',
|
||||||
|
chat: 'M2 2h10v7H6l-2 2v-2H2z',
|
||||||
|
wrench: 'M10 2 L8 4 9 5 5 9 4 8 2 10 4 12 6 10 5 9 9 5 10 6 12 4z',
|
||||||
|
lock: 'M4 6V4a3 3 0 0 1 6 0v2h1v6H3V6zm2-2v2h2V4a1 1 0 0 0-2 0z',
|
||||||
|
check: 'M2 7 L5 10 L12 3',
|
||||||
|
xmark: 'M3 3 L11 11 M11 3 L3 11',
|
||||||
|
bell: 'M7 1a1 1 0 0 0-1 1v1A4 4 0 0 0 3 7v3l-1 1v1h10v-1l-1-1V7a4 4 0 0 0-3-4V2a1 1 0 0 0-1-1zm-1 12a1 1 0 0 0 2 0z',
|
||||||
|
fork: 'M4 2v5a3 3 0 0 0 3 3h0a3 3 0 0 0 3-3V2 M4 5h6 M7 10v2',
|
||||||
|
merge: 'M4 12V7a3 3 0 0 1 3-3h0a3 3 0 0 1 3 3v5 M4 9h6 M7 4V2',
|
||||||
|
moon: 'M9 2A5 5 0 1 0 9 12 4 4 0 0 1 9 2z',
|
||||||
|
trophy: 'M4 2h6v5a3 3 0 0 1-6 0z M2 2h2 M10 2h2 M5 10h4v2H5z M2 3v2a2 2 0 0 0 2 2 M12 3v2a2 2 0 0 1-2 2',
|
||||||
|
gear: 'M7 1l1 2h-2l1-2zM7 13l-1-2h2l-1 2zM1 7l2-1v2l-2-1zM13 7l-2 1V6l2 1zM2.5 3l2 .5-1 1.5-1-2zM11.5 11l-2-.5 1-1.5 1 2zM3 11.5l.5-2 1.5 1-2 1zM11 2.5l-.5 2-1.5-1 2-1zM7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z',
|
||||||
|
compress: 'M2 5h4V1 M12 9H8v4 M2 9h4v4 M12 5H8V1',
|
||||||
|
power: 'M7 1v5 M4 3A5 5 0 1 0 10 3',
|
||||||
|
stop: 'M3 3h8v8H3z',
|
||||||
|
} as const
|
||||||
|
|
||||||
const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
|
const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
|
||||||
SessionStart: { color: '#60a5fa', label: 'Session started', category: 'session' },
|
SessionStart: { color: '#60a5fa', label: 'Session started', icon: 'play', category: 'session' },
|
||||||
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', category: 'user' },
|
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', icon: 'chat', category: 'user' },
|
||||||
PreToolUse: { color: '#fbbf24', label: 'Tool starting', category: 'tool', processing: true },
|
PreToolUse: { color: '#fbbf24', label: 'Tool starting', icon: 'wrench', category: 'tool', processing: true },
|
||||||
PermissionRequest: { color: '#fb923c', label: 'Permission required', category: 'tool', processing: true },
|
PermissionRequest: { color: '#fb923c', label: 'Permission required', icon: 'lock', category: 'tool', processing: true },
|
||||||
PostToolUse: { color: '#4ade80', label: 'Tool completed', category: 'tool' },
|
PostToolUse: { color: '#4ade80', label: 'Tool completed', icon: 'check', category: 'tool' },
|
||||||
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', category: 'tool' },
|
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', icon: 'xmark', category: 'tool' },
|
||||||
Notification: { color: '#38bdf8', label: 'Notification', category: 'system' },
|
Notification: { color: '#38bdf8', label: 'Notification', icon: 'bell', category: 'system' },
|
||||||
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', category: 'agent', processing: true },
|
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', icon: 'fork', category: 'agent', processing: true },
|
||||||
SubagentStop: { color: '#a855f7', label: 'Subagent finished', category: 'agent' },
|
SubagentStop: { color: '#a855f7', label: 'Subagent finished', icon: 'merge', category: 'agent' },
|
||||||
Stop: { color: '#22d3ee', label: 'Response complete', category: 'session' },
|
Stop: { color: '#22d3ee', label: 'Response complete', icon: 'stop', category: 'session' },
|
||||||
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', category: 'agent' },
|
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', icon: 'moon', category: 'agent' },
|
||||||
TaskCompleted: { color: '#34d399', label: 'Task completed', category: 'system' },
|
TaskCompleted: { color: '#34d399', label: 'Task completed', icon: 'trophy', category: 'system' },
|
||||||
ConfigChange: { color: '#e879f9', label: 'Config changed', category: 'system' },
|
ConfigChange: { color: '#e879f9', label: 'Config changed', icon: 'gear', category: 'system' },
|
||||||
PreCompact: { color: '#f59e0b', label: 'Compacting context', category: 'system', processing: true },
|
PreCompact: { color: '#f59e0b', label: 'Compacting context', icon: 'compress', category: 'system', processing: true },
|
||||||
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' },
|
SessionEnd: { color: '#6b7280', label: 'Session ended', icon: 'power', category: 'session' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_ORDER: Record<Category, number> = {
|
const CATEGORY_ORDER: Record<Category, number> = {
|
||||||
session: 0, user: 1, tool: 2, agent: 3, system: 4
|
session: 0, user: 1, tool: 2, agent: 3, system: 4
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockStep {
|
|
||||||
event: LifecycleEvent
|
|
||||||
duration: number
|
|
||||||
detail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_SEQUENCE: MockStep[] = [
|
|
||||||
{ event: 'SessionStart', duration: 2000, detail: 'claude-opus-4-6' },
|
|
||||||
{ event: 'UserPromptSubmit', duration: 1500, detail: '"Fix the login bug"' },
|
|
||||||
{ event: 'PreToolUse', duration: 800, detail: 'Grep' },
|
|
||||||
{ event: 'PostToolUse', duration: 1200, detail: 'Grep' },
|
|
||||||
{ event: 'PreToolUse', duration: 600, detail: 'Read' },
|
|
||||||
{ event: 'PostToolUse', duration: 1000, detail: 'Read' },
|
|
||||||
{ event: 'PreToolUse', duration: 500, detail: 'Edit' },
|
|
||||||
{ event: 'PermissionRequest', duration: 3000, detail: 'Edit src/auth.ts' },
|
|
||||||
{ event: 'PostToolUse', duration: 1500, detail: 'Edit' },
|
|
||||||
{ event: 'PreToolUse', duration: 400, detail: 'Bash' },
|
|
||||||
{ event: 'PermissionRequest', duration: 2500, detail: 'npm test' },
|
|
||||||
{ event: 'PostToolUseFailure', duration: 2000, detail: 'Bash (exit 1)' },
|
|
||||||
{ event: 'Notification', duration: 1500, detail: 'Test failed, retrying' },
|
|
||||||
{ event: 'PreToolUse', duration: 500, detail: 'Edit' },
|
|
||||||
{ event: 'PostToolUse', duration: 1000, detail: 'Edit' },
|
|
||||||
{ event: 'PreToolUse', duration: 400, detail: 'Bash' },
|
|
||||||
{ event: 'PostToolUse', duration: 1500, detail: 'Bash (tests pass)' },
|
|
||||||
{ event: 'SubagentStart', duration: 1000, detail: 'code-review' },
|
|
||||||
{ event: 'SubagentStop', duration: 1500, detail: 'code-review' },
|
|
||||||
{ event: 'PreCompact', duration: 2000 },
|
|
||||||
{ event: 'TaskCompleted', duration: 1500, detail: 'Login bug fixed' },
|
|
||||||
{ event: 'Stop', duration: 3000, detail: 'Done' },
|
|
||||||
{ event: 'TeammateIdle', duration: 1500 },
|
|
||||||
{ event: 'ConfigChange', duration: 1200, detail: '.claude/settings.json' },
|
|
||||||
{ event: 'SessionEnd', duration: 4000 },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface HookHistoryEntry {
|
interface HookHistoryEntry {
|
||||||
event: string
|
event: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -91,24 +77,29 @@ const props = defineProps<{
|
|||||||
hookHistory?: HookHistoryEntry[]
|
hookHistory?: HookHistoryEntry[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isMock = computed(() => !props.currentEvent || !(props.currentEvent in LIFECYCLE_DISPLAY))
|
const hasEvent = computed(() => !!props.currentEvent && props.currentEvent in LIFECYCLE_DISPLAY)
|
||||||
|
|
||||||
const mockIndex = ref(0)
|
const activeEvent = computed<LifecycleEvent | null>(() => {
|
||||||
const mockHistory = ref<{ event: string }[]>([])
|
if (!hasEvent.value) return null
|
||||||
let mockTimer: ReturnType<typeof setTimeout> | null = null
|
return props.currentEvent as LifecycleEvent
|
||||||
|
|
||||||
const activeEvent = computed<LifecycleEvent>(() => {
|
|
||||||
if (!isMock.value) return props.currentEvent as LifecycleEvent
|
|
||||||
return MOCK_SEQUENCE[mockIndex.value].event
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeDetail = computed(() => {
|
const activeDetail = computed(() => props.eventDetail || '')
|
||||||
if (!isMock.value) return props.eventDetail || ''
|
|
||||||
return MOCK_SEQUENCE[mockIndex.value].detail || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayInfo = computed(() => LIFECYCLE_DISPLAY[activeEvent.value])
|
const displayInfo = computed(() => activeEvent.value ? LIFECYCLE_DISPLAY[activeEvent.value] : null)
|
||||||
const isProcessing = computed(() => !!displayInfo.value.processing)
|
const isProcessing = computed(() => !!displayInfo.value?.processing)
|
||||||
|
const iconPath = computed(() => displayInfo.value ? ICON[displayInfo.value.icon as keyof typeof ICON] : '')
|
||||||
|
const isFilled = computed(() => {
|
||||||
|
if (!displayInfo.value) return false
|
||||||
|
const i = displayInfo.value.icon
|
||||||
|
return i === 'play' || i === 'chat' || i === 'bell' || i === 'moon' || i === 'stop'
|
||||||
|
})
|
||||||
|
const tooltipText = computed(() => {
|
||||||
|
if (!displayInfo.value) return ''
|
||||||
|
const label = displayInfo.value.label
|
||||||
|
const detail = activeDetail.value
|
||||||
|
return detail ? `${label} — ${detail}` : label
|
||||||
|
})
|
||||||
|
|
||||||
// ── Badge counts ──
|
// ── Badge counts ──
|
||||||
|
|
||||||
@@ -141,57 +132,11 @@ function countEvents(entries: { event: string }[]): EventCountEntry[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const realCounts = computed(() => countEvents(props.hookHistory || []))
|
const displayCounts = computed(() => countEvents(props.hookHistory || []))
|
||||||
const mockCounts = computed(() => countEvents(mockHistory.value))
|
|
||||||
const displayCounts = computed(() => isMock.value ? mockCounts.value : realCounts.value)
|
|
||||||
|
|
||||||
// ── Mock mode ──
|
|
||||||
|
|
||||||
function advanceMock() {
|
|
||||||
const nextIndex = (mockIndex.value + 1) % MOCK_SEQUENCE.length
|
|
||||||
const nextEvent = MOCK_SEQUENCE[nextIndex].event
|
|
||||||
|
|
||||||
if (nextEvent === 'SessionStart') {
|
|
||||||
mockHistory.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
mockHistory.value = [...mockHistory.value, { event: nextEvent }]
|
|
||||||
mockIndex.value = nextIndex
|
|
||||||
scheduleMock()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleMock() {
|
|
||||||
if (mockTimer) clearTimeout(mockTimer)
|
|
||||||
mockTimer = setTimeout(advanceMock, MOCK_SEQUENCE[mockIndex.value].duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isMock, (mock) => {
|
|
||||||
if (mock) {
|
|
||||||
mockHistory.value = [{ event: MOCK_SEQUENCE[mockIndex.value].event }]
|
|
||||||
scheduleMock()
|
|
||||||
} else if (mockTimer) {
|
|
||||||
clearTimeout(mockTimer)
|
|
||||||
mockTimer = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (isMock.value) {
|
|
||||||
mockHistory.value = [{ event: MOCK_SEQUENCE[mockIndex.value].event }]
|
|
||||||
scheduleMock()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (mockTimer) {
|
|
||||||
clearTimeout(mockTimer)
|
|
||||||
mockTimer = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="lifecycle-ribbon" :class="['category-' + displayInfo.category]">
|
<div v-if="displayInfo" class="lifecycle-ribbon" :class="['category-' + displayInfo.category]">
|
||||||
<!-- Badge counts -->
|
<!-- Badge counts -->
|
||||||
<div class="lc-badges" v-if="displayCounts.length > 0">
|
<div class="lc-badges" v-if="displayCounts.length > 0">
|
||||||
<TransitionGroup name="badge">
|
<TransitionGroup name="badge">
|
||||||
@@ -209,16 +154,21 @@ onBeforeUnmount(() => {
|
|||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current event display -->
|
<!-- Current event icon + tooltip -->
|
||||||
<Transition name="lc" mode="out-in">
|
<Transition name="lc" mode="out-in">
|
||||||
<div class="lc-content" :key="activeEvent + activeDetail">
|
<div class="lc-icon-wrap" :key="activeEvent + activeDetail" :class="{ processing: isProcessing }">
|
||||||
<span
|
<svg
|
||||||
class="lc-dot"
|
class="lc-icon"
|
||||||
:class="{ processing: isProcessing }"
|
width="14" height="14" viewBox="0 0 14 14"
|
||||||
:style="{ background: displayInfo.color }"
|
:fill="isFilled ? displayInfo.color : 'none'"
|
||||||
/>
|
:stroke="isFilled ? 'none' : displayInfo.color"
|
||||||
<span class="lc-event" :style="{ color: displayInfo.color }">{{ activeEvent }}</span>
|
stroke-width="1.5"
|
||||||
<span v-if="activeDetail" class="lc-detail">{{ activeDetail }}</span>
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path :d="iconPath" />
|
||||||
|
</svg>
|
||||||
|
<span class="lc-tooltip">{{ tooltipText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,49 +238,54 @@ onBeforeUnmount(() => {
|
|||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Current event ── */
|
/* ── Icon + tooltip ── */
|
||||||
|
|
||||||
.lc-content {
|
.lc-icon-wrap {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
justify-content: center;
|
||||||
flex: 1;
|
width: 18px;
|
||||||
min-width: 0;
|
height: 18px;
|
||||||
}
|
|
||||||
|
|
||||||
.lc-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 0;
|
cursor: default;
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lc-dot.processing {
|
.lc-icon {
|
||||||
animation: pulse-lifecycle 1.5s ease-in-out infinite;
|
transition: transform 0.2s ease, filter 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-lifecycle {
|
.lc-icon-wrap.processing .lc-icon {
|
||||||
0%, 100% { opacity: 0.5; transform: scale(0.8); }
|
animation: pulse-icon 1.5s ease-in-out infinite;
|
||||||
50% { opacity: 1; transform: scale(1.2); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lc-event {
|
@keyframes pulse-icon {
|
||||||
|
0%, 100% { opacity: 0.5; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: rgba(15, 15, 25, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: color 0.3s ease;
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lc-detail {
|
.lc-icon-wrap:hover .lc-tooltip {
|
||||||
font-size: 9px;
|
opacity: 1;
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 120px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition classes */
|
/* Transition classes */
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { useTranscriptDebug } from './useTranscriptDebug'
|
export { useTranscriptDebug } from './useTranscriptDebug'
|
||||||
export { useHooksApproval } from './useHooksApproval'
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { apiFetch } from '@/lib/tauri'
|
|
||||||
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
|
||||||
|
|
||||||
export function useHooksApproval() {
|
|
||||||
const pendingPermissions = ref<HooksApprovalPermissionRequest[]>([])
|
|
||||||
const pendingPlans = ref<HooksApprovalPlanRequest[]>([])
|
|
||||||
|
|
||||||
// ── WS message handler (called from useTranscriptDebug) ──
|
|
||||||
|
|
||||||
function handleApprovalMessage(msg: { type: string; [key: string]: unknown }) {
|
|
||||||
if (msg.type === 'hooks-approval-permission') {
|
|
||||||
pendingPermissions.value.push({
|
|
||||||
requestId: msg.requestId as string,
|
|
||||||
tool_name: msg.tool_name as string | undefined,
|
|
||||||
tool_input: msg.tool_input,
|
|
||||||
agent_name: msg.agent_name as string | undefined,
|
|
||||||
session_id: msg.session_id as string | undefined,
|
|
||||||
cwd: msg.cwd as string | undefined,
|
|
||||||
timestamp: msg.timestamp as number
|
|
||||||
})
|
|
||||||
} else if (msg.type === 'hooks-approval-plan') {
|
|
||||||
pendingPlans.value.push({
|
|
||||||
requestId: msg.requestId as string,
|
|
||||||
session_id: msg.session_id as string | undefined,
|
|
||||||
permission_mode: msg.permission_mode as string | undefined,
|
|
||||||
lastAssistantText: (msg.lastAssistantText as string) || '',
|
|
||||||
timestamp: msg.timestamp as number
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Respond to permission ──
|
|
||||||
|
|
||||||
async function respondPermission(requestId: string, decision: 'allow' | 'deny') {
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/hooks-approval/respond', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ requestId, decision })
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[HooksApproval] Failed to respond permission:', e)
|
|
||||||
}
|
|
||||||
pendingPermissions.value = pendingPermissions.value.filter(p => p.requestId !== requestId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Respond to plan ──
|
|
||||||
|
|
||||||
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/hooks-approval/respond-plan', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ requestId, decision, reason })
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[HooksApproval] Failed to respond plan:', e)
|
|
||||||
}
|
|
||||||
pendingPlans.value = pendingPlans.value.filter(p => p.requestId !== requestId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fetch pending on page load (state recovery) ──
|
|
||||||
|
|
||||||
async function fetchPending() {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/hooks-approval')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
if (Array.isArray(data.permissions)) {
|
|
||||||
for (const p of data.permissions) {
|
|
||||||
if (!pendingPermissions.value.some(e => e.requestId === p.requestId)) {
|
|
||||||
pendingPermissions.value.push({
|
|
||||||
requestId: p.requestId,
|
|
||||||
tool_name: p.tool_name,
|
|
||||||
tool_input: p.tool_input,
|
|
||||||
agent_name: p.agent_name,
|
|
||||||
session_id: p.session_id,
|
|
||||||
timestamp: p.createdAt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(data.plans)) {
|
|
||||||
for (const p of data.plans) {
|
|
||||||
if (!pendingPlans.value.some(e => e.requestId === p.requestId)) {
|
|
||||||
pendingPlans.value.push({
|
|
||||||
requestId: p.requestId,
|
|
||||||
session_id: p.session_id,
|
|
||||||
permission_mode: p.permission_mode,
|
|
||||||
lastAssistantText: p.lastAssistantText || '',
|
|
||||||
timestamp: p.createdAt
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[HooksApproval] Failed to fetch pending:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pendingPermissions,
|
|
||||||
pendingPlans,
|
|
||||||
handleApprovalMessage,
|
|
||||||
respondPermission,
|
|
||||||
respondPlan,
|
|
||||||
fetchPending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -322,8 +322,12 @@ class SessionStateManager {
|
|||||||
|
|
||||||
// Build notification
|
// Build notification
|
||||||
const notification = buildNotification(payload)
|
const notification = buildNotification(payload)
|
||||||
const updatedNotifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
|
if (payload.hook_event_name === 'SessionStart') {
|
||||||
patch.notifications = updatedNotifications
|
// Reset notifications on new session, only keep the SessionStart notification
|
||||||
|
patch.notifications = [notification]
|
||||||
|
} else {
|
||||||
|
patch.notifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
// Build hook history entry
|
// Build hook history entry
|
||||||
const historyEntry: HookHistoryEntry = {
|
const historyEntry: HookHistoryEntry = {
|
||||||
|
|||||||
Reference in New Issue
Block a user