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"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 }}%
+