feat: voice mic, pixel life layer, enhanced transcript-debug UX

VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble
with voice display, AgentBadge terminal switcher, ChatContainer voice integration,
FloatingTranscriptDebug ocean life enhancements, and terminal registry support.
Remove traefik config.
This commit is contained in:
2026-02-20 12:12:53 -06:00
parent b7f03a777b
commit 220d595568
19 changed files with 2221 additions and 358 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedUserMessage } from '@/types/transcript-debug'
import MarkdownContent from './MarkdownContent.vue'
const props = defineProps<{
message: ParsedUserMessage
@@ -14,6 +15,57 @@ const emit = defineEmits<{
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()
@@ -21,7 +73,65 @@ function formatTime(ts: string): string {
</script>
<template>
<div :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
<!-- 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">
@@ -46,25 +156,142 @@ function formatTime(ts: string): string {
</button>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="divider-text">{{ message.content }}</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 {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(129, 140, 248, 0.2) 10%, rgba(129, 140, 248, 0.2) 90%, transparent);
margin-bottom: 0.5rem;
display: none;
}
.divider-content {
padding: 0 0.25rem;
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 {
@@ -103,19 +330,34 @@ function formatTime(ts: string): string {
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
white-space: pre-wrap;
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;
}
.user-divider.meta .divider-line {
background: linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.15) 10%, rgba(251, 191, 36, 0.15) 90%, transparent);
}
/* Collapse button */
.collapse-btn {
display: inline-flex;
@@ -155,10 +397,6 @@ function formatTime(ts: string): string {
opacity: 0.6;
}
.user-divider.optimistic .divider-line {
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1) 10%, rgba(99, 102, 241, 0.1) 90%, transparent);
}
.sending-badge {
display: inline-flex;
align-items: center;