Files
agent-ui/frontend/src/components/transcript-debug/ChatContainer.vue
josedario87 c8e8e50fd6 feat: Animated pixel art ocean floor background for FloatingTranscriptDebug
Replace galaxy background with animated underwater scene featuring water
depth gradient, pixel art sea floor (sand, seaweed, coral, starfish),
rising bubbles with water sway, and swimming pixel art fish. Also update
transcript-debug tool cards styling and add .claude-*/tasks/ to gitignore.
2026-02-19 14:44:25 -06:00

643 lines
17 KiB
Vue

<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)
}
// ── Collapse sections ──
const collapsedSections = ref(new Set<string>())
// For each message, find which user message "owns" it (section leader)
const sectionMap = computed(() => {
const map = new Map<string, string>() // messageUuid → ownerUserUuid
let currentUserUuid: string | null = null
for (const msg of props.conversation.messages) {
if (msg.kind === 'user') {
currentUserUuid = msg.uuid
} else if (currentUserUuid) {
map.set(msg.uuid, currentUserUuid)
}
}
return map
})
// Count of non-user messages per section
const sectionCounts = computed(() => {
const counts = new Map<string, number>()
let currentUserUuid: string | null = null
for (const msg of props.conversation.messages) {
if (msg.kind === 'user') {
currentUserUuid = msg.uuid
if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0)
} else if (currentUserUuid) {
counts.set(currentUserUuid, (counts.get(currentUserUuid) || 0) + 1)
}
}
return counts
})
function toggleCollapse(userUuid: string) {
const s = new Set(collapsedSections.value)
if (s.has(userUuid)) s.delete(userUuid)
else s.add(userUuid)
collapsedSections.value = s
}
function isCollapsedChild(msg: { uuid: string; kind: string }): boolean {
if (msg.kind === 'user') return false
const owner = sectionMap.value.get(msg.uuid)
return !!owner && collapsedSections.value.has(owner)
}
// 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">
<template
v-for="(msg, idx) in conversation.messages"
:key="msg.uuid"
>
<div
v-if="!isCollapsedChild(msg)"
:class="['message-wrapper', {
resolved: resolvedUuids.has(msg.uuid),
selected: selectedUuids.has(msg.uuid),
'assistant-continuation': msg.kind === 'assistant' && idx > 0 && conversation.messages[idx - 1].kind === 'assistant' && !isCollapsedChild(conversation.messages[idx - 1])
}]"
>
<!-- 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)">
<UserMessageBubble
v-if="msg.kind === 'user'"
:message="msg"
:collapsed="collapsedSections.has(msg.uuid)"
:section-count="sectionCounts.get(msg.uuid) || 0"
@toggle-collapse="toggleCollapse(msg.uuid)"
/>
<AssistantMessageBubble
v-else-if="msg.kind === 'assistant'"
:message="msg"
:show-header="!(idx > 0 && conversation.messages[idx - 1].kind === 'assistant')"
/>
<ProgressEvent
v-else-if="msg.kind === 'progress'"
:group="msg"
/>
<SystemMessage
v-else-if="msg.kind === 'system'"
:message="msg"
/>
</div>
</div>
</template>
</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;
}
/* Consecutive assistant messages: tighter spacing */
.message-wrapper.assistant-continuation {
margin-top: -0.5rem;
}
/* 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>