Files
agent-ui/frontend/src/components/transcript-debug/UserMessageBubble.vue
josedario87 da26bc7b9e feat: rich collapse badge, auto-collapse, aquatic scroll nav arrows
- 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
2026-02-20 19:57:56 -06:00

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>