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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export function createTorchHandlers(): ToolConfig[] {
const isMe = client.id === status.clientId const isMe = client.id === status.clientId
const hasTorch = client.hasTorch 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 += ` Hostname: ${client.hostname}\n`
result += ` User Agent: ${client.userAgent.substring(0, 80)}...\n` result += ` User Agent: ${client.userAgent.substring(0, 80)}...\n`
result += ` Conectado: ${client.connectedAt}\n\n` result += ` Conectado: ${client.connectedAt}\n\n`
@@ -46,8 +46,10 @@ export function createTorchHandlers(): ToolConfig[] {
const status = getTorchStatus() const status = getTorchStatus()
const clients = getTorchClients() const clients = getTorchClients()
const myClient = clients.find(c => c.id === status.clientId)
let result = '=== Estado de la Antorcha ===\n\n' let result = '=== Estado de la Antorcha ===\n\n'
result += `Mi ID: ${status.clientId || 'No registrado'}\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 += `Tengo la antorcha: ${status.hasTorch ? 'Si' : 'No'}\n`
result += `Holder actual: ${status.torchHolderId || 'Nadie'}\n` result += `Holder actual: ${status.torchHolderId || 'Nadie'}\n`
result += `Clientes conectados: ${status.clientCount}\n\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) const holder = clients.find(c => c.id === status.torchHolderId)
if (holder) { if (holder) {
result += `Detalles del holder:\n` result += `Detalles del holder:\n`
result += ` Nombre: ${holder.name || 'Anonymous'}\n`
result += ` Hostname: ${holder.hostname}\n` result += ` Hostname: ${holder.hostname}\n`
result += ` Conectado desde: ${holder.connectedAt}\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) const targetExists = clients.some(c => c.id === args.target_id)
if (!targetExists) { 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) { if (args.target_id === status.clientId) {
return 'Error: No puedes transferir la antorcha a ti mismo' 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) const success = await transferTorch(args.target_id)
if (success) { 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 { } else {
return 'Error al transferir la antorcha' return 'Error al transferir la antorcha'
} }

View File

@@ -22,11 +22,14 @@ function connectToTorchServer(): Promise<void> {
torchWs.onopen = () => { torchWs.onopen = () => {
console.log('[Torch] Connected to server') console.log('[Torch] Connected to server')
// Register this client const torchStore = useTorchStore()
// Register this client with name and autoRequest
torchWs?.send(JSON.stringify({ torchWs?.send(JSON.stringify({
type: 'register', type: 'register',
userAgent: navigator.userAgent, userAgent: navigator.userAgent,
hostname: window.location.hostname hostname: window.location.hostname,
name: torchStore.clientName || 'Anonymous',
autoRequest: torchStore.autoRequest
})) }))
resolve() resolve()
} }
@@ -93,6 +96,12 @@ async function handleMessage(data: any) {
console.log('[Torch] Got torch, connecting to MCP') console.log('[Torch] Got torch, connecting to MCP')
await connectToMCP() 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 break
} }
@@ -157,6 +166,18 @@ export async function transferTorch(targetId: string): Promise<boolean> {
return true 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 * Get list of connected clients
*/ */

View File

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

View File

@@ -8,6 +8,7 @@
interface TorchClient { interface TorchClient {
ws: any ws: any
id: string id: string
name: string
userAgent: string userAgent: string
hostname: string hostname: string
connectedAt: Date connectedAt: Date
@@ -27,6 +28,7 @@ function generateClientId(): string {
function broadcastTorchState(broadcast: (message: string, filter?: (ws: any) => boolean) => void) { function broadcastTorchState(broadcast: (message: string, filter?: (ws: any) => boolean) => void) {
const clientList = Array.from(torchClients.values()).map(c => ({ const clientList = Array.from(torchClients.values()).map(c => ({
id: c.id, id: c.id,
name: c.name,
userAgent: c.userAgent, userAgent: c.userAgent,
hostname: c.hostname, hostname: c.hostname,
connectedAt: c.connectedAt.toISOString(), connectedAt: c.connectedAt.toISOString(),
@@ -50,6 +52,7 @@ export function handleTorchConnect(ws: any, broadcast: (message: string, filter?
torchClients.set(ws, { torchClients.set(ws, {
ws, ws,
id, id,
name: 'Anonymous',
userAgent: 'Unknown', userAgent: 'Unknown',
hostname: 'Unknown', hostname: 'Unknown',
connectedAt: new Date() connectedAt: new Date()
@@ -68,8 +71,14 @@ export function handleTorchMessage(ws: any, data: any, broadcast: (message: stri
case 'register': { case 'register': {
client.userAgent = data.userAgent || 'Unknown' client.userAgent = data.userAgent || 'Unknown'
client.hostname = data.hostname || 'Unknown' client.hostname = data.hostname || 'Unknown'
client.name = data.name || 'Anonymous'
// Auto-grant torch if requested and no one holds it
if (data.autoRequest && torchHolderId === null) {
torchHolderId = client.id
console.log(`[Torch] Auto-granted torch to ${client.name} (${client.id})`)
}
// No auto-assign - torch must be explicitly requested
const hasTorch = torchHolderId === client.id const hasTorch = torchHolderId === client.id
ws.send(JSON.stringify({ ws.send(JSON.stringify({
@@ -78,7 +87,15 @@ export function handleTorchMessage(ws: any, data: any, broadcast: (message: stri
hasTorch hasTorch
})) }))
console.log(`[Torch] Registered: ${client.id} (torch: ${hasTorch})`) console.log(`[Torch] Registered: ${client.name} (${client.id}) (torch: ${hasTorch})`)
broadcastTorchState(broadcast)
break
}
case 'update-name': {
const newName = (data.name || '').substring(0, 20) || 'Anonymous'
client.name = newName
console.log(`[Torch] Name updated: ${client.id}${newName}`)
broadcastTorchState(broadcast) broadcastTorchState(broadcast)
break break
} }
@@ -88,7 +105,7 @@ export function handleTorchMessage(ws: any, data: any, broadcast: (message: stri
torchHolderId = client.id torchHolderId = client.id
ws.send(JSON.stringify({ type: 'granted' })) ws.send(JSON.stringify({ type: 'granted' }))
console.log(`[Torch] Transferred: ${previousHolder}${client.id}`) console.log(`[Torch] Transferred: ${previousHolder}${client.name} (${client.id})`)
broadcastTorchState(broadcast) broadcastTorchState(broadcast)
break break
} }
@@ -97,7 +114,7 @@ export function handleTorchMessage(ws: any, data: any, broadcast: (message: stri
if (torchHolderId === client.id) { if (torchHolderId === client.id) {
torchHolderId = null torchHolderId = null
ws.send(JSON.stringify({ type: 'released' })) ws.send(JSON.stringify({ type: 'released' }))
console.log(`[Torch] Released by: ${client.id}`) console.log(`[Torch] Released by: ${client.name} (${client.id})`)
broadcastTorchState(broadcast) broadcastTorchState(broadcast)
} }
break break
@@ -135,7 +152,7 @@ export function handleTorchMessage(ws: any, data: any, broadcast: (message: stri
torchHolderId = targetId torchHolderId = targetId
ws.send(JSON.stringify({ type: 'transferred', targetId })) ws.send(JSON.stringify({ type: 'transferred', targetId }))
console.log(`[Torch] Transferred by ${client.id}: ${previousHolder}${targetId}`) console.log(`[Torch] Transferred by ${client.name} (${client.id}): ${previousHolder}${targetId}`)
broadcastTorchState(broadcast) broadcastTorchState(broadcast)
break break
} }
@@ -148,7 +165,7 @@ export function handleTorchMessage(ws: any, data: any, broadcast: (message: stri
export function handleTorchDisconnect(ws: any, broadcast: (message: string, filter?: (ws: any) => boolean) => void) { export function handleTorchDisconnect(ws: any, broadcast: (message: string, filter?: (ws: any) => boolean) => void) {
const client = torchClients.get(ws) const client = torchClients.get(ws)
if (client) { if (client) {
console.log(`[Torch] Client disconnected: ${client.id}`) console.log(`[Torch] Client disconnected: ${client.name} (${client.id})`)
// If this client had the torch, release it (no auto-assign) // If this client had the torch, release it (no auto-assign)
if (torchHolderId === client.id) { if (torchHolderId === client.id) {

View File

@@ -78,7 +78,7 @@ export function startSyncServer() {
const data = JSON.parse(message.toString()) const data = JSON.parse(message.toString())
// Route to appropriate handler based on message type // Route to appropriate handler based on message type
if (data.type?.startsWith('torch-') || ['register', 'request', 'release', 'transfer'].includes(data.type)) { if (data.type?.startsWith('torch-') || ['register', 'request', 'release', 'transfer', 'update-name'].includes(data.type)) {
handleTorchMessage(ws, data, broadcast) handleTorchMessage(ws, data, broadcast)
} }
// Git doesn't expect messages from client // Git doesn't expect messages from client