feat: add new session FAB button to terminal stack, always show FABs

- Add "+" FAB in TerminalFabStack with create-session emit
- Expose handleCreateSession from FloatingTranscriptDebug
- Remove !showTranscriptDebug condition so FABs stay visible when panel is open
- Wire handleFabCreateSession in App.vue to open panel + show modal
This commit is contained in:
2026-02-20 21:35:31 -06:00
parent 9945be07b1
commit 653c4e6d23
3 changed files with 272 additions and 30 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
import Toolbar from './components/Toolbar.vue' import Toolbar from './components/Toolbar.vue'
import TorchButton from './components/TorchButton.vue' import TorchButton from './components/TorchButton.vue'
@@ -7,16 +7,19 @@ import FloatingResponse from './components/FloatingResponse.vue'
import { initWhisperSocket } from './services/whisperSocket' import { initWhisperSocket } from './services/whisperSocket'
import FloatingVoice from './components/FloatingVoice.vue' import FloatingVoice from './components/FloatingVoice.vue'
import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue' import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue'
import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue' import HooksApprovalModal from './components/HooksApprovalModal.vue'
import { useGlobalApproval } from './composables/useGlobalApproval' import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp' import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch' import { initTorch, destroyTorch } from './services/torch'
import { initSessionStateWS, destroySessionStateWS } from './services/session-state-ws'
import { endpoints } from './config/endpoints' import { endpoints } from './config/endpoints'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry' import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
import { setResponseControls } from './services/tools/handlers/responseHandlers' import { setResponseControls } from './services/tools/handlers/responseHandlers'
import { useCanvasStore } from './stores/canvas' import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas' import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -70,6 +73,7 @@ const transcriptDebugRef = ref<InstanceType<typeof FloatingTranscriptDebug> | nu
const mousePos = ref({ x: 0, y: 0 }) const mousePos = ref({ x: 0, y: 0 })
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore() const projectCanvasStore = useProjectCanvasStore()
const sessionState = useSessionState()
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval() const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
// Voice FAB push-to-talk state // Voice FAB push-to-talk state
const voicePTTActive = ref(false) const voicePTTActive = ref(false)
@@ -78,6 +82,22 @@ let voicePTTTimeout: number | null = null
const keyboardVisible = ref(false) // Virtual keyboard visible const keyboardVisible = ref(false) // Virtual keyboard visible
// Extra terminals (T2-T5) from Pinia store — fully reactive, no template ref dependency
const extraTerminals = computed(() => {
const reg = sessionState.terminalRegistry
if (reg.length <= 1) return []
return reg.slice(1).map(entry => ({
sessionId: entry.transcriptSessionId,
ephemeralSessionId: entry.ephemeralSessionId,
agent: entry.agent,
label: entry.label,
command: entry.command,
active: entry.transcriptSessionId === transcriptDebugRef.value?.activeTerminalSessionId,
alive: entry.alive,
clients: entry.clients
}))
})
function hardRefresh() { function hardRefresh() {
location.reload() location.reload()
} }
@@ -86,6 +106,27 @@ function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY } mousePos.value = { x: e.clientX, y: e.clientY }
} }
function handleFabTerminalSelect(sessionId: string) {
if (transcriptDebugRef.value) {
transcriptDebugRef.value.switchToTerminal(sessionId)
}
showTranscriptDebug.value = true
}
function handleFabCreateSession() {
showTranscriptDebug.value = true
nextTick(() => {
transcriptDebugRef.value?.handleCreateSession()
})
}
function handleMainFabClick() {
if (transcriptDebugRef.value?.openTerminals?.length) {
transcriptDebugRef.value.switchToTerminal(transcriptDebugRef.value.openTerminals[0].sessionId)
}
showTranscriptDebug.value = !showTranscriptDebug.value
}
function handleGlobalKeydown(e: KeyboardEvent) { function handleGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') { if (e.ctrlKey && e.key === 'e') {
e.preventDefault() e.preventDefault()
@@ -153,6 +194,9 @@ onMounted(async () => {
connectApproval() connectApproval()
fetchApprovalPending() fetchApprovalPending()
// Connect centralized session state WS
initSessionStateWS()
// Initialize Whisper WebSocket connection early // Initialize Whisper WebSocket connection early
initWhisperSocket() initWhisperSocket()
@@ -213,6 +257,7 @@ onUnmounted(() => {
document.removeEventListener('keydown', handleGlobalKeydown) document.removeEventListener('keydown', handleGlobalKeydown)
destroyTorch() destroyTorch()
disconnectApproval() disconnectApproval()
destroySessionStateWS()
}) })
// Watch for route changes and update tools // Watch for route changes and update tools
@@ -289,30 +334,38 @@ watch(() => route.name, (newPage) => {
<!-- Transcript Debug FAB Button (pixel art ocean) --> <!-- Transcript Debug FAB Button (pixel art ocean) -->
<div class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent> <div class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
<span class="fab-bubble b1"></span> <TerminalFabStack
<span class="fab-bubble b2"></span> :terminals="extraTerminals"
<span class="fab-bubble b3"></span> :active-session-id="transcriptDebugRef?.activeTerminalSessionId ?? null"
<button @select="handleFabTerminalSelect"
class="transcript-fab" @create-session="handleFabCreateSession"
:class="{ active: showTranscriptDebug }" />
@click="showTranscriptDebug = !showTranscriptDebug" <div class="fab-button-area">
@contextmenu.prevent <span class="fab-bubble b1"></span>
title="Transcript Debug" <span class="fab-bubble b2"></span>
> <span class="fab-bubble b3"></span>
<!-- Pixel art chat bubble icon --> <button
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" shape-rendering="crispEdges"> class="transcript-fab"
<rect x="4" y="2" width="12" height="2" fill="currentColor"/> :class="{ active: showTranscriptDebug }"
<rect x="2" y="4" width="2" height="8" fill="currentColor"/> @click="handleMainFabClick"
<rect x="16" y="4" width="2" height="8" fill="currentColor"/> @contextmenu.prevent
<rect x="4" y="12" width="12" height="2" fill="currentColor"/> title="Transcript Debug"
<rect x="4" y="14" width="2" height="2" fill="currentColor"/> >
<rect x="2" y="16" width="2" height="2" fill="currentColor"/> <!-- Pixel art chat bubble icon -->
<!-- Dots inside --> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" shape-rendering="crispEdges">
<rect x="6" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/> <rect x="4" y="2" width="12" height="2" fill="currentColor"/>
<rect x="9" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/> <rect x="2" y="4" width="2" height="8" fill="currentColor"/>
<rect x="12" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/> <rect x="16" y="4" width="2" height="8" fill="currentColor"/>
</svg> <rect x="4" y="12" width="12" height="2" fill="currentColor"/>
</button> <rect x="4" y="14" width="2" height="2" fill="currentColor"/>
<rect x="2" y="16" width="2" height="2" fill="currentColor"/>
<!-- Dots inside -->
<rect x="6" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
<rect x="9" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
<rect x="12" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
</svg>
</button>
</div>
</div> </div>
<!-- Voice FAB Button --> <!-- Voice FAB Button -->
@@ -738,18 +791,20 @@ watch(() => route.name, (newPage) => {
50% { box-shadow: 0 0 50px rgba(249, 115, 22, 0.9); } 50% { box-shadow: 0 0 50px rgba(249, 115, 22, 0.9); }
} }
/* Transcript Debug FAB wrapper — holds button + bubbles */ /* Transcript Debug FAB wrapper — holds button + terminal stack */
.transcript-fab-wrap { .transcript-fab-wrap {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
width: 44px; z-index: 10000;
height: 44px;
z-index: 9998;
pointer-events: none; pointer-events: none;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
-webkit-touch-callout: none; -webkit-touch-callout: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
} }
/* Transcript Debug FAB — pixel art night ocean, elevated */ /* Transcript Debug FAB — pixel art night ocean, elevated */
@@ -812,6 +867,12 @@ watch(() => route.name, (newPage) => {
color: #a5f3fc; color: #a5f3fc;
} }
.fab-button-area {
position: relative;
width: 44px;
height: 44px;
}
/* Bubble particles — very occasional */ /* Bubble particles — very occasional */
.fab-bubble { .fab-bubble {
position: absolute; position: absolute;
@@ -881,6 +942,9 @@ watch(() => route.name, (newPage) => {
.transcript-fab-wrap { .transcript-fab-wrap {
bottom: 80px; bottom: 80px;
right: 16px; right: 16px;
}
.transcript-fab-wrap .fab-button-area {
width: 40px; width: 40px;
height: 40px; height: 40px;
} }

View File

@@ -435,7 +435,7 @@ function openAtCursor(x: number, y: number) {
}) })
} }
defineExpose({ openAtCursor, openTerminals, activeTerminalSessionId, switchToTerminal }) defineExpose({ openAtCursor, openTerminals, activeTerminalSessionId, switchToTerminal, handleCreateSession })
function handleAgentSwitch(agent: AgentName) { function handleAgentSwitch(agent: AgentName) {
switchAgent(agent) switchAgent(agent)

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import type { TerminalSlot } from '@/types/transcript-debug'
defineProps<{
terminals: TerminalSlot[]
activeSessionId: string | null
}>()
const emit = defineEmits<{
select: [sessionId: string]
'create-session': []
}>()
function stateColor(t: TerminalSlot): string {
if (!t.alive) return '#ef4444'
if (t.clients > 0) return '#22c55e'
return '#f59e0b'
}
// Pixel art SVG backgrounds for terminals 2-5
const artVariants = [
// T2: Coral reef — orange/pink corals, warm deep water
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%230c1a2e'/%3E%3Crect y='6' width='44' height='4' fill='%230e2040'/%3E%3Crect y='10' width='44' height='4' fill='%23122848'/%3E%3Crect y='14' width='44' height='4' fill='%23152e50'/%3E%3Crect y='18' width='44' height='4' fill='%23183458'/%3E%3Crect y='22' width='44' height='4' fill='%231a3860'/%3E%3Crect y='26' width='44' height='4' fill='%231c3c68'/%3E%3Crect y='30' width='44' height='4' fill='%231e3e6a'/%3E%3Crect y='34' width='44' height='10' fill='%232a1e10'/%3E%3Crect y='38' width='44' height='6' fill='%23342414'/%3E%3Crect x='6' y='26' width='3' height='8' fill='%23e85d2a' opacity='0.8'/%3E%3Crect x='5' y='24' width='2' height='3' fill='%23f07030' opacity='0.7'/%3E%3Crect x='8' y='23' width='2' height='4' fill='%23f07030' opacity='0.65'/%3E%3Crect x='4' y='22' width='1' height='3' fill='%23ff8844' opacity='0.5'/%3E%3Crect x='10' y='25' width='1' height='2' fill='%23ff8844' opacity='0.45'/%3E%3Crect x='18' y='28' width='4' height='6' fill='%23d4467a' opacity='0.8'/%3E%3Crect x='17' y='26' width='2' height='3' fill='%23e0558a' opacity='0.65'/%3E%3Crect x='21' y='25' width='2' height='4' fill='%23e0558a' opacity='0.6'/%3E%3Crect x='19' y='24' width='1' height='3' fill='%23f06898' opacity='0.45'/%3E%3Crect x='30' y='27' width='3' height='7' fill='%23c83030' opacity='0.75'/%3E%3Crect x='29' y='25' width='2' height='3' fill='%23d84040' opacity='0.6'/%3E%3Crect x='32' y='24' width='2' height='4' fill='%23d84040' opacity='0.55'/%3E%3Crect x='34' y='26' width='1' height='2' fill='%23e85050' opacity='0.45'/%3E%3Crect x='38' y='30' width='2' height='4' fill='%23e8752a' opacity='0.5'/%3E%3Crect x='37' y='28' width='1' height='3' fill='%23f08838' opacity='0.4'/%3E%3Crect x='12' y='16' width='1' height='1' fill='%2388ccff' opacity='0.3'/%3E%3Crect x='25' y='12' width='1' height='1' fill='%2388ccff' opacity='0.25'/%3E%3Crect x='36' y='8' width='1' height='1' fill='%2388ccff' opacity='0.2'/%3E%3Crect x='14' y='14' width='3' height='1' fill='%23ffaa44' opacity='0.4'/%3E%3Crect x='13' y='15' width='1' height='1' fill='%23ffaa44' opacity='0.3'/%3E%3Crect x='8' y='36' width='2' height='1' fill='%234a3820' opacity='0.4'/%3E%3Crect x='24' y='38' width='3' height='1' fill='%234a3820' opacity='0.3'/%3E%3Crect x='15' y='40' width='2' height='1' fill='%23c8a860' opacity='0.15'/%3E%3Crect x='36' y='41' width='2' height='1' fill='%23c8a860' opacity='0.12'/%3E%3C/svg%3E")`,
// T3: Deep sea — very dark, bioluminescent green/cyan dots
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='44' fill='%23020810'/%3E%3Crect y='4' width='44' height='4' fill='%23030a14'/%3E%3Crect y='8' width='44' height='4' fill='%23040c18'/%3E%3Crect y='12' width='44' height='4' fill='%23050e1c'/%3E%3Crect y='16' width='44' height='4' fill='%23061020'/%3E%3Crect y='20' width='44' height='4' fill='%23051018'/%3E%3Crect y='24' width='44' height='4' fill='%23040e16'/%3E%3Crect y='28' width='44' height='4' fill='%23030c14'/%3E%3Crect y='32' width='44' height='4' fill='%23030a12'/%3E%3Crect y='36' width='44' height='8' fill='%23020810'/%3E%3Crect x='8' y='10' width='2' height='2' fill='%2300ffaa' opacity='0.5'/%3E%3Crect x='7' y='9' width='1' height='1' fill='%2300ffaa' opacity='0.2'/%3E%3Crect x='10' y='11' width='1' height='1' fill='%2300ffaa' opacity='0.15'/%3E%3Crect x='28' y='6' width='2' height='2' fill='%2300e4ff' opacity='0.45'/%3E%3Crect x='27' y='5' width='1' height='1' fill='%2300e4ff' opacity='0.15'/%3E%3Crect x='30' y='7' width='1' height='1' fill='%2300e4ff' opacity='0.12'/%3E%3Crect x='16' y='20' width='2' height='2' fill='%2340ff90' opacity='0.4'/%3E%3Crect x='15' y='19' width='1' height='1' fill='%2340ff90' opacity='0.15'/%3E%3Crect x='36' y='16' width='2' height='2' fill='%2300ffcc' opacity='0.35'/%3E%3Crect x='35' y='15' width='1' height='1' fill='%2300ffcc' opacity='0.12'/%3E%3Crect x='4' y='28' width='2' height='2' fill='%2300e4ff' opacity='0.3'/%3E%3Crect x='22' y='32' width='2' height='2' fill='%2340ff90' opacity='0.35'/%3E%3Crect x='38' y='26' width='2' height='2' fill='%2300ffaa' opacity='0.3'/%3E%3Crect x='12' y='36' width='1' height='1' fill='%2300e4ff' opacity='0.2'/%3E%3Crect x='32' y='38' width='1' height='1' fill='%2340ff90' opacity='0.2'/%3E%3Crect x='20' y='14' width='1' height='1' fill='%2300ffcc' opacity='0.15'/%3E%3Crect x='40' y='34' width='1' height='1' fill='%2300ffaa' opacity='0.15'/%3E%3Crect x='2' y='18' width='1' height='1' fill='%2340ff90' opacity='0.12'/%3E%3Crect x='18' y='38' width='6' height='3' fill='%23081828' opacity='0.5'/%3E%3Crect x='19' y='36' width='3' height='2' fill='%230a1c30' opacity='0.4'/%3E%3Crect x='6' y='40' width='4' height='2' fill='%23081828' opacity='0.4'/%3E%3Crect x='34' y='40' width='5' height='2' fill='%23081828' opacity='0.35'/%3E%3C/svg%3E")`,
// T4: Tropical lagoon — turquoise water, golden sand
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%23104858'/%3E%3Crect y='6' width='44' height='4' fill='%23148898'/%3E%3Crect y='10' width='44' height='4' fill='%231898a8'/%3E%3Crect y='14' width='44' height='4' fill='%231ca8b8'/%3E%3Crect y='18' width='44' height='4' fill='%2320b8c8'/%3E%3Crect y='22' width='44' height='4' fill='%2318a0b0'/%3E%3Crect y='26' width='44' height='4' fill='%231490a0'/%3E%3Crect y='30' width='44' height='4' fill='%23108090'/%3E%3Crect y='34' width='44' height='4' fill='%23c8a850'/%3E%3Crect y='38' width='44' height='6' fill='%23d4b460'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.4'/%3E%3Crect x='12' y='3' width='1' height='1' fill='%23e0f0ff' opacity='0.3'/%3E%3Crect x='30' y='1' width='1' height='1' fill='%23e0f0ff' opacity='0.35'/%3E%3Crect x='4' y='8' width='6' height='2' fill='%2340d8e8' opacity='0.25'/%3E%3Crect x='16' y='9' width='8' height='2' fill='%2340d8e8' opacity='0.2'/%3E%3Crect x='30' y='8' width='6' height='2' fill='%2340d8e8' opacity='0.25'/%3E%3Crect x='8' y='16' width='4' height='1' fill='%23e0f8ff' opacity='0.15'/%3E%3Crect x='24' y='18' width='5' height='1' fill='%23e0f8ff' opacity='0.12'/%3E%3Crect x='6' y='24' width='3' height='1' fill='%23e0f8ff' opacity='0.1'/%3E%3Crect x='14' y='28' width='2' height='2' fill='%2388ddaa' opacity='0.25'/%3E%3Crect x='32' y='26' width='3' height='2' fill='%2388ddaa' opacity='0.2'/%3E%3Crect x='22' y='22' width='2' height='1' fill='%23ffcc44' opacity='0.2'/%3E%3Crect x='10' y='36' width='2' height='1' fill='%23b89840' opacity='0.4'/%3E%3Crect x='26' y='38' width='3' height='1' fill='%23b89840' opacity='0.35'/%3E%3Crect x='18' y='40' width='2' height='1' fill='%23e0c870' opacity='0.3'/%3E%3Crect x='36' y='36' width='2' height='1' fill='%23b89840' opacity='0.3'/%3E%3Crect x='4' y='34' width='3' height='2' fill='%2388ddaa' opacity='0.15'/%3E%3Crect x='38' y='34' width='2' height='2' fill='%2390cc88' opacity='0.12'/%3E%3Crect x='6' y='42' width='1' height='1' fill='%23e8d088' opacity='0.2'/%3E%3Crect x='34' y='42' width='1' height='1' fill='%23e8d088' opacity='0.18'/%3E%3C/svg%3E")`,
// T5: Arctic — ice blue, white icebergs
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='6' fill='%230a1828'/%3E%3Crect y='6' width='44' height='4' fill='%23102030'/%3E%3Crect y='10' width='44' height='4' fill='%23142838'/%3E%3Crect y='14' width='44' height='4' fill='%23183040'/%3E%3Crect y='18' width='44' height='4' fill='%231c3848'/%3E%3Crect y='22' width='44' height='4' fill='%23203e50'/%3E%3Crect y='26' width='44' height='4' fill='%23244458'/%3E%3Crect y='30' width='44' height='4' fill='%23284a60'/%3E%3Crect y='34' width='44' height='4' fill='%232c5068'/%3E%3Crect y='38' width='44' height='6' fill='%23182838'/%3E%3Crect x='4' y='12' width='8' height='6' fill='%23d0e8f4' opacity='0.8'/%3E%3Crect x='5' y='10' width='6' height='2' fill='%23e0f0ff' opacity='0.7'/%3E%3Crect x='6' y='8' width='4' height='2' fill='%23eef6ff' opacity='0.6'/%3E%3Crect x='4' y='18' width='8' height='2' fill='%23a0c8e0' opacity='0.4'/%3E%3Crect x='28' y='16' width='10' height='6' fill='%23d0e8f4' opacity='0.75'/%3E%3Crect x='29' y='14' width='8' height='2' fill='%23e0f0ff' opacity='0.65'/%3E%3Crect x='30' y='12' width='6' height='2' fill='%23eef6ff' opacity='0.55'/%3E%3Crect x='28' y='22' width='10' height='2' fill='%23a0c8e0' opacity='0.35'/%3E%3Crect x='16' y='24' width='6' height='4' fill='%23c8e0f0' opacity='0.5'/%3E%3Crect x='17' y='22' width='4' height='2' fill='%23d8ecf8' opacity='0.45'/%3E%3Crect x='16' y='28' width='6' height='1' fill='%2390b8d0' opacity='0.3'/%3E%3Crect x='8' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.5'/%3E%3Crect x='20' y='4' width='1' height='1' fill='%23e0f0ff' opacity='0.4'/%3E%3Crect x='36' y='2' width='1' height='1' fill='%23e0f0ff' opacity='0.45'/%3E%3Crect x='14' y='6' width='1' height='1' fill='%23e0f0ff' opacity='0.3'/%3E%3Crect x='24' y='8' width='1' height='1' fill='%23e0f0ff' opacity='0.25'/%3E%3Crect x='40' y='6' width='1' height='1' fill='%23e0f0ff' opacity='0.35'/%3E%3Crect x='2' y='36' width='1' height='1' fill='%2380a8c0' opacity='0.2'/%3E%3Crect x='22' y='38' width='1' height='1' fill='%2380a8c0' opacity='0.15'/%3E%3Crect x='38' y='36' width='1' height='1' fill='%2380a8c0' opacity='0.18'/%3E%3Crect x='10' y='40' width='1' height='1' fill='%2380a8c0' opacity='0.12'/%3E%3C/svg%3E")`,
]
</script>
<template>
<TransitionGroup name="fab-stack" tag="div" class="terminal-fab-stack">
<button
v-for="(t, idx) in terminals"
:key="t.sessionId"
class="terminal-fab"
:class="{ active: t.sessionId === activeSessionId }"
:style="{ backgroundImage: artVariants[idx] || artVariants[0], transitionDelay: `${idx * 30}ms` }"
:title="t.label || t.sessionId.slice(0, 8)"
@click="emit('select', t.sessionId)"
>
<span class="fab-number">{{ idx + 2 }}</span>
<span class="fab-dot" :style="{ background: stateColor(t) }" />
</button>
<!-- New session "+" button last in DOM = top in column-reverse -->
<button
key="__new__"
class="terminal-fab new-session-fab"
title="New session"
@click="emit('create-session')"
>
<svg class="plus-icon" width="18" height="18" viewBox="0 0 18 18" shape-rendering="crispEdges">
<rect x="8" y="2" width="2" height="14" fill="currentColor"/>
<rect x="2" y="8" width="14" height="2" fill="currentColor"/>
</svg>
</button>
</TransitionGroup>
</template>
<style scoped>
.terminal-fab-stack {
display: flex;
flex-direction: column-reverse;
gap: 6px;
align-items: center;
}
.terminal-fab {
position: relative;
width: 44px;
height: 44px;
border: 1px solid rgba(14, 165, 233, 0.15);
border-radius: 0;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 3px 4px;
transition: all 0.2s ease;
image-rendering: pixelated;
pointer-events: auto;
background-size: cover;
background-repeat: no-repeat;
}
.terminal-fab:hover {
transform: translateY(-2px);
border-color: rgba(14, 165, 233, 0.35);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.5),
0 10px 24px rgba(0, 0, 0, 0.6),
0 0 14px rgba(14, 165, 233, 0.15),
inset 0 1px 0 rgba(14, 165, 233, 0.2);
}
.terminal-fab:active {
transform: scale(0.95);
}
.terminal-fab.active {
border-color: rgba(34, 211, 238, 0.4);
box-shadow:
0 0 6px rgba(34, 211, 238, 0.15),
inset 0 0 4px rgba(34, 211, 238, 0.08);
}
.fab-number {
font-size: 10px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.55);
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.terminal-fab:hover .fab-number {
color: rgba(255, 255, 255, 0.8);
}
.terminal-fab.active .fab-number {
color: #67e8f9;
}
.new-session-fab {
background: rgba(14, 165, 233, 0.08) !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
color: rgba(103, 232, 249, 0.5);
}
.new-session-fab:hover {
color: rgba(103, 232, 249, 0.9);
background: rgba(14, 165, 233, 0.18) !important;
}
.plus-icon {
filter: drop-shadow(0 0 3px rgba(103, 232, 249, 0.3));
}
.fab-dot {
position: absolute;
top: 3px;
right: 3px;
width: 5px;
height: 5px;
border-radius: 0;
box-shadow: 0 0 4px currentColor;
}
/* TransitionGroup animations */
.fab-stack-enter-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.fab-stack-leave-active {
transition: all 0.2s ease-in;
}
.fab-stack-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.8);
}
.fab-stack-leave-to {
opacity: 0;
transform: translateX(8px) scale(0.8);
}
.fab-stack-move {
transition: transform 0.25s ease;
}
</style>