feat: AgentBadge component, force mobile mode, and transcript UX improvements

- Extract agent badge into AgentBadge component with dropdown (TODO placeholder)
- Realtime connection indicated by badge color (green=connected, indigo=disconnected)
- Remove Transcript label and chat bubble icon from titlebar
- Add force mobile mode button (bottom sheet panel on desktop)
- Size toggle persisted in localStorage and controls sheet height in mobile mode
- Replace full titlebar drag with thin 5px top edge grip
- Remove sheet-handle touch bar, size controlled via toggle button only
- Large mobile mode respects app header height
- Slide-up/down animation for mobile panel enter/exit
This commit is contained in:
2026-02-19 17:39:01 -06:00
parent eb69c0b2cf
commit ca315cf040
3 changed files with 261 additions and 171 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug'
import { ChatContainer, AquaticBackground } from '@/components/transcript-debug'
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
const props = defineProps<{
@@ -56,7 +56,7 @@ const isFocusWithin = ref(false)
const showChrome = computed(() =>
isHovered.value || isFocusWithin.value || isDragging.value ||
isResizing.value || showSelector.value || isDraggingSheet.value
isResizing.value || showSelector.value
)
// ============================================================================
@@ -78,21 +78,41 @@ const zoom = ref(1)
// Size mode: pin (small, anchored to FAB), medium (default), large
type SizeMode = 'pin' | 'medium' | 'large'
const sizeMode = ref<SizeMode>('medium')
const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null
const sizeMode = ref<SizeMode>(savedSize && ['pin', 'medium', 'large'].includes(savedSize) ? savedSize : 'medium')
// Force mobile (bottom sheet) mode on desktop
const forceMobile = ref(false)
const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
function toggleForceMobile() {
if (isMobile.value) return // already native mobile
forceMobile.value = !forceMobile.value
if (forceMobile.value) {
// Enter mobile: set sheet height based on current sizeMode
const mobileSnaps: Record<SizeMode, number> = { pin: 20, medium: 55, large: 100 }
sheetHeight.value = mobileSnaps[sizeMode.value]
hasCustomPosition.value = false
}
}
function cycleSizeMode() {
const modes: SizeMode[] = ['pin', 'medium', 'large']
const i = modes.indexOf(sizeMode.value)
sizeMode.value = modes[(i + 1) % modes.length]
localStorage.setItem('transcript-size-mode', sizeMode.value)
hasCustomPosition.value = false
// In mobile mode, also update sheet height
if (effectiveMobile.value) {
const mobileSnaps: Record<SizeMode, number> = { pin: 20, medium: 55, large: 100 }
sheetHeight.value = mobileSnaps[sizeMode.value]
}
}
// Mobile bottom sheet state
const isMobile = ref(false)
const sheetHeight = ref(55)
const isDraggingSheet = ref(false)
const sheetDragStart = ref({ y: 0, height: 0 })
const snapPoints = [20, 55, 85]
// ============================================================================
// MOBILE DETECTION
@@ -104,78 +124,29 @@ function checkMobile() {
isMobile.value = isTouchDevice && isSmallScreen
}
function findNearestSnap(height: number): number {
return snapPoints.reduce((prev, curr) =>
Math.abs(curr - height) < Math.abs(prev - height) ? curr : prev
)
}
// ============================================================================
// MOBILE SHEET DRAG
// ============================================================================
function startSheetDrag(e: TouchEvent) {
if (!isMobile.value) return
const touch = e.touches[0]
if (!touch) return
isDraggingSheet.value = true
sheetDragStart.value = { y: touch.clientY, height: sheetHeight.value }
}
function onSheetDrag(e: TouchEvent) {
if (!isDraggingSheet.value || !isMobile.value) return
const touch = e.touches[0]
if (!touch) return
const deltaY = sheetDragStart.value.y - touch.clientY
const deltaPercent = (deltaY / window.innerHeight) * 100
sheetHeight.value = Math.max(15, Math.min(90, sheetDragStart.value.height + deltaPercent))
}
function stopSheetDrag() {
if (!isDraggingSheet.value) return
isDraggingSheet.value = false
sheetHeight.value = findNearestSnap(sheetHeight.value)
}
// ============================================================================
// WINDOW DRAG
// ============================================================================
function startDrag(e: MouseEvent | TouchEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
if ((e.target as HTMLElement).closest('.header-selector')) return
if (isMobile.value) {
if (e instanceof TouchEvent) startSheetDrag(e)
return
}
function startDrag(e: MouseEvent) {
if (effectiveMobile.value) return
isDragging.value = true
const rect = windowRef.value?.getBoundingClientRect()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
function onDrag(e: MouseEvent | TouchEvent) {
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
if (e instanceof TouchEvent) e.preventDefault()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const w = windowRef.value?.offsetWidth || 480
const h = windowRef.value?.offsetHeight || 600
@@ -185,8 +156,8 @@ function onDrag(e: MouseEvent | TouchEvent) {
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(clientX - dragOffset.value.x, maxX)),
y: Math.max(minY, Math.min(clientY - dragOffset.value.y, maxY))
x: Math.max(minX, Math.min(e.clientX - dragOffset.value.x, maxX)),
y: Math.max(minY, Math.min(e.clientY - dragOffset.value.y, maxY))
}
}
@@ -195,8 +166,6 @@ function stopDrag() {
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// ============================================================================
@@ -239,16 +208,25 @@ const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
}
const windowStyle = computed((): Record<string, string> => {
if (isMobile.value) {
return {
if (effectiveMobile.value) {
const isFullScreen = sheetHeight.value >= 100
const style: Record<string, string> = {
position: 'fixed',
left: '0',
right: '0',
bottom: '0',
width: '100%',
height: `${sheetHeight.value}vh`,
maxHeight: '90vh'
}
if (isFullScreen) {
// Full height but respect app header
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const headerH = headerEl ? headerEl.offsetHeight : 40
style.top = `${headerH}px`
style.height = 'auto'
} else {
style.height = `${sheetHeight.value}dvh`
}
return style
}
const preset = sizeModePresets[sizeMode.value]
@@ -306,7 +284,7 @@ function close() {
}
function openAtCursor(x: number, y: number) {
if (isMobile.value) {
if (effectiveMobile.value) {
isOpen.value = !isOpen.value
return
}
@@ -399,8 +377,6 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', handleZoomKey)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
window.removeEventListener('resize', checkMobile)
@@ -417,8 +393,7 @@ onBeforeUnmount(() => {
:class="{
dragging: isDragging,
resizing: isResizing,
mobile: isMobile,
'sheet-dragging': isDraggingSheet,
mobile: effectiveMobile,
'chrome-visible': showChrome,
'selector-open': showSelector
}"
@@ -429,34 +404,33 @@ onBeforeUnmount(() => {
@focusout="isFocusWithin = false"
>
<div class="glass" :style="{ zoom: zoom !== 1 ? zoom : undefined }">
<!-- Mobile drag handle -->
<!-- Thin drag edge (replaces full titlebar drag) -->
<div
v-if="isMobile"
class="sheet-handle"
@touchstart.passive="startSheetDrag"
@touchmove.prevent="onSheetDrag"
@touchend="stopSheetDrag"
>
<div class="handle-bar"></div>
</div>
v-if="!effectiveMobile"
class="drag-edge"
@mousedown="startDrag"
></div>
<!-- Titlebar -->
<div
class="titlebar"
@mousedown="startDrag"
@touchstart.passive="startDrag"
@touchmove="onSheetDrag"
@touchend="stopSheetDrag"
>
<div class="titlebar">
<div class="left">
<svg class="title-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span class="title-label">Transcript</span>
<i class="dot" :class="{ on: isRealtime }"></i>
<span v-if="selectedAgent" class="agent-badge">{{ selectedAgent }}</span>
<AgentBadge v-if="selectedAgent" :agent="selectedAgent" :connected="isRealtime" />
</div>
<div class="window-controls">
<button
v-if="!isMobile"
@click.stop="toggleForceMobile"
class="mobile-btn"
:class="{ active: forceMobile }"
title="Toggle mobile panel"
>
<!-- Phone/panel-from-bottom icon -->
<svg width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="2" y="0" width="6" height="10" fill="currentColor" opacity="0.25"/>
<rect x="3" y="1" width="4" height="7" fill="currentColor"/>
<rect x="4" y="9" width="2" height="1" fill="currentColor" opacity="0.5"/>
</svg>
</button>
<button @click.stop="cycleSizeMode" class="size-btn" :title="`Size: ${sizeMode}`">
<!-- Pin icon: small square bottom-left -->
<svg v-if="sizeMode === 'pin'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
@@ -531,7 +505,7 @@ onBeforeUnmount(() => {
</div>
<!-- Resize handle -->
<div v-if="!isMobile" class="resize-handle" @mousedown="startResize"></div>
<div v-if="!effectiveMobile" class="resize-handle" @mousedown="startResize"></div>
</div>
</div>
</Transition>
@@ -615,6 +589,11 @@ onBeforeUnmount(() => {
transition: opacity 0.35s ease, max-height 0.35s ease, padding 0.35s ease;
}
/* Idle: hide drag edge */
.aero-win:not(.chrome-visible) .drag-edge {
pointer-events: none;
}
/* Idle: slide titlebar up and fade out */
.aero-win:not(.chrome-visible) .titlebar {
opacity: 0;
@@ -704,6 +683,25 @@ onBeforeUnmount(() => {
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
/* ── Drag edge: thin strip at top for window dragging ── */
.drag-edge {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
z-index: 11;
cursor: grab;
}
.drag-edge:active {
cursor: grabbing;
}
.aero-win.dragging .drag-edge {
cursor: grabbing;
}
/* ── Titlebar: absolute overlay at top of .glass ── */
.titlebar {
position: absolute;
@@ -723,13 +721,10 @@ onBeforeUnmount(() => {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.04);
cursor: grab;
user-select: none;
transition: opacity 0.35s ease, transform 0.35s ease;
}
.aero-win.dragging .titlebar { cursor: grabbing; }
.left {
display: flex;
align-items: center;
@@ -738,48 +733,6 @@ onBeforeUnmount(() => {
font: 500 10px/1 'Courier New', monospace;
}
.title-icon {
opacity: 0.4;
flex-shrink: 0;
color: rgba(255,255,255,0.4);
}
.title-label {
font-weight: 700;
font-size: 10px;
color: #818cf8;
letter-spacing: 1px;
text-transform: uppercase;
font-family: 'Courier New', monospace;
/* No gradient - pixel art aesthetic = flat color */
-webkit-text-fill-color: #818cf8;
}
.dot {
width: 4px;
height: 4px;
border-radius: 0;
background: #444;
}
.dot.on {
background: #22c55e;
box-shadow: 0 0 6px #22c55e;
}
.agent-badge {
font-size: 8px;
font-weight: 700;
font-family: 'Courier New', monospace;
padding: 1px 4px;
border-radius: 0;
border: 1px solid rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.window-controls {
display: flex;
gap: 2px;
@@ -810,6 +763,22 @@ onBeforeUnmount(() => {
color: #a5b4fc;
}
.window-controls .mobile-btn {
color: #a78bfa;
}
.window-controls .mobile-btn:hover {
color: #c4b5fd;
background: rgba(167, 139, 250, 0.15);
border-color: rgba(167, 139, 250, 0.25);
}
.window-controls .mobile-btn.active {
background: rgba(167, 139, 250, 0.2);
border-color: rgba(167, 139, 250, 0.3);
color: #c4b5fd;
}
.window-controls .size-btn {
color: #0ea5e9;
}
@@ -1181,40 +1150,11 @@ onBeforeUnmount(() => {
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.aero-win.mobile.sheet-dragging {
transition: none;
}
.aero-win.mobile .glass {
border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.sheet-handle {
display: flex;
justify-content: center;
align-items: center;
height: 24px;
cursor: grab;
touch-action: none;
background: rgba(255,255,255,0.02);
}
.sheet-handle:active {
cursor: grabbing;
}
.handle-bar {
width: 36px;
height: 4px;
background: rgba(255,255,255,0.12);
border-radius: 0;
}
.aero-win.mobile .titlebar {
cursor: grab;
touch-action: none;
}
.aero-win.mobile .resize-handle {
display: none;
@@ -1227,9 +1167,17 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.aero-win.mobile.win-slide-enter-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.aero-win.mobile.win-slide-leave-active {
transition: transform 0.25s cubic-bezier(0.4, 0, 1, 1) !important;
}
.aero-win.mobile.win-slide-enter-from,
.aero-win.mobile.win-slide-leave-to {
transform: translateY(100%);
opacity: 1;
transform: translateY(100%) !important;
opacity: 1 !important;
}
</style>