From 20556bb21168ab26d136e2d89847576dc34a45f7 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Tue, 8 Jul 2025 01:06:34 +0000 Subject: [PATCH 1/3] Add streaming pormpt --- src/components/common/ModelSelector.tsx | 36 ++++++---- src/components/panels/ChatPanel.tsx | 35 +++++---- src/controllers/RunnerController.ts | 90 +++++++++++++++-------- src/core/models/Task.ts | 1 + src/core/services/ClaudeExecutor.ts | 63 ++++++++++++++-- src/services/ClaudeCodeService.ts | 2 +- src/services/ClaudeService.ts | 95 ++++++++++++++++++++++++- 7 files changed, 261 insertions(+), 61 deletions(-) diff --git a/src/components/common/ModelSelector.tsx b/src/components/common/ModelSelector.tsx index 51a9ad8..29c2707 100644 --- a/src/components/common/ModelSelector.tsx +++ b/src/components/common/ModelSelector.tsx @@ -5,34 +5,44 @@ interface ModelSelectorProps { model: string; onUpdateModel: (model: string) => void; disabled?: boolean; + hideLabel?: boolean; } const ModelSelector: React.FC = ({ model, onUpdateModel, disabled = false, + hideLabel = false, }) => { const models = AVAILABLE_MODELS.map((m) => ({ value: m.id, label: m.name, })); + const selectElement = ( + + ); + + if (hideLabel) { + return selectElement; + } + return (
- + {selectElement}
); }; diff --git a/src/components/panels/ChatPanel.tsx b/src/components/panels/ChatPanel.tsx index 87fcd59..31e6481 100644 --- a/src/components/panels/ChatPanel.tsx +++ b/src/components/panels/ChatPanel.tsx @@ -109,9 +109,10 @@ const ChatPanel: React.FC = ({ disabled }) => { disabled={disabled} /> - -
-
+ +
+

Chat

+
- +
+ + +
= ({ disabled }) => {
) : (
- +
+ + +
diff --git a/src/controllers/RunnerController.ts b/src/controllers/RunnerController.ts index 837b180..0afd7b1 100644 --- a/src/controllers/RunnerController.ts +++ b/src/controllers/RunnerController.ts @@ -1103,47 +1103,81 @@ export class RunnerController implements EventBus { chatSending: true, }); - // Build command and execute directly to get raw JSON output - const args = this.claudeCodeService.buildTaskCommand( + // Create assistant message placeholder + const assistantMessage = { + role: "assistant" as const, + content: "", + timestamp: new Date().toISOString(), + }; + + const messagesWithAssistant = [...updatedMessages, assistantMessage]; + let sessionId: string | undefined = isFirstMessage + ? undefined + : currentState.chatSessionId; + + // Stream message handler + const onStreamMessage = (streamMsg: any) => { + // Update session ID if provided + if (streamMsg.type === "system" && streamMsg.session_id) { + sessionId = streamMsg.session_id; + } + + // Handle process complete message + if (streamMsg.type === "process" && streamMsg.subtype === "complete") { + return; // Don't update UI yet, wait for executeStreamingTask to complete + } + + // Handle assistant messages + if (streamMsg.type === "assistant" && streamMsg.message) { + const content = streamMsg.message.content; + if (Array.isArray(content)) { + for (const item of content) { + if (item.type === "text" && item.text) { + assistantMessage.content += item.text; + } + } + } + + // Update UI with streaming content (but keep chatSending true) + this.updateState({ + chatMessages: messagesWithAssistant, + chatSessionId: sessionId, + chatSending: true, // Keep showing as sending + }); + + // Send update to webview + this.callbacks.postMessage?.({ + command: "chatConversationUpdate", + chatMessages: messagesWithAssistant, + sessionId: sessionId, + isStreaming: true, // Indicate we're still streaming + }); + } + }; + + // Execute streaming task + const result = await this.claudeService.executeStreamingTask( message, currentState.model, + currentState.rootPath, { allowAllTools: currentState.allowAllTools, - outputFormat: "json", - resumeSessionId: isFirstMessage - ? undefined - : currentState.chatSessionId, + resumeSessionId: sessionId, }, + onStreamMessage, ); - const commandResult = await this.claudeCodeService.executeCommand( - args, - currentState.rootPath, - ); - - if (!commandResult.success) { - throw new Error(commandResult.error ?? "Chat command failed"); - } - - // Parse the JSON response from raw output - const jsonResponse = JSON.parse(commandResult.output.trim()); - const assistantMessage = { - role: "assistant" as const, - content: jsonResponse.result || "No response", - timestamp: new Date().toISOString(), - }; - + // Final update - only set chatSending to false after process completes this.updateState({ - chatMessages: [...updatedMessages, assistantMessage], - chatSessionId: jsonResponse.session_id, + chatMessages: messagesWithAssistant, + chatSessionId: sessionId ?? result.sessionId, chatSending: false, }); - // Send full conversation state back to webview this.callbacks.postMessage?.({ command: "chatConversationUpdate", - chatMessages: [...updatedMessages, assistantMessage], - sessionId: jsonResponse.session_id, + chatMessages: messagesWithAssistant, + sessionId: sessionId ?? result.sessionId, }); } catch (error) { this.updateState({ chatSending: false }); diff --git a/src/core/models/Task.ts b/src/core/models/Task.ts index ffa8b68..42a843f 100644 --- a/src/core/models/Task.ts +++ b/src/core/models/Task.ts @@ -19,6 +19,7 @@ export interface TaskOptions { mcpConfig?: string; permissionPromptTool?: string; workingDirectory?: string; + onStreamMessage?: (message: any) => void; } export interface CommandResult { diff --git a/src/core/services/ClaudeExecutor.ts b/src/core/services/ClaudeExecutor.ts index fc0d39e..18f4316 100644 --- a/src/core/services/ClaudeExecutor.ts +++ b/src/core/services/ClaudeExecutor.ts @@ -44,6 +44,7 @@ export class ClaudeExecutor { args, workingDirectory, options.outputFormat, + options.onStreamMessage, ); if (!result.success) { @@ -477,6 +478,7 @@ export class ClaudeExecutor { args, workingDirectory, options.outputFormat, + options.onStreamMessage, ); } @@ -484,6 +486,7 @@ export class ClaudeExecutor { args: string[], cwd: string, outputFormat?: string, + onStreamMessage?: (message: any) => void, ): Promise { return new Promise((resolve) => { const child = spawn(args[0], args.slice(1), { @@ -497,6 +500,8 @@ export class ClaudeExecutor { let stdout = ""; let stderr = ""; + let buffer = ""; + let sessionId: string | undefined; if (child.stdin) { child.stdin.end(); @@ -504,7 +509,29 @@ export class ClaudeExecutor { if (child.stdout) { child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); + const chunk = data.toString(); + stdout += chunk; + + // Handle streaming JSON + if (outputFormat === "stream-json" && onStreamMessage) { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.trim()) { + try { + const message = JSON.parse(line); + if (message.type === "system" && message.session_id) { + sessionId = message.session_id; + } + onStreamMessage(message); + } catch (e) { + // Ignore invalid JSON lines + } + } + } + } }); } @@ -517,11 +544,37 @@ export class ClaudeExecutor { child.on("close", (code: number | null) => { this.currentProcess = null; + // Process any remaining buffer + if ( + outputFormat === "stream-json" && + onStreamMessage && + buffer.trim() + ) { + try { + const message = JSON.parse(buffer); + if (message.type === "system" && message.session_id) { + sessionId = message.session_id; + } + onStreamMessage(message); + } catch (e) { + // Ignore invalid JSON + } + } + + // Send process complete message for streaming + if (outputFormat === "stream-json" && onStreamMessage) { + onStreamMessage({ + type: "process", + subtype: "complete", + exitCode: code, + session_id: sessionId, + }); + } + const exitCode = code ?? 0; if (exitCode === 0) { // Extract sessionId if output format is JSON - let sessionId: string | undefined; - if (outputFormat === "json") { + if (outputFormat === "json" && !sessionId) { const parsed = this.parseTaskResult(stdout, outputFormat); sessionId = parsed.sessionId; } @@ -564,7 +617,7 @@ export class ClaudeExecutor { }); } - private buildTaskCommand( + buildTaskCommand( task: string, model: string, options: TaskOptions, @@ -593,7 +646,7 @@ export class ClaudeExecutor { args.push("--max-turns", options.maxTurns.toString()); } - if (options.verbose) { + if (options.verbose ?? options.outputFormat === "stream-json") { args.push("--verbose"); } diff --git a/src/services/ClaudeCodeService.ts b/src/services/ClaudeCodeService.ts index bfdcc9b..79ae1e0 100644 --- a/src/services/ClaudeCodeService.ts +++ b/src/services/ClaudeCodeService.ts @@ -508,7 +508,7 @@ export class ClaudeCodeService { args.push("--max-turns", options.maxTurns.toString()); } - if (options.verbose) { + if (options.verbose ?? options.outputFormat === "stream-json") { args.push("--verbose"); } diff --git a/src/services/ClaudeService.ts b/src/services/ClaudeService.ts index b8544d9..a68aa53 100644 --- a/src/services/ClaudeService.ts +++ b/src/services/ClaudeService.ts @@ -1,5 +1,10 @@ import { ClaudeExecutor } from "../core/services/ClaudeExecutor"; -import { TaskOptions, TaskItem, TaskResult } from "../core/models/Task"; +import { + TaskOptions, + TaskItem, + TaskResult, + CommandResult, +} from "../core/models/Task"; import { VSCodeLogger, VSCodeConfigSource } from "../adapters/vscode"; import { ConfigManager } from "../core/services/ConfigManager"; import { ClaudeDetectionService } from "./ClaudeDetectionService"; @@ -277,6 +282,94 @@ export class ClaudeService { } } + async executeStreamingTask( + task: string, + model: string, + workingDirectory: string, + options: TaskOptions = {}, + onStreamMessage: (message: any) => void, + ): Promise { + const startTime = Date.now(); + + try { + if (model !== "auto" && !this.configManager.validateModel(model)) { + throw new Error(`Invalid model: ${model}`); + } + + if (!this.configManager.validatePath(workingDirectory)) { + throw new Error(`Invalid working directory: ${workingDirectory}`); + } + + const streamOptions = { + ...options, + outputFormat: "stream-json" as const, + verbose: true, + }; + const args = this.buildTaskCommand(task, model, streamOptions); + + // Use executeCommand directly to handle streaming properly + const result = await this.executeCommand( + args, + workingDirectory, + "stream-json", + onStreamMessage, + ); + + if (!result.success) { + throw new Error(result.error ?? "Command execution failed"); + } + + const executionTime = Date.now() - startTime; + + return { + taskId: `task-${Date.now()}`, + success: true, + output: result.output, + sessionId: result.sessionId, + executionTimeMs: executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Streaming task execution failed", + error instanceof Error ? error : new Error(errorMessage), + ); + + return { + taskId: `task-${Date.now()}`, + success: false, + output: "", + error: errorMessage, + executionTimeMs: executionTime, + }; + } + } + + private buildTaskCommand( + task: string, + model: string, + options: TaskOptions, + ): string[] { + return this.executor.buildTaskCommand(task, model, options); + } + + private async executeCommand( + args: string[], + cwd: string, + outputFormat?: string, + onStreamMessage?: (message: any) => void, + ): Promise { + return await (this.executor as any).executeCommand( + args, + cwd, + outputFormat, + onStreamMessage, + ); + } + async pausePipelineExecution(): Promise { // Set pause flag - don't modify current task status yet this.pauseAfterCurrentTask = true; From 5aea755caac9ef6415219970e024431001d26c1e Mon Sep 17 00:00:00 2001 From: Mehdi Date: Tue, 8 Jul 2025 03:30:08 +0000 Subject: [PATCH 2/3] Fixed streaming --- src/components/panels/ChatPanel.tsx | 50 +++++++++------- src/contexts/ExtensionContext.tsx | 8 ++- src/controllers/RunnerController.ts | 58 ++++++++++++++++++- src/core/services/ClaudeExecutor.ts | 39 +++++++++++-- src/services/ClaudeCodeService.ts | 40 ++++++++++--- src/styles/components.css | 41 +++++++++++++ src/types/runner.ts | 3 + .../panels/ChatPanel.history.test.tsx | 1 + .../unit/components/panels/ChatPanel.test.tsx | 48 +++++++-------- 9 files changed, 225 insertions(+), 63 deletions(-) diff --git a/src/components/panels/ChatPanel.tsx b/src/components/panels/ChatPanel.tsx index 31e6481..9e0db85 100644 --- a/src/components/panels/ChatPanel.tsx +++ b/src/components/panels/ChatPanel.tsx @@ -59,9 +59,6 @@ const ChatPanel: React.FC = ({ disabled }) => { setMessages((prev) => [...prev, userMessage]); setInputMessage(""); - // Set chatSending to true to show the spinner - actions.updateMainState({ chatSending: true }); - try { // Send to extension host - this will handle full conversation actions.sendChatMessage( @@ -270,24 +267,35 @@ const ChatPanel: React.FC = ({ disabled }) => {
- + {(main.chatSending ?? false) || + (main.chatStopping ?? false) ? ( + + ) : ( + + )}
diff --git a/src/contexts/ExtensionContext.tsx b/src/contexts/ExtensionContext.tsx index aad92e9..cf27235 100644 --- a/src/contexts/ExtensionContext.tsx +++ b/src/contexts/ExtensionContext.tsx @@ -129,6 +129,7 @@ export interface MainViewState { }>; chatSessionId?: string; chatSending?: boolean; + chatStopping?: boolean; // Pause/Resume state isPaused: boolean; @@ -366,6 +367,7 @@ export interface ExtensionActions { // Chat Actions sendChatMessage: (message: string, isFirstMessage: boolean) => void; + stopChatGeneration: () => void; clearChatSession: () => void; // Commands View Actions @@ -560,6 +562,10 @@ export const ExtensionProvider: React.FC<{ children: ReactNode }> = ({ sendMessage("sendChatMessage", { message, isFirstMessage }); }, + stopChatGeneration: () => { + sendMessage("stopChatGeneration"); + }, + clearChatSession: () => { sendMessage("clearChatSession"); }, @@ -747,7 +753,7 @@ export const ExtensionProvider: React.FC<{ children: ReactNode }> = ({ updates: { chatMessages: message.chatMessages, chatSessionId: message.sessionId, - chatSending: false, + chatSending: message.isStreaming ?? false, }, }); } diff --git a/src/controllers/RunnerController.ts b/src/controllers/RunnerController.ts index 0afd7b1..7e99170 100644 --- a/src/controllers/RunnerController.ts +++ b/src/controllers/RunnerController.ts @@ -35,6 +35,7 @@ export class RunnerController implements EventBus { readonly state$ = new BehaviorSubject(this.getInitialState()); private callbacks: ControllerCallbacks = {}; private readonly commandsService: CommandsService; + private isChatCancelled = false; // Public method to get current state value public getCurrentState(): UIState { @@ -182,6 +183,9 @@ export class RunnerController implements EventBus { case "sendChatMessage": void this.sendChatMessage(cmd.message, cmd.isFirstMessage); break; + case "stopChatGeneration": + void this.stopChatGeneration(); + break; case "clearChatSession": this.clearChatSession(); break; @@ -475,7 +479,7 @@ export class RunnerController implements EventBus { private async cancelTask(): Promise { try { - this.claudeCodeService.cancelCurrentTask(); + await this.claudeCodeService.cancelCurrentTask(); // Clear all task and pause state on cancellation this.updateState({ @@ -1084,6 +1088,9 @@ export class RunnerController implements EventBus { isFirstMessage: boolean, ): Promise { try { + // Reset cancellation flag at start of new message + this.isChatCancelled = false; + const currentState = this.state$.value; // Add user message to chat @@ -1117,6 +1124,11 @@ export class RunnerController implements EventBus { // Stream message handler const onStreamMessage = (streamMsg: any) => { + // Check for cancellation + if (this.isChatCancelled) { + return; // Stop processing if cancelled + } + // Update session ID if provided if (streamMsg.type === "system" && streamMsg.session_id) { sessionId = streamMsg.session_id; @@ -1155,6 +1167,12 @@ export class RunnerController implements EventBus { } }; + // Check for cancellation before starting execution + if (this.isChatCancelled) { + this.updateState({ chatSending: false }); + return; + } + // Execute streaming task const result = await this.claudeService.executeStreamingTask( message, @@ -1167,6 +1185,12 @@ export class RunnerController implements EventBus { onStreamMessage, ); + // Check for cancellation after execution + if (this.isChatCancelled) { + this.updateState({ chatSending: false }); + return; + } + // Final update - only set chatSending to false after process completes this.updateState({ chatMessages: messagesWithAssistant, @@ -1178,6 +1202,7 @@ export class RunnerController implements EventBus { command: "chatConversationUpdate", chatMessages: messagesWithAssistant, sessionId: sessionId ?? result.sessionId, + isStreaming: false, }); } catch (error) { this.updateState({ chatSending: false }); @@ -1204,6 +1229,37 @@ export class RunnerController implements EventBus { }); } + private async stopChatGeneration(): Promise { + try { + // Set cancellation flag to prevent race conditions + this.isChatCancelled = true; + + // Set chatStopping state immediately for responsive UI + this.updateState({ chatStopping: true }); + + // Cancel the actual task + await this.claudeCodeService.cancelCurrentTask(); + + // Reset chat states after cancellation completes + this.updateState({ + chatSending: false, + chatStopping: false, + }); + + // Reset cancellation flag + this.isChatCancelled = false; + } catch (error) { + console.error("Error stopping chat generation:", error); + + // Reset states even on error + this.updateState({ + chatSending: false, + chatStopping: false, + }); + this.isChatCancelled = false; + } + } + private async deleteWorkflowState(executionId: string): Promise { try { await this.claudeCodeService.deleteWorkflowState(executionId); diff --git a/src/core/services/ClaudeExecutor.ts b/src/core/services/ClaudeExecutor.ts index 18f4316..da6b645 100644 --- a/src/core/services/ClaudeExecutor.ts +++ b/src/core/services/ClaudeExecutor.ts @@ -296,12 +296,39 @@ export class ClaudeExecutor { onComplete?.(tasks); } - cancelCurrentTask(): void { - if (this.currentProcess) { - this.logger.info("Cancelling current Claude task"); - this.currentProcess.kill("SIGTERM"); - this.currentProcess = null; - } + cancelCurrentTask(): Promise { + return new Promise((resolve) => { + if (this.currentProcess) { + this.logger.info("Cancelling current Claude task"); + const processToCancel = this.currentProcess; + this.currentProcess = null; // Prevent new commands from using this handle + + // The ONLY thing that resolves this promise is the process actually exiting + const onExit = () => { + processToCancel.removeAllListeners(); // Clean up listeners + resolve(); + }; + + processToCancel.once("exit", onExit); + processToCancel.once("error", onExit); // Also handle spawn errors + + // 1. Attempt graceful shutdown + processToCancel.kill("SIGTERM"); + + // 2. Set a non-resolving fallback to forcibly kill if it gets stuck + setTimeout(() => { + if (!processToCancel.killed) { + this.logger.warn( + "Process did not respond to SIGTERM, sending SIGKILL", + ); + processToCancel.kill("SIGKILL"); + } + // NOTE: We do NOT resolve() here - only the exit event resolves + }, 2000); // 2-second grace period + } else { + resolve(); + } + }); } isTaskRunning(): boolean { diff --git a/src/services/ClaudeCodeService.ts b/src/services/ClaudeCodeService.ts index 79ae1e0..e51076d 100644 --- a/src/services/ClaudeCodeService.ts +++ b/src/services/ClaudeCodeService.ts @@ -574,15 +574,39 @@ export class ClaudeCodeService { return args; } - cancelCurrentTask(): void { - if (this.currentProcess) { - // Cancelling current Claude task - this.currentProcess.kill("SIGTERM"); - this.currentProcess = null; - } + cancelCurrentTask(): Promise { + return new Promise((resolve) => { + if (this.currentProcess) { + const processToCancel = this.currentProcess; + this.currentProcess = null; // Prevent new commands from using this handle + + // The ONLY thing that resolves this promise is the process actually exiting + const onExit = () => { + processToCancel.removeAllListeners(); // Clean up listeners + resolve(); + }; - // Cancel pipeline execution - this.currentPipelineExecution = null; + processToCancel.once("exit", onExit); + processToCancel.once("error", onExit); // Also handle spawn errors + + // 1. Attempt graceful shutdown + processToCancel.kill("SIGTERM"); + + // 2. Set a non-resolving fallback to forcibly kill if it gets stuck + setTimeout(() => { + if (!processToCancel.killed) { + console.warn("Process did not respond to SIGTERM, sending SIGKILL"); + processToCancel.kill("SIGKILL"); + } + // NOTE: We do NOT resolve() here - only the exit event resolves + }, 2000); // 2-second grace period + } else { + resolve(); + } + + // Cancel pipeline execution + this.currentPipelineExecution = null; + }); } isTaskRunning(): boolean { diff --git a/src/styles/components.css b/src/styles/components.css index 3b97a58..44a2348 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -522,3 +522,44 @@ button { gap: var(--spacing-xs); align-items: center; } + +/* Chat send/stop button styles */ +.chat-send-button, +.chat-stop-button { + display: flex; + align-items: center; + gap: var(--spacing-xs); + min-width: calc(var(--spacing-lg) * 5); + justify-content: center; +} + +.send-icon, +.stop-icon { + font-size: var(--font-size-sm); + font-weight: bold; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--spacing-lg); + height: var(--spacing-lg); +} + +.send-icon { + color: var(--vscode-button-foreground); +} + +.stop-icon { + color: var(--vscode-button-secondaryForeground); + font-size: var(--font-size-sm); +} + +/* Chat stop button specific styling */ +.chat-stop-button { + background-color: var(--vscode-errorBackground); + color: var(--vscode-errorForeground); + border-color: var(--vscode-errorBackground); +} + +.chat-stop-button:hover:not(:disabled) { + background-color: var(--vscode-button-secondaryHoverBackground); +} diff --git a/src/types/runner.ts b/src/types/runner.ts index bf71f69..47d9883 100644 --- a/src/types/runner.ts +++ b/src/types/runner.ts @@ -85,6 +85,7 @@ export type RunnerCommand = | { kind: "createCommand"; name: string; isGlobal: boolean; rootPath: string } | { kind: "deleteCommand"; path: string } | { kind: "sendChatMessage"; message: string; isFirstMessage: boolean } + | { kind: "stopChatGeneration" } | { kind: "clearChatSession" } | { kind: "webviewError"; error: string }; @@ -256,6 +257,7 @@ export const RunnerCommandRegistry: { message: isString(m.message) ? m.message : "", isFirstMessage: isBoolean(m.isFirstMessage) ? m.isFirstMessage : false, }), + stopChatGeneration: () => ({ kind: "stopChatGeneration" }), clearChatSession: () => ({ kind: "clearChatSession" }), webviewError: (m) => ({ kind: "webviewError", @@ -317,6 +319,7 @@ export interface UIState { }>; chatSessionId?: string; chatSending?: boolean; + chatStopping?: boolean; // Claude version state claudeVersion: string; diff --git a/tests/unit/components/panels/ChatPanel.history.test.tsx b/tests/unit/components/panels/ChatPanel.history.test.tsx index 74dc2ae..f3c1edc 100644 --- a/tests/unit/components/panels/ChatPanel.history.test.tsx +++ b/tests/unit/components/panels/ChatPanel.history.test.tsx @@ -184,6 +184,7 @@ const createMockActions = (): ExtensionActions => ({ deleteWorkflowState: jest.fn(), getResumableWorkflows: jest.fn(), sendChatMessage: jest.fn(), + stopChatGeneration: jest.fn(), clearChatSession: jest.fn(), updateCommandsState: jest.fn(), scanCommands: jest.fn(), diff --git a/tests/unit/components/panels/ChatPanel.test.tsx b/tests/unit/components/panels/ChatPanel.test.tsx index d22aee2..ad29596 100644 --- a/tests/unit/components/panels/ChatPanel.test.tsx +++ b/tests/unit/components/panels/ChatPanel.test.tsx @@ -262,6 +262,7 @@ const createMockActions = (): ExtensionActions => ({ deleteWorkflowState: jest.fn(), getResumableWorkflows: jest.fn(), sendChatMessage: jest.fn(), + stopChatGeneration: jest.fn(), clearChatSession: jest.fn(), updateCommandsState: jest.fn(), scanCommands: jest.fn(), @@ -425,11 +426,6 @@ describe("ChatPanel", () => { const sendButton = screen.getByText("Send"); fireEvent.click(sendButton); - // Should set chatSending to true immediately - expect(mockActions.updateMainState).toHaveBeenCalledWith({ - chatSending: true, - }); - // Should send the message expect(mockActions.sendChatMessage).toHaveBeenCalledWith( "Test message", @@ -468,32 +464,32 @@ describe("ChatPanel", () => { it("disables send button when message is empty", () => { render(); - const sendButton = screen.getByText("Send"); + const sendButton = screen.getByText("Send").closest("button"); expect(sendButton).toBeDisabled(); }); - it("shows 'Processing...' when message is being sent", () => { + it("shows 'Stop' button when message is being sent", () => { const state = createMockExtensionState({ main: { chatSending: true }, }); render(); - expect(screen.getByText("Processing...")).toBeInTheDocument(); + expect(screen.getByText("Stop")).toBeInTheDocument(); }); - it("shows spinner element when message is being sent", () => { + it("shows stop icon when message is being sent", () => { const state = createMockExtensionState({ main: { chatSending: true }, }); render(); - // Check that the loading spinner element exists - const spinner = document.querySelector(".loading-spinner") as HTMLElement; - expect(spinner).toBeInTheDocument(); + // Check that the stop icon element exists + const stopIcon = document.querySelector(".stop-icon") as HTMLElement; + expect(stopIcon).toBeInTheDocument(); - // Check that the send button shows both spinner and text - const sendButton = screen.getByText("Processing...").parentElement; - expect(sendButton).toContainElement(spinner as HTMLElement); + // Check that the stop button shows both icon and text + const stopButton = screen.getByText("Stop").parentElement; + expect(stopButton).toContainElement(stopIcon as HTMLElement); }); it("clears chat when Clear Chat button is clicked", () => { @@ -556,7 +552,7 @@ describe("ChatPanel", () => { const terminalButton = screen.getByText("Terminal"); const extensionButton = screen.getByText("VSCode"); const input = screen.getByPlaceholderText(/Type your message/); - const sendButton = screen.getByText("Send"); + const sendButton = screen.getByText("Send").closest("button"); expect(terminalButton).toBeDisabled(); expect(extensionButton).toBeDisabled(); @@ -761,12 +757,12 @@ describe("ChatPanel", () => { render(); - // Should show the sending text with spinner - expect(screen.getByText("Processing...")).toBeInTheDocument(); + // Should show the stop button with icon + expect(screen.getByText("Stop")).toBeInTheDocument(); - // Should show spinner element - const spinner = document.querySelector(".loading-spinner"); - expect(spinner).toBeInTheDocument(); + // Should show stop icon element + const stopIcon = document.querySelector(".stop-icon"); + expect(stopIcon).toBeInTheDocument(); // Should show both user message and loading expect(screen.getByText("Hello")).toBeInTheDocument(); @@ -791,12 +787,12 @@ describe("ChatPanel", () => { , ); - // Should show sending text immediately - expect(screen.getByText("Processing...")).toBeInTheDocument(); + // Should show stop button when sending + expect(screen.getByText("Stop")).toBeInTheDocument(); - // Should show spinner element - const spinner = document.querySelector(".loading-spinner"); - expect(spinner).toBeInTheDocument(); + // Should show stop icon + const stopIcon = document.querySelector(".stop-icon"); + expect(stopIcon).toBeInTheDocument(); expect(screen.getByText("Test message")).toBeInTheDocument(); }); From c3d26b657ec6d1a094618d4545bb6e02a4763387 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Tue, 8 Jul 2025 03:50:54 +0000 Subject: [PATCH 3/3] Update version --- .github/workflows/docker-e2e.yml | 12 +++--------- .github/workflows/test-pipeline.yml | 8 ++++---- Makefile | 7 ++++++- VERSION | 2 +- jest.config.js | 2 ++ package.json | 22 ++++++++++++---------- 6 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docker-e2e.yml b/.github/workflows/docker-e2e.yml index 79295a0..c544726 100644 --- a/.github/workflows/docker-e2e.yml +++ b/.github/workflows/docker-e2e.yml @@ -2,17 +2,11 @@ name: Docker E2E Tests on: workflow_dispatch: - schedule: - - cron: '0 2 * * 0' # Weekly on Sunday at 2 AM - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] jobs: docker-e2e-tests: name: "Docker E2E Tests" - runs-on: ubuntu-latest + runs-on: self-hosted strategy: matrix: @@ -61,7 +55,7 @@ jobs: docker-test-summary: name: "Docker Test Summary" - runs-on: ubuntu-latest + runs-on: self-hosted needs: docker-e2e-tests if: always() @@ -81,7 +75,7 @@ jobs: echo "With Claude CLI:" >> $GITHUB_STEP_SUMMARY echo "- Installs Claude CLI via npm" >> $GITHUB_STEP_SUMMARY echo "- Tests full integration capabilities" >> $GITHUB_STEP_SUMMARY - echo "- Runs comprehensive E2E test suite" >> $GITHUB_STEP_SUMMARY + echo "- Runs comprehensive test suite (E2E tests temporarily disabled)" >> $GITHUB_STEP_SUMMARY echo "- Validates CLI detection and functionality" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Environment:" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test-pipeline.yml b/.github/workflows/test-pipeline.yml index ce55949..93b1121 100644 --- a/.github/workflows/test-pipeline.yml +++ b/.github/workflows/test-pipeline.yml @@ -10,7 +10,7 @@ on: jobs: test-without-claude: name: "Test Extension without Claude CLI" - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout code @@ -47,7 +47,7 @@ jobs: test-with-claude: name: "Test Extension with Claude CLI" - runs-on: ubuntu-latest + runs-on: self-hosted needs: test-without-claude steps: @@ -87,7 +87,7 @@ jobs: test-report: name: "Generate Test Report" - runs-on: ubuntu-latest + runs-on: self-hosted needs: [test-without-claude, test-with-claude] if: always() @@ -116,4 +116,4 @@ jobs: echo "- Main Window Loading Tests" >> $GITHUB_STEP_SUMMARY echo "- Claude CLI Detection Tests" >> $GITHUB_STEP_SUMMARY echo "- Claude CLI Integration Tests" >> $GITHUB_STEP_SUMMARY - echo "- End-to-End Workflow Tests" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "- End-to-End Workflow Tests (temporarily disabled)" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/Makefile b/Makefile index 46f264b..90a080b 100644 --- a/Makefile +++ b/Makefile @@ -134,9 +134,14 @@ test-unit: # Run end-to-end tests only test-e2e: - @echo "🧪 Running end-to-end tests..." + @echo "🚫 End-to-end tests are temporarily disabled in CI pipelines" @npm run test:e2e +# Run end-to-end tests manually (for development) +test-e2e-manual: + @echo "🧪 Running end-to-end tests manually..." + @npm run test:e2e:manual + # Run integration tests only test-integration: @echo "🧪 Running integration tests..." diff --git a/VERSION b/VERSION index 60a2d3e..44bb5d1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 \ No newline at end of file +0.4.1 \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 2a1914d..3ca2bbd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,8 @@ module.exports = { testPathIgnorePatterns: [ "/node_modules/", "/tests/unit/suite/", // Exclude VSCode extension tests (they use Mocha, not Jest) + "/tests/e2e/", // Exclude E2E tests (temporarily disabled in CI pipelines) + "/tests/integration/", // Exclude integration tests from unit test runs ], transform: { "^.+\\.(ts|tsx)$": [ diff --git a/package.json b/package.json index 0624ee6..11c6ebc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-runner", "displayName": "Claude Runner", "description": "Execute Claude Code commands directly from VS Code with an intuitive interface", - "version": "0.4.0", + "version": "0.4.1", "publisher": "Codingworkflow", "private": false, "license": "GPL-3.0", @@ -238,16 +238,18 @@ "lint:fix": "eslint . --ext ts,tsx --fix --ignore-path .gitignore", "test": "npm run test:unit", "test:integration": "npm run compile-tests && node ./out/tests/unit/runTest.js", - "test:unit": "jest", - "test:unit:watch": "jest --watch", - "test:unit:coverage": "jest --coverage", - "test:e2e": "jest --testPathPattern=tests/e2e --passWithNoTests", - "test:e2e:coverage": "jest --testPathPattern=tests/e2e --coverage", - "test:integration:coverage": "jest --testPathPattern=tests/integration --coverage", - "test:all": "npm run test:unit && npm run test:e2e && npm run test:integration", - "test:all:coverage": "jest --coverage --testPathPattern=\"(tests/e2e|tests/integration|src/test/services)\"", + "test:unit": "jest --testPathPattern=tests/unit", + "test:unit:watch": "jest --testPathPattern=tests/unit --watch", + "test:unit:coverage": "jest --testPathPattern=tests/unit --coverage", + "test:e2e": "echo 'E2E tests are temporarily disabled in CI pipelines'", + "test:e2e:manual": "jest --testPathPattern=tests/e2e --passWithNoTests", + "test:e2e:coverage": "echo 'E2E tests are temporarily disabled in CI pipelines'", + "test:e2e:coverage:manual": "jest --testPathPattern=tests/e2e --coverage", + "test:integration:coverage": "jest --testPathPattern=tests/integration --testPathIgnorePatterns=/node_modules/ --coverage", + "test:all": "npm run test:unit && npm run test:integration", + "test:all:coverage": "jest --coverage --testPathPattern=\"(tests/unit|tests/integration)\" --testPathIgnorePatterns=/node_modules/", "test:ci:without-claude": "npm run test:unit", - "test:ci:with-claude": "npm run test:ci:without-claude && npm run test:e2e && npm run test:integration", + "test:ci:with-claude": "npm run test:ci:without-claude && npm run test:integration", "test:watch": "npm run test -- --watch", "clean": "rimraf dist out coverage *.vsix *.log", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",