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

@@ -244,6 +244,9 @@ onMounted(async () => {
// Connect to WebSocket for Claude status updates
connectStatusWs()
// Fire torch connection early (don't await yet)
const torchReady = initTorch()
// Initialize WebMCP connection
await initWebMCP()
@@ -320,8 +323,8 @@ onMounted(async () => {
})
}
// Initialize torch system (handles MCP connection based on torch state)
await initTorch()
// Ensure torch connection is established
await torchReady
})
onUnmounted(() => {

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>

View File

@@ -170,6 +170,7 @@ const pageCategories: Record<PageName, ToolCategory[]> = {
let currentPage: PageName | null = null
let isInitialized = false
let pendingTorchConnection = false
/**
* Check if connected to MCP
@@ -185,6 +186,12 @@ function isConnectedToMCP(): boolean {
export function initToolRegistry(router: any) {
setRouter(router)
isInitialized = true
// Fulfill any pending torch connection that arrived before init
if (pendingTorchConnection) {
pendingTorchConnection = false
onTorchConnected()
}
}
/**
@@ -291,7 +298,8 @@ export async function initToolsOnRefresh(pageName: PageName) {
*/
export async function onTorchConnected() {
if (!isInitialized) {
console.warn('[ToolRegistry] Not initialized')
console.log('[ToolRegistry] Not initialized yet, deferring torch connection')
pendingTorchConnection = true
return
}

View File

@@ -25,7 +25,7 @@ export function createTorchHandlers(): ToolConfig[] {
const isMe = client.id === status.clientId
const hasTorch = client.hasTorch
result += `${hasTorch ? '🔥' : '⚪'} ${client.id}${isMe ? ' (este browser)' : ''}\n`
result += `${hasTorch ? '🔥' : '⚪'} ${client.name || 'Anonymous'} (${client.id})${isMe ? ' (este browser)' : ''}\n`
result += ` Hostname: ${client.hostname}\n`
result += ` User Agent: ${client.userAgent.substring(0, 80)}...\n`
result += ` Conectado: ${client.connectedAt}\n\n`
@@ -46,8 +46,10 @@ export function createTorchHandlers(): ToolConfig[] {
const status = getTorchStatus()
const clients = getTorchClients()
const myClient = clients.find(c => c.id === status.clientId)
let result = '=== Estado de la Antorcha ===\n\n'
result += `Mi ID: ${status.clientId || 'No registrado'}\n`
result += `Mi nombre: ${myClient?.name || 'Anonymous'}\n`
result += `Tengo la antorcha: ${status.hasTorch ? 'Si' : 'No'}\n`
result += `Holder actual: ${status.torchHolderId || 'Nadie'}\n`
result += `Clientes conectados: ${status.clientCount}\n\n`
@@ -56,6 +58,7 @@ export function createTorchHandlers(): ToolConfig[] {
const holder = clients.find(c => c.id === status.torchHolderId)
if (holder) {
result += `Detalles del holder:\n`
result += ` Nombre: ${holder.name || 'Anonymous'}\n`
result += ` Hostname: ${holder.hostname}\n`
result += ` Conectado desde: ${holder.connectedAt}\n`
}
@@ -89,16 +92,17 @@ export function createTorchHandlers(): ToolConfig[] {
const targetExists = clients.some(c => c.id === args.target_id)
if (!targetExists) {
return `Error: Cliente "${args.target_id}" no encontrado.\n\nClientes disponibles:\n${clients.map(c => ` - ${c.id}`).join('\n')}`
return `Error: Cliente "${args.target_id}" no encontrado.\n\nClientes disponibles:\n${clients.map(c => ` - ${c.name || 'Anonymous'} (${c.id})`).join('\n')}`
}
if (args.target_id === status.clientId) {
return 'Error: No puedes transferir la antorcha a ti mismo'
}
const targetClient = clients.find(c => c.id === args.target_id)
const success = await transferTorch(args.target_id)
if (success) {
return `Antorcha transferida a ${args.target_id}. El otro browser ahora tiene control del MCP.`
return `Antorcha transferida a ${targetClient?.name || args.target_id}. El otro browser ahora tiene control del MCP.`
} else {
return 'Error al transferir la antorcha'
}

View File

@@ -22,11 +22,14 @@ function connectToTorchServer(): Promise<void> {
torchWs.onopen = () => {
console.log('[Torch] Connected to server')
// Register this client
const torchStore = useTorchStore()
// Register this client with name and autoRequest
torchWs?.send(JSON.stringify({
type: 'register',
userAgent: navigator.userAgent,
hostname: window.location.hostname
hostname: window.location.hostname,
name: torchStore.clientName || 'Anonymous',
autoRequest: torchStore.autoRequest
}))
resolve()
}
@@ -93,6 +96,12 @@ async function handleMessage(data: any) {
console.log('[Torch] Got torch, connecting to MCP')
await connectToMCP()
}
// Auto-request: if no one holds the torch and we have autoRequest enabled
if (!hasTorchNow && data.holderId === null && torchStore.autoRequest) {
console.log('[Torch] Auto-requesting torch (no holder)')
requestTorch()
}
break
}
@@ -157,6 +166,18 @@ export async function transferTorch(targetId: string): Promise<boolean> {
return true
}
/**
* Update client name on server
*/
export function updateName(name: string): void {
const torchStore = useTorchStore()
torchStore.setClientName(name)
if (torchWs?.readyState === WebSocket.OPEN) {
torchWs.send(JSON.stringify({ type: 'update-name', name }))
}
}
/**
* Get list of connected clients
*/

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
export interface TorchClient {
id: string
name: string
userAgent: string
hostname: string
url: string
@@ -17,6 +18,8 @@ export const useTorchStore = defineStore('torch', () => {
const torchHolderId = ref<string | null>(null)
const clients = ref<TorchClient[]>([])
const isRequesting = ref(false)
const clientName = ref(localStorage.getItem('torch-client-name') || '')
const autoRequest = ref(localStorage.getItem('torch-auto-request') === 'true')
// Computed
const isConnected = computed(() => clientId.value !== null)
@@ -44,12 +47,23 @@ export const useTorchStore = defineStore('torch', () => {
isRequesting.value = value
}
function setClientName(name: string) {
clientName.value = name
localStorage.setItem('torch-client-name', name)
}
function setAutoRequest(val: boolean) {
autoRequest.value = val
localStorage.setItem('torch-auto-request', String(val))
}
function reset() {
clientId.value = null
hasTorch.value = false
torchHolderId.value = null
clients.value = []
isRequesting.value = false
// Do NOT reset clientName/autoRequest — persist across sessions
}
return {
@@ -59,6 +73,8 @@ export const useTorchStore = defineStore('torch', () => {
torchHolderId,
clients,
isRequesting,
clientName,
autoRequest,
// Computed
isConnected,
torchHolder,
@@ -67,6 +83,8 @@ export const useTorchStore = defineStore('torch', () => {
setTorchState,
setClients,
setRequesting,
setClientName,
setAutoRequest,
reset
}
})