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:
@@ -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>
|
||||
583
frontend/src/components/transcript-debug/ChatContainer.vue
Normal file
583
frontend/src/components/transcript-debug/ChatContainer.vue
Normal 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>
|
||||
213
frontend/src/components/transcript-debug/PermissionApproval.vue
Normal file
213
frontend/src/components/transcript-debug/PermissionApproval.vue
Normal 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>
|
||||
253
frontend/src/components/transcript-debug/PlanApproval.vue
Normal file
253
frontend/src/components/transcript-debug/PlanApproval.vue
Normal 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>
|
||||
279
frontend/src/components/transcript-debug/ProgressEvent.vue
Normal file
279
frontend/src/components/transcript-debug/ProgressEvent.vue
Normal 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">⚙</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">⚡</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">•</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>
|
||||
216
frontend/src/components/transcript-debug/RawJsonViewer.vue
Normal file
216
frontend/src/components/transcript-debug/RawJsonViewer.vue
Normal 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">×</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>
|
||||
110
frontend/src/components/transcript-debug/SessionSelector.vue
Normal file
110
frontend/src/components/transcript-debug/SessionSelector.vue
Normal 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) + '...' }} — {{ 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>
|
||||
90
frontend/src/components/transcript-debug/SystemMessage.vue
Normal file
90
frontend/src/components/transcript-debug/SystemMessage.vue
Normal 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">ⓘ</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>
|
||||
87
frontend/src/components/transcript-debug/ThinkingBlock.vue
Normal file
87
frontend/src/components/transcript-debug/ThinkingBlock.vue
Normal 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>
|
||||
390
frontend/src/components/transcript-debug/ToolCallBlock.vue
Normal file
390
frontend/src/components/transcript-debug/ToolCallBlock.vue
Normal 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">⚙</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">⚙</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">⚡</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>
|
||||
102
frontend/src/components/transcript-debug/ToolResultBlock.vue
Normal file
102
frontend/src/components/transcript-debug/ToolResultBlock.vue
Normal 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>
|
||||
166
frontend/src/components/transcript-debug/UserInput.vue
Normal file
166
frontend/src/components/transcript-debug/UserInput.vue
Normal 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>
|
||||
122
frontend/src/components/transcript-debug/UserMessageBubble.vue
Normal file
122
frontend/src/components/transcript-debug/UserMessageBubble.vue
Normal 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>
|
||||
13
frontend/src/components/transcript-debug/index.ts
Normal file
13
frontend/src/components/transcript-debug/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user