feat: Add transcript-debug page with multi-agent support, hooks approval, and message selection

- Transcript debug: JSONL viewer, parsed chat view, realtime WebSocket updates, session selector
- Multi-agent: ejecutor, nucleo000, and claude (global ~/.claude/projects/) with agent switcher
- Hooks approval: permission/plan request forwarding via PowerShell hooks, long-poll API, UI modals
- Chat features: session ID copy, select mode with checkboxes, multi-select copy, select all/deselect all
- File watchers for all agent transcript directories with polling fallback on Windows
This commit is contained in:
2026-02-18 23:55:09 -06:00
parent d0fdd04132
commit 9bd6123f97
37 changed files with 5663 additions and 30 deletions

View File

@@ -8,6 +8,8 @@ import FloatingResponse from './components/FloatingResponse.vue'
import FloatingVoice from './components/FloatingVoice.vue'
import AgentBar from './components/AgentBar.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue'
import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch'
import { endpoints } from './config/endpoints'
@@ -68,6 +70,7 @@ const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore()
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
// Voice FAB push-to-talk state
const voicePTTActive = ref(false)
let voiceTouchStarted = false
@@ -264,6 +267,10 @@ onMounted(async () => {
// Connect to WebSocket for Claude status updates
connectStatusWs()
// Connect global hooks approval WS
connectApproval()
fetchApprovalPending()
// Fire torch connection early (don't await yet)
const torchReady = initTorch()
@@ -349,6 +356,7 @@ onMounted(async () => {
onUnmounted(() => {
destroyTorch()
disconnectApproval()
if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout)
if (processingTimeout) clearTimeout(processingTimeout)
if (sessionStartTimeout) clearTimeout(sessionStartTimeout)
@@ -398,6 +406,18 @@ watch(() => route.name, (newPage) => {
<PwaInstallBanner />
</div>
<div class="header-right">
<button
v-if="totalPending > 0 || modalVisible"
class="approval-badge-btn"
:class="{ active: modalVisible, pulse: totalPending > 0 }"
@click="modalVisible = !modalVisible"
title="Hooks Approval"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
</button>
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
@@ -532,6 +552,9 @@ watch(() => route.name, (newPage) => {
<!-- Floating Voice Input -->
<FloatingVoice ref="voiceRef" v-model="showVoice" />
<!-- Global Hooks Approval Modal -->
<HooksApprovalModal />
<!-- Debug Console Panel -->
<Teleport to="body">
<Transition name="debug-slide">
@@ -800,6 +823,56 @@ watch(() => route.name, (newPage) => {
transform: rotate(180deg);
}
/* Approval badge button */
.approval-badge-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
width: auto;
min-width: 28px;
height: 28px;
padding: 0 6px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: #f59e0b;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.approval-badge-btn:hover {
background: var(--bg-hover);
border-color: #f59e0b;
}
.approval-badge-btn.active {
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.approval-badge-btn.pulse {
animation: approval-badge-pulse 2s ease-in-out infinite;
}
.approval-count {
background: #ef4444;
color: white;
font-size: 9px;
font-weight: 700;
padding: 0 4px;
border-radius: 8px;
min-width: 14px;
text-align: center;
line-height: 1.4;
}
@keyframes approval-badge-pulse {
0%, 100% { box-shadow: none; }
50% { box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
}
.app-main {
display: flex;
flex: 1;

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useGlobalApproval } from '@/composables/useGlobalApproval'
import PermissionApproval from './transcript-debug/PermissionApproval.vue'
import PlanApproval from './transcript-debug/PlanApproval.vue'
const {
totalPending,
groupedBySession,
modalVisible,
respondPermission,
respondPlan
} = useGlobalApproval()
function truncateId(id: string): string {
if (id.length <= 12) return id
return id.slice(0, 6) + '...' + id.slice(-4)
}
// Auto-hide after 1s when empty
let autoHideTimer: ReturnType<typeof setTimeout> | null = null
watch(totalPending, (val) => {
if (autoHideTimer) {
clearTimeout(autoHideTimer)
autoHideTimer = null
}
if (val === 0 && modalVisible.value) {
autoHideTimer = setTimeout(() => {
modalVisible.value = false
}, 1000)
}
})
</script>
<template>
<Teleport to="body">
<Transition name="approval-modal">
<div v-if="modalVisible" class="approval-backdrop" @click.self="modalVisible = false">
<div class="approval-panel">
<div class="panel-header">
<span class="panel-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Hooks Approval
</span>
<span v-if="totalPending > 0" class="panel-count">{{ totalPending }}</span>
<button class="panel-close" @click="modalVisible = false" title="Minimize">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div class="panel-body">
<template v-if="groupedBySession.length > 0">
<div v-for="group in groupedBySession" :key="group.sessionId" class="session-group">
<div class="session-header">
<span class="session-agent">{{ group.agent }}</span>
<span class="session-sep">/</span>
<span class="session-id" :title="group.sessionId">{{ truncateId(group.sessionId) }}</span>
<span class="session-count">{{ group.permissions.length + group.plans.length }}</span>
</div>
<PermissionApproval
v-for="perm in group.permissions"
:key="perm.requestId"
:request="perm"
@respond="respondPermission"
/>
<PlanApproval
v-for="plan in group.plans"
:key="plan.requestId"
:request="plan"
@respond="(id, decision, reason) => respondPlan(id, decision, reason)"
/>
</div>
</template>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>No pending approvals</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.approval-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 10012;
display: flex;
align-items: center;
justify-content: center;
}
.approval-panel {
width: 90%;
max-width: 600px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.panel-title svg {
color: #f59e0b;
}
.panel-count {
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
padding: 0.1rem 0.45rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
line-height: 1.3;
}
.panel-close {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.panel-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.session-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-header {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0;
}
.session-agent {
font-size: 12px;
font-weight: 600;
color: var(--accent, #6366f1);
}
.session-sep {
font-size: 11px;
color: var(--text-muted);
opacity: 0.4;
}
.session-id {
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.session-count {
margin-left: auto;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-hover);
padding: 0.1rem 0.4rem;
border-radius: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-muted);
font-size: 13px;
}
/* Modal transition */
.approval-modal-enter-active,
.approval-modal-leave-active {
transition: opacity 0.2s ease;
}
.approval-modal-enter-active .approval-panel,
.approval-modal-leave-active .approval-panel {
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}
.approval-modal-enter-from,
.approval-modal-leave-to {
opacity: 0;
}
.approval-modal-enter-from .approval-panel {
transform: scale(0.95);
opacity: 0;
}
.approval-modal-leave-to .approval-panel {
transform: scale(0.95);
opacity: 0;
}
</style>

View File

@@ -131,6 +131,15 @@ onMounted(() => {
<path d="M12 2L9.5 7.5 4 8.5l4 4-1 5.5L12 15l5 3-1-5.5 4-4-5.5-1z"/>
</svg>
</RouterLink>
<RouterLink to="/transcript-debug" class="toolbar-btn" :class="{ active: route.path === '/transcript-debug' }" title="Transcript Debug">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</RouterLink>
</div>
<div class="toolbar-divider"></div>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedAssistantMessage } from '@/types/transcript-debug'
import ThinkingBlock from './ThinkingBlock.vue'
import ToolCallBlock from './ToolCallBlock.vue'
const props = defineProps<{
message: ParsedAssistantMessage
}>()
// Filter out empty text blocks (streaming placeholders)
const visibleTextBlocks = computed(() =>
props.message.textBlocks.filter(t => t.trim().length > 0)
)
// Show thinking animation when assistant has no real content yet
const isStreaming = computed(() =>
visibleTextBlocks.value.length === 0 &&
props.message.thinkingBlocks.length === 0 &&
props.message.toolCalls.length === 0 &&
!props.message.stopReason
)
function formatTime(ts: string): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
}
function formatTokens(n?: number): string {
if (!n) return '0'
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
</script>
<template>
<div class="assistant-bubble">
<div class="bubble-header">
<span class="role-badge">Assistant</span>
<span class="model-badge">{{ message.model }}</span>
<span v-if="message.usage" class="token-info">
{{ formatTokens(message.usage.input_tokens) }}in / {{ formatTokens(message.usage.output_tokens) }}out
</span>
<span v-if="message.stopReason" class="stop-reason">{{ message.stopReason }}</span>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<!-- Streaming/thinking animation -->
<div v-if="isStreaming" class="thinking-animation">
<div class="thinking-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<span class="thinking-label">Thinking...</span>
</div>
<!-- Thinking blocks -->
<ThinkingBlock
v-for="(t, i) in message.thinkingBlocks"
:key="'think-' + i"
:content="t"
/>
<!-- Text content (filtered) -->
<div v-for="(text, i) in visibleTextBlocks" :key="'text-' + i" class="text-content">
{{ text }}
</div>
<!-- Tool calls -->
<ToolCallBlock
v-for="tc in message.toolCalls"
:key="tc.id"
:call="tc"
/>
</div>
</template>
<style scoped>
.assistant-bubble {
background: rgba(34, 197, 94, 0.05);
border: 1px solid rgba(34, 197, 94, 0.15);
border-radius: 12px;
padding: 0.75rem 1rem;
margin-right: 2rem;
}
.bubble-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.role-badge {
font-size: 11px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.model-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.token-info {
font-size: 10px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.stop-reason {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--bg-hover);
color: var(--text-muted);
}
.timestamp {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Thinking animation */
.thinking-animation {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
}
.thinking-dots {
display: flex;
gap: 4px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
opacity: 0.4;
animation: pulse-dot 1.4s ease-in-out infinite;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse-dot {
0%, 80%, 100% {
opacity: 0.2;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1.1);
}
}
.thinking-label {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
.text-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
margin: 0.25rem 0;
}
</style>

View File

@@ -0,0 +1,583 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import type {
ParsedConversation,
ParsedUserMessage,
ParsedAssistantMessage,
ParsedSystemMessage,
ConversationMessage
} from '@/types/transcript-debug'
import UserMessageBubble from './UserMessageBubble.vue'
import AssistantMessageBubble from './AssistantMessageBubble.vue'
import ProgressEvent from './ProgressEvent.vue'
import SystemMessage from './SystemMessage.vue'
import UserInput from './UserInput.vue'
const props = defineProps<{
conversation: ParsedConversation
processing?: boolean
}>()
const emit = defineEmits<{
send: [message: string]
}>()
const scrollContainer = ref<HTMLElement | null>(null)
// ── Clipboard for session ID ──
const idCopied = ref(false)
async function copySessionId() {
await navigator.clipboard.writeText(props.conversation.sessionId)
idCopied.value = true
setTimeout(() => (idCopied.value = false), 1500)
}
// ── Multi-select ──
const selectMode = ref(false)
const selectedUuids = ref(new Set<string>())
const hasSelection = computed(() => selectedUuids.value.size > 0)
function toggleSelectMode() {
selectMode.value = !selectMode.value
if (!selectMode.value) selectedUuids.value = new Set()
}
function toggleSelect(uuid: string) {
if (!selectMode.value) return
const s = new Set(selectedUuids.value)
if (s.has(uuid)) s.delete(uuid)
else s.add(uuid)
selectedUuids.value = s
}
const selectableUuids = computed(() =>
props.conversation.messages.filter(m => m.kind !== 'progress').map(m => m.uuid)
)
const allSelected = computed(() =>
selectableUuids.value.length > 0 && selectableUuids.value.every(u => selectedUuids.value.has(u))
)
function toggleSelectAll() {
if (allSelected.value) {
selectedUuids.value = new Set()
} else {
selectedUuids.value = new Set(selectableUuids.value)
}
}
function clearSelection() {
selectedUuids.value = new Set()
}
function messageToText(msg: ConversationMessage): string {
switch (msg.kind) {
case 'user': {
const u = msg as ParsedUserMessage
return `[User] ${u.content}`
}
case 'assistant': {
const a = msg as ParsedAssistantMessage
const parts: string[] = []
for (const t of a.textBlocks) {
if (t.trim()) parts.push(t)
}
for (const tc of a.toolCalls) {
parts.push(`[Tool: ${tc.name}] ${JSON.stringify(tc.input)}`)
if (tc.result) {
parts.push(`[Result${tc.result.isError ? ' ERROR' : ''}] ${tc.result.content}`)
}
}
return `[Assistant] ${parts.join('\n')}`
}
case 'system': {
const s = msg as ParsedSystemMessage
return `[System] ${s.content}`
}
default:
return ''
}
}
const selectedCopied = ref(false)
async function copySelected() {
const ordered = props.conversation.messages.filter(m => selectedUuids.value.has(m.uuid))
const text = ordered.map(messageToText).filter(Boolean).join('\n\n')
await navigator.clipboard.writeText(text)
selectedCopied.value = true
setTimeout(() => (selectedCopied.value = false), 1500)
}
// Track messages that just resolved from optimistic → real
// These skip the bounce animation and get a smooth transition instead
const resolvedUuids = ref(new Set<string>())
// Scroll to bottom when a new transcript is loaded
watch(
() => props.conversation.sessionId,
async () => {
await nextTick()
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
},
{ immediate: true }
)
// Auto-scroll to bottom when messages change
watch(
() => props.conversation.messages.length,
async () => {
await nextTick()
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
}
)
// Watch for optimistic messages disappearing
watch(
() => props.conversation.messages.map(m => m.uuid).join(','),
(newKeys, oldKeys) => {
if (!oldKeys) return
const oldHadOptimistic = oldKeys.includes('optimistic-')
const newHasOptimistic = newKeys.includes('optimistic-')
if (oldHadOptimistic && !newHasOptimistic) {
// Optimistic just got replaced — mark new user messages as resolved
const oldSet = new Set(oldKeys.split(','))
const newUuids = newKeys.split(',').filter(k => !oldSet.has(k))
for (const uuid of newUuids) {
resolvedUuids.value.add(uuid)
}
// Clear after animation
setTimeout(() => resolvedUuids.value.clear(), 400)
}
}
)
function formatDuration(start: string, end: string): string {
if (!start || !end) return ''
const ms = new Date(end).getTime() - new Date(start).getTime()
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const rs = s % 60
return `${m}m ${rs}s`
}
</script>
<template>
<div class="chat-container">
<div class="chat-header">
<div class="chat-title-row">
<span class="session-id" :title="conversation.sessionId">{{ conversation.sessionId }}</span>
<button class="copy-id-btn" :class="{ copied: idCopied }" @click="copySessionId" title="Copy session ID">
<svg v-if="!idCopied" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<div class="header-spacer"></div>
<button :class="['select-mode-btn', { active: selectMode }]" @click="toggleSelectMode" title="Toggle select mode">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="selectMode" points="20 6 9 17 4 12"/>
<template v-else>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</template>
</svg>
<span class="select-mode-label">{{ selectMode ? 'Done' : 'Select' }}</span>
</button>
</div>
<div class="chat-meta">
<span v-if="conversation.model" class="meta-badge model">{{ conversation.model }}</span>
<span v-if="conversation.version" class="meta-badge version">v{{ conversation.version }}</span>
<span v-if="conversation.metadata.cwd" class="meta-cwd" :title="conversation.metadata.cwd">
{{ conversation.metadata.cwd }}
</span>
<span class="meta-duration">
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
</span>
<span class="meta-count">{{ conversation.messages.length }} messages</span>
</div>
</div>
<div ref="scrollContainer" class="messages-scroll">
<div
v-for="msg in conversation.messages"
:key="msg.uuid"
:class="['message-wrapper', {
resolved: resolvedUuids.has(msg.uuid),
selected: selectedUuids.has(msg.uuid)
}]"
>
<!-- Select checkbox -->
<button
v-if="selectMode && msg.kind !== 'progress'"
:class="['select-checkbox', { checked: selectedUuids.has(msg.uuid) }]"
@click.stop="toggleSelect(msg.uuid)"
:title="selectedUuids.has(msg.uuid) ? 'Deselect' : 'Select'"
>
<svg v-if="selectedUuids.has(msg.uuid)" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<div :class="['message-content', { selectable: selectMode && msg.kind !== 'progress' }]" @click="selectMode && msg.kind !== 'progress' ? toggleSelect(msg.uuid) : undefined">
<UserMessageBubble
v-if="msg.kind === 'user'"
:message="msg"
/>
<AssistantMessageBubble
v-else-if="msg.kind === 'assistant'"
:message="msg"
/>
<ProgressEvent
v-else-if="msg.kind === 'progress'"
:group="msg"
/>
<SystemMessage
v-else-if="msg.kind === 'system'"
:message="msg"
/>
</div>
</div>
</div>
<!-- Selection floating bar -->
<Transition name="slide-up">
<div v-if="selectMode" class="selection-bar">
<button class="selection-btn toggle-all" @click="toggleSelectAll">
{{ allSelected ? 'Deselect all' : 'Select all' }}
</button>
<span class="selection-count">{{ selectedUuids.size }} selected</span>
<button v-if="hasSelection" class="selection-btn copy" @click="copySelected">
<svg v-if="!selectedCopied" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ selectedCopied ? 'Copied!' : 'Copy' }}
</button>
<button v-if="hasSelection" class="selection-btn cancel" @click="clearSelection" title="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</Transition>
<UserInput
:processing="props.processing"
@send="emit('send', $event)"
/>
</div>
</template>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.chat-header {
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.chat-title-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.header-spacer {
flex: 1;
}
.select-mode-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: 5px;
background: transparent;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
font-weight: 500;
transition: all 0.15s;
flex-shrink: 0;
}
.select-mode-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.select-mode-btn.active {
background: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
color: white;
}
.select-mode-label {
white-space: nowrap;
}
.session-id {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
font-family: 'SF Mono', 'Fira Code', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
letter-spacing: 0.3px;
}
.copy-id-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.copy-id-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.copy-id-btn.copied {
color: #22c55e;
}
.chat-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
flex-wrap: wrap;
}
.meta-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.meta-badge.model {
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
}
.meta-badge.version {
background: var(--bg-hover);
color: var(--text-muted);
}
.meta-cwd {
font-size: 10px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.meta-duration {
font-size: 10px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.meta-count {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
}
.messages-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message-wrapper {
animation: fadeIn 0.15s ease-out;
display: flex;
align-items: flex-start;
gap: 0.4rem;
position: relative;
}
.message-wrapper.selected {
background: rgba(99, 102, 241, 0.06);
border-radius: 8px;
margin: -0.25rem -0.4rem;
padding: 0.25rem 0.4rem;
}
/* Messages that replaced an optimistic one: smooth settle instead of bounce */
.message-wrapper.resolved {
animation: resolveIn 0.3s ease-out;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-content.selectable {
cursor: pointer;
}
/* ── Select checkbox ── */
.select-checkbox {
width: 20px;
height: 20px;
min-width: 20px;
border: 1.5px solid var(--border-color);
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.6rem;
transition: all 0.15s;
color: transparent;
flex-shrink: 0;
}
.select-checkbox:hover {
border-color: var(--accent, #6366f1);
background: rgba(99, 102, 241, 0.06);
}
.select-checkbox.checked {
background: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
color: white;
}
/* ── Selection floating bar ── */
.selection-bar {
position: absolute;
bottom: 4.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.selection-count {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
padding: 0 0.25rem;
}
.selection-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.selection-btn.toggle-all {
background: var(--bg-hover);
color: var(--text-secondary);
}
.selection-btn.toggle-all:hover {
color: var(--text-primary);
filter: brightness(1.1);
}
.selection-btn.copy {
background: var(--accent, #6366f1);
color: white;
}
.selection-btn.copy:hover {
filter: brightness(1.1);
}
.selection-btn.cancel {
background: transparent;
color: var(--text-muted);
padding: 0.3rem;
}
.selection-btn.cancel:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── Slide-up transition ── */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes resolveIn {
from { opacity: 0.7; }
to { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import type { HooksApprovalPermissionRequest } from '@/types/hooks-approval'
const props = defineProps<{
request: HooksApprovalPermissionRequest
}>()
const emit = defineEmits<{
respond: [requestId: string, decision: 'allow' | 'deny']
}>()
function formatInput(input: unknown): string {
if (!input) return ''
if (typeof input === 'string') return input
try {
return JSON.stringify(input, null, 2)
} catch {
return String(input)
}
}
function elapsed(): string {
const ms = Date.now() - props.request.timestamp
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
return `${Math.floor(s / 60)}m ${s % 60}s ago`
}
</script>
<template>
<div class="permission-card">
<div class="card-header">
<span class="card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</span>
<span class="card-label">Permission Request</span>
<span class="card-elapsed">{{ elapsed() }}</span>
</div>
<div class="card-body">
<div class="info-row" v-if="request.tool_name">
<span class="info-label">Tool</span>
<code class="info-value tool-name">{{ request.tool_name }}</code>
</div>
<div class="info-row" v-if="request.agent_name">
<span class="info-label">Agent</span>
<span class="info-value">{{ request.agent_name }}</span>
</div>
<div v-if="request.tool_input" class="input-block">
<span class="info-label">Input</span>
<pre class="input-pre">{{ formatInput(request.tool_input) }}</pre>
</div>
</div>
<div class="card-actions">
<button class="btn btn-allow" @click="emit('respond', request.requestId, 'allow')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
Allow
</button>
<button class="btn btn-deny" @click="emit('respond', request.requestId, 'deny')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Deny
</button>
</div>
</div>
</template>
<style scoped>
.permission-card {
border: 1px solid var(--border-color);
border-left: 3px solid #f59e0b;
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
animation: slideIn 0.2s ease-out;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(245, 158, 11, 0.06);
border-bottom: 1px solid var(--border-color);
}
.card-icon {
color: #f59e0b;
display: flex;
align-items: center;
}
.card-label {
font-size: 12px;
font-weight: 600;
color: #f59e0b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-elapsed {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.card-body {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.info-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.info-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
min-width: 40px;
flex-shrink: 0;
}
.info-value {
font-size: 13px;
color: var(--text-primary);
}
.tool-name {
background: rgba(99, 102, 241, 0.1);
color: var(--accent, #6366f1);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.input-block {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input-pre {
margin: 0;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 150px;
overflow-y: auto;
}
.card-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-color);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.btn-allow {
background: #22c55e;
color: white;
}
.btn-allow:hover {
background: #16a34a;
}
.btn-deny {
background: #ef4444;
color: white;
}
.btn-deny:hover {
background: #dc2626;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,253 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { HooksApprovalPlanRequest } from '@/types/hooks-approval'
const props = defineProps<{
request: HooksApprovalPlanRequest
}>()
const emit = defineEmits<{
respond: [requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string]
}>()
const showEditor = ref(false)
const editReason = ref('')
function elapsed(): string {
const ms = Date.now() - props.request.timestamp
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
return `${Math.floor(s / 60)}m ${s % 60}s ago`
}
function handleEdit() {
if (!showEditor.value) {
showEditor.value = true
return
}
emit('respond', props.request.requestId, 'edit', editReason.value)
}
</script>
<template>
<div class="plan-card">
<div class="card-header">
<span class="card-icon">
<svg width="16" height="16" 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"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</span>
<span class="card-label">Plan Approval</span>
<span class="card-elapsed">{{ elapsed() }}</span>
</div>
<div class="card-body">
<div v-if="request.lastAssistantText" class="plan-text">
<pre class="plan-pre">{{ request.lastAssistantText }}</pre>
</div>
<div v-else class="plan-empty">
Claude is waiting for plan approval
</div>
<Transition name="expand">
<div v-if="showEditor" class="edit-section">
<textarea
v-model="editReason"
class="edit-textarea"
placeholder="Add instructions or feedback..."
rows="3"
></textarea>
</div>
</Transition>
</div>
<div class="card-actions">
<button class="btn btn-approve" @click="emit('respond', request.requestId, 'approve')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
Approve
</button>
<button class="btn btn-edit" @click="handleEdit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{{ showEditor ? 'Send' : 'Edit & Continue' }}
</button>
<button class="btn btn-reject" @click="emit('respond', request.requestId, 'reject')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Reject
</button>
</div>
</div>
</template>
<style scoped>
.plan-card {
border: 1px solid var(--border-color);
border-left: 3px solid #8b5cf6;
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
animation: slideIn 0.2s ease-out;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(139, 92, 246, 0.06);
border-bottom: 1px solid var(--border-color);
}
.card-icon {
color: #8b5cf6;
display: flex;
align-items: center;
}
.card-label {
font-size: 12px;
font-weight: 600;
color: #8b5cf6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-elapsed {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.card-body {
padding: 0.6rem 0.75rem;
}
.plan-text {
max-height: 250px;
overflow-y: auto;
}
.plan-pre {
margin: 0;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.plan-empty {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0;
}
.edit-section {
margin-top: 0.5rem;
}
.edit-textarea {
width: 100%;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.edit-textarea:focus {
border-color: #8b5cf6;
}
.card-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border-color);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.btn-approve {
background: #22c55e;
color: white;
}
.btn-approve:hover {
background: #16a34a;
}
.btn-edit {
background: #8b5cf6;
color: white;
}
.btn-edit:hover {
background: #7c3aed;
}
.btn-reject {
background: #ef4444;
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
/* ── Expand transition ── */
.expand-enter-active,
.expand-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
}
.expand-enter-to,
.expand-leave-from {
max-height: 200px;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedProgressGroup, ParsedProgressEvent } from '@/types/transcript-debug'
const props = defineProps<{
group: ParsedProgressGroup
}>()
const expanded = ref(false)
const hookEvents = computed(() =>
props.group.events.filter(e => e.dataType === 'hook_progress')
)
const mcpEvents = computed(() =>
props.group.events.filter(e => e.dataType === 'mcp_progress')
)
function hookLabel(e: ParsedProgressEvent): string {
return e.hookEvent || e.hookName?.split(':')[0] || 'hook'
}
</script>
<template>
<div class="progress-group">
<button class="progress-toggle" @click="expanded = !expanded">
<svg
:class="['chevron', { rotated: expanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="toggle-pills">
<span v-for="he in hookEvents" :key="he.uuid" :class="['hook-pill', he.hookEvent?.toLowerCase()]">
{{ hookLabel(he) }}
</span>
<span v-for="me in mcpEvents" :key="me.uuid" :class="['mcp-pill', me.mcpStatus]">
MCP {{ me.mcpStatus }}
<span v-if="me.mcpElapsedMs != null" class="mcp-ms">{{ me.mcpElapsedMs }}ms</span>
</span>
<span v-if="!hookEvents.length && !mcpEvents.length" class="generic-pill">
{{ group.events.length }} progress events
</span>
</span>
</button>
<div v-if="expanded" class="progress-details">
<div v-for="e in group.events" :key="e.uuid" class="detail-row">
<!-- Hook progress -->
<template v-if="e.dataType === 'hook_progress'">
<span class="row-icon hook-color">&#9881;</span>
<span :class="['row-event', e.hookEvent?.toLowerCase()]">{{ e.hookEvent }}</span>
<span class="row-name">{{ e.hookName }}</span>
<span v-if="e.command" class="row-command" :title="e.command">
{{ e.command.length > 80 ? e.command.slice(0, 80) + '...' : e.command }}
</span>
</template>
<!-- MCP progress -->
<template v-else-if="e.dataType === 'mcp_progress'">
<span class="row-icon mcp-color">&#9889;</span>
<span :class="['row-status', e.mcpStatus]">{{ e.mcpStatus }}</span>
<span class="row-server">{{ e.mcpServerName }}</span>
<span class="row-tool">{{ e.mcpToolName }}</span>
<span v-if="e.mcpElapsedMs != null" class="row-elapsed">{{ e.mcpElapsedMs }}ms</span>
</template>
<!-- Generic -->
<template v-else>
<span class="row-icon">&#8226;</span>
<span class="row-generic">{{ e.dataType }}</span>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.progress-group {
border-radius: 6px;
overflow: hidden;
opacity: 0.7;
transition: opacity 0.15s;
}
.progress-group:hover {
opacity: 0.9;
}
.progress-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
text-align: left;
}
.progress-toggle:hover {
background: var(--bg-hover);
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
.toggle-pills {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
/* Hook pills */
.hook-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
}
.hook-pill.pretooluse {
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
}
.hook-pill.posttooluse {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
.hook-pill.sessionstart {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
/* MCP pills */
.mcp-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.mcp-pill.started {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
.mcp-pill.completed {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.mcp-ms {
opacity: 0.8;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.generic-pill {
font-size: 10px;
color: var(--text-muted);
}
/* Expanded details */
.progress-details {
padding: 0.25rem 0.5rem 0.4rem 1.5rem;
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 6px 6px;
background: var(--bg-primary);
}
.detail-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 10px;
color: var(--text-muted);
border-left: 2px solid var(--border-color);
padding-left: 0.5rem;
margin-left: 0.25rem;
}
.row-icon {
font-size: 10px;
flex-shrink: 0;
}
.hook-color { color: #fbbf24; }
.mcp-color { color: #38bdf8; }
.row-event {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.row-event.pretooluse { color: #fbbf24; }
.row-event.posttooluse { color: #a855f7; }
.row-event.sessionstart { color: #22c55e; }
.row-name {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.row-command {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
opacity: 0.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.row-status {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.row-status.started { color: #38bdf8; }
.row-status.completed { color: #22c55e; }
.row-server {
font-size: 9px;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background: rgba(56, 189, 248, 0.08);
color: #38bdf8;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
}
.row-tool {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-elapsed {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #22c55e;
white-space: nowrap;
margin-left: auto;
}
.row-generic {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
content: string
}>()
const selectedLine = ref<number | null>(null)
const lines = computed(() => {
if (!props.content) return []
return props.content.split('\n').filter(l => l.trim())
})
function selectLine(idx: number) {
selectedLine.value = selectedLine.value === idx ? null : idx
}
function formatJson(line: string): string {
try {
const obj = JSON.parse(line)
return JSON.stringify(obj, null, 2)
} catch {
return line
}
}
function getLinePreview(line: string): string {
try {
const obj = JSON.parse(line)
const type = obj.type || 'unknown'
if (type === 'assistant' && obj.message?.content) {
const first = obj.message.content[0]
const hint = first?.type || ''
return `assistant [${hint}]`
}
if (type === 'user') {
const c = obj.message?.content
const preview = typeof c === 'string' ? c.slice(0, 40) : '[blocks]'
return `user: ${preview}`
}
if (type === 'progress') {
return `progress: ${obj.data?.type || '?'}`
}
return type
} catch {
return line.slice(0, 40)
}
}
function typeClass(line: string): string {
try {
const obj = JSON.parse(line)
return `type-${obj.type || 'unknown'}`
} catch {
return 'type-unknown'
}
}
</script>
<template>
<div class="raw-viewer">
<div class="viewer-header">
<span class="viewer-title">Raw JSONL</span>
<span class="line-count">{{ lines.length }} lines</span>
</div>
<div class="lines-container">
<div
v-for="(line, idx) in lines"
:key="idx"
:class="['line-row', typeClass(line), { selected: selectedLine === idx }]"
@click="selectLine(idx)"
>
<span class="line-num">{{ idx + 1 }}</span>
<span class="line-preview">{{ getLinePreview(line) }}</span>
</div>
</div>
<!-- Expanded view for selected line -->
<div v-if="selectedLine !== null && lines[selectedLine]" class="expanded-view">
<div class="expanded-header">
<span>Line {{ selectedLine + 1 }}</span>
<button class="close-btn" @click="selectedLine = null">&times;</button>
</div>
<pre class="expanded-json">{{ formatJson(lines[selectedLine]) }}</pre>
</div>
</div>
</template>
<style scoped>
.raw-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.viewer-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.line-count {
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.lines-container {
flex: 1;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
}
.line-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.1s;
}
.line-row:hover {
background: var(--bg-hover);
}
.line-row.selected {
background: rgba(99, 102, 241, 0.1);
border-left-color: var(--accent);
}
.line-num {
color: var(--text-muted);
min-width: 36px;
text-align: right;
user-select: none;
opacity: 0.6;
}
.line-preview {
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Type-based colors */
.type-user .line-preview { color: #60a5fa; }
.type-assistant .line-preview { color: #34d399; }
.type-progress .line-preview { color: var(--text-muted); opacity: 0.7; }
.type-system .line-preview { color: #fbbf24; }
.type-file-history-snapshot .line-preview { color: var(--text-muted); opacity: 0.5; }
.expanded-view {
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
max-height: 40%;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.expanded-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 0.25rem;
}
.close-btn:hover {
color: var(--text-primary);
}
.expanded-json {
flex: 1;
overflow: auto;
margin: 0;
padding: 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { SessionInfo } from '@/types/transcript-debug'
defineProps<{
sessions: SessionInfo[]
selectedId: string | null
loading: boolean
}>()
const emit = defineEmits<{
select: [sessionId: string]
}>()
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return 'just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text
return text.slice(0, max) + '...'
}
</script>
<template>
<div class="session-selector">
<label class="selector-label">Session</label>
<select
class="session-select"
:value="selectedId || ''"
@change="emit('select', ($event.target as HTMLSelectElement).value)"
:disabled="loading"
>
<option value="" disabled>Select a transcript session...</option>
<option
v-for="s in sessions"
:key="s.id"
:value="s.id"
>
{{ s.firstUserMessage ? truncate(s.firstUserMessage, 60) : s.id.slice(0, 8) + '...' }} &mdash; {{ formatDate(s.mtimeISO) }} ({{ formatSize(s.size) }})
</option>
</select>
<span v-if="loading" class="loading-indicator">
<span class="spinner-sm"></span>
</span>
</div>
</template>
<style scoped>
.session-selector {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.selector-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.session-select {
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
.session-select:focus {
outline: none;
border-color: var(--accent);
}
.loading-indicator {
display: flex;
align-items: center;
}
.spinner-sm {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ParsedSystemMessage } from '@/types/transcript-debug'
defineProps<{
message: ParsedSystemMessage
}>()
const expanded = ref(false)
</script>
<template>
<div class="system-message">
<button class="system-toggle" @click="expanded = !expanded">
<span class="system-icon">&#9432;</span>
<span class="system-label">System</span>
<span v-if="message.subtype" class="subtype-badge">{{ message.subtype }}</span>
<span class="content-preview">{{ message.content.slice(0, 80) }}{{ message.content.length > 80 ? '...' : '' }}</span>
</button>
<pre v-if="expanded" class="system-content">{{ message.content }}</pre>
</div>
</template>
<style scoped>
.system-message {
border-radius: 6px;
overflow: hidden;
border: 1px dashed rgba(251, 191, 36, 0.3);
}
.system-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.75rem;
background: rgba(251, 191, 36, 0.04);
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
text-align: left;
}
.system-toggle:hover {
background: rgba(251, 191, 36, 0.08);
}
.system-icon {
color: #fbbf24;
font-size: 13px;
}
.system-label {
font-weight: 500;
color: #fbbf24;
font-size: 11px;
}
.subtype-badge {
font-size: 10px;
padding: 0.1rem 0.3rem;
border-radius: 3px;
background: rgba(251, 191, 36, 0.1);
color: #fbbf24;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.content-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
opacity: 0.7;
}
.system-content {
margin: 0;
padding: 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
border-top: 1px dashed rgba(251, 191, 36, 0.2);
background: rgba(251, 191, 36, 0.02);
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
content: string
}>()
const expanded = ref(false)
</script>
<template>
<div class="thinking-block">
<button class="thinking-toggle" @click="expanded = !expanded">
<svg
:class="['chevron', { rotated: expanded }]"
width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="thinking-label">Thinking</span>
<span class="thinking-length">{{ content.length }} chars</span>
</button>
<div v-if="expanded" class="thinking-content">{{ content }}</div>
</div>
</template>
<style scoped>
.thinking-block {
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4rem 0.75rem;
background: rgba(168, 85, 247, 0.06);
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
text-align: left;
}
.thinking-toggle:hover {
background: rgba(168, 85, 247, 0.1);
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
.thinking-label {
font-weight: 500;
color: #a855f7;
}
.thinking-length {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.thinking-content {
padding: 0.75rem;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
border-top: 1px solid rgba(168, 85, 247, 0.15);
background: rgba(168, 85, 247, 0.03);
}
</style>

View File

@@ -0,0 +1,390 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ParsedToolCall, ParsedProgressEvent } from '@/types/transcript-debug'
import ToolResultBlock from './ToolResultBlock.vue'
const props = defineProps<{
call: ParsedToolCall
}>()
const inputExpanded = ref(false)
const progressExpanded = ref(false)
const hookEvents = computed(() =>
props.call.progressEvents.filter(e => e.dataType === 'hook_progress')
)
const mcpEvents = computed(() =>
props.call.progressEvents.filter(e => e.dataType === 'mcp_progress')
)
const mcpCompleted = computed(() =>
mcpEvents.value.find(e => e.mcpStatus === 'completed')
)
function hookLabel(e: ParsedProgressEvent): string {
if (!e.hookName) return e.hookEvent || 'hook'
// "PreToolUse:mcp__agent-ui__navigate_to" → "PreToolUse"
const event = e.hookEvent || e.hookName.split(':')[0]
return event
}
</script>
<template>
<div class="tool-call">
<div class="tool-header">
<span class="tool-icon">&#9881;</span>
<span class="tool-name">{{ call.name }}</span>
<span v-if="call.result?.isError" class="error-indicator">error</span>
<span v-if="mcpCompleted?.mcpElapsedMs != null" class="timing-badge">
{{ mcpCompleted.mcpElapsedMs }}ms
</span>
<span v-if="mcpCompleted?.mcpServerName" class="server-badge">
{{ mcpCompleted.mcpServerName }}
</span>
</div>
<!-- Hook + MCP progress timeline -->
<div v-if="call.progressEvents.length" class="progress-timeline">
<button class="timeline-toggle" @click="progressExpanded = !progressExpanded">
<svg
:class="['chevron', { rotated: progressExpanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="timeline-summary">
<span v-for="he in hookEvents" :key="he.uuid" :class="['hook-pill', he.hookEvent?.toLowerCase()]">
{{ hookLabel(he) }}
</span>
<span v-for="me in mcpEvents" :key="me.uuid" :class="['mcp-pill', me.mcpStatus]">
MCP {{ me.mcpStatus }}
<span v-if="me.mcpElapsedMs != null" class="mcp-ms">{{ me.mcpElapsedMs }}ms</span>
</span>
</span>
</button>
<div v-if="progressExpanded" class="timeline-details">
<div v-for="e in call.progressEvents" :key="e.uuid" class="timeline-row">
<!-- Hook progress -->
<template v-if="e.dataType === 'hook_progress'">
<span class="tl-icon hook-icon">&#9881;</span>
<span :class="['tl-event', e.hookEvent?.toLowerCase()]">{{ e.hookEvent }}</span>
<span class="tl-name">{{ e.hookName }}</span>
<span v-if="e.command" class="tl-command" :title="e.command">
{{ e.command.length > 80 ? e.command.slice(0, 80) + '...' : e.command }}
</span>
</template>
<!-- MCP progress -->
<template v-else-if="e.dataType === 'mcp_progress'">
<span class="tl-icon mcp-icon">&#9889;</span>
<span :class="['tl-status', e.mcpStatus]">{{ e.mcpStatus }}</span>
<span class="tl-server">{{ e.mcpServerName }}</span>
<span class="tl-tool">{{ e.mcpToolName }}</span>
<span v-if="e.mcpElapsedMs != null" class="tl-elapsed">{{ e.mcpElapsedMs }}ms</span>
</template>
</div>
</div>
</div>
<!-- Tool input -->
<div class="tool-input-section">
<button class="input-toggle" @click="inputExpanded = !inputExpanded">
<svg
:class="['chevron', { rotated: inputExpanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Input</span>
</button>
<pre v-if="inputExpanded" class="input-json">{{ JSON.stringify(call.input, null, 2) }}</pre>
</div>
<!-- Tool result -->
<ToolResultBlock v-if="call.result" :result="call.result" />
</div>
</template>
<style scoped>
.tool-call {
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
background: var(--bg-primary);
}
.tool-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tool-icon {
font-size: 12px;
opacity: 0.6;
}
.tool-name {
font-size: 12px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #f59e0b;
}
.error-indicator {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-weight: 500;
}
.timing-badge {
margin-left: auto;
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.server-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(56, 189, 248, 0.1);
color: #38bdf8;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Progress timeline */
.progress-timeline {
border-bottom: 1px solid var(--border-color);
}
.timeline-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.75rem;
background: rgba(99, 102, 241, 0.03);
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.timeline-toggle:hover {
background: rgba(99, 102, 241, 0.06);
}
.timeline-summary {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
/* Hook pills */
.hook-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
letter-spacing: 0.3px;
}
.hook-pill.pretooluse {
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
}
.hook-pill.posttooluse {
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
}
.hook-pill.sessionstart {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
/* MCP pills */
.mcp-pill {
font-size: 9px;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.mcp-pill.started {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
.mcp-pill.completed {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.mcp-ms {
opacity: 0.8;
font-family: 'SF Mono', 'Fira Code', monospace;
}
/* Expanded timeline rows */
.timeline-details {
padding: 0.25rem 0.5rem 0.4rem 1.5rem;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
}
.timeline-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 10px;
color: var(--text-muted);
border-left: 2px solid var(--border-color);
padding-left: 0.5rem;
margin-left: 0.25rem;
}
.tl-icon {
font-size: 10px;
flex-shrink: 0;
}
.hook-icon { color: #fbbf24; }
.mcp-icon { color: #38bdf8; }
.tl-event {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.tl-event.pretooluse { color: #fbbf24; }
.tl-event.posttooluse { color: #a855f7; }
.tl-event.sessionstart { color: #22c55e; }
.tl-name {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 250px;
}
.tl-command {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 9px;
opacity: 0.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.tl-status {
font-weight: 600;
font-size: 10px;
white-space: nowrap;
}
.tl-status.started { color: #38bdf8; }
.tl-status.completed { color: #22c55e; }
.tl-server {
font-size: 9px;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background: rgba(56, 189, 248, 0.08);
color: #38bdf8;
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: nowrap;
}
.tl-tool {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-elapsed {
font-size: 9px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #22c55e;
white-space: nowrap;
margin-left: auto;
}
/* Chevron */
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
/* Tool input section */
.tool-input-section {
border-bottom: 1px solid var(--border-color);
}
.input-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.input-toggle:hover {
background: var(--bg-hover);
}
.input-json {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
max-height: 250px;
overflow-y: auto;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
font-family: 'SF Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ParsedToolResult } from '@/types/transcript-debug'
defineProps<{
result: ParsedToolResult
}>()
const expanded = ref(false)
</script>
<template>
<div :class="['tool-result', { error: result.isError }]">
<button class="result-toggle" @click="expanded = !expanded">
<svg
:class="['chevron', { rotated: expanded }]"
width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span :class="['result-icon', { error: result.isError }]">
{{ result.isError ? '✗' : '✓' }}
</span>
<span class="result-label">Result</span>
<span class="result-size">{{ result.content.length }} chars</span>
</button>
<pre v-if="expanded" class="result-content">{{ result.content }}</pre>
</div>
</template>
<style scoped>
.tool-result {
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
margin-top: 0.25rem;
}
.tool-result.error {
border-color: rgba(239, 68, 68, 0.3);
}
.result-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.6rem;
background: var(--bg-secondary);
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 11px;
text-align: left;
}
.result-toggle:hover {
background: var(--bg-hover);
}
.chevron {
transition: transform 0.2s;
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(90deg);
}
.result-icon {
font-weight: 600;
color: #22c55e;
}
.result-icon.error {
color: #ef4444;
}
.result-label {
font-weight: 500;
}
.result-size {
margin-left: auto;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.result-content {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 11px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
background: var(--bg-primary);
}
</style>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
processing?: boolean
}>()
const emit = defineEmits<{
send: [message: string]
}>()
const input = ref('')
const isDisabled = computed(() => !input.value.trim() || props.processing)
function handleSend() {
const msg = input.value.trim()
if (!msg || props.processing) return
emit('send', msg)
input.value = ''
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
</script>
<template>
<div class="user-input">
<div v-if="processing" class="processing-bar">
<span class="processing-dots">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</span>
<span>Agent is processing...</span>
</div>
<div class="input-container" :class="{ disabled: processing }">
<textarea
v-model="input"
class="input-field"
:placeholder="processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
rows="1"
:disabled="processing"
@keydown="handleKeydown"
/>
<button
class="send-btn"
:disabled="isDisabled"
@click="handleSend"
title="Send prompt (resumes this session)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.user-input {
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.processing-bar {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.25rem 0.35rem;
font-size: 10px;
color: var(--accent);
}
.processing-dots {
display: flex;
gap: 3px;
}
.processing-dots .dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
animation: pulse-dot 1.2s ease-in-out infinite;
}
.processing-dots .dot:nth-child(2) { animation-delay: 0.15s; }
.processing-dots .dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes pulse-dot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.input-container {
display: flex;
align-items: flex-end;
gap: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.4rem 0.5rem;
transition: border-color 0.15s, opacity 0.15s;
}
.input-container:focus-within {
border-color: var(--accent);
}
.input-container.disabled {
opacity: 0.5;
}
.input-field {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
line-height: 1.5;
resize: none;
min-height: 20px;
max-height: 120px;
padding: 0.15rem 0.25rem;
font-family: inherit;
}
.input-field::placeholder {
color: var(--text-muted);
}
.input-field:disabled {
cursor: not-allowed;
}
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--accent);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.send-btn:hover:not(:disabled) {
filter: brightness(1.15);
}
.send-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedUserMessage } from '@/types/transcript-debug'
const props = defineProps<{
message: ParsedUserMessage
}>()
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
function formatTime(ts: string): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
}
</script>
<template>
<div :class="['user-bubble', { meta: message.isMeta, optimistic: isOptimistic }]">
<div class="bubble-header">
<span class="role-badge">User</span>
<span v-if="message.isMeta" class="meta-badge">meta</span>
<span v-if="isOptimistic" class="sending-badge">
<span class="sending-dot"></span>
<span class="sending-dot"></span>
<span class="sending-dot"></span>
Sending
</span>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="bubble-content">{{ message.content }}</div>
</div>
</template>
<style scoped>
.user-bubble {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 12px;
padding: 0.75rem 1rem;
margin-left: 2rem;
transition: opacity 0.3s, border-color 0.3s, background 0.3s;
}
.user-bubble.meta {
opacity: 0.5;
border-style: dashed;
}
/* Optimistic / sending state */
.user-bubble.optimistic {
opacity: 0.7;
border-color: rgba(99, 102, 241, 0.12);
border-style: dashed;
background: rgba(99, 102, 241, 0.04);
}
.sending-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
color: var(--accent, #6366f1);
font-weight: 500;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.08);
}
.sending-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--accent, #6366f1);
animation: sending-pulse 1.2s ease-in-out infinite;
}
.sending-dot:nth-child(2) { animation-delay: 0.15s; }
.sending-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes sending-pulse {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
.bubble-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.role-badge {
font-size: 11px;
font-weight: 600;
color: #818cf8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-badge {
font-size: 10px;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.timestamp {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.bubble-content {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,13 @@
export { default as SessionSelector } from './SessionSelector.vue'
export { default as RawJsonViewer } from './RawJsonViewer.vue'
export { default as ChatContainer } from './ChatContainer.vue'
export { default as UserMessageBubble } from './UserMessageBubble.vue'
export { default as AssistantMessageBubble } from './AssistantMessageBubble.vue'
export { default as ThinkingBlock } from './ThinkingBlock.vue'
export { default as ToolCallBlock } from './ToolCallBlock.vue'
export { default as ToolResultBlock } from './ToolResultBlock.vue'
export { default as ProgressEvent } from './ProgressEvent.vue'
export { default as SystemMessage } from './SystemMessage.vue'
export { default as UserInput } from './UserInput.vue'
export { default as PermissionApproval } from './PermissionApproval.vue'
export { default as PlanApproval } from './PlanApproval.vue'

View File

@@ -0,0 +1,2 @@
export { useTranscriptDebug } from './useTranscriptDebug'
export { useHooksApproval } from './useHooksApproval'

View File

@@ -0,0 +1,111 @@
import { ref } from 'vue'
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 fetch('/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 fetch('/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 fetch('/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
}
}

View File

@@ -0,0 +1,620 @@
import { ref, computed, onUnmounted } from 'vue'
import { endpoints } from '@/config/endpoints'
import type {
AgentName,
SessionInfo,
ParsedConversation,
ConversationMessage,
ParsedUserMessage,
ParsedAssistantMessage,
ParsedProgressGroup,
ParsedSystemMessage,
ParsedToolCall,
ParsedToolResult,
ParsedProgressEvent,
TranscriptEntry,
UserEntry,
AssistantEntry,
ProgressEntry,
ContentBlock,
ToolUseBlock,
ToolResultBlock
} from '@/types/transcript-debug'
export function useTranscriptDebug() {
// ── Persistence ──
const STORAGE_KEY = 'transcript-debug-selection'
function restoreState(): { agent: AgentName; sessionId: string | null } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const data = JSON.parse(raw)
if (data.agent === 'ejecutor' || data.agent === 'nucleo000' || data.agent === 'claude') {
return { agent: data.agent, sessionId: data.sessionId || null }
}
} catch {}
return null
}
function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
agent: selectedAgent.value,
sessionId: selectedSessionId.value
}))
} catch {}
}
const saved = restoreState()
const selectedAgent = ref<AgentName>(saved?.agent || 'ejecutor')
const sessions = ref<SessionInfo[]>([])
const selectedSessionId = ref<string | null>(saved?.sessionId || null)
const rawContent = ref<string>('')
const conversation = ref<ParsedConversation | null>(null)
const loading = ref(false)
const transitioning = ref(!!saved?.sessionId)
const error = ref<string | null>(null)
const isRealtime = ref(false)
// ── WebSocket realtime ──
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
function connectRealtime() {
if (socket?.readyState === WebSocket.OPEN) return
// Same sync server as git (port 4105)
socket = new WebSocket(endpoints.git)
socket.onopen = () => {
isRealtime.value = true
console.log('[TranscriptDebug] Realtime connected')
}
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'transcript-debug-change') {
handleRealtimeChange(msg.sessionId)
} else if (msg.type === 'transcript-debug-done') {
handleRealtimeDone(msg.sessionId)
}
} catch {
// Ignore non-JSON
}
}
socket.onclose = () => {
isRealtime.value = false
console.log('[TranscriptDebug] Realtime disconnected, reconnecting...')
reconnectTimer = setTimeout(connectRealtime, 2000)
}
socket.onerror = () => {
isRealtime.value = false
}
}
function disconnectRealtime() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socket) {
socket.close()
socket = null
}
isRealtime.value = false
}
async function handleRealtimeChange(changedSessionId: string) {
// Refresh session list (new sessions or size changes)
await fetchSessions()
// If the changed session is the one we're viewing, re-fetch it
if (selectedSessionId.value && selectedSessionId.value === changedSessionId) {
await reloadCurrentSession()
} else if (!selectedSessionId.value && sessions.value.length > 0) {
// No session selected yet — auto-select the newest
await selectSession(sessions.value[0].id)
}
}
function handleRealtimeDone(changedSessionId: string) {
// Process exited — safe to send again (runningProcesses cleared on backend)
if (selectedSessionId.value === changedSessionId && processing.value) {
processing.value = false
}
}
async function reloadCurrentSession() {
if (!selectedSessionId.value) return
try {
const res = await fetch(`/api/transcript-debug/${selectedSessionId.value}/raw?agent=${selectedAgent.value}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
rawContent.value = await res.text()
const parsed = parseJsonl(rawContent.value, selectedSessionId.value)
// Check if the optimistic user message now exists in the real JSONL
if (optimisticMessage.value) {
const optimisticText = optimisticMessage.value.content
const found = parsed.messages.some(
m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText)
)
if (found) {
// Real message arrived, drop the optimistic one
optimisticMessage.value = null
} else {
// Not yet in JSONL, keep showing it at the end
parsed.messages.push(optimisticMessage.value)
}
}
// Detect turn completion from JSONL content:
// If we're processing, the optimistic was replaced (real user entry exists),
// and the last substantive message is an assistant response → turn is done
if (processing.value && !optimisticMessage.value) {
const lastSubstantive = [...parsed.messages]
.reverse()
.find(m => m.kind === 'user' || m.kind === 'assistant')
if (lastSubstantive?.kind === 'assistant') {
processing.value = false
}
}
conversation.value = parsed
} catch (e: any) {
console.error('[TranscriptDebug] Reload failed:', e.message)
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
disconnectRealtime()
})
// ── Send prompt (spawns independent claude process) ──
const sending = ref(false)
const processing = ref(false)
const optimisticMessage = ref<ParsedUserMessage | null>(null)
async function sendPrompt(text: string) {
if (!text.trim() || !selectedSessionId.value) return
sending.value = true
error.value = null
try {
const res = await fetch('/api/transcript-debug/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent: selectedAgent.value,
sessionId: selectedSessionId.value,
prompt: text
})
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
}
// POST returned — agent is spawned
processing.value = true
// Optimistic: show user message immediately in chat
// Stays until the real user entry appears in the JSONL
optimisticMessage.value = {
kind: 'user',
uuid: `optimistic-${Date.now()}`,
timestamp: new Date().toISOString(),
content: text,
isMeta: false,
isToolResult: false,
toolResults: []
} as ParsedUserMessage
if (conversation.value) {
conversation.value.messages.push(optimisticMessage.value)
}
} catch (e: any) {
error.value = `Failed to send: ${e.message}`
} finally {
sending.value = false
}
}
// ── API ──
async function fetchSessions() {
try {
const res = await fetch(`/api/transcript-debug/sessions?agent=${selectedAgent.value}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
sessions.value = await res.json()
} catch (e: any) {
error.value = `Failed to load sessions: ${e.message}`
}
}
async function fetchSessionContent(sessionId: string): Promise<boolean> {
try {
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${selectedAgent.value}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
rawContent.value = await res.text()
conversation.value = parseJsonl(rawContent.value, sessionId)
return true
} catch (e: any) {
error.value = `Failed to load session: ${e.message}`
return false
}
}
async function init() {
await fetchSessions()
let targetSession = selectedSessionId.value
// Validate saved session still exists
if (targetSession && !sessions.value.some(s => s.id === targetSession)) {
targetSession = null
}
// Fall back to newest
if (!targetSession && sessions.value.length > 0) {
targetSession = sessions.value[0].id
}
if (targetSession) {
selectedSessionId.value = targetSession
saveState()
await fetchSessionContent(targetSession)
} else {
selectedSessionId.value = null
saveState()
}
transitioning.value = false
connectRealtime()
}
async function switchAgent(agent: AgentName) {
if (agent === selectedAgent.value) return
selectedAgent.value = agent
error.value = null
loading.value = true
transitioning.value = true
// Wait for fade-out
await new Promise(r => setTimeout(r, 150))
rawContent.value = ''
conversation.value = null
await fetchSessions()
if (sessions.value.length > 0) {
selectedSessionId.value = sessions.value[0].id
await fetchSessionContent(sessions.value[0].id)
} else {
selectedSessionId.value = null
}
saveState()
transitioning.value = false
loading.value = false
}
async function selectSession(sessionId: string) {
if (sessionId === selectedSessionId.value) return
error.value = null
loading.value = true
transitioning.value = true
// Wait for fade-out
await new Promise(r => setTimeout(r, 150))
selectedSessionId.value = sessionId
saveState()
await fetchSessionContent(sessionId)
transitioning.value = false
loading.value = false
}
// ── JSONL Parser ──
function parseJsonl(text: string, sessionId: string): ParsedConversation {
const lines = text.split('\n').filter(l => l.trim())
const entries: TranscriptEntry[] = []
for (const line of lines) {
try {
entries.push(JSON.parse(line))
} catch {
// Skip malformed lines
}
}
// Extract metadata from first meaningful entry
let model = ''
let version = ''
let cwd = ''
let gitBranch = ''
let startTime = ''
let endTime = ''
for (const e of entries) {
if ('sessionId' in e && e.sessionId) {
if (!version && 'version' in e && e.version) version = e.version
if (!cwd && 'cwd' in e && e.cwd) cwd = e.cwd
if (!gitBranch && 'gitBranch' in e && e.gitBranch) gitBranch = e.gitBranch
}
if (e.type === 'assistant') {
const ae = e as AssistantEntry
if (!model && ae.message?.model) model = ae.message.model
}
if ('timestamp' in e && e.timestamp) {
if (!startTime) startTime = e.timestamp
endTime = e.timestamp
}
}
// Build conversation messages
const messages: ConversationMessage[] = []
// Group assistant entries by message.id (streaming chunks)
const assistantGroups = new Map<string, AssistantEntry[]>()
// Collect tool results from user entries
const toolResultMap = new Map<string, ParsedToolResult>()
// Collect progress events by toolUseID
const progressByTool = new Map<string, ParsedProgressEvent[]>()
// Helper to build a ParsedProgressEvent from a raw ProgressEntry
function buildProgressEvent(pe: ProgressEntry): ParsedProgressEvent {
return {
uuid: pe.uuid,
timestamp: pe.timestamp,
dataType: pe.data?.type || 'unknown',
hookEvent: pe.data?.hookEvent as string | undefined,
hookName: pe.data?.hookName as string | undefined,
command: pe.data?.command as string | undefined,
mcpStatus: pe.data?.status as string | undefined,
mcpServerName: pe.data?.serverName as string | undefined,
mcpToolName: pe.data?.toolName as string | undefined,
mcpElapsedMs: pe.data?.elapsedTimeMs as number | undefined,
data: pe.data as Record<string, unknown>
}
}
// First pass: categorize all entries
for (const entry of entries) {
if (entry.type === 'file-history-snapshot') continue
if (entry.type === 'assistant') {
const ae = entry as AssistantEntry
const msgId = ae.message?.id
if (msgId) {
if (!assistantGroups.has(msgId)) assistantGroups.set(msgId, [])
assistantGroups.get(msgId)!.push(ae)
}
continue
}
if (entry.type === 'user') {
const ue = entry as UserEntry
const content = ue.message?.content
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === 'object' && block.type === 'tool_result') {
const tr = block as ToolResultBlock
const resultContent = typeof tr.content === 'string'
? tr.content
: Array.isArray(tr.content)
? tr.content.map(b => 'text' in b ? b.text : JSON.stringify(b)).join('\n')
: ''
toolResultMap.set(tr.tool_use_id, {
toolUseId: tr.tool_use_id,
content: resultContent,
isError: !!tr.is_error
})
}
}
}
continue
}
if (entry.type === 'progress') {
const pe = entry as ProgressEntry
if (pe.toolUseID) {
if (!progressByTool.has(pe.toolUseID)) progressByTool.set(pe.toolUseID, [])
progressByTool.get(pe.toolUseID)!.push(buildProgressEvent(pe))
}
continue
}
}
// Second pass: build linear conversation
let currentProgressBatch: ParsedProgressEvent[] = []
for (const entry of entries) {
if (entry.type === 'file-history-snapshot') continue
if (entry.type === 'progress') {
const pe = entry as ProgressEntry
if (!pe.toolUseID) {
currentProgressBatch.push(buildProgressEvent(pe))
}
continue
}
// Flush progress batch before next non-progress entry
if (currentProgressBatch.length > 0) {
messages.push({
kind: 'progress',
uuid: currentProgressBatch[0].uuid,
timestamp: currentProgressBatch[0].timestamp,
events: [...currentProgressBatch]
} as ParsedProgressGroup)
currentProgressBatch = []
}
if (entry.type === 'user') {
const ue = entry as UserEntry
const content = ue.message?.content
const hasToolResult = Array.isArray(content) &&
content.some(b => typeof b === 'object' && b.type === 'tool_result')
const hasText = Array.isArray(content)
? content.some(b => typeof b === 'object' && b.type === 'text')
: typeof content === 'string' && content.length > 0
if (hasToolResult && !hasText) continue
const textContent = typeof content === 'string'
? content
: Array.isArray(content)
? content.filter(b => typeof b === 'object' && b.type === 'text').map(b => (b as any).text).join('\n')
: ''
messages.push({
kind: 'user',
uuid: ue.uuid,
timestamp: ue.timestamp || '',
content: textContent,
isMeta: !!ue.isMeta,
isToolResult: false,
toolResults: []
} as ParsedUserMessage)
continue
}
if (entry.type === 'assistant') {
const ae = entry as AssistantEntry
const msgId = ae.message?.id
if (!msgId) continue
const group = assistantGroups.get(msgId)
if (!group) continue
// Only process first entry in group (we merge all chunks)
if (group[0].uuid !== ae.uuid) continue
const textBlocks: string[] = []
const thinkingBlocks: string[] = []
const toolCalls: ParsedToolCall[] = []
for (const chunk of group) {
if (!chunk.message?.content) continue
for (const block of chunk.message.content) {
if (block.type === 'text') {
textBlocks.push(block.text)
} else if (block.type === 'thinking') {
thinkingBlocks.push(block.thinking)
} else if (block.type === 'tool_use') {
const tu = block as ToolUseBlock
toolCalls.push({
id: tu.id,
name: tu.name,
input: tu.input,
result: toolResultMap.get(tu.id),
progressEvents: progressByTool.get(tu.id) || []
})
}
}
}
const lastChunk = group[group.length - 1]
messages.push({
kind: 'assistant',
uuid: ae.uuid,
messageId: msgId,
timestamp: ae.timestamp || '',
model: ae.message?.model || model,
textBlocks,
thinkingBlocks,
toolCalls,
usage: lastChunk.message?.usage,
stopReason: lastChunk.message?.stop_reason
} as ParsedAssistantMessage)
continue
}
if (entry.type === 'system') {
const se = entry as any
const content = se.message?.content
const textContent = typeof content === 'string'
? content
: Array.isArray(content)
? content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n')
: JSON.stringify(se.data || se.message || '')
messages.push({
kind: 'system',
uuid: se.uuid || crypto.randomUUID(),
timestamp: se.timestamp || '',
content: textContent,
subtype: se.subtype
} as ParsedSystemMessage)
}
}
// Flush remaining progress
if (currentProgressBatch.length > 0) {
messages.push({
kind: 'progress',
uuid: currentProgressBatch[0].uuid,
timestamp: currentProgressBatch[0].timestamp,
events: [...currentProgressBatch]
} as ParsedProgressGroup)
}
return {
sessionId,
model,
version,
messages,
metadata: {
cwd,
gitBranch,
startTime,
endTime,
totalEntries: entries.length
}
}
}
const lineCount = computed(() => {
if (!rawContent.value) return 0
return rawContent.value.split('\n').filter(l => l.trim()).length
})
return {
selectedAgent,
sessions,
selectedSessionId,
rawContent,
conversation,
loading,
transitioning,
error,
lineCount,
isRealtime,
sending,
processing,
init,
fetchSessions,
switchAgent,
selectSession,
connectRealtime,
disconnectRealtime,
sendPrompt
}
}

View File

@@ -0,0 +1,199 @@
import { ref, computed } from 'vue'
import { endpoints } from '@/config/endpoints'
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
export interface ApprovalSessionGroup {
sessionId: string
agent: string
permissions: HooksApprovalPermissionRequest[]
plans: HooksApprovalPlanRequest[]
}
// Singleton state (shared across all callers)
const pendingPermissions = ref<HooksApprovalPermissionRequest[]>([])
const pendingPlans = ref<HooksApprovalPlanRequest[]>([])
const modalVisible = ref(false)
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let connected = false
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
const map = new Map<string, ApprovalSessionGroup>()
for (const p of pendingPermissions.value) {
const key = p.session_id || 'unknown'
if (!map.has(key)) {
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
}
map.get(key)!.permissions.push(p)
}
for (const p of pendingPlans.value) {
const key = p.session_id || 'unknown'
if (!map.has(key)) {
map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] })
}
map.get(key)!.plans.push(p)
}
return Array.from(map.values())
})
function connect() {
if (connected || socket?.readyState === WebSocket.OPEN) return
socket = new WebSocket(endpoints.git)
socket.onopen = () => {
connected = true
console.log('[GlobalApproval] WebSocket connected')
}
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'hooks-approval-permission') {
console.log(`[GlobalApproval] WS received permission: ${msg.requestId} tool=${msg.tool_name} agent=${msg.agent_name}`)
const newReq: HooksApprovalPermissionRequest = {
requestId: msg.requestId,
tool_name: msg.tool_name,
tool_input: msg.tool_input,
agent_name: msg.agent_name,
session_id: msg.session_id,
cwd: msg.cwd,
timestamp: msg.timestamp
}
pendingPermissions.value = [...pendingPermissions.value, newReq]
if (!modalVisible.value) modalVisible.value = true
} else if (msg.type === 'hooks-approval-plan') {
console.log(`[GlobalApproval] WS received plan: ${msg.requestId} session=${msg.session_id}`)
const newReq: HooksApprovalPlanRequest = {
requestId: msg.requestId,
session_id: msg.session_id,
permission_mode: msg.permission_mode,
lastAssistantText: msg.lastAssistantText || '',
timestamp: msg.timestamp
}
pendingPlans.value = [...pendingPlans.value, newReq]
if (!modalVisible.value) modalVisible.value = true
}
} catch {
// Ignore non-JSON or unrelated messages
}
}
socket.onclose = () => {
connected = false
console.log('[GlobalApproval] WebSocket disconnected, reconnecting...')
reconnectTimer = setTimeout(connect, 2000)
}
socket.onerror = () => {
connected = false
}
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socket) {
socket.close()
socket = null
}
connected = false
}
async function fetchPending() {
try {
console.log('[GlobalApproval] Fetching pending approvals...')
const res = await fetch('/api/hooks-approval')
if (!res.ok) return
const data = await res.json()
console.log(`[GlobalApproval] Fetched: ${data.permissions?.length || 0} permissions, ${data.plans?.length || 0} plans`)
if (Array.isArray(data.permissions)) {
for (const p of data.permissions) {
if (!pendingPermissions.value.some(e => e.requestId === p.requestId)) {
pendingPermissions.value = [...pendingPermissions.value, {
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 = [...pendingPlans.value, {
requestId: p.requestId,
session_id: p.session_id,
permission_mode: p.permission_mode,
lastAssistantText: p.lastAssistantText || '',
timestamp: p.createdAt
}]
}
}
}
// Auto-show if there are pending requests
if (totalPending.value > 0 && !modalVisible.value) {
modalVisible.value = true
}
} catch (e) {
console.error('[GlobalApproval] Failed to fetch pending:', e)
}
}
async function respondPermission(requestId: string, decision: 'allow' | 'deny') {
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}`)
try {
await fetch('/api/hooks-approval/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision })
})
} catch (e) {
console.error('[GlobalApproval] Failed to respond permission:', e)
}
pendingPermissions.value = pendingPermissions.value.filter(p => p.requestId !== requestId)
}
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
try {
await fetch('/api/hooks-approval/respond-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
} catch (e) {
console.error('[GlobalApproval] Failed to respond plan:', e)
}
pendingPlans.value = pendingPlans.value.filter(p => p.requestId !== requestId)
}
export function useGlobalApproval() {
return {
pendingPermissions,
pendingPlans,
totalPending,
groupedBySession,
modalVisible,
connect,
disconnect,
fetchPending,
respondPermission,
respondPlan
}
}

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug'
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
const {
selectedAgent,
sessions,
selectedSessionId,
rawContent,
conversation,
loading,
transitioning,
error,
lineCount,
isRealtime,
sending,
processing,
init,
switchAgent,
selectSession,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
const agents: { id: AgentName; label: string }[] = [
{ id: 'ejecutor', label: 'Ejecutor' },
{ id: 'nucleo000', label: 'nucleo000' },
{ id: 'claude', label: 'Claude' }
]
function handleSend(message: string) {
sendPrompt(message)
}
onMounted(() => {
init()
})
onUnmounted(() => {
disconnectRealtime()
})
</script>
<template>
<div class="transcript-debug-page">
<!-- Header -->
<header class="page-header">
<div class="header-left">
<h2>Transcript Debug</h2>
<span :class="['realtime-dot', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
</span>
</div>
<div class="header-selectors">
<!-- Agent selector -->
<div class="agent-selector">
<button
v-for="a in agents"
:key="a.id"
:class="['agent-btn', { active: selectedAgent === a.id }]"
@click="switchAgent(a.id)"
>
{{ a.label }}
</button>
</div>
<!-- Session selector -->
<SessionSelector
:sessions="sessions"
:selected-id="selectedSessionId"
:loading="loading"
@select="selectSession"
/>
</div>
</header>
<!-- Error -->
<div v-if="error" class="error-bar">{{ error }}</div>
<!-- Content -->
<div class="content-area">
<div v-if="!selectedSessionId" :class="['empty-state', { fading: transitioning }]">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<p>Select a transcript session to begin</p>
<span>{{ sessions.length }} sessions available</span>
</div>
<div v-else class="split-panels">
<!-- Left: Raw JSONL -->
<div :class="['panel-left', { fading: transitioning }]">
<RawJsonViewer :content="rawContent" />
</div>
<!-- Resize handle -->
<div class="resize-handle"></div>
<!-- Right: Chat -->
<div :class="['panel-right', { fading: transitioning }]">
<ChatContainer
v-if="conversation"
:conversation="conversation"
:processing="processing"
@send="handleSend"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.transcript-debug-page {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.header-left h2 {
font-size: 15px;
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.realtime-dot {
color: var(--text-muted);
opacity: 0.4;
transition: all 0.3s;
}
.realtime-dot.connected {
color: #22c55e;
opacity: 1;
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% { filter: drop-shadow(0 0 2px currentColor); }
50% { filter: drop-shadow(0 0 6px currentColor); }
}
.header-selectors {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
/* Agent selector */
.agent-selector {
display: flex;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.agent-btn {
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.agent-btn:not(:last-child) {
border-right: 1px solid var(--border-color);
}
.agent-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.agent-btn.active {
background: var(--accent);
color: white;
}
.error-bar {
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.1);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
font-size: 13px;
flex-shrink: 0;
}
.content-area {
display: flex;
flex: 1;
overflow: hidden;
}
.fading {
opacity: 0 !important;
}
.empty-state {
transition: opacity 0.15s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 0.75rem;
color: var(--text-muted);
}
.empty-state svg {
opacity: 0.4;
}
.empty-state p {
font-size: 15px;
color: var(--text-secondary);
margin: 0;
}
.empty-state span {
font-size: 13px;
}
.split-panels {
display: flex;
flex: 1;
overflow: hidden;
}
.panel-left {
width: 35%;
min-width: 250px;
display: flex;
flex-direction: column;
padding: 0.5rem;
padding-right: 0;
transition: opacity 0.15s ease;
}
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
flex-shrink: 0;
margin: 0.5rem 2px;
border-radius: 2px;
transition: background 0.15s;
}
.resize-handle:hover {
background: var(--accent);
}
.panel-right {
flex: 1;
min-width: 300px;
display: flex;
flex-direction: column;
padding: 0.5rem;
padding-left: 0;
transition: opacity 0.15s ease;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.header-left h2 {
font-size: 14px;
}
.header-selectors {
flex-direction: column;
align-items: stretch;
width: 100%;
gap: 0.5rem;
}
.split-panels {
flex-direction: column;
}
.panel-left {
width: 100%;
height: 40%;
min-width: 0;
padding: 0.5rem;
}
.resize-handle {
width: 100%;
height: 4px;
margin: 2px 0.5rem;
cursor: row-resize;
}
.panel-right {
flex: 1;
min-width: 0;
min-height: 0;
padding: 0.5rem;
}
}
</style>

View File

@@ -58,6 +58,11 @@ const router = createRouter({
path: '/agents',
name: 'agents',
component: () => import('../pages/AgentsPage.vue')
},
{
path: '/transcript-debug',
name: 'transcript-debug',
component: () => import('../pages/TranscriptDebugPage.vue')
}
]
})

View File

@@ -0,0 +1,17 @@
export interface HooksApprovalPermissionRequest {
requestId: string
tool_name?: string
tool_input?: unknown
agent_name?: string
session_id?: string
cwd?: string
timestamp: number
}
export interface HooksApprovalPlanRequest {
requestId: string
session_id?: string
permission_mode?: string
lastAssistantText: string
timestamp: number
}

View File

@@ -0,0 +1,227 @@
// ── Base entry (every JSONL line has these) ──
export interface TranscriptEntryBase {
uuid: string
parentUuid: string | null
type: string
timestamp: string
sessionId?: string
isSidechain?: boolean
userType?: string
cwd?: string
version?: string
gitBranch?: string
}
// ── Specific entry types ──
export interface UserEntry extends TranscriptEntryBase {
type: 'user'
message: {
role: 'user'
content: string | ContentBlock[]
}
isMeta?: boolean
}
export interface AssistantEntry extends TranscriptEntryBase {
type: 'assistant'
message: {
id: string
model: string
role: 'assistant'
type: 'message'
content: ContentBlock[]
usage?: {
input_tokens: number
output_tokens: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
stop_reason?: string
}
}
export interface ProgressEntry extends TranscriptEntryBase {
type: 'progress'
data: {
type: string // 'hook_progress', 'tool_progress', 'mcp_progress'
hookEvent?: string
hookName?: string
command?: string
toolName?: string
[key: string]: unknown
}
toolUseID?: string
parentToolUseID?: string
}
export interface SystemEntry extends TranscriptEntryBase {
type: 'system'
message?: {
role: 'user'
content: string | ContentBlock[]
}
subtype?: string
}
export interface FileHistoryEntry {
type: 'file-history-snapshot'
messageId: string
snapshot: {
messageId: string
trackedFileBackups: Record<string, unknown>
timestamp: string
}
isSnapshotUpdate: boolean
}
// ── Content blocks (inside messages) ──
export type ContentBlock =
| TextBlock
| ThinkingBlock
| ToolUseBlock
| ToolResultBlock
export interface TextBlock {
type: 'text'
text: string
}
export interface ThinkingBlock {
type: 'thinking'
thinking: string
}
export interface ToolUseBlock {
type: 'tool_use'
id: string
name: string
input: Record<string, unknown>
}
export interface ToolResultBlock {
type: 'tool_result'
tool_use_id: string
content: string | ContentBlock[]
is_error?: boolean
}
// ── Parsed conversation (what the UI renders) ──
export type AgentName = 'ejecutor' | 'nucleo000' | 'claude'
export interface SessionInfo {
id: string
filename: string
size: number
mtime: number
mtimeISO: string
firstUserMessage: string
}
export interface ParsedConversation {
sessionId: string
model: string
version: string
messages: ConversationMessage[]
metadata: ConversationMetadata
}
export interface ConversationMetadata {
cwd: string
gitBranch: string
startTime: string
endTime: string
totalEntries: number
}
export type ConversationMessage =
| ParsedUserMessage
| ParsedAssistantMessage
| ParsedProgressGroup
| ParsedSystemMessage
export interface ParsedUserMessage {
kind: 'user'
uuid: string
timestamp: string
content: string
isMeta: boolean
isToolResult: boolean
toolResults: ParsedToolResult[]
}
export interface ParsedToolResult {
toolUseId: string
content: string
isError: boolean
}
export interface ParsedAssistantMessage {
kind: 'assistant'
uuid: string
messageId: string
timestamp: string
model: string
textBlocks: string[]
thinkingBlocks: string[]
toolCalls: ParsedToolCall[]
usage?: {
input_tokens: number
output_tokens: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
stopReason?: string
}
export interface ParsedToolCall {
id: string
name: string
input: Record<string, unknown>
result?: ParsedToolResult
progressEvents: ParsedProgressEvent[]
}
export interface ParsedProgressEvent {
uuid: string
timestamp: string
dataType: string // 'hook_progress' | 'mcp_progress'
// hook_progress fields
hookEvent?: string // 'SessionStart' | 'PreToolUse' | 'PostToolUse'
hookName?: string
command?: string
// mcp_progress fields
mcpStatus?: string // 'started' | 'completed'
mcpServerName?: string
mcpToolName?: string
mcpElapsedMs?: number
data: Record<string, unknown>
}
export interface ParsedProgressGroup {
kind: 'progress'
uuid: string
timestamp: string
events: ParsedProgressEvent[]
}
export interface ParsedSystemMessage {
kind: 'system'
uuid: string
timestamp: string
content: string
subtype?: string
}
// ── Raw entry union ──
export type TranscriptEntry =
| UserEntry
| AssistantEntry
| ProgressEntry
| SystemEntry
| FileHistoryEntry
| (TranscriptEntryBase & { type: string })