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
@@ -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,
};
}