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:
2026-02-24 12:07:02 -06:00
parent 509ec1847b
commit a92e4ffbda
3 changed files with 84 additions and 69 deletions

View File

@@ -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>

View File

@@ -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);

View File

@@ -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) {