mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Desktop session interruptions were leaving two different classes of stale UI artifacts behind: reconnects could silently lose in-flight output, and history reloads could replay synthetic interruption or internal command breadcrumbs as if they were user-facing transcript content. This change rebinds active session output to the latest client websocket on reconnect, preserves queued outbound messages across transient disconnects, and filters synthetic transcript entries before they reach the desktop history API so reloads reconstruct only the messages users should actually see. Constraint: Desktop transcript history is shared with CLI JSONL files and must stay compatible with persisted message shapes Rejected: Hide these artifacts only in React renderers | other transcript consumers would still receive polluted history Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep transcript filtering aligned with real synthetic/internal message shapes before adding new hidden message types Tested: bun test src/server/__tests__/sessions.test.ts Tested: bun test src/server/__tests__/conversations.test.ts Tested: cd /Users/nanmi/.codex/worktrees/e7ac/claude-code-haha/desktop && bun run test -- websocket.test.ts Tested: cd /Users/nanmi/.codex/worktrees/e7ac/claude-code-haha/desktop && bun run lint Not-tested: Manual browser verification of the refreshed session transcript after interrupt on this exact session
182 lines
4.7 KiB
TypeScript
182 lines
4.7 KiB
TypeScript
import type { ClientMessage, ServerMessage } from '../types/chat'
|
|
import { getBaseUrl } from './client'
|
|
|
|
type MessageHandler = (msg: ServerMessage) => void
|
|
|
|
type Connection = {
|
|
ws: WebSocket
|
|
handlers: Set<MessageHandler>
|
|
reconnectTimer: ReturnType<typeof setTimeout> | null
|
|
reconnectAttempt: number
|
|
pingInterval: ReturnType<typeof setInterval> | null
|
|
intentionalClose: boolean
|
|
pendingMessages: ClientMessage[]
|
|
}
|
|
|
|
class WebSocketManager {
|
|
private connections = new Map<string, Connection>()
|
|
|
|
isConnected(sessionId: string): boolean {
|
|
const conn = this.connections.get(sessionId)
|
|
return conn?.ws.readyState === WebSocket.OPEN
|
|
}
|
|
|
|
getConnectedSessionIds(): string[] {
|
|
return [...this.connections.keys()]
|
|
}
|
|
|
|
connect(sessionId: string) {
|
|
const existing = this.connections.get(sessionId)
|
|
if (
|
|
existing &&
|
|
!existing.intentionalClose &&
|
|
(
|
|
existing.ws.readyState === WebSocket.OPEN ||
|
|
existing.ws.readyState === WebSocket.CONNECTING ||
|
|
existing.reconnectTimer !== null
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
const wsUrl = getBaseUrl().replace(/^http/, 'ws')
|
|
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
|
|
const conn: Connection = {
|
|
ws,
|
|
handlers: existing?.handlers ?? new Set(),
|
|
reconnectTimer: null,
|
|
reconnectAttempt: existing?.reconnectAttempt ?? 0,
|
|
pingInterval: null,
|
|
intentionalClose: false,
|
|
pendingMessages: existing?.pendingMessages ?? [],
|
|
}
|
|
this.connections.set(sessionId, conn)
|
|
|
|
ws.onopen = () => {
|
|
conn.reconnectAttempt = 0
|
|
this.startPingLoop(sessionId)
|
|
while (conn.pendingMessages.length > 0) {
|
|
const msg = conn.pendingMessages.shift()!
|
|
ws.send(JSON.stringify(msg))
|
|
}
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data as string) as ServerMessage
|
|
for (const handler of conn.handlers) {
|
|
handler(msg)
|
|
}
|
|
} catch {
|
|
// Ignore malformed messages
|
|
}
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
this.stopPingLoop(sessionId)
|
|
if (!conn.intentionalClose && this.connections.get(sessionId) === conn) {
|
|
this.scheduleReconnect(sessionId, conn)
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
// onclose will fire after onerror
|
|
}
|
|
}
|
|
|
|
disconnect(sessionId: string) {
|
|
const conn = this.connections.get(sessionId)
|
|
if (!conn) return
|
|
|
|
conn.intentionalClose = true
|
|
this.stopPingLoop(sessionId)
|
|
if (conn.reconnectTimer) {
|
|
clearTimeout(conn.reconnectTimer)
|
|
conn.reconnectTimer = null
|
|
}
|
|
conn.pendingMessages = []
|
|
|
|
conn.ws.close()
|
|
this.connections.delete(sessionId)
|
|
}
|
|
|
|
disconnectAll() {
|
|
for (const sessionId of [...this.connections.keys()]) {
|
|
this.disconnect(sessionId)
|
|
}
|
|
}
|
|
|
|
send(sessionId: string, message: ClientMessage) {
|
|
let conn = this.connections.get(sessionId)
|
|
if (!conn) {
|
|
this.connect(sessionId)
|
|
conn = this.connections.get(sessionId)
|
|
if (!conn) return
|
|
}
|
|
|
|
if (conn.ws.readyState === WebSocket.OPEN) {
|
|
conn.ws.send(JSON.stringify(message))
|
|
return
|
|
}
|
|
|
|
conn.pendingMessages.push(message)
|
|
|
|
if (
|
|
conn.ws.readyState === WebSocket.CLOSED ||
|
|
conn.ws.readyState === WebSocket.CLOSING
|
|
) {
|
|
if (!conn.intentionalClose && !conn.reconnectTimer) {
|
|
this.scheduleReconnect(sessionId, conn)
|
|
}
|
|
}
|
|
}
|
|
|
|
onMessage(sessionId: string, handler: MessageHandler): () => void {
|
|
const conn = this.connections.get(sessionId)
|
|
if (!conn) return () => {}
|
|
conn.handlers.add(handler)
|
|
return () => { conn.handlers.delete(handler) }
|
|
}
|
|
|
|
clearHandlers(sessionId: string) {
|
|
const conn = this.connections.get(sessionId)
|
|
if (conn) conn.handlers.clear()
|
|
}
|
|
|
|
private startPingLoop(sessionId: string) {
|
|
this.stopPingLoop(sessionId)
|
|
const conn = this.connections.get(sessionId)
|
|
if (!conn) return
|
|
conn.pingInterval = setInterval(() => {
|
|
this.send(sessionId, { type: 'ping' })
|
|
}, 30_000)
|
|
}
|
|
|
|
private stopPingLoop(sessionId: string) {
|
|
const conn = this.connections.get(sessionId)
|
|
if (conn?.pingInterval) {
|
|
clearInterval(conn.pingInterval)
|
|
conn.pingInterval = null
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(sessionId: string, conn: Connection) {
|
|
if (conn.reconnectTimer) {
|
|
clearTimeout(conn.reconnectTimer)
|
|
}
|
|
|
|
const delay = Math.min(1000 * 2 ** conn.reconnectAttempt, 30_000)
|
|
conn.reconnectAttempt++
|
|
|
|
conn.reconnectTimer = setTimeout(() => {
|
|
if (this.connections.get(sessionId) === conn && !conn.intentionalClose) {
|
|
conn.reconnectTimer = null
|
|
this.connect(sessionId)
|
|
}
|
|
}, delay)
|
|
}
|
|
}
|
|
|
|
export const wsManager = new WebSocketManager()
|