feat: TurnEndDivider with prismarine floor, elevated FAB with bubbles

- Add TurnEndDivider component with pixel art ocean reef divider
- Parser merges stop_hook_summary + turn_duration into single turn_end
- Prismarine-inspired mosaic floor with SVG pattern and crystal highlights
- Animated duration badge with underwater glow effect
- Move transcript FAB to bottom-right, add elevated multi-layer shadow
- Add occasional bubble particles rising from FAB button
- Prevent long-touch selection on FAB (contextmenu + touch-callout)
- FAB stays fixed on mobile when terminal sheet opens
This commit is contained in:
2026-02-20 14:28:37 -06:00
parent abe6766a85
commit 18378adb77
8 changed files with 558 additions and 64 deletions

View File

@@ -288,10 +288,15 @@ watch(() => route.name, (newPage) => {
</main> </main>
<!-- Transcript Debug FAB Button (pixel art ocean) --> <!-- Transcript Debug FAB Button (pixel art ocean) -->
<div class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
<span class="fab-bubble b1"></span>
<span class="fab-bubble b2"></span>
<span class="fab-bubble b3"></span>
<button <button
class="transcript-fab" class="transcript-fab"
:class="{ active: showTranscriptDebug, 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" :class="{ active: showTranscriptDebug }"
@click="showTranscriptDebug = !showTranscriptDebug" @click="showTranscriptDebug = !showTranscriptDebug"
@contextmenu.prevent
title="Transcript Debug" title="Transcript Debug"
> >
<!-- Pixel art chat bubble icon --> <!-- Pixel art chat bubble icon -->
@@ -308,6 +313,7 @@ watch(() => route.name, (newPage) => {
<rect x="12" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/> <rect x="12" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
</svg> </svg>
</button> </button>
</div>
<!-- Voice FAB Button --> <!-- Voice FAB Button -->
<button <button
@@ -732,48 +738,138 @@ watch(() => route.name, (newPage) => {
50% { box-shadow: 0 0 50px rgba(249, 115, 22, 0.9); } 50% { box-shadow: 0 0 50px rgba(249, 115, 22, 0.9); }
} }
/* Transcript Debug FAB — pixel art night ocean */ /* Transcript Debug FAB wrapper — holds button + bubbles */
.transcript-fab { .transcript-fab-wrap {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
left: 80px; right: 20px;
width: 44px;
height: 44px;
z-index: 9998;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Transcript Debug FAB — pixel art night ocean, elevated */
.transcript-fab {
position: relative;
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 0; border-radius: 0;
background: background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='8' fill='%23050a14'/%3E%3Crect y='8' width='44' height='4' fill='%23061020'/%3E%3Crect y='12' width='44' height='4' fill='%23071828'/%3E%3Crect y='16' width='44' height='4' fill='%23081e30'/%3E%3Crect y='20' width='44' height='4' fill='%23092438'/%3E%3Crect y='24' width='44' height='4' fill='%230a2a3e'/%3E%3Crect y='28' width='44' height='4' fill='%23082030'/%3E%3Crect y='32' width='44' height='4' fill='%23061828'/%3E%3Crect y='36' width='44' height='8' fill='%231a1810'/%3E%3Crect x='34' y='2' width='3' height='3' fill='%23e8e4c8' opacity='0.6'/%3E%3Crect x='35' y='1' width='2' height='1' fill='%23e8e4c8' opacity='0.35'/%3E%3Crect x='35' y='5' width='1' height='1' fill='%23c8c4a8' opacity='0.2'/%3E%3Crect x='6' y='2' width='1' height='1' fill='%23c8d8f0' opacity='0.5'/%3E%3Crect x='14' y='4' width='1' height='1' fill='%23c8d8f0' opacity='0.35'/%3E%3Crect x='24' y='1' width='1' height='1' fill='%23c8d8f0' opacity='0.4'/%3E%3Crect x='40' y='6' width='1' height='1' fill='%23c8d8f0' opacity='0.3'/%3E%3Crect x='18' y='7' width='1' height='1' fill='%23c8d8f0' opacity='0.25'/%3E%3Crect x='2' y='9' width='1' height='1' fill='%23c8d8f0' opacity='0.2'/%3E%3Crect x='30' y='3' width='1' height='1' fill='%23c8d8f0' opacity='0.3'/%3E%3Crect x='10' y='6' width='1' height='1' fill='%23fde68a' opacity='0.2'/%3E%3Crect x='3' y='10' width='6' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='9' y='11' width='8' height='2' fill='%230284c7' opacity='0.12'/%3E%3Crect x='17' y='10' width='4' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='25' y='11' width='10' height='2' fill='%230284c7' opacity='0.12'/%3E%3Crect x='35' y='10' width='6' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='3' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='17' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.08'/%3E%3Crect x='35' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='10' y='18' width='3' height='2' fill='%23f97316' opacity='0.35'/%3E%3Crect x='9' y='19' width='1' height='1' fill='%23fdba74' opacity='0.2'/%3E%3Crect x='30' y='24' width='2' height='1' fill='%23818cf8' opacity='0.3'/%3E%3Crect x='32' y='24' width='1' height='1' fill='%23a5b4fc' opacity='0.2'/%3E%3Crect x='20' y='30' width='1' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='36' y='22' width='1' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='6' y='26' width='1' height='1' fill='%23c8d8f0' opacity='0.08'/%3E%3Crect x='4' y='36' width='6' height='5' fill='%23052e1e' opacity='0.7'/%3E%3Crect x='5' y='32' width='2' height='4' fill='%23064e33' opacity='0.5'/%3E%3Crect x='8' y='34' width='2' height='2' fill='%23059669' opacity='0.35'/%3E%3Crect x='30' y='38' width='5' height='4' fill='%23052e1e' opacity='0.6'/%3E%3Crect x='32' y='35' width='2' height='3' fill='%23064e33' opacity='0.45'/%3E%3Crect x='18' y='39' width='4' height='3' fill='%23500e28' opacity='0.45'/%3E%3Crect x='19' y='37' width='2' height='2' fill='%23701838' opacity='0.35'/%3E%3Crect x='14' y='40' width='2' height='2' fill='%23052e1e' opacity='0.5'/%3E%3Crect x='38' y='40' width='3' height='2' fill='%231a1810' opacity='0.6'/%3E%3Crect x='24' y='42' width='2' height='1' fill='%23c8b060' opacity='0.15'/%3E%3Crect x='10' y='42' width='2' height='1' fill='%23c8b060' opacity='0.12'/%3E%3C/svg%3E"); url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='8' fill='%23050a14'/%3E%3Crect y='8' width='44' height='4' fill='%23061020'/%3E%3Crect y='12' width='44' height='4' fill='%23071828'/%3E%3Crect y='16' width='44' height='4' fill='%23081e30'/%3E%3Crect y='20' width='44' height='4' fill='%23092438'/%3E%3Crect y='24' width='44' height='4' fill='%230a2a3e'/%3E%3Crect y='28' width='44' height='4' fill='%23082030'/%3E%3Crect y='32' width='44' height='4' fill='%23061828'/%3E%3Crect y='36' width='44' height='8' fill='%231a1810'/%3E%3Crect x='34' y='2' width='3' height='3' fill='%23e8e4c8' opacity='0.6'/%3E%3Crect x='35' y='1' width='2' height='1' fill='%23e8e4c8' opacity='0.35'/%3E%3Crect x='35' y='5' width='1' height='1' fill='%23c8c4a8' opacity='0.2'/%3E%3Crect x='6' y='2' width='1' height='1' fill='%23c8d8f0' opacity='0.5'/%3E%3Crect x='14' y='4' width='1' height='1' fill='%23c8d8f0' opacity='0.35'/%3E%3Crect x='24' y='1' width='1' height='1' fill='%23c8d8f0' opacity='0.4'/%3E%3Crect x='40' y='6' width='1' height='1' fill='%23c8d8f0' opacity='0.3'/%3E%3Crect x='18' y='7' width='1' height='1' fill='%23c8d8f0' opacity='0.25'/%3E%3Crect x='2' y='9' width='1' height='1' fill='%23c8d8f0' opacity='0.2'/%3E%3Crect x='30' y='3' width='1' height='1' fill='%23c8d8f0' opacity='0.3'/%3E%3Crect x='10' y='6' width='1' height='1' fill='%23fde68a' opacity='0.2'/%3E%3Crect x='3' y='10' width='6' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='9' y='11' width='8' height='2' fill='%230284c7' opacity='0.12'/%3E%3Crect x='17' y='10' width='4' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='25' y='11' width='10' height='2' fill='%230284c7' opacity='0.12'/%3E%3Crect x='35' y='10' width='6' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='3' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='17' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.08'/%3E%3Crect x='35' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='10' y='18' width='3' height='2' fill='%23f97316' opacity='0.35'/%3E%3Crect x='9' y='19' width='1' height='1' fill='%23fdba74' opacity='0.2'/%3E%3Crect x='30' y='24' width='2' height='1' fill='%23818cf8' opacity='0.3'/%3E%3Crect x='32' y='24' width='1' height='1' fill='%23a5b4fc' opacity='0.2'/%3E%3Crect x='20' y='30' width='1' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='36' y='22' width='1' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='6' y='26' width='1' height='1' fill='%23c8d8f0' opacity='0.08'/%3E%3Crect x='4' y='36' width='6' height='5' fill='%23052e1e' opacity='0.7'/%3E%3Crect x='5' y='32' width='2' height='4' fill='%23064e33' opacity='0.5'/%3E%3Crect x='8' y='34' width='2' height='2' fill='%23059669' opacity='0.35'/%3E%3Crect x='30' y='38' width='5' height='4' fill='%23052e1e' opacity='0.6'/%3E%3Crect x='32' y='35' width='2' height='3' fill='%23064e33' opacity='0.45'/%3E%3Crect x='18' y='39' width='4' height='3' fill='%23500e28' opacity='0.45'/%3E%3Crect x='19' y='37' width='2' height='2' fill='%23701838' opacity='0.35'/%3E%3Crect x='14' y='40' width='2' height='2' fill='%23052e1e' opacity='0.5'/%3E%3Crect x='38' y='40' width='3' height='2' fill='%231a1810' opacity='0.6'/%3E%3Crect x='24' y='42' width='2' height='1' fill='%23c8b060' opacity='0.15'/%3E%3Crect x='10' y='42' width='2' height='1' fill='%23c8b060' opacity='0.12'/%3E%3C/svg%3E");
color: #0ea5e9; color: #0ea5e9;
border: none; border: 1px solid rgba(14, 165, 233, 0.2);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); box-shadow:
0 2px 4px rgba(0, 0, 0, 0.5),
0 6px 16px rgba(0, 0, 0, 0.6),
0 12px 28px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(14, 165, 233, 0.12);
transition: all 0.2s ease; transition: all 0.2s ease;
z-index: 9998;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
-webkit-touch-callout: none; -webkit-touch-callout: none;
touch-action: manipulation; touch-action: manipulation;
image-rendering: pixelated; image-rendering: pixelated;
pointer-events: auto;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.transcript-fab *,
.transcript-fab svg {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
pointer-events: none;
} }
.transcript-fab:hover { .transcript-fab:hover {
transform: translateY(-2px); transform: translateY(-2px);
border-color: rgba(14, 165, 233, 0.35);
box-shadow: box-shadow:
0 6px 20px rgba(0, 0, 0, 0.7), 0 4px 8px rgba(0, 0, 0, 0.5),
0 0 10px rgba(14, 165, 233, 0.2); 0 10px 24px rgba(0, 0, 0, 0.6),
0 16px 36px rgba(0, 0, 0, 0.4),
0 0 14px rgba(14, 165, 233, 0.15),
inset 0 1px 0 rgba(14, 165, 233, 0.2);
color: #38bdf8; color: #38bdf8;
} }
.transcript-fab.active { .transcript-fab.active {
color: #67e8f9; color: #67e8f9;
border-color: rgba(14, 165, 233, 0.3);
} }
.transcript-fab.active:hover { .transcript-fab.active:hover {
color: #a5f3fc; color: #a5f3fc;
} }
/* Bubble particles — very occasional */
.fab-bubble {
position: absolute;
width: 4px;
height: 4px;
background: rgba(14, 165, 233, 0.5);
border-radius: 50%;
pointer-events: none;
opacity: 0;
}
.fab-bubble.b1 {
left: 10px;
bottom: 36px;
animation: fab-bubble-rise 18s ease-in 2s infinite;
}
.fab-bubble.b2 {
left: 28px;
bottom: 38px;
animation: fab-bubble-rise 22s ease-in 9s infinite;
width: 3px;
height: 3px;
background: rgba(34, 211, 238, 0.45);
}
.fab-bubble.b3 {
left: 18px;
bottom: 34px;
animation: fab-bubble-rise 25s ease-in 16s infinite;
width: 5px;
height: 5px;
background: rgba(14, 165, 233, 0.4);
}
@keyframes fab-bubble-rise {
0%, 85%, 100% {
opacity: 0;
transform: translateY(0) translateX(0);
}
88% {
opacity: 0.7;
transform: translateY(-10px) translateX(2px);
}
92% {
opacity: 0.5;
transform: translateY(-24px) translateX(-1px);
}
96% {
opacity: 0.2;
transform: translateY(-40px) translateX(3px);
}
98% {
opacity: 0;
transform: translateY(-52px) translateX(1px);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.voice-fab { .voice-fab {
bottom: 80px; bottom: 80px;
@@ -782,9 +878,14 @@ watch(() => route.name, (newPage) => {
height: 44px; height: 44px;
} }
.transcript-fab { .transcript-fab-wrap {
bottom: 80px; bottom: 80px;
left: 68px; right: 16px;
width: 40px;
height: 40px;
}
.transcript-fab-wrap .transcript-fab {
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
@@ -792,24 +893,24 @@ watch(() => route.name, (newPage) => {
/* Mobile: FABs above bottom sheets */ /* Mobile: FABs above bottom sheets */
@media (max-width: 1024px) and (pointer: coarse) { @media (max-width: 1024px) and (pointer: coarse) {
.voice-fab, .voice-fab {
.transcript-fab {
z-index: 10001; z-index: 10001;
transition: bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s ease; transition: bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s ease;
} }
.voice-fab.sheet-open, .transcript-fab-wrap {
.transcript-fab.sheet-open { z-index: 10001;
}
.voice-fab.sheet-open {
bottom: calc(15vh + 100px); bottom: calc(15vh + 100px);
} }
.voice-fab.keyboard-visible, .voice-fab.keyboard-visible {
.transcript-fab.keyboard-visible {
bottom: 35vh; bottom: 35vh;
} }
.voice-fab.keyboard-visible.sheet-open, .voice-fab.keyboard-visible.sheet-open {
.transcript-fab.keyboard-visible.sheet-open {
bottom: 45vh; bottom: 45vh;
} }
} }

View File

@@ -2,7 +2,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug' import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput' import { useVoiceInput } from '@/composables/useVoiceInput'
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug' import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug' import type { AgentName } from '@/types/transcript-debug'
const props = defineProps<{ const props = defineProps<{
@@ -65,6 +65,7 @@ const agents: { id: AgentName; label: string }[] = [
] ]
const showSelector = ref(false) const showSelector = ref(false)
const showNewSessionModal = ref(false)
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null) const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
let initialized = false let initialized = false
@@ -416,7 +417,23 @@ function handleSend(message: string) {
} }
function handleCreateSession() { function handleCreateSession() {
createNewSession() showNewSessionModal.value = true
}
async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
showNewSessionModal.value = false
if (agent !== selectedAgent.value) {
await switchAgent(agent)
}
createNewSession(initialPrompt || undefined)
}
async function handleModalResume(sessionId: string, agent: AgentName) {
showNewSessionModal.value = false
if (agent !== selectedAgent.value) {
await switchAgent(agent)
}
selectSession(sessionId)
} }
// ============================================================================ // ============================================================================
@@ -432,20 +449,20 @@ watch(isOpen, async (open) => {
}) })
// ============================================================================ // ============================================================================
// KEYBOARD SHORTCUTS // KEYBOARD SHORTCUTS (Ctrl+1..5 terminal switch, Ctrl+/- zoom)
// ============================================================================ // ============================================================================
function handleKeydown(e: KeyboardEvent) { function handleGlobalKeydown(e: KeyboardEvent) {
if (!e.ctrlKey) return if (!e.ctrlKey) return
// Ctrl+1..5 → switch to terminal by index // Ctrl+1..5 → switch to terminal by index
const num = parseInt(e.key) const num = parseInt(e.key)
if (num >= 1 && num <= 5) { if (num >= 1 && num <= 5) {
const slot = openTerminals.value[num - 1] const terminal = openTerminals.value[num - 1]
if (!slot) return if (!terminal) return
e.preventDefault() e.preventDefault()
if (!isOpen.value) isOpen.value = true if (!isOpen.value) isOpen.value = true
switchToTerminal(slot.sessionId) switchToTerminal(terminal.sessionId)
return return
} }
@@ -467,7 +484,7 @@ function handleKeydown(e: KeyboardEvent) {
onMounted(async () => { onMounted(async () => {
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleGlobalKeydown)
oceanLifeTimer = setInterval(tickOceanLife, 20000) oceanLifeTimer = setInterval(tickOceanLife, 20000)
tickOceanLife() tickOceanLife()
await voice.init() await voice.init()
@@ -477,7 +494,7 @@ onBeforeUnmount(() => {
if (oceanLifeTimer) clearInterval(oceanLifeTimer) if (oceanLifeTimer) clearInterval(oceanLifeTimer)
disconnectRealtime() disconnectRealtime()
voice.cleanup() voice.cleanup()
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleGlobalKeydown)
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize) document.removeEventListener('mousemove', onResize)
@@ -686,6 +703,15 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</Transition> </Transition>
<NewSessionModal
:visible="showNewSessionModal"
:agents="agents"
:current-agent="selectedAgent"
@close="showNewSessionModal = false"
@create-new="handleModalCreateNew"
@resume="handleModalResume"
/>
</Teleport> </Teleport>
</template> </template>
@@ -1217,8 +1243,6 @@ onBeforeUnmount(() => {
color: rgba(255,255,255,0.85); color: rgba(255,255,255,0.85);
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.5;
field-sizing: content;
} }
/* Send button: pixel art daytime ocean, no border */ /* Send button: pixel art daytime ocean, no border */

View File

@@ -13,6 +13,7 @@ import UserMessageBubble from './UserMessageBubble.vue'
import AssistantMessageBubble from './AssistantMessageBubble.vue' import AssistantMessageBubble from './AssistantMessageBubble.vue'
import ProgressEvent from './ProgressEvent.vue' import ProgressEvent from './ProgressEvent.vue'
import SystemMessage from './SystemMessage.vue' import SystemMessage from './SystemMessage.vue'
import TurnEndDivider from './TurnEndDivider.vue'
import UserInput from './UserInput.vue' import UserInput from './UserInput.vue'
import ResumeTerminalButton from './ResumeTerminalButton.vue' import ResumeTerminalButton from './ResumeTerminalButton.vue'
@@ -445,6 +446,10 @@ function formatDuration(start: string, end: string): string {
v-else-if="msg.kind === 'progress'" v-else-if="msg.kind === 'progress'"
:group="msg" :group="msg"
/> />
<TurnEndDivider
v-else-if="msg.kind === 'system' && msg.subtype === 'turn_end'"
:message="msg"
/>
<SystemMessage <SystemMessage
v-else-if="msg.kind === 'system'" v-else-if="msg.kind === 'system'"
:message="msg" :message="msg"
@@ -503,6 +508,12 @@ function formatDuration(start: string, end: string): string {
:session-id="conversation.sessionId" :session-id="conversation.sessionId"
:terminal="terminal ?? null" :terminal="terminal ?? null"
/> />
<button class="new-session-status-btn" @click="emit('createSession')" title="New session">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration"> <span v-if="conversation.metadata.startTime && conversation.metadata.endTime" class="meta-duration">
{{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }} {{ formatDuration(conversation.metadata.startTime, conversation.metadata.endTime) }}
</span> </span>
@@ -601,6 +612,26 @@ function formatDuration(start: string, end: string): string {
flex-shrink: 0; flex-shrink: 0;
} }
.new-session-status-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
border-radius: 3px;
cursor: pointer;
color: var(--text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.new-session-status-btn:hover {
background: var(--bg-hover);
color: var(--accent, #6366f1);
}
.status-id { .status-id {
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsedSystemMessage } from '@/types/transcript-debug'
const props = defineProps<{
message: ParsedSystemMessage
}>()
const duration = computed(() => {
const ms = props.message.durationMs
if (!ms) return ''
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
return `${m}m ${s % 60}s`
})
</script>
<template>
<div class="turn-end">
<div class="reef-line">
<!-- Left reef: anchored to left edge, extends right -->
<svg class="reef reef-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
<!-- Ocean stone floor (prismarine-inspired mosaic) -->
<defs>
<pattern id="pfl" x="0" y="0" width="12" height="2" patternUnits="userSpaceOnUse">
<rect width="12" height="2" fill="#0e3b3b"/>
<rect x="0" y="0" width="5" height="1" fill="#1f7270"/>
<rect x="6" y="0" width="5" height="1" fill="#2a8a7e"/>
<rect x="0" y="1" width="2" height="1" fill="#35a098"/>
<rect x="3" y="1" width="5" height="1" fill="#1f7270"/>
<rect x="9" y="1" width="2" height="1" fill="#2a8a7e"/>
</pattern>
</defs>
<rect x="0" y="12" width="200" height="2" fill="url(#pfl)" opacity="0.85"/>
<!-- Crystal highlights -->
<rect x="3" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.5"/>
<rect x="15" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="28" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="42" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="58" y="12" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="75" y="13" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="91" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="108" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="130" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.3"/>
<rect x="155" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.25"/>
<rect x="175" y="12" width="1" height="1" fill="#7edcd2" opacity="0.2"/>
<!-- Tall coral cluster (left edge) -->
<rect x="2" y="4" width="2" height="10" fill="#f87171" opacity="0.85"/>
<rect x="4" y="2" width="2" height="12" fill="#fb923c" opacity="0.8"/>
<rect x="6" y="5" width="2" height="9" fill="#f87171" opacity="0.75"/>
<rect x="8" y="7" width="2" height="7" fill="#ef4444" opacity="0.65"/>
<rect x="3" y="1" width="2" height="2" fill="#fca5a5" opacity="0.55"/>
<!-- Seaweed grove -->
<rect x="14" y="1" width="2" height="13" fill="#22c55e" opacity="0.75"/>
<rect x="16" y="3" width="2" height="11" fill="#4ade80" opacity="0.7"/>
<rect x="18" y="5" width="2" height="9" fill="#16a34a" opacity="0.65"/>
<rect x="13" y="0" width="2" height="2" fill="#86efac" opacity="0.5"/>
<!-- Orange fish school (3 fish) -->
<rect x="26" y="4" width="3" height="2" fill="#f97316" opacity="0.85"/>
<rect x="25" y="5" width="1" height="1" fill="#fb923c" opacity="0.7"/>
<rect x="30" y="6" width="3" height="2" fill="#f97316" opacity="0.75"/>
<rect x="29" y="7" width="1" height="1" fill="#fb923c" opacity="0.65"/>
<rect x="33" y="3" width="3" height="2" fill="#ea580c" opacity="0.7"/>
<rect x="32" y="4" width="1" height="1" fill="#fdba74" opacity="0.55"/>
<!-- Bubbles -->
<rect x="24" y="1" width="1" height="1" fill="white" opacity="0.4"/>
<rect x="22" y="3" width="1" height="1" fill="white" opacity="0.35"/>
<rect x="37" y="2" width="1" height="1" fill="white" opacity="0.35"/>
<!-- Purple brain coral -->
<rect x="42" y="7" width="4" height="5" fill="#a855f7" opacity="0.7"/>
<rect x="43" y="6" width="2" height="2" fill="#c084fc" opacity="0.65"/>
<rect x="46" y="8" width="2" height="4" fill="#7c3aed" opacity="0.55"/>
<rect x="44" y="12" width="2" height="2" fill="#6d28d9" opacity="0.4"/>
<!-- Jellyfish -->
<rect x="54" y="2" width="3" height="2" fill="#c084fc" opacity="0.65"/>
<rect x="55" y="1" width="1" height="1" fill="#e9d5ff" opacity="0.5"/>
<rect x="54" y="4" width="1" height="2" fill="#a855f7" opacity="0.4"/>
<rect x="56" y="4" width="1" height="2" fill="#a855f7" opacity="0.4"/>
<!-- Starfish -->
<rect x="62" y="10" width="3" height="2" fill="#fbbf24" opacity="0.65"/>
<rect x="63" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
<rect x="63" y="12" width="1" height="1" fill="#f59e0b" opacity="0.5"/>
<!-- Anemone -->
<rect x="70" y="6" width="2" height="8" fill="#ec4899" opacity="0.65"/>
<rect x="72" y="7" width="2" height="7" fill="#f472b6" opacity="0.55"/>
<rect x="69" y="5" width="2" height="2" fill="#f9a8d4" opacity="0.5"/>
<rect x="73" y="6" width="2" height="2" fill="#f9a8d4" opacity="0.5"/>
<!-- Blue fish -->
<rect x="80" y="5" width="3" height="2" fill="#3b82f6" opacity="0.75"/>
<rect x="79" y="6" width="1" height="1" fill="#93c5fd" opacity="0.65"/>
<!-- Seaweed tuft -->
<rect x="88" y="4" width="2" height="10" fill="#059669" opacity="0.6"/>
<rect x="90" y="6" width="2" height="8" fill="#10b981" opacity="0.5"/>
<!-- Small coral -->
<rect x="96" y="8" width="2" height="6" fill="#f87171" opacity="0.6"/>
<rect x="98" y="9" width="2" height="5" fill="#fb923c" opacity="0.5"/>
<!-- Seahorse -->
<rect x="106" y="4" width="2" height="2" fill="#fbbf24" opacity="0.65"/>
<rect x="106" y="6" width="2" height="3" fill="#f59e0b" opacity="0.55"/>
<rect x="107" y="9" width="1" height="2" fill="#d97706" opacity="0.5"/>
<!-- Bubbles -->
<rect x="104" y="1" width="1" height="1" fill="white" opacity="0.35"/>
<rect x="112" y="3" width="1" height="1" fill="white" opacity="0.3"/>
<!-- More coral -->
<rect x="118" y="7" width="2" height="7" fill="#0ea5e9" opacity="0.5"/>
<rect x="120" y="9" width="2" height="5" fill="#22d3ee" opacity="0.4"/>
<!-- Tiny fish -->
<rect x="130" y="6" width="2" height="1" fill="#f97316" opacity="0.55"/>
<rect x="140" y="4" width="2" height="1" fill="#818cf8" opacity="0.5"/>
<!-- Shell -->
<rect x="150" y="10" width="3" height="2" fill="#fde68a" opacity="0.5"/>
<rect x="151" y="9" width="1" height="1" fill="#fef3c7" opacity="0.4"/>
<!-- Fade-out elements -->
<rect x="160" y="8" width="2" height="6" fill="#22c55e" opacity="0.35"/>
<rect x="170" y="9" width="2" height="5" fill="#a855f7" opacity="0.3"/>
<rect x="180" y="7" width="1" height="1" fill="white" opacity="0.2"/>
<rect x="190" y="10" width="2" height="4" fill="#f87171" opacity="0.2"/>
</svg>
<!-- Center badge -->
<span v-if="duration" class="duration-badge">{{ duration }}</span>
<span v-else class="end-badge">~</span>
<!-- Right reef: anchored to right edge, extends left -->
<svg class="reef reef-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
<!-- Ocean stone floor (prismarine-inspired mosaic) -->
<defs>
<pattern id="pfr" x="0" y="0" width="12" height="2" patternUnits="userSpaceOnUse">
<rect width="12" height="2" fill="#0e3b3b"/>
<rect x="0" y="0" width="5" height="1" fill="#2a8a7e"/>
<rect x="6" y="0" width="5" height="1" fill="#1f7270"/>
<rect x="0" y="1" width="2" height="1" fill="#1f7270"/>
<rect x="3" y="1" width="5" height="1" fill="#35a098"/>
<rect x="9" y="1" width="2" height="1" fill="#1f7270"/>
</pattern>
</defs>
<rect x="0" y="12" width="200" height="2" fill="url(#pfr)" opacity="0.85"/>
<!-- Crystal highlights -->
<rect x="190" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.5"/>
<rect x="178" y="13" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="162" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="145" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="128" y="12" width="1" height="1" fill="#7edcd2" opacity="0.4"/>
<rect x="110" y="13" width="1" height="1" fill="#4ebdb2" opacity="0.45"/>
<rect x="92" y="12" width="1" height="1" fill="#5ec4b8" opacity="0.35"/>
<rect x="70" y="13" width="1" height="1" fill="#7edcd2" opacity="0.3"/>
<rect x="45" y="12" width="1" height="1" fill="#4ebdb2" opacity="0.25"/>
<rect x="22" y="13" width="1" height="1" fill="#5ec4b8" opacity="0.2"/>
<!-- Tall coral cluster (right edge) -->
<rect x="192" y="3" width="2" height="11" fill="#ec4899" opacity="0.85"/>
<rect x="194" y="5" width="2" height="9" fill="#f472b6" opacity="0.8"/>
<rect x="196" y="4" width="2" height="10" fill="#ec4899" opacity="0.75"/>
<rect x="190" y="6" width="2" height="8" fill="#db2777" opacity="0.65"/>
<rect x="193" y="1" width="2" height="3" fill="#fbcfe8" opacity="0.55"/>
<!-- Seaweed grove -->
<rect x="182" y="2" width="2" height="12" fill="#10b981" opacity="0.75"/>
<rect x="184" y="4" width="2" height="10" fill="#34d399" opacity="0.7"/>
<rect x="180" y="0" width="2" height="3" fill="#6ee7b7" opacity="0.5"/>
<!-- Purple fish school -->
<rect x="170" y="5" width="3" height="2" fill="#818cf8" opacity="0.85"/>
<rect x="173" y="6" width="1" height="1" fill="#a5b4fc" opacity="0.7"/>
<rect x="166" y="3" width="3" height="2" fill="#6366f1" opacity="0.75"/>
<rect x="169" y="4" width="1" height="1" fill="#c7d2fe" opacity="0.65"/>
<rect x="163" y="7" width="3" height="2" fill="#818cf8" opacity="0.7"/>
<rect x="166" y="8" width="1" height="1" fill="#a5b4fc" opacity="0.55"/>
<!-- Bubbles -->
<rect x="175" y="1" width="1" height="1" fill="white" opacity="0.4"/>
<rect x="178" y="3" width="1" height="1" fill="white" opacity="0.35"/>
<rect x="160" y="2" width="1" height="1" fill="white" opacity="0.35"/>
<!-- Orange fan coral -->
<rect x="152" y="6" width="4" height="6" fill="#fb923c" opacity="0.7"/>
<rect x="153" y="5" width="2" height="2" fill="#fdba74" opacity="0.65"/>
<rect x="150" y="8" width="2" height="4" fill="#ea580c" opacity="0.55"/>
<!-- Turtle -->
<rect x="140" y="4" width="4" height="2" fill="#22c55e" opacity="0.7"/>
<rect x="139" y="5" width="1" height="1" fill="#4ade80" opacity="0.55"/>
<rect x="144" y="5" width="1" height="1" fill="#4ade80" opacity="0.55"/>
<rect x="141" y="3" width="2" height="1" fill="#86efac" opacity="0.5"/>
<!-- Shell -->
<rect x="132" y="10" width="3" height="2" fill="#fde68a" opacity="0.55"/>
<rect x="133" y="9" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
<!-- Cyan coral -->
<rect x="124" y="7" width="2" height="7" fill="#0ea5e9" opacity="0.65"/>
<rect x="126" y="8" width="2" height="6" fill="#22d3ee" opacity="0.55"/>
<rect x="122" y="9" width="2" height="5" fill="#0284c7" opacity="0.5"/>
<!-- Red anemone -->
<rect x="112" y="6" width="2" height="8" fill="#f87171" opacity="0.6"/>
<rect x="114" y="7" width="2" height="7" fill="#fca5a5" opacity="0.5"/>
<rect x="111" y="5" width="2" height="2" fill="#fecaca" opacity="0.4"/>
<!-- Tiny fish -->
<rect x="104" y="5" width="2" height="1" fill="#f97316" opacity="0.55"/>
<!-- Seaweed -->
<rect x="96" y="5" width="2" height="9" fill="#059669" opacity="0.5"/>
<rect x="94" y="7" width="2" height="7" fill="#10b981" opacity="0.4"/>
<!-- Starfish -->
<rect x="86" y="10" width="3" height="2" fill="#fbbf24" opacity="0.5"/>
<rect x="87" y="9" width="1" height="1" fill="#fde68a" opacity="0.4"/>
<!-- Bubbles -->
<rect x="80" y="2" width="1" height="1" fill="white" opacity="0.3"/>
<rect x="72" y="4" width="1" height="1" fill="white" opacity="0.25"/>
<!-- Fade-out elements -->
<rect x="60" y="8" width="2" height="6" fill="#ec4899" opacity="0.3"/>
<rect x="46" y="9" width="2" height="5" fill="#22c55e" opacity="0.25"/>
<rect x="30" y="7" width="1" height="1" fill="white" opacity="0.2"/>
<rect x="14" y="10" width="2" height="4" fill="#0ea5e9" opacity="0.2"/>
</svg>
</div>
</div>
</template>
<style scoped>
.turn-end {
padding: 0.25rem 0;
user-select: none;
}
.reef-line {
display: flex;
align-items: center;
gap: 0;
height: 28px;
}
.reef {
flex: 1;
height: 100%;
image-rendering: pixelated;
overflow: hidden;
}
.duration-badge,
.end-badge {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(14, 165, 233, 0.85);
padding: 0 6px;
letter-spacing: 1px;
z-index: 1;
text-shadow: 0 0 8px rgba(14, 165, 233, 0.4);
animation: badge-glow 3s ease-in-out infinite;
}
.end-badge {
font-size: 12px;
color: rgba(14, 165, 233, 0.5);
animation: badge-drift 4s ease-in-out infinite;
}
@keyframes badge-glow {
0%, 100% {
text-shadow: 0 0 6px rgba(14, 165, 233, 0.3);
color: rgba(14, 165, 233, 0.8);
}
50% {
text-shadow: 0 0 12px rgba(14, 165, 233, 0.6), 0 0 4px rgba(34, 211, 238, 0.3);
color: rgba(14, 165, 233, 0.95);
}
}
@keyframes badge-drift {
0%, 100% {
opacity: 0.5;
}
50% {
opacity: 0.8;
}
}
</style>

View File

@@ -81,6 +81,7 @@ watch(() => props.voiceTranscript, (newText) => {
class="input-field" class="input-field"
:style="{ maxHeight: maxH }" :style="{ maxHeight: maxH }"
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'" :placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
rows="1"
:disabled="processing || notReady" :disabled="processing || notReady"
@keydown="handleKeydown" @keydown="handleKeydown"
/> />
@@ -181,7 +182,7 @@ watch(() => props.voiceTranscript, (newText) => {
field-sizing: content; field-sizing: content;
min-height: 1lh; min-height: 1lh;
overflow-y: auto; overflow-y: auto;
padding: 0 0.25rem; padding: 0.15rem 0.25rem;
font-family: inherit; font-family: inherit;
} }

View File

@@ -8,6 +8,7 @@ export { default as ToolCallBlock } from './ToolCallBlock.vue'
export { default as ToolResultBlock } from './ToolResultBlock.vue' export { default as ToolResultBlock } from './ToolResultBlock.vue'
export { default as ProgressEvent } from './ProgressEvent.vue' export { default as ProgressEvent } from './ProgressEvent.vue'
export { default as SystemMessage } from './SystemMessage.vue' export { default as SystemMessage } from './SystemMessage.vue'
export { default as TurnEndDivider } from './TurnEndDivider.vue'
export { default as UserInput } from './UserInput.vue' export { default as UserInput } from './UserInput.vue'
export { default as PermissionApproval } from './PermissionApproval.vue' export { default as PermissionApproval } from './PermissionApproval.vue'
export { default as PlanApproval } from './PlanApproval.vue' export { default as PlanApproval } from './PlanApproval.vue'
@@ -15,4 +16,5 @@ export { default as CodeBlock } from './CodeBlock.vue'
export { default as AgentBadge } from './AgentBadge.vue' export { default as AgentBadge } from './AgentBadge.vue'
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue' export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
export { default as VoiceMicButton } from './VoiceMicButton.vue' export { default as VoiceMicButton } from './VoiceMicButton.vue'
export { default as NewSessionModal } from './NewSessionModal.vue'
export { AquaticBackground } from './aquaticBackground' export { AquaticBackground } from './aquaticBackground'

View File

@@ -113,6 +113,7 @@ export function useTranscriptDebug() {
) )
const awaitingNewSession = ref(false) const awaitingNewSession = ref(false)
const pendingPrompt = ref<string | null>(null)
// ── Server registry HTTP helpers ── // ── Server registry HTTP helpers ──
@@ -324,7 +325,7 @@ export function useTranscriptDebug() {
activeTerminalSessionId.value = null activeTerminalSessionId.value = null
} }
async function createNewSession() { async function createNewSession(initialPrompt?: string) {
parkCurrentTerminal() parkCurrentTerminal()
selectedSessionId.value = null selectedSessionId.value = null
@@ -333,6 +334,7 @@ export function useTranscriptDebug() {
error.value = null error.value = null
processing.value = false processing.value = false
optimisticMessage.value = null optimisticMessage.value = null
pendingPrompt.value = initialPrompt?.trim() || null
awaitingNewSession.value = true awaitingNewSession.value = true
startTerminal() // no sessionId → brand new session startTerminal() // no sessionId → brand new session
@@ -421,6 +423,14 @@ export function useTranscriptDebug() {
selectedSessionId.value = changedSessionId selectedSessionId.value = changedSessionId
saveState() saveState()
await fetchSessionContent(changedSessionId) await fetchSessionContent(changedSessionId)
// Auto-send queued initial prompt
if (pendingPrompt.value) {
const prompt = pendingPrompt.value
pendingPrompt.value = null
// Small delay to let terminal fully settle
setTimeout(() => sendPrompt(prompt), 300)
}
return return
} }
@@ -900,6 +910,27 @@ export function useTranscriptDebug() {
if (entry.type === 'system') { if (entry.type === 'system') {
const se = entry as any const se = entry as any
const isTurnEnd = se.subtype === 'stop_hook_summary' || se.subtype === 'turn_duration'
if (isTurnEnd) {
// Merge consecutive turn-end messages into one divider
const prev = messages[messages.length - 1]
if (prev?.kind === 'system' && (prev as ParsedSystemMessage).subtype === 'turn_end') {
// Merge into existing turn_end
if (se.subtype === 'turn_duration' && se.durationMs != null) {
(prev as ParsedSystemMessage).durationMs = se.durationMs
}
} else {
messages.push({
kind: 'system',
uuid: se.uuid || crypto.randomUUID(),
timestamp: se.timestamp || '',
content: '',
subtype: 'turn_end',
durationMs: se.subtype === 'turn_duration' ? se.durationMs : undefined
} as ParsedSystemMessage)
}
} else {
const content = se.message?.content const content = se.message?.content
const textContent = typeof content === 'string' const textContent = typeof content === 'string'
? content ? content
@@ -916,6 +947,7 @@ export function useTranscriptDebug() {
} as ParsedSystemMessage) } as ParsedSystemMessage)
} }
} }
}
if (currentProgressBatch.length > 0) { if (currentProgressBatch.length > 0) {
messages.push({ messages.push({

View File

@@ -214,6 +214,7 @@ export interface ParsedSystemMessage {
timestamp: string timestamp: string
content: string content: string
subtype?: string subtype?: string
durationMs?: number
} }
// ── Terminal slot (persistent terminal registry) ── // ── Terminal slot (persistent terminal registry) ──