feat: Add torch client identity, early connection and auto-request

- Named clients persisted in localStorage, editable from dropdown
- Auto-request: clients can auto-receive torch when no holder exists
- Early torch init in App.vue (fires before WebMCP, awaited later)
- Deferred torch connection in toolRegistry for race condition safety
- TorchButton rewritten as dropdown with name editor, toggle, client list
- Server broadcasts client names, supports update-name message
- MCP tool handlers display client names
This commit is contained in:
2026-02-14 23:30:56 -06:00
parent 3f15aa590b
commit 2a80b7751b
8 changed files with 597 additions and 135 deletions

View File

@@ -1,23 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useTorchStore } from '../stores/torch'
import { useCanvasStore } from '../stores/canvas'
import { requestTorch, releaseTorch } from '../services/torch'
import { requestTorch, releaseTorch, transferTorch, updateName } 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)
// Visual states:
// - No torch: gray, "Sin control"
// - Has torch, reconnecting: orange pulse, "Conectando..."
// - Has torch, not connected: orange, "Conectando..."
// - Has torch, connected: green glow, "Conectado"
const buttonState = computed(() => {
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'
@@ -25,134 +28,249 @@ const buttonState = computed(() => {
})
const statusText = computed(() => {
if (!hasTorch.value) return 'Sin control'
if (isReconnecting.value) return 'Conectando...'
if (isConnected.value) return 'Conectado'
return 'Conectando...'
if (!torchStore.isConnected) return 'Offline'
if (!hasTorch.value) return 'No torch'
if (isReconnecting.value) return 'Reconnecting'
if (isConnected.value) return 'Connected'
return 'Connecting...'
})
const tooltipText = computed(() => {
if (!hasTorch.value) {
return torchStore.torchHolderId
? 'Otro browser tiene el control - click para solicitar'
: 'Click para solicitar control MCP'
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
}
if (isReconnecting.value) return 'Reconectando al MCP...'
if (isConnected.value) return 'Conectado al MCP - click para liberar control'
return 'Tiene control, conectando al MCP...'
})
}
async function handleClick() {
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() {
torchStore.setAutoRequest(!torchStore.autoRequest)
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<template>
<button
class="torch-btn"
:class="[buttonState, { requesting: torchStore.isRequesting }]"
@click="handleClick"
:title="tooltipText"
>
<span class="status-dot"></span>
<span class="status-text">{{ statusText }}</span>
<span v-if="torchStore.isRequesting" class="requesting-indicator"></span>
</button>
<div class="torch-dropdown-container">
<!-- Trigger Button -->
<button
class="dropdown-trigger"
:class="[statusClass, { requesting: torchStore.isRequesting }]"
@click.stop="toggleDropdown"
:title="displayName"
>
<span class="status-dot" :class="statusBadgeClass"></span>
<span class="trigger-name">{{ displayName }}</span>
<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>
<span v-if="torchStore.isRequesting" class="requesting-indicator"></span>
</button>
<!-- 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-btn {
.torch-dropdown-container {
position: relative;
overflow: visible;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.3s ease;
transition: all 0.15s ease;
position: relative;
}
.dropdown-trigger:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
color: var(--text-primary);
}
.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%;
transition: all 0.3s ease;
flex-shrink: 0;
}
.status-text {
transition: all 0.3s ease;
.status-dot.success {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
}
/* No torch - gray/red, dimmed */
.torch-btn.no-torch {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
.status-dot.warning {
background: #f59e0b;
animation: dot-pulse 1.5s infinite;
}
.torch-btn.no-torch .status-dot {
.status-dot.error {
background: #ef4444;
}
.torch-btn.no-torch:hover {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
.chevron {
transition: transform 0.2s ease;
flex-shrink: 0;
}
.torch-btn.no-torch:hover .status-dot {
background: #f59e0b;
box-shadow: 0 0 8px #f59e0b;
}
/* Has torch but connecting - orange pulse */
.torch-btn.has-torch {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
animation: status-pulse 2s ease-in-out infinite;
}
.torch-btn.has-torch .status-dot {
background: #f59e0b;
box-shadow: 0 0 8px #f59e0b;
animation: dot-pulse 1.5s ease-in-out infinite;
}
/* Reconnecting - orange with faster pulse */
.torch-btn.reconnecting {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
animation: status-pulse 1s ease-in-out infinite;
}
.torch-btn.reconnecting .status-dot {
background: #f59e0b;
animation: dot-pulse 0.8s ease-in-out infinite;
}
/* Connected - green glow */
.torch-btn.connected {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.torch-btn.connected .status-dot {
background: #22c55e;
box-shadow: 0 0 8px #22c55e;
}
.torch-btn.connected:hover {
background: rgba(34, 197, 94, 0.2);
}
/* Requesting state */
.torch-btn.requesting {
pointer-events: none;
.chevron.open {
transform: rotate(180deg);
}
.requesting-indicator {
@@ -166,35 +284,308 @@ async function handleClick() {
animation: request-pulse 1s ease-in-out infinite;
}
/* Animations */
@keyframes status-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
/* 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% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.3);
opacity: 0.8;
}
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;
}
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.7; }
}
</style>