Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion ui/src/components/chat/hooks/useChatComposerState.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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');
});
});
88 changes: 55 additions & 33 deletions ui/src/components/chat/hooks/useChatComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface UseChatComposerStateArgs {
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendMessage: (message: unknown) => boolean | void;
sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
Expand Down Expand Up @@ -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;
Expand All @@ -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 '';
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -724,37 +727,13 @@ 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.
sessionStorage.removeItem('pendingSessionId');
}
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
Expand All @@ -780,7 +759,7 @@ export function useChatComposerState({
const toolsSettings = getToolsSettings();
const sessionSummary = getNotificationSessionSummary(submitSelectedSession, userVisibleInput);

startSessionCommand({
const launchResult = startSessionCommand({
sendMessage,
selectedProject,
command: messageContent,
Expand All @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions ui/src/components/chat/utils/sessionLauncher.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
18 changes: 13 additions & 5 deletions ui/src/components/chat/utils/sessionLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +19,11 @@ type StartSessionOptions = {
workspaceCwd?: string;
};

type StartSessionResult = {
sessionId: string;
sent: boolean;
};

const VALID_PERMISSION_MODES = new Set<PermissionMode>([
'default',
'acceptEdits',
Expand Down Expand Up @@ -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: {
Expand All @@ -112,7 +117,10 @@ export function startSessionCommand({
...(Array.isArray(attachments) && attachments.length > 0 ? { attachments } : {}),
...(workspaceCwd ? { workspaceCwd } : {}),
},
});
}) !== false;

return sessionToActivate;
return {
sessionId: sessionToActivate,
sent,
};
}
14 changes: 10 additions & 4 deletions ui/src/contexts/WebSocketContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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<WebSocketContextType['subscribe']>((handler) => {
Expand Down