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:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user