Files
agent-ui/frontend/src/components/TorchButton.vue
josedario87 9a636e26a7 feat: Enforce exclusive auto-request (one client at a time)
Server is now source of truth for autoRequest. When a client enables it,
all other clients lose it. Broadcast includes autoRequest per client,
frontend syncs from server state on each torch-update.
2026-02-14 23:40:30 -06:00

619 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useTorchStore } from '../stores/torch'
import { useCanvasStore } from '../stores/canvas'
import { requestTorch, releaseTorch, transferTorch, updateName, setAutoRequest } from '../services/torch'
const torchStore = useTorchStore()
const canvasStore = useCanvasStore()
const isOpen = ref(false)
const isEditingName = ref(false)
const nameInput = ref('')
const nameInputRef = ref<HTMLInputElement | null>(null)
// Combined state
const hasTorch = computed(() => torchStore.hasTorch)
const isConnected = computed(() => canvasStore.isConnected)
const isReconnecting = computed(() => canvasStore.isReconnecting)
const displayName = computed(() => torchStore.clientName || 'Anonymous')
const statusClass = computed(() => {
if (!torchStore.isConnected) return 'disconnected'
if (!hasTorch.value) return 'no-torch'
if (isReconnecting.value) return 'reconnecting'
if (isConnected.value) return 'connected'
return 'has-torch'
})
const statusText = computed(() => {
if (!torchStore.isConnected) return 'Offline'
if (!hasTorch.value) return 'No torch'
if (isReconnecting.value) return 'Reconnecting'
if (isConnected.value) return 'Connected'
return 'Connecting...'
})
const statusBadgeClass = computed(() => {
if (!torchStore.isConnected) return 'error'
if (!hasTorch.value) return 'error'
if (isReconnecting.value || !isConnected.value) return 'warning'
return 'success'
})
function toggleDropdown() {
isOpen.value = !isOpen.value
}
function closeDropdown(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.torch-dropdown-container')) {
isOpen.value = false
isEditingName.value = false
}
}
function startEditingName() {
nameInput.value = torchStore.clientName
isEditingName.value = true
nextTick(() => {
nameInputRef.value?.focus()
nameInputRef.value?.select()
})
}
function saveName() {
const trimmed = nameInput.value.trim().substring(0, 20)
updateName(trimmed)
isEditingName.value = false
}
function cancelEditName() {
isEditingName.value = false
}
async function handleAction() {
if (torchStore.hasTorch) {
await releaseTorch()
} else {
await requestTorch()
}
}
async function handleTransfer(targetId: string) {
await transferTorch(targetId)
}
function toggleAutoRequest() {
setAutoRequest(!torchStore.autoRequest)
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<template>
<div class="torch-dropdown-container">
<!-- Split Trigger: main area = request/release, chevron = dropdown -->
<div class="trigger-split" :class="[statusClass, { requesting: torchStore.isRequesting }]">
<button class="trigger-main" @click.stop="handleAction" :title="hasTorch ? 'Release torch' : 'Request torch'">
<span class="status-dot" :class="statusBadgeClass"></span>
<span class="trigger-name">{{ displayName }}</span>
<span v-if="torchStore.isRequesting" class="requesting-indicator"></span>
</button>
<button class="trigger-chevron" @click.stop="toggleDropdown" title="Settings">
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
<!-- Dropdown -->
<div v-if="isOpen" class="dropdown-menu" @click.stop>
<!-- Header -->
<div class="dropdown-header">
<span class="header-title">Torch</span>
<span class="status-badge" :class="statusBadgeClass">{{ statusText }}</span>
</div>
<!-- Name Section -->
<div class="name-section">
<label class="section-label">Name</label>
<div v-if="isEditingName" class="name-edit">
<input
ref="nameInputRef"
v-model="nameInput"
type="text"
class="name-input"
maxlength="20"
placeholder="Anonymous"
@keyup.enter="saveName"
@keyup.escape="cancelEditName"
@blur="saveName"
/>
</div>
<button v-else class="name-display" @click="startEditingName">
<span>{{ displayName }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
<!-- Auto-request toggle -->
<div class="auto-request-section">
<label class="toggle-row" @click="toggleAutoRequest">
<span class="toggle-label">Auto-request</span>
<span class="toggle-switch" :class="{ active: torchStore.autoRequest }">
<span class="toggle-knob"></span>
</span>
</label>
</div>
<!-- Clients list -->
<div class="clients-section">
<label class="section-label">Clients ({{ torchStore.clients.length }})</label>
<div class="clients-list">
<div
v-for="client in torchStore.clients"
:key="client.id"
class="client-row"
>
<span class="client-dot" :class="{ holder: client.hasTorch }"></span>
<span class="client-name">{{ client.name || 'Anonymous' }}</span>
<span v-if="client.id === torchStore.clientId" class="you-badge">you</span>
<span v-if="client.hasTorch" class="torch-badge">torch</span>
<button
v-if="!client.hasTorch && client.id !== torchStore.clientId && torchStore.hasTorch"
class="transfer-btn"
@click="handleTransfer(client.id)"
title="Transfer torch"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
</svg>
</button>
</div>
<div v-if="torchStore.clients.length === 0" class="no-clients">
No clients connected
</div>
</div>
</div>
<!-- Action button -->
<div class="action-section">
<button
class="action-btn"
:class="hasTorch ? 'release' : 'request'"
@click="handleAction"
:disabled="torchStore.isRequesting"
>
{{ torchStore.isRequesting ? 'Requesting...' : hasTorch ? 'Release Torch' : 'Request Torch' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.torch-dropdown-container {
position: relative;
overflow: visible;
}
.trigger-split {
display: flex;
align-items: center;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
transition: all 0.15s ease;
position: relative;
}
.trigger-main {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem 0.375rem 0.75rem;
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
transition: background 0.1s;
border-radius: 7px 0 0 7px;
}
.trigger-main:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
}
.trigger-chevron {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
background: none;
border: none;
border-left: 1px solid var(--border-color);
color: inherit;
cursor: pointer;
transition: background 0.1s;
border-radius: 0 7px 7px 0;
}
.trigger-chevron:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
}
.trigger-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.trigger-name {
display: none;
}
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.success {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
}
.status-dot.warning {
background: #f59e0b;
animation: dot-pulse 1.5s infinite;
}
.status-dot.error {
background: #ef4444;
}
.chevron {
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron.open {
transform: rotate(180deg);
}
.requesting-indicator {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #6366f1;
border-radius: 50%;
animation: request-pulse 1s ease-in-out infinite;
}
/* Dropdown menu */
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.header-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.status-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: 9999px;
}
.status-badge.success {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.status-badge.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* Name section */
.name-section {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.section-label {
display: block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.name-display {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.name-display:hover {
border-color: var(--border-color);
background: var(--bg-hover);
}
.name-display svg {
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s;
}
.name-display:hover svg {
opacity: 1;
}
.name-input {
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
}
/* Auto-request section */
.auto-request-section {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.25rem 0;
}
.toggle-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
transition: all 0.2s ease;
}
.toggle-switch.active {
background: #10b981;
border-color: #10b981;
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-switch.active .toggle-knob {
transform: translateX(16px);
}
/* Clients section */
.clients-section {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.clients-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 160px;
overflow-y: auto;
}
.client-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 6px;
transition: background 0.1s;
}
.client-row:hover {
background: var(--bg-hover);
}
.client-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.client-dot.holder {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
}
.client-name {
flex: 1;
font-size: 0.8rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.you-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
flex-shrink: 0;
}
.torch-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
flex-shrink: 0;
}
.transfer-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.transfer-btn:hover {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.no-clients {
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
padding: 0.5rem 0;
}
/* Action section */
.action-section {
padding: 0.75rem 1rem;
}
.action-btn {
width: 100%;
padding: 0.5rem;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.action-btn.request {
background: var(--accent, #6366f1);
color: white;
}
.action-btn.request:hover:not(:disabled) {
filter: brightness(1.1);
}
.action-btn.release {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.action-btn.release:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Animations */
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes request-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.7; }
}
</style>