fix: smooth message transitions, reliable scroll-to-bottom, permission mode icons
- Replace scrollIntoView with scrollTop=scrollHeight (fixes scroll jumping up
on WS updates due to content-visibility: auto miscalculating positions)
- Watch messages array reference instead of .length to catch intermediate
updates (tool calls resolving, content appended to existing messages)
- Add TransitionGroup for message list (outer) and assistant bubble content
(inner) with non-scoped CSS to ensure classes reach child components
- Override content-visibility during transitions to prevent skipped frames
- Replace permission mode text badge with Unicode symbols matching Claude Code
CLI: ⏵⏵ acceptEdits, ⏸ plan, ⚡ bypass, ⊘ dontAsk
- Remove lifecycle ribbon and agent status icon from bottom bar
- Use x-file-size header for knownByteSize instead of TextEncoder
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, TransitionGroup } from 'vue'
|
||||||
import type { ParsedAssistantMessage } from '@/types/transcript-debug'
|
import type { ParsedAssistantMessage } from '@/types/transcript-debug'
|
||||||
import ThinkingBlock from './ThinkingBlock.vue'
|
import ThinkingBlock from './ThinkingBlock.vue'
|
||||||
import ToolCallBlock from './ToolCallBlock.vue'
|
import ToolCallBlock from './ToolCallBlock.vue'
|
||||||
@@ -59,8 +59,10 @@ function formatTokens(n?: number): string {
|
|||||||
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- All dynamic content in one TransitionGroup (fragment mode, no wrapper) -->
|
||||||
|
<TransitionGroup name="inner">
|
||||||
<!-- Streaming/thinking animation -->
|
<!-- Streaming/thinking animation -->
|
||||||
<div v-if="isStreaming" class="thinking-animation">
|
<div v-if="isStreaming" key="streaming" class="thinking-animation">
|
||||||
<div class="thinking-dots">
|
<div class="thinking-dots">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
@@ -83,8 +85,8 @@ function formatTokens(n?: number): string {
|
|||||||
:content="text"
|
:content="text"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tool calls -->
|
<!-- Tool calls (wrapped in div for TransitionGroup keying) -->
|
||||||
<template v-for="tc in message.toolCalls" :key="tc.id">
|
<div v-for="tc in message.toolCalls" :key="tc.id">
|
||||||
<AskUserQuestionCard v-if="tc.name === 'AskUserQuestion'" :call="tc" />
|
<AskUserQuestionCard v-if="tc.name === 'AskUserQuestion'" :call="tc" />
|
||||||
<ExitPlanModeCard v-else-if="tc.name === 'ExitPlanMode'" :call="tc" />
|
<ExitPlanModeCard v-else-if="tc.name === 'ExitPlanMode'" :call="tc" />
|
||||||
<EnterPlanModeCard v-else-if="tc.name === 'EnterPlanMode'" :call="tc" />
|
<EnterPlanModeCard v-else-if="tc.name === 'EnterPlanMode'" :call="tc" />
|
||||||
@@ -96,7 +98,8 @@ function formatTokens(n?: number): string {
|
|||||||
<GlobCard v-else-if="tc.name === 'Glob'" :call="tc" />
|
<GlobCard v-else-if="tc.name === 'Glob'" :call="tc" />
|
||||||
<TaskCard v-else-if="TASK_TOOLS.has(tc.name)" :call="tc" />
|
<TaskCard v-else-if="TASK_TOOLS.has(tc.name)" :call="tc" />
|
||||||
<ToolCallBlock v-else :call="tc" />
|
<ToolCallBlock v-else :call="tc" />
|
||||||
</template>
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -213,4 +216,26 @@ function formatTokens(n?: number): string {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Non-scoped: TransitionGroup classes must reach child component roots -->
|
||||||
|
<style>
|
||||||
|
.inner-enter-active {
|
||||||
|
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-leave-active {
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -116,18 +116,19 @@ const hookHistory = computed(() => {
|
|||||||
// ── Derived display values ──
|
// ── Derived display values ──
|
||||||
const permissionMode = computed(() => props.hookPermissionMode || '')
|
const permissionMode = computed(() => props.hookPermissionMode || '')
|
||||||
|
|
||||||
const PERMISSION_ICONS: Record<string, { icon: string; color: string; label: string; filled?: boolean }> = {
|
// Permission mode display — matches Claude Code CLI indicators
|
||||||
default: { icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', color: '#6b7280', label: 'Default — asks before risky actions' },
|
const PERMISSION_DISPLAY: Record<string, { symbol: string; color: string; label: string; tight?: boolean }> = {
|
||||||
acceptEdits: { icon: 'M17 3l4 4L7 21H3v-4z', color: '#4ade80', label: 'Accept Edits — auto-approves file changes' },
|
default: { symbol: '●', color: '#6b7280', label: 'Default' },
|
||||||
plan: { icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8zm11-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6z', color: '#60a5fa', label: 'Plan — read-only, proposes before acting' },
|
acceptEdits: { symbol: '⏵⏵', color: '#4ade80', label: 'Accept edits on', tight: true },
|
||||||
bypassPermissions: { icon: 'M13 2L3 14h9l-1 8 10-12h-9z', color: '#f87171', label: 'Bypass — auto-approves everything', filled: true },
|
plan: { symbol: '⏸', color: '#60a5fa', label: 'Plan mode on' },
|
||||||
dontAsk: { icon: 'M18 6L6 18M6 6l12 12', color: '#fb923c', label: 'Don\'t Ask — denies unless pre-approved' },
|
bypassPermissions: { symbol: '⚡', color: '#ff6eb4', label: 'Bypass permissions' },
|
||||||
|
dontAsk: { symbol: '⊘', color: '#fb923c', label: 'Don\'t ask' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionIcon = computed(() => {
|
const permissionDisplay = computed(() => {
|
||||||
const mode = permissionMode.value
|
const mode = permissionMode.value
|
||||||
if (!mode) return null
|
if (!mode) return null
|
||||||
return PERMISSION_ICONS[mode] || { icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', color: '#94a3b8', label: mode }
|
return PERMISSION_DISPLAY[mode] || { symbol: '?', color: '#94a3b8', label: mode }
|
||||||
})
|
})
|
||||||
const fullCwd = computed(() => props.conversation.metadata.cwd || '')
|
const fullCwd = computed(() => props.conversation.metadata.cwd || '')
|
||||||
const displayCwd = computed(() => {
|
const displayCwd = computed(() => {
|
||||||
@@ -734,12 +735,6 @@ function formatDuration(start: string, end: string): string {
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="bottom-overlay">
|
<div class="bottom-overlay">
|
||||||
<SessionLifecycleStatus
|
|
||||||
:current-event="lifecycleEvent"
|
|
||||||
:event-detail="lifecycleDetail"
|
|
||||||
:hook-history="hookHistory"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UserInput
|
<UserInput
|
||||||
:terminal-ready="props.terminalReady"
|
:terminal-ready="props.terminalReady"
|
||||||
:voice-transcript="voiceTranscript"
|
:voice-transcript="voiceTranscript"
|
||||||
@@ -753,21 +748,9 @@ function formatDuration(start: string, end: string): string {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<span v-if="agentStatus" class="agent-status-icon" :class="{ processing: agentStatus.processing }">
|
<span v-if="permissionDisplay" class="perm-icon-wrap" :style="{ color: permissionDisplay.color }">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24"
|
<span class="perm-symbol" :class="{ tight: permissionDisplay.tight }">{{ permissionDisplay.symbol }}</span>
|
||||||
:fill="agentStatus.filled ? agentStatus.color : 'none'"
|
<span class="perm-tooltip">{{ permissionDisplay.label }}</span>
|
||||||
:stroke="agentStatus.filled ? 'none' : agentStatus.color"
|
|
||||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
><path :d="agentStatus.icon" /></svg>
|
|
||||||
<span class="agent-status-tooltip">{{ agentStatus.tooltip }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-if="permissionIcon" class="perm-icon-wrap">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24"
|
|
||||||
:fill="permissionIcon.filled ? permissionIcon.color : 'none'"
|
|
||||||
:stroke="permissionIcon.filled ? 'none' : permissionIcon.color"
|
|
||||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
><path :d="permissionIcon.icon" /></svg>
|
|
||||||
<span class="perm-tooltip">{{ permissionIcon.label }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-if="displayCwd" class="meta-badge origin" tabindex="0">{{ fullCwd }}</span>
|
<span v-if="displayCwd" class="meta-badge origin" tabindex="0">{{ fullCwd }}</span>
|
||||||
<span class="meta-count">{{ conversation.messages.length }} msgs</span>
|
<span class="meta-count">{{ conversation.messages.length }} msgs</span>
|
||||||
@@ -958,18 +941,25 @@ function formatDuration(start: string, end: string): string {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Permission mode icon ── */
|
/* ── Permission mode symbol ── */
|
||||||
.perm-icon-wrap {
|
.perm-icon-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.perm-symbol {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-symbol.tight {
|
||||||
|
letter-spacing: -0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
.perm-tooltip {
|
.perm-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 5px);
|
bottom: calc(100% + 5px);
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ export function useTranscriptDebug() {
|
|||||||
const res = await apiFetch(`/api/transcript-debug/${selectedSessionId.value}/raw?agent=${selectedAgent.value}`)
|
const res = await apiFetch(`/api/transcript-debug/${selectedSessionId.value}/raw?agent=${selectedAgent.value}`)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
rawContent.value = await res.text()
|
rawContent.value = await res.text()
|
||||||
knownByteSize = new TextEncoder().encode(rawContent.value).length
|
knownByteSize = parseInt(res.headers.get('x-file-size') || res.headers.get('content-length') || '0', 10)
|
||||||
const parsed = parseJsonl(rawContent.value, selectedSessionId.value)
|
const parsed = parseJsonl(rawContent.value, selectedSessionId.value)
|
||||||
applyOptimisticState(parsed)
|
applyOptimisticState(parsed)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -623,7 +623,7 @@ export function useTranscriptDebug() {
|
|||||||
const res = await apiFetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
|
const res = await apiFetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
rawContent.value = await res.text()
|
rawContent.value = await res.text()
|
||||||
knownByteSize = new TextEncoder().encode(rawContent.value).length
|
knownByteSize = parseInt(res.headers.get('x-file-size') || res.headers.get('content-length') || '0', 10)
|
||||||
conversation.value = parseJsonl(rawContent.value, sessionId)
|
conversation.value = parseJsonl(rawContent.value, sessionId)
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user