From b9d539c1bd165b0c50f188ff85c6d43dbd6e5e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 26 Jul 2026 23:22:53 +0000 Subject: [PATCH] =?UTF-8?q?chat:=20"Disconnect=20session"=20=E2=80=94=20en?= =?UTF-8?q?d=20the=20whole=20session=20from=20the=20UI=20(first=20stab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-session teardown, distinct from the existing turn-only "stop": - New WS 'disconnect' message → handleDisconnect → sessionManager.deleteSession, which fires _claudeKill (kills any in-flight Claude/OpenCode turn) + _sidecarUnsub, clears the idle timer, and drops the in-memory session. WS stays open so a new prompt starts fresh. Server acks with 'disconnected'. - useChat: disconnectSession() + a 'disconnected' handler (commit partial stream, settle to idle). - UI: an Unplug button in the chat DetailBar (shown while connected). Scope note: targets the CURRENTLY-OPEN session (correct in-memory sessionKey). Disconnecting an arbitrary *listed* session isn't wired yet — session-list rows are keyed by the on-disk transcript uuid, which isn't the live sessionKey, so that needs a reverse lookup + a REST endpoint. NOT yet deployed (needs a server restart). Co-Authored-By: Claude Opus 4.8 --- src/servers/api/chat/types.ts | 9 +++++++++ src/servers/api/chat/websocket.ts | 19 +++++++++++++++++++ .../officerdev/src/apps/Chat/types.ts | 3 ++- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 16 ++++++++++++++-- .../officerdev/src/hooks/useChat.ts | 14 ++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index fe4b8cee..6201291a 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -67,6 +67,11 @@ export type ClientMessage = } | { type: 'stop'; + } + | { + // Tear down the whole session (kill any in-flight turn + drop the in-memory session), not just + // the current turn. Frees the session so its transcript can be resumed elsewhere. + type: 'disconnect'; }; export type ServerMessage = @@ -117,6 +122,10 @@ export type ServerMessage = } | { type: 'stopped'; + } + | { + // Ack for a client 'disconnect': the session was torn down server-side. + type: 'disconnected'; }; export type ChatEvent = diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7e5695f3..d6a17d7d 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -120,6 +120,8 @@ export function message(ws: ServerWebSocket, raw: string | Buffer): void await handleResume(ws, clientMsg); } else if (clientMsg.type === 'stop') { await handleStop(ws); + } else if (clientMsg.type === 'disconnect') { + await handleDisconnect(ws); } } catch (err) { logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) }); @@ -538,6 +540,23 @@ async function handleStop(ws: ServerWebSocket): Promise { sendToClient(ws, { type: 'stopped' }); } +// Tear down the whole session (not just the current turn): deleteSession fires _claudeKill (kills any +// in-flight Claude/OpenCode turn) + _sidecarUnsub, clears the idle timer, and drops the session from the +// manager's maps. The WS stays open so the client can immediately start a fresh session. +async function handleDisconnect(ws: ServerWebSocket): Promise { + const sessionId = wsToSessionMap.get(ws); + if (sessionId) { + try { + sessionManager.deleteSession(sessionId); + wsToSessionMap.delete(ws as any); + logger.info('Disconnected chat session', { sessionId }); + } catch (err) { + logger.error('Failed to disconnect session', { sessionId, error: String(err) }); + } + } + sendToClient(ws, { type: 'disconnected' }); +} + export const chatWebsocket = { open, message, diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index 83636e7c..5ce9b6a4 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -49,7 +49,8 @@ export type ServerMessage = | { type: 'result'; sessionId: string; cost: MessageCost } | { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string } | { type: 'error'; message: string; errorCode?: string } - | { type: 'stopped' }; + | { type: 'stopped' } + | { type: 'disconnected' }; export type Message = { id: string; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 0f6d7fb6..28a2abc6 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useLocation } from 'react-router'; +import { Unplug } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useAuth } from 'hooks/useAuth'; import { useClaudeSessions } from 'state/useClaudeSessions'; @@ -29,9 +30,10 @@ type DetailBarProps = { sessionTitle: string | undefined; isConnected: boolean; isGenerating: boolean; + onDisconnect?: () => void; }; -function DetailBar({ sessionTitle, isConnected, isGenerating }: DetailBarProps) { +function DetailBar({ sessionTitle, isConnected, isGenerating, onDisconnect }: DetailBarProps) { return (
@@ -46,6 +48,16 @@ function DetailBar({ sessionTitle, isConnected, isGenerating }: DetailBarProps) )} {!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''} + {isConnected && onDisconnect && ( + + )}
); @@ -92,7 +104,7 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro return (
- +