diff --git a/frontend/src/components/FloatingTranscriptDebug.vue b/frontend/src/components/FloatingTranscriptDebug.vue index 24f654e..e6fbe18 100644 --- a/frontend/src/components/FloatingTranscriptDebug.vue +++ b/frontend/src/components/FloatingTranscriptDebug.vue @@ -33,6 +33,7 @@ const { processing, ephemeral, terminalReady, + hookMeta, openTerminals, activeTerminalSessionId, init, @@ -161,6 +162,28 @@ function setInputMaxLines(val: number) { localStorage.setItem('transcript-input-max-lines', String(val)) } +// Scroll jump percent +const savedScrollJump = localStorage.getItem('transcript-scroll-jump') +const scrollJumpPercent = ref(savedScrollJump !== null ? parseInt(savedScrollJump) : 50) + +function setScrollJumpPercent(val: number) { + scrollJumpPercent.value = val + localStorage.setItem('transcript-scroll-jump', String(val)) +} + +function scrollJump(direction: 'up' | 'down') { + const el = (chatRef.value as any)?.$el?.querySelector('.messages-scroll') as HTMLElement | null + if (!el) return + const amount = el.clientHeight * (scrollJumpPercent.value / 100) + el.scrollBy({ top: direction === 'up' ? -amount : amount, behavior: 'smooth' }) +} + +function scrollToEdge(edge: 'top' | 'bottom') { + const el = (chatRef.value as any)?.$el?.querySelector('.messages-scroll') as HTMLElement | null + if (!el) return + el.scrollTo({ top: edge === 'top' ? 0 : el.scrollHeight, behavior: 'smooth' }) +} + // Force mobile (bottom sheet) mode on desktop const forceMobile = ref(false) const effectiveMobile = computed(() => isMobile.value || forceMobile.value) @@ -573,6 +596,8 @@ onBeforeUnmount(() => { :connected="isRealtime" :terminals="openTerminals" :active-session-id="activeTerminalSessionId" + :model="conversation?.model" + :version="conversation?.version" @switch-terminal="switchToTerminal" @close-terminal="closeTerminal" /> @@ -677,6 +702,8 @@ onBeforeUnmount(() => { :is-playing-audio="isPlayingAudio" :overlay-opacity="overlayOpacity" :input-max-lines="inputMaxLines" + :scroll-jump-percent="scrollJumpPercent" + :hook-permission-mode="hookMeta.permissionMode" @send="handleSend" @switch-agent="handleAgentSwitch" @select-session="handleSessionSelect" @@ -689,6 +716,7 @@ onBeforeUnmount(() => { @play-last-audio="voice.playLastAudio()" @update:overlay-opacity="setOverlayOpacity" @update:input-max-lines="setInputMaxLines" + @update:scroll-jump-percent="setScrollJumpPercent" />
@@ -699,6 +727,96 @@ onBeforeUnmount(() => {
+ + +
+ + + + + + + + +
+
+
@@ -1086,7 +1204,11 @@ onBeforeUnmount(() => { padding-top: 5rem !important; padding-bottom: 5rem !important; flex: 1 !important; - scrollbar-gutter: stable !important; + scrollbar-width: none !important; +} + +.content :deep(.messages-scroll)::-webkit-scrollbar { + display: none !important; } .content :deep(.meta-badge) { @@ -1133,38 +1255,6 @@ onBeforeUnmount(() => { color: #c7d2fe; } -/* Pixel art scrollbar */ -.content :deep(.messages-scroll)::-webkit-scrollbar { - width: 8px; - transition: opacity 0.35s ease; -} - -/* Idle: hide scrollbar visuals (gutter stays via scrollbar-gutter: stable) */ -.aero-win:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-track { - background: transparent !important; -} - -.aero-win:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-thumb { - background: transparent !important; - border-color: transparent !important; -} - -.content :deep(.messages-scroll)::-webkit-scrollbar-track { - background: - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='8' fill='%230c2d4a' opacity='0.4'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23075985' opacity='0.15'/%3E%3Crect x='6' y='6' width='2' height='2' fill='%23075985' opacity='0.1'/%3E%3C/svg%3E") repeat; -} - -.content :deep(.messages-scroll)::-webkit-scrollbar-thumb { - background: - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.3'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.15'/%3E%3C/svg%3E") repeat; - border: 1px solid rgba(14, 165, 233, 0.15); -} - -.content :deep(.messages-scroll)::-webkit-scrollbar-thumb:hover { - background: - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.45'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.25'/%3E%3C/svg%3E") repeat; - border-color: rgba(14, 165, 233, 0.3); -} /* Status bar: absolute overlay at very bottom */ .content :deep(.status-bar) { @@ -1674,4 +1764,64 @@ onBeforeUnmount(() => { transform: translateY(100%) !important; opacity: 1 !important; } + +/* ── Scroll arrows (aquatic pixel art) ── */ +.scroll-arrows { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 4px; + z-index: 5; + pointer-events: auto; +} + +.scroll-arrow { + display: flex; + align-items: center; + justify-content: center; + min-width: 30px; + min-height: 24px; + border: 1px solid rgba(14, 165, 233, 0.12); + background: rgba(0, 10, 30, 0.45); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + cursor: pointer; + padding: 0; + transition: all 0.2s ease; +} + +.scroll-arrow:hover { + background: rgba(14, 165, 233, 0.08); + border-color: rgba(14, 165, 233, 0.3); + transform: scale(1.1); +} + +.scroll-arrow:active { + transform: scale(0.92); +} + +.scroll-arrow.sa-surface svg { animation: sa-float 3s ease-in-out infinite; } +.scroll-arrow.sa-up svg { animation: sa-bob-up 2.5s ease-in-out infinite; } +.scroll-arrow.sa-down svg { animation: sa-bob-down 2.5s ease-in-out infinite; } +.scroll-arrow.sa-seabed svg { animation: sa-sway 4s ease-in-out infinite; } + +@keyframes sa-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1px); } } +@keyframes sa-bob-up { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } } +@keyframes sa-bob-down { 0%,100% { transform: translateY(0); } 50% { transform: translateY(1.5px); } } +@keyframes sa-sway { 0%,100% { transform: translateX(0); } 50% { transform: translateX(0.5px); } } + +/* Transition for scroll arrows appear/disappear */ +.scroll-arrows-enter-active, +.scroll-arrows-leave-active { + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.scroll-arrows-enter-from, +.scroll-arrows-leave-to { + opacity: 0; + transform: translateY(-50%) translateX(8px); +} diff --git a/frontend/src/components/transcript-debug/ChatContainer.vue b/frontend/src/components/transcript-debug/ChatContainer.vue index fcfc19c..54723b9 100644 --- a/frontend/src/components/transcript-debug/ChatContainer.vue +++ b/frontend/src/components/transcript-debug/ChatContainer.vue @@ -6,7 +6,8 @@ import type { ParsedAssistantMessage, ParsedSystemMessage, ConversationMessage, - AgentName + AgentName, + SectionSummary } from '@/types/transcript-debug' import type { EphemeralTerminal } from '@/composables/useEphemeralTerminal' import UserMessageBubble from './UserMessageBubble.vue' @@ -38,8 +39,20 @@ const props = defineProps<{ isPlayingAudio?: boolean overlayOpacity?: number inputMaxLines?: number + scrollJumpPercent?: number + hookPermissionMode?: string }>() +// ── Derived display values ── +const permissionMode = computed(() => props.hookPermissionMode || '') +const fullCwd = computed(() => props.conversation.metadata.cwd || '') +const displayCwd = computed(() => { + const cwd = fullCwd.value + if (!cwd) return '' + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : cwd +}) + const emit = defineEmits<{ send: [message: string] switchAgent: [agent: AgentName] @@ -53,6 +66,7 @@ const emit = defineEmits<{ playLastAudio: [] 'update:overlayOpacity': [value: number] 'update:inputMaxLines': [value: number] + 'update:scrollJumpPercent': [value: number] }>() const scrollContainer = ref(null) @@ -190,18 +204,71 @@ const sectionMap = computed(() => { return map }) -// Count of non-leader messages per section -const sectionCounts = computed(() => { - const counts = new Map() +// Rich summary of non-leader messages per section +const sectionSummaries = computed(() => { + const summaries = new Map() let currentUserUuid: string | null = null + + function getOrCreate(uuid: string): SectionSummary { + if (!summaries.has(uuid)) { + summaries.set(uuid, { + total: 0, assistantCount: 0, systemCount: 0, progressCount: 0, + toolNames: [], hasErrors: false, errorCount: 0, + inputTokens: 0, outputTokens: 0 + }) + } + return summaries.get(uuid)! + } + + const toolSets = new Map>() + for (const msg of props.conversation.messages) { if (msg.kind === 'user' && !isSpecialUserMessage(msg)) { currentUserUuid = msg.uuid - if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0) + getOrCreate(currentUserUuid) + if (!toolSets.has(currentUserUuid)) toolSets.set(currentUserUuid, new Set()) } else if (currentUserUuid) { - counts.set(currentUserUuid, (counts.get(currentUserUuid) || 0) + 1) + const s = getOrCreate(currentUserUuid) + const ts = toolSets.get(currentUserUuid)! + s.total++ + + if (msg.kind === 'assistant') { + const a = msg as ParsedAssistantMessage + s.assistantCount++ + if (a.usage) { + s.inputTokens += a.usage.input_tokens || 0 + s.outputTokens += a.usage.output_tokens || 0 + } + for (const tc of a.toolCalls) { + ts.add(tc.name) + if (tc.result?.isError) { + s.hasErrors = true + s.errorCount++ + } + } + } else if (msg.kind === 'system') { + s.systemCount++ + } else if (msg.kind === 'progress') { + s.progressCount++ + } } } + + // Flatten tool sets into sorted arrays + for (const [uuid, ts] of toolSets) { + const s = summaries.get(uuid) + if (s) s.toolNames = [...ts].sort() + } + + return summaries +}) + +// Keep simple counts for v-if checks +const sectionCounts = computed(() => { + const counts = new Map() + for (const [uuid, s] of sectionSummaries.value) { + counts.set(uuid, s.total) + } return counts }) @@ -226,6 +293,8 @@ const userUuids = computed(() => .map(m => m.uuid) ) +const autoCollapseEnabled = ref(false) + const allCollapsed = computed(() => { const uuids = userUuids.value if (uuids.length <= 1) return false @@ -238,11 +307,20 @@ function collapseAllExceptLast() { if (uuids.length <= 1) return if (allCollapsed.value) { collapsedSections.value = new Set() + autoCollapseEnabled.value = false } else { collapsedSections.value = new Set(uuids.slice(0, -1)) + autoCollapseEnabled.value = true } } +// Auto-collapse: when enabled, collapse new sections as they appear +watch(userUuids, (newUuids) => { + if (!autoCollapseEnabled.value) return + if (newUuids.length <= 1) return + collapsedSections.value = new Set(newUuids.slice(0, -1)) +}) + defineExpose({ selectMode, toggleSelectMode, allCollapsed, collapseAllExceptLast }) // Track messages that just resolved from optimistic → real @@ -428,6 +506,19 @@ function formatDuration(start: string, end: string): string { /> {{ inputMaxLines ?? 6 }} +
+ + + {{ scrollJumpPercent ?? 50 }}% +