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:
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>
|
||||
Reference in New Issue
Block a user