Newer
Older
navi-1 / webclient / src / composables / useWebSocket.js
import { ref, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat.js'

const WS_BASE = import.meta.env.DEV
  ? `ws://${location.hostname}:8000`
  : `ws://${location.host}`

const MAX_FAST_RETRIES = 3
const FAST_RETRY_BASE_MS = 1000
const BACKGROUND_RETRY_MS = 15000

// Auth extension point — wire in token here when multi-user is ready
function getWsUrl(sessionId) {
  return `${WS_BASE}/ws/sessions/${sessionId}`
}

export function useWebSocket() {
  const chat = useChatStore()

  const connected = ref(false)
  const reconnecting = ref(false)
  const reconnectFailed = ref(false)  // true after MAX_FAST_RETRIES exhausted

  let ws = null
  let sessionId = null
  let retryCount = 0
  let retryTimer = null
  let destroyed = false

  function connect(id) {
    sessionId = id
    retryCount = 0
    reconnectFailed.value = false
    _connect()
  }

  function disconnect() {
    destroyed = true
    clearTimeout(retryTimer)
    ws?.close()
    ws = null
    connected.value = false
    reconnecting.value = false
  }

  function send(payload) {
    if (ws?.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(payload))
    }
  }

  function _connect() {
    if (destroyed) return
    if (ws) {
      ws.onclose = null  // Prevent old socket's onclose from triggering reconnect loop
      ws.close()
    }
    ws = new WebSocket(getWsUrl(sessionId))

    ws.onopen = () => {
      connected.value = true
      reconnecting.value = false
      reconnectFailed.value = false
      retryCount = 0
    }

    ws.onmessage = (e) => {
      let event
      try { event = JSON.parse(e.data) } catch { return }
      _dispatch(event)
    }

    ws.onclose = () => {
      connected.value = false
      if (!destroyed) _scheduleReconnect()
    }

    ws.onerror = () => {
      // onclose fires after onerror, no extra handling needed
    }
  }

  function _scheduleReconnect() {
    if (destroyed) return
    reconnecting.value = true
    retryCount++

    if (retryCount <= MAX_FAST_RETRIES) {
      const delay = FAST_RETRY_BASE_MS * Math.pow(2, retryCount - 1)
      retryTimer = setTimeout(_connect, delay)
    } else {
      // Switch to background retry
      reconnectFailed.value = true
      reconnecting.value = false
      retryTimer = setTimeout(() => {
        if (!destroyed) {
          reconnecting.value = true
          _connect()
        }
      }, BACKGROUND_RETRY_MS)
    }
  }

  function _dispatch(event) {
    switch (event.type) {
      case 'stream_start':      chat.onStreamStart(); break
      case 'thinking_delta':    chat.onThinkingDelta(event.delta ?? ''); break
      case 'thinking_end':      chat.onThinkingEnd(); break
      case 'turn_thinking':     chat.onTurnThinking(event); break
      case 'plan_ready':        chat.onPlanReady(event); break
      case 'tool_started':      chat.onToolStarted(event); break
      case 'tool_call':         chat.onToolCall(event); break
      case 'stream_delta':      chat.onStreamDelta(event.delta ?? ''); break
      case 'stream_end':        chat.onStreamEnd(event); break
      case 'stream_stopped':    chat.onStreamStopped(); break
      case 'profile_switched':  chat.onProfileSwitched(event); break
      case 'context_compressed':chat.onContextCompressed(event); break
      case 'error':             chat.onError(event); break
    }
  }

  onUnmounted(disconnect)

  return { connected, reconnecting, reconnectFailed, connect, disconnect, send }
}