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.
643 lines
17 KiB
Vue
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>
|