diff --git a/ui/src/components/chat/hooks/useChatComposerState.test.ts b/ui/src/components/chat/hooks/useChatComposerState.test.ts index 104f5193..b5e1f7a0 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.test.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { shouldCycleRunModeOnKeyDown } from './useChatComposerState'; +import { + createWebSocketSendFailureMessage, + shouldCycleRunModeOnKeyDown, +} from './useChatComposerState'; function keyEvent(key: string, shiftKey = false) { return { key, shiftKey }; @@ -28,3 +31,13 @@ describe('useChatComposerState keyboard shortcuts', () => { })).toBe(false); }); }); + +describe('createWebSocketSendFailureMessage', () => { + it('builds a visible error message for failed websocket sends', () => { + const message = createWebSocketSendFailureMessage(); + + expect(message.type).toBe('error'); + expect(message.content).toContain('not connected'); + expect(message.content).toContain('try again'); + }); +}); diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index d9ef95fb..8aa93b76 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -49,7 +49,7 @@ interface UseChatComposerStateArgs { isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; - sendMessage: (message: unknown) => void; + sendMessage: (message: unknown) => boolean | void; sendByCtrlEnter?: boolean; onSessionActive?: (sessionId?: string | null) => void; onSessionProcessing?: (sessionId?: string | null) => void; @@ -105,6 +105,8 @@ const createFakeSubmitEvent = () => { const MAX_ATTACHMENT_SIZE_BYTES = 20 * 1024 * 1024; const MAX_ATTACHMENTS = 10; +const WEBSOCKET_SEND_FAILURE_TEXT = + 'PilotDeck is not connected. Please wait for the WebSocket connection to reconnect and try again.'; type UploadedAttachmentFile = { name: string; @@ -126,6 +128,14 @@ export function shouldCycleRunModeOnKeyDown( return event.key === 'Tab' && event.shiftKey && !showFileDropdown && !showCommandMenu; } +export function createWebSocketSendFailureMessage(): ChatMessage { + return { + type: 'error', + content: WEBSOCKET_SEND_FAILURE_TEXT, + timestamp: new Date(), + }; +} + function buildAttachmentPathNote(files: UploadedAttachmentFile[]): string { if (!files.length) { return ''; @@ -677,13 +687,6 @@ export function useChatComposerState({ // arrives with the real id). const optimisticSessionId = submitTargetSessionId || createTemporarySessionId(); - if (selectedProject?.name) { - onSessionActivityBump?.( - selectedProject.name, - optimisticSessionId, - userVisibleInput, - ); - } let uploadedImages: unknown[] = []; let uploadedFiles: UploadedAttachmentFile[] = []; @@ -724,26 +727,6 @@ export function useChatComposerState({ const effectiveSessionId = submitTargetSessionId; const sessionToActivate = effectiveSessionId || optimisticSessionId; - const userMessage: ChatMessage = { - type: 'user', - content: userVisibleInput, - images: uploadedImages as any, - attachments: uploadedFiles as any, - timestamp: new Date(), - }; - - addMessage(userMessage, submitTargetSessionId); - setIsLoading(true); // Processing banner starts - setCanAbortSession(true); - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true, - }); - - setIsUserScrolledUp(false); - setTimeout(() => scrollToBottom(), 100); - if (!effectiveSessionId && !submitSelectedSession?.id) { if (typeof window !== 'undefined') { // Reset stale pending IDs from previous interrupted runs before creating a new one. @@ -751,10 +734,6 @@ export function useChatComposerState({ } pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; } - onSessionActive?.(sessionToActivate); - if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) { - onSessionProcessing?.(effectiveSessionId); - } // PilotDeck-only: a single localStorage entry (`pilotdeck-settings`) // tracks tool consent + skip-permissions for every chat. The legacy @@ -780,7 +759,7 @@ export function useChatComposerState({ const toolsSettings = getToolsSettings(); const sessionSummary = getNotificationSessionSummary(submitSelectedSession, userVisibleInput); - startSessionCommand({ + const launchResult = startSessionCommand({ sendMessage, selectedProject, command: messageContent, @@ -793,6 +772,49 @@ export function useChatComposerState({ images: uploadedImages, }); + if (!launchResult.sent) { + pendingViewSessionRef.current = null; + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + setPilotDeckStatus(null); + addMessage(createWebSocketSendFailureMessage(), submitTargetSessionId); + return; + } + + if (selectedProject?.name) { + onSessionActivityBump?.( + selectedProject.name, + launchResult.sessionId, + userVisibleInput, + ); + } + + const userMessage: ChatMessage = { + type: 'user', + content: userVisibleInput, + images: uploadedImages as any, + attachments: uploadedFiles as any, + timestamp: new Date(), + }; + + addMessage(userMessage, submitTargetSessionId); + setIsLoading(true); // Processing banner starts + setCanAbortSession(true); + setClaudeStatus({ + text: 'Processing', + tokens: 0, + can_interrupt: true, + }); + + setIsUserScrolledUp(false); + setTimeout(() => scrollToBottom(), 100); + + onSessionActive?.(launchResult.sessionId); + if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) { + onSessionProcessing?.(effectiveSessionId); + } + setInput(''); inputValueRef.current = ''; resetCommandMenuState(); diff --git a/ui/src/components/chat/utils/sessionLauncher.test.ts b/ui/src/components/chat/utils/sessionLauncher.test.ts new file mode 100644 index 00000000..5e81314e --- /dev/null +++ b/ui/src/components/chat/utils/sessionLauncher.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { startSessionCommand } from './sessionLauncher'; +import type { Project } from '../../../types/app'; + +const project = { + name: 'demo', + path: '/workspace/demo', + fullPath: '/workspace/demo', +} as Project; + +describe('startSessionCommand', () => { + it('reports successful websocket sends', () => { + const result = startSessionCommand({ + sendMessage: () => true, + selectedProject: project, + command: 'hello', + temporarySessionId: 'new-session-test', + }); + + expect(result).toEqual({ + sessionId: 'new-session-test', + sent: true, + }); + }); + + it('reports when the websocket command could not be sent', () => { + const result = startSessionCommand({ + sendMessage: () => false, + selectedProject: project, + command: 'hello', + temporarySessionId: 'new-session-test', + }); + + expect(result).toEqual({ + sessionId: 'new-session-test', + sent: false, + }); + }); +}); diff --git a/ui/src/components/chat/utils/sessionLauncher.ts b/ui/src/components/chat/utils/sessionLauncher.ts index 763b1b62..f432769e 100644 --- a/ui/src/components/chat/utils/sessionLauncher.ts +++ b/ui/src/components/chat/utils/sessionLauncher.ts @@ -3,7 +3,7 @@ import type { ChatAttachment, PilotDeckSettings, PermissionMode } from '../types import { getPilotDeckSettings, safeLocalStorage } from './chatStorage'; type StartSessionOptions = { - sendMessage: (message: unknown) => void; + sendMessage: (message: unknown) => boolean | void; selectedProject: Project; command: string; sessionId?: string | null; @@ -19,6 +19,11 @@ type StartSessionOptions = { workspaceCwd?: string; }; +type StartSessionResult = { + sessionId: string; + sent: boolean; +}; + const VALID_PERMISSION_MODES = new Set([ 'default', 'acceptEdits', @@ -90,12 +95,12 @@ export function startSessionCommand({ alwaysOnPlanId, alwaysOnExecutionToken, workspaceCwd, -}: StartSessionOptions): string { +}: StartSessionOptions): StartSessionResult { const sessionToActivate = sessionId || temporarySessionId || createTemporarySessionId(); const resolvedProjectPath = getSelectedProjectPath(selectedProject); - sendMessage({ + const sent = sendMessage({ type: 'pilotdeck-command', command, options: { @@ -112,7 +117,10 @@ export function startSessionCommand({ ...(Array.isArray(attachments) && attachments.length > 0 ? { attachments } : {}), ...(workspaceCwd ? { workspaceCwd } : {}), }, - }); + }) !== false; - return sessionToActivate; + return { + sessionId: sessionToActivate, + sent, + }; } diff --git a/ui/src/contexts/WebSocketContext.tsx b/ui/src/contexts/WebSocketContext.tsx index 729b97a5..9a33548a 100644 --- a/ui/src/contexts/WebSocketContext.tsx +++ b/ui/src/contexts/WebSocketContext.tsx @@ -6,7 +6,7 @@ type WSSubscriber = (msg: any) => void; type WebSocketContextType = { ws: WebSocket | null; - sendMessage: (message: any) => void; + sendMessage: (message: any) => boolean; latestMessage: any | null; isConnected: boolean; /** @@ -140,10 +140,16 @@ const useWebSocketProviderState = (): WebSocketContextType => { const sendMessage = useCallback((message: any) => { const socket = wsRef.current; if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(message)); - } else { - console.warn('WebSocket not connected'); + try { + socket.send(JSON.stringify(message)); + return true; + } catch (error) { + console.error('WebSocket send failed:', error); + return false; + } } + console.warn('WebSocket not connected'); + return false; }, []); const subscribe = useCallback((handler) => {