chat: "Disconnect session" — end the whole session from the UI (first stab)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 23:22:53 +00:00
co-authored by Claude Opus 4.8
parent bff11bc86d
commit b9d539c1bd
5 changed files with 58 additions and 3 deletions
+9
View File
@@ -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 =
+19
View File
@@ -120,6 +120,8 @@ export function message(ws: ServerWebSocket<WSData>, 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<WSData>): Promise<void> {
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<WSData>): Promise<void> {
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,
@@ -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;
@@ -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 (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
@@ -46,6 +48,16 @@ function DetailBar({ sessionTitle, isConnected, isGenerating }: DetailBarProps)
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
{isConnected && onDisconnect && (
<button
type="button"
onClick={onDisconnect}
title="End session — kills any running turn and releases it so it can be resumed elsewhere"
className="ml-1 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Unplug className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
@@ -92,7 +104,7 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
return (
<div className="flex flex-col h-full">
<DetailBar sessionTitle={undefined} isConnected={chat.isConnected} isGenerating={chat.isGenerating} />
<DetailBar sessionTitle={undefined} isConnected={chat.isConnected} isGenerating={chat.isGenerating} onDisconnect={chat.disconnectSession} />
<EmbeddableChat
chat={chat}
sessionId={undefined}
@@ -202,6 +202,13 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
commitStreaming();
setIsGenerating(false);
break;
case 'disconnected':
// Session torn down server-side (turn killed, in-memory session dropped). Commit any partial
// stream and settle to idle; the WS stays open so a new prompt starts a fresh session.
commitStreaming();
setIsGenerating(false);
break;
}
}
@@ -273,6 +280,12 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
send({ type: 'stop' });
}
// Tear down the whole session server-side (kill any in-flight turn + drop the in-memory session),
// freeing it so its transcript can be resumed elsewhere. Stronger than stopGeneration (turn only).
function disconnectSession() {
send({ type: 'disconnect' });
}
return {
messages,
streamingText,
@@ -288,6 +301,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setThinkingLevel,
sendPrompt,
stopGeneration,
disconnectSession,
};
}