- SectionSummary type with tool names, error count, token usage - Informative inline badge on collapsed sections (tools, errors, tokens) - Auto-collapse keeps older sections collapsed as new messages arrive - Replace scrollbar with pixel art aquatic scroll navigation arrows - Configurable scroll jump percentage in settings - Double chevrons for top/bottom, single for page jump
503 lines
14 KiB
Vue
503 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { ParsedUserMessage, SectionSummary } from '@/types/transcript-debug'
|
|
import MarkdownContent from './MarkdownContent.vue'
|
|
|
|
const props = defineProps<{
|
|
message: ParsedUserMessage
|
|
collapsed?: boolean
|
|
sectionCount?: number
|
|
sectionSummary?: SectionSummary
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
toggleCollapse: []
|
|
}>()
|
|
|
|
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
|
|
|
|
// ── Command / special message detection ──
|
|
type CommandInfo =
|
|
| { type: 'caveat'; text: string }
|
|
| { type: 'command'; name: string; message: string; args: string }
|
|
| { type: 'stdout'; text: string }
|
|
| { type: 'interrupted' }
|
|
| { type: 'meta-action'; text: string }
|
|
| null
|
|
|
|
const commandInfo = computed<CommandInfo>(() => {
|
|
const c = props.message.content
|
|
if (!c) return null
|
|
|
|
// [Request interrupted by user ...]
|
|
if (c.includes('[Request interrupted by user')) {
|
|
return { type: 'interrupted' }
|
|
}
|
|
|
|
// Meta messages: "Continue from where you left off", etc.
|
|
if (props.message.isMeta) {
|
|
return { type: 'meta-action', text: c.trim() }
|
|
}
|
|
|
|
// <local-command-caveat>...</local-command-caveat>
|
|
const caveatMatch = c.match(/<local-command-caveat>([\s\S]*?)<\/local-command-caveat>/)
|
|
if (caveatMatch) return { type: 'caveat', text: caveatMatch[1].trim() }
|
|
|
|
// <command-name>...</command-name>
|
|
const cmdNameMatch = c.match(/<command-name>([\s\S]*?)<\/command-name>/)
|
|
if (cmdNameMatch) {
|
|
const msgMatch = c.match(/<command-message>([\s\S]*?)<\/command-message>/)
|
|
const argsMatch = c.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
|
return {
|
|
type: 'command',
|
|
name: cmdNameMatch[1].trim(),
|
|
message: msgMatch?.[1]?.trim() || '',
|
|
args: argsMatch?.[1]?.trim() || ''
|
|
}
|
|
}
|
|
|
|
// <local-command-stdout>...</local-command-stdout>
|
|
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)
|
|
if (stdoutMatch !== null && c.includes('<local-command-stdout>')) {
|
|
return { type: 'stdout', text: stdoutMatch[1].trim() }
|
|
}
|
|
|
|
return null
|
|
})
|
|
|
|
const isCommand = computed(() => commandInfo.value !== null)
|
|
|
|
function formatTime(ts: string): string {
|
|
if (!ts) return ''
|
|
return new Date(ts).toLocaleTimeString()
|
|
}
|
|
|
|
function formatTokens(n: number): string {
|
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
|
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
|
|
return String(n)
|
|
}
|
|
|
|
const totalTokens = computed(() => {
|
|
if (!props.sectionSummary) return 0
|
|
return props.sectionSummary.inputTokens + props.sectionSummary.outputTokens
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ═══════ COMMAND MESSAGE (compact) ═══════ -->
|
|
<div v-if="isCommand" class="cmd-row">
|
|
<!-- Caveat: system note about local commands -->
|
|
<template v-if="commandInfo!.type === 'caveat'">
|
|
<span class="cmd-icon cmd-icon-caveat">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
</span>
|
|
<span class="cmd-label cmd-caveat-text">local command caveat</span>
|
|
</template>
|
|
|
|
<!-- Command invocation -->
|
|
<template v-else-if="commandInfo!.type === 'command'">
|
|
<span class="cmd-icon">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
|
</svg>
|
|
</span>
|
|
<code class="cmd-name">{{ (commandInfo as any).name }}</code>
|
|
<span v-if="(commandInfo as any).args" class="cmd-args">{{ (commandInfo as any).args }}</span>
|
|
</template>
|
|
|
|
<!-- Stdout -->
|
|
<template v-else-if="commandInfo!.type === 'stdout'">
|
|
<span class="cmd-icon cmd-icon-out">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
|
</svg>
|
|
</span>
|
|
<span v-if="(commandInfo as any).text" class="cmd-stdout">{{ (commandInfo as any).text }}</span>
|
|
<span v-else class="cmd-empty">no output</span>
|
|
</template>
|
|
|
|
<!-- Interrupted -->
|
|
<template v-else-if="commandInfo!.type === 'interrupted'">
|
|
<span class="cmd-icon cmd-icon-interrupted">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/>
|
|
</svg>
|
|
</span>
|
|
<span class="cmd-label cmd-interrupted-text">interrupted by user</span>
|
|
</template>
|
|
|
|
<!-- Meta action (continue, etc.) -->
|
|
<template v-else-if="commandInfo!.type === 'meta-action'">
|
|
<span class="cmd-icon cmd-icon-meta">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
</svg>
|
|
</span>
|
|
<span class="cmd-label cmd-meta-text">{{ (commandInfo as any).text }}</span>
|
|
</template>
|
|
|
|
<span class="cmd-time">{{ formatTime(message.timestamp) }}</span>
|
|
</div>
|
|
|
|
<!-- ═══════ NORMAL USER MESSAGE ═══════ -->
|
|
<div v-else :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
|
|
<div class="divider-line" />
|
|
<div class="divider-content">
|
|
<div class="divider-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>
|
|
<button
|
|
v-if="sectionCount && sectionCount > 0"
|
|
:class="['collapse-btn', { collapsed }]"
|
|
@click.stop="emit('toggleCollapse')"
|
|
:title="collapsed ? 'Expand section' : 'Collapse section'"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline :points="collapsed ? '9 18 15 12 9 6' : '6 9 12 15 18 9'" />
|
|
</svg>
|
|
<template v-if="collapsed && sectionSummary">
|
|
<span class="collapse-count">{{ sectionSummary.total }}</span>
|
|
<template v-if="sectionSummary.toolNames.length">
|
|
<span class="badge-sep">|</span>
|
|
<span v-for="t in sectionSummary.toolNames" :key="t" class="tool-chip">{{ t }}</span>
|
|
</template>
|
|
<template v-if="sectionSummary.hasErrors">
|
|
<span class="badge-sep">|</span>
|
|
<span class="error-chip">{{ sectionSummary.errorCount }} err</span>
|
|
</template>
|
|
<template v-if="totalTokens > 0">
|
|
<span class="badge-sep">|</span>
|
|
<span class="token-chip">{{ formatTokens(totalTokens) }} tok</span>
|
|
</template>
|
|
</template>
|
|
</button>
|
|
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
|
</div>
|
|
<div class="divider-text">
|
|
<MarkdownContent :content="message.content" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ══════════════════════════════════════
|
|
Command row — compact inline display
|
|
══════════════════════════════════════ */
|
|
.cmd-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.15rem 0.5rem;
|
|
margin: 0.1rem 0;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
opacity: 0.55;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.cmd-row:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.cmd-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
color: #64748b;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cmd-icon-caveat { color: #f59e0b; }
|
|
.cmd-icon-out { color: #64748b; }
|
|
|
|
.cmd-label {
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
.cmd-name {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: #818cf8;
|
|
background: rgba(129, 140, 248, 0.08);
|
|
padding: 0 0.3rem;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.cmd-args {
|
|
font-size: 10px;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.cmd-stdout {
|
|
font-size: 10px;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: var(--text-secondary);
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-width: 300px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.cmd-empty {
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.cmd-caveat-text {
|
|
max-width: 250px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cmd-icon-interrupted { color: #ef4444; }
|
|
.cmd-interrupted-text {
|
|
color: rgba(239, 68, 68, 0.7);
|
|
font-weight: 500;
|
|
font-style: normal;
|
|
}
|
|
|
|
.cmd-icon-meta { color: #f59e0b; }
|
|
.cmd-meta-text {
|
|
color: rgba(251, 191, 36, 0.65);
|
|
font-weight: 500;
|
|
font-style: normal;
|
|
max-width: 300px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.cmd-time {
|
|
margin-left: auto;
|
|
font-size: 9px;
|
|
color: var(--text-muted);
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
opacity: 0.7;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ══════════════════════════════════════
|
|
Normal user message
|
|
══════════════════════════════════════ */
|
|
.user-divider {
|
|
width: 100%;
|
|
margin: 0.75rem 0 0.25rem;
|
|
}
|
|
|
|
.divider-line {
|
|
display: none;
|
|
}
|
|
|
|
.divider-content {
|
|
padding: 0.45rem 0.65rem;
|
|
background: linear-gradient(170deg, rgba(148, 163, 184, 0.10) 0%, rgba(100, 116, 139, 0.15) 50%, rgba(71, 85, 105, 0.12) 100%);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
|
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
|
|
border-radius: 8px;
|
|
box-shadow:
|
|
0 2px 6px rgba(0, 0, 0, 0.12),
|
|
0 1px 2px rgba(0, 0, 0, 0.08),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.divider-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.role-badge {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
color: #818cf8;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.meta-badge {
|
|
font-size: 9px;
|
|
padding: 0.05rem 0.3rem;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
color: rgba(251, 191, 36, 0.7);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.timestamp {
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
margin-left: auto;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
}
|
|
|
|
.divider-text {
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: var(--text-primary);
|
|
font-weight: 500;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.divider-text :deep(.md-content) {
|
|
font-size: inherit;
|
|
line-height: inherit;
|
|
color: inherit;
|
|
}
|
|
|
|
/* Tighten markdown spacing inside user bubbles */
|
|
.divider-text :deep(.md-content p) {
|
|
margin: 0.15em 0;
|
|
}
|
|
|
|
.divider-text :deep(.md-content pre) {
|
|
margin: 0.4em 0;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.divider-text :deep(.md-content code) {
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
/* Meta messages: dimmed */
|
|
.user-divider.meta {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
/* Collapse button */
|
|
.collapse-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
padding: 1px 4px;
|
|
border: none;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
transition: all 0.15s;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.collapse-btn:hover {
|
|
background: rgba(129, 140, 248, 0.1);
|
|
color: #818cf8;
|
|
}
|
|
|
|
.collapse-btn svg {
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.collapse-count {
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
}
|
|
|
|
.collapse-btn:hover .collapse-count {
|
|
color: #818cf8;
|
|
}
|
|
|
|
.badge-sep {
|
|
font-size: 9px;
|
|
color: var(--text-muted);
|
|
opacity: 0.35;
|
|
user-select: none;
|
|
}
|
|
|
|
.tool-chip {
|
|
font-size: 8px;
|
|
font-weight: 600;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: #818cf8;
|
|
background: rgba(129, 140, 248, 0.1);
|
|
padding: 0 3px;
|
|
border-radius: 3px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.collapse-btn:hover .tool-chip {
|
|
color: #a5b4fc;
|
|
background: rgba(129, 140, 248, 0.18);
|
|
}
|
|
|
|
.error-chip {
|
|
font-size: 8px;
|
|
font-weight: 600;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: #f87171;
|
|
background: rgba(239, 68, 68, 0.12);
|
|
padding: 0 3px;
|
|
border-radius: 3px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.collapse-btn:hover .error-chip {
|
|
color: #fca5a5;
|
|
background: rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.token-chip {
|
|
font-size: 8px;
|
|
font-weight: 600;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
color: var(--text-muted);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.collapse-btn:hover .token-chip {
|
|
color: #818cf8;
|
|
}
|
|
|
|
/* Optimistic / sending */
|
|
.user-divider.optimistic {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.sending-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
font-size: 9px;
|
|
color: var(--accent, #6366f1);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.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; }
|
|
}
|
|
</style>
|