From b39975f9b4a4aabd078d9f93d106f6536efca746 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 16:56:46 +0000 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stop=20auto-retry?= =?UTF-8?q?=20for=20non-retryable=20error=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes perpetual retry loop when authentication or other non-retryable errors occur. Previously, all stream errors would trigger auto-retry, causing the system to infinitely retry errors that require user action. ## Problem When a user provides a bad API key (or hits quota limits, model not found, context exceeded), the system would enter a perpetual retry loop: 1. User sets API key via /providers set 2. Request fails with authentication error 3. useResumeManager auto-retries every 1-60s with exponential backoff 4. Error repeats forever until user force-closes cmux This happened because hasInterruptedStream() returned true for ALL stream-error types, triggering auto-retry even when user action is needed. ## Solution Classify error types into retryable vs non-retryable: **Non-retryable (require user action):** - authentication - Bad API key - quota - Billing/usage limits - model_not_found - Invalid model selection - context_exceeded - Message too long - aborted - User cancelled **Retryable (transient issues):** - network - Temporary network issues - server_error - Provider issues (5xx) - rate_limit - Can retry with backoff - api - Generic API errors - unknown - Defensive retry hasInterruptedStream() now checks errorType and returns false for non-retryable errors, preventing useResumeManager from auto-retrying. ## Testing Added 8 new test cases covering: - Each non-retryable error type returns false - Retryable error types (network, server_error, rate_limit) return true - All 19 tests pass _Generated with `cmux`_ --- src/hooks/useResumeManager.ts | 6 +- src/utils/messages/retryEligibility.test.ts | 322 +++++++++++++++++++- src/utils/messages/retryEligibility.ts | 58 +++- 3 files changed, 374 insertions(+), 12 deletions(-) diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 48f8cd1d3..4c33fdfba 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -4,7 +4,7 @@ import { CUSTOM_EVENTS } from "@/constants/events"; import { getAutoRetryKey, getRetryStateKey } from "@/constants/storage"; import { getSendOptionsFromStorage } from "@/utils/messages/sendOptions"; import { readPersistedState } from "./usePersistedState"; -import { hasInterruptedStream } from "@/utils/messages/retryEligibility"; +import { isEligibleForAutoRetry } from "@/utils/messages/retryEligibility"; import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; interface RetryState { @@ -92,10 +92,10 @@ export function useResumeManager() { return false; } - // 1. Must have interrupted stream (not currently streaming) + // 1. Must have interrupted stream that's eligible for auto-retry (not currently streaming) if (state.canInterrupt) return false; // Currently streaming - if (!hasInterruptedStream(state.messages, state.pendingStreamStartTime)) { + if (!isEligibleForAutoRetry(state.messages, state.pendingStreamStartTime)) { return false; } diff --git a/src/utils/messages/retryEligibility.test.ts b/src/utils/messages/retryEligibility.test.ts index b716f6626..f1b2985f0 100644 --- a/src/utils/messages/retryEligibility.test.ts +++ b/src/utils/messages/retryEligibility.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "@jest/globals"; -import { hasInterruptedStream } from "./retryEligibility"; +import { hasInterruptedStream, isEligibleForAutoRetry } from "./retryEligibility"; import type { DisplayedMessage } from "@/types/message"; describe("hasInterruptedStream", () => { @@ -234,4 +234,324 @@ describe("hasInterruptedStream", () => { const longAgo = Date.now() - 4000; // 4s ago - past 3s threshold expect(hasInterruptedStream(messages, longAgo)).toBe(true); }); + + describe("stream error types (all show manual retry UI)", () => { + it("returns true for authentication errors (shows manual retry)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Invalid API key", + errorType: "authentication", + historySequence: 2, + }, + ]; + expect(hasInterruptedStream(messages)).toBe(true); + }); + + it("returns true for network errors", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Network connection failed", + errorType: "network", + historySequence: 2, + }, + ]; + expect(hasInterruptedStream(messages)).toBe(true); + }); + }); +}); + +describe("isEligibleForAutoRetry", () => { + it("returns false for empty messages", () => { + expect(isEligibleForAutoRetry([])).toBe(false); + }); + + it("returns false for completed messages", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-1", + historyId: "assistant-1", + content: "Complete response", + historySequence: 2, + streamSequence: 0, + isStreaming: false, + isPartial: false, + isLastPartOfMessage: true, + isCompacted: false, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + + describe("non-retryable error types", () => { + it("returns false for authentication errors (requires user to fix API key)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Invalid API key", + errorType: "authentication", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + + it("returns false for quota errors (requires user to upgrade/wait)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Usage quota exceeded", + errorType: "quota", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + + it("returns false for model_not_found errors (requires user to select different model)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Model not found", + errorType: "model_not_found", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + + it("returns false for context_exceeded errors (requires user to reduce context)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Context length exceeded", + errorType: "context_exceeded", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + + it("returns false for aborted errors (user cancelled)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Request aborted", + errorType: "aborted", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + }); + + describe("retryable error types", () => { + it("returns true for network errors", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Network connection failed", + errorType: "network", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); + + it("returns true for server errors", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Internal server error", + errorType: "server_error", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); + + it("returns true for rate limit errors", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Rate limit exceeded", + errorType: "rate_limit", + historySequence: 2, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); + }); + + describe("partial messages and user messages", () => { + it("returns true for partial assistant messages", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-1", + historyId: "assistant-1", + content: "Incomplete response", + historySequence: 2, + streamSequence: 0, + isStreaming: false, + isPartial: true, + isLastPartOfMessage: true, + isCompacted: false, + }, + ]; + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); + + it("returns true for trailing user messages (app restart scenario)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "assistant", + id: "assistant-1", + historyId: "assistant-1", + content: "Complete response", + historySequence: 2, + streamSequence: 0, + isStreaming: false, + isPartial: false, + isLastPartOfMessage: true, + isCompacted: false, + }, + { + type: "user", + id: "user-2", + historyId: "user-2", + content: "Another question", + historySequence: 3, + }, + ]; + expect(isEligibleForAutoRetry(messages, null)).toBe(true); + }); + + it("returns false when user message sent very recently (< 3s)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + ]; + const justSent = Date.now() - 500; // 0.5s ago + expect(isEligibleForAutoRetry(messages, justSent)).toBe(false); + }); + }); }); diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index f222b52c1..6686744e3 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -1,16 +1,27 @@ import type { DisplayedMessage } from "@/types/message"; +import type { StreamErrorType } from "@/types/errors"; /** - * Check if messages contain an interrupted stream that can be retried - * - * Used by both: - * - AIView: To determine if RetryBarrier should be shown - * - useResumeManager: To determine if workspace is eligible for auto-retry + * Error types that should NOT be auto-retried because they require user action + * These errors won't resolve on their own - the user must fix the underlying issue + */ +const NON_RETRYABLE_ERRORS: StreamErrorType[] = [ + "authentication", // Bad API key - user must fix credentials + "quota", // Billing/usage limits - user must upgrade or wait for reset + "model_not_found", // Invalid model - user must select different model + "context_exceeded", // Message too long - user must reduce context + "aborted", // User cancelled - should not auto-retry +]; + +/** + * Check if messages contain an interrupted stream * - * This ensures DRY - both use the same logic for what constitutes a retryable state. + * Used by AIView to determine if RetryBarrier should be shown. + * Shows retry UI for ALL interrupted streams, including non-retryable errors + * (so users can manually retry after fixing the issue). * * Returns true if: - * 1. Last message is a stream-error + * 1. Last message is a stream-error (any type - user may have fixed the issue) * 2. Last message is a partial assistant/tool/reasoning message * 3. Last message is a user message (indicating we sent it but never got a response) * - This handles app restarts during slow model responses (models can take 30-60s to first token) @@ -34,10 +45,41 @@ export function hasInterruptedStream( const lastMessage = messages[messages.length - 1]; return ( - lastMessage.type === "stream-error" || // Stream errored out + lastMessage.type === "stream-error" || // Stream errored out (show UI for ALL error types) lastMessage.type === "user" || // No response received yet (app restart during slow model) (lastMessage.type === "assistant" && lastMessage.isPartial === true) || (lastMessage.type === "tool" && lastMessage.isPartial === true) || (lastMessage.type === "reasoning" && lastMessage.isPartial === true) ); } + +/** + * Check if messages are eligible for automatic retry + * + * Used by useResumeManager to determine if workspace should be auto-retried. + * Returns false for errors that require user action (authentication, quota, etc.), + * but still allows manual retry via RetryBarrier UI. + * + * This separates auto-retry logic from manual retry UI: + * - Manual retry: Always available for any error (hasInterruptedStream) + * - Auto retry: Only for transient errors that might resolve on their own + */ +export function isEligibleForAutoRetry( + messages: DisplayedMessage[], + pendingStreamStartTime: number | null = null +): boolean { + // First check if there's an interrupted stream at all + if (!hasInterruptedStream(messages, pendingStreamStartTime)) { + return false; + } + + // If the last message is a non-retryable error, don't auto-retry + // (but manual retry is still available via hasInterruptedStream) + const lastMessage = messages[messages.length - 1]; + if (lastMessage.type === "stream-error") { + return !NON_RETRYABLE_ERRORS.includes(lastMessage.errorType); + } + + // Other interrupted states (partial messages, user messages) are auto-retryable + return true; +} From 9ae760f4503ca881c59bd2b8fbb69dd8ca6ce166 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 17:39:36 +0000 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20-h=20and=20-?= =?UTF-8?q?p=20flags=20for=20server=20host/port=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users to specify which host and port to bind to when running in server mode, instead of hardcoding 0.0.0.0:3000. Usage: node dist/main.js server # default: 0.0.0.0:3000 node dist/main.js server -h localhost # bind to localhost only node dist/main.js server -h 0.0.0.0 -p 8080 # custom port Defaults to 0.0.0.0:3000 for network accessibility. _Generated with `cmux`_ --- src/main-server.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main-server.ts b/src/main-server.ts index 626371be9..8789b9d09 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -1,6 +1,11 @@ /** * HTTP/WebSocket Server for cmux * Allows accessing cmux backend from mobile devices + * + * Usage: node dist/main.js server [-h HOST] [-p PORT] + * Options: + * -h HOST Bind to specific host (default: 0.0.0.0) + * -p PORT Bind to specific port (default: 3000) */ import { Config } from "./config"; import { IPC_CHANNELS } from "@/constants/ipc-constants"; @@ -13,6 +18,22 @@ import * as path from "path"; import type { RawData } from "ws"; import { WebSocket, WebSocketServer } from "ws"; +// Parse command line arguments +const args = process.argv.slice(3); // Skip node, script, and "server" +let HOST = "0.0.0.0"; +let PORT = 3000; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "-h" && i + 1 < args.length) { + HOST = args[i + 1]; + i++; // Skip next arg since we consumed it + } else if (arg === "-p" && i + 1 < args.length) { + PORT = parseInt(args[i + 1], 10); + i++; + } +} + // Mock Electron's ipcMain for HTTP class HttpIpcMainAdapter { private handlers = new Map Promise>(); @@ -232,6 +253,6 @@ wss.on("connection", (ws) => { }); }); -server.listen(3000, () => { - console.log("Server is running on port 3000"); +server.listen(PORT, HOST, () => { + console.log(`Server is running on http://${HOST}:${PORT}`); }); From 00d47cfcc09b39fa67ddd152003857a6c39e03c2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 17:49:14 +0000 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20commander=20?= =?UTF-8?q?for=20proper=20CLI=20flag=20parsing=20in=20server=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces manual argument parsing with commander library for better UX: - Automatic help generation with --help - Proper flag validation (errors on unknown flags) - Supports both short (-h, -p) and long (--host, --port) flags - Type-safe option parsing Usage: node dist/main.js server --help node dist/main.js server # 0.0.0.0:3000 node dist/main.js server --host localhost node dist/main.js server -h 127.0.0.1 -p 8080 Dependencies: Added commander@14.0.2 _Generated with `cmux`_ --- bun.lock | 9 ++++++--- package.json | 7 ++++--- src/main-server.ts | 32 +++++++++++++------------------- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/bun.lock b/bun.lock index 1e66b2c80..89a4b1dc9 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@tailwindcss/vite": "^4.1.15", "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", + "@types/commander": "^2.12.5", "@types/cors": "^2.8.19", "@types/diff": "^8.0.0", "@types/escape-html": "^1.0.4", @@ -708,6 +709,8 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/commander": ["@types/commander@2.12.5", "", { "dependencies": { "commander": "*" } }, "sha512-YXGZ/rz+s57VbzcvEV9fUoXeJlBt5HaKu5iUheiIWNsJs23bz6AnRuRiZBRVBLYyPnixNvVnuzM5pSaxr8Yp/g=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], @@ -1186,7 +1189,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -3042,8 +3045,6 @@ "find-process/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "find-process/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "foreground-child/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -3252,6 +3253,8 @@ "ts-jest/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "tsc-alias/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "unzip-crx-3/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], diff --git a/package.json b/package.json index 79019211d..43f48d727 100644 --- a/package.json +++ b/package.json @@ -82,12 +82,14 @@ "devDependencies": { "@eslint/js": "^9.36.0", "@playwright/test": "^1.56.0", + "@storybook/addon-docs": "^10.0.0", "@storybook/addon-links": "^10.0.0", "@storybook/react-vite": "^10.0.0", "@storybook/test-runner": "^0.24.0", "@tailwindcss/vite": "^4.1.15", "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", + "@types/commander": "^2.12.5", "@types/cors": "^2.8.19", "@types/diff": "^8.0.0", "@types/escape-html": "^1.0.4", @@ -120,6 +122,7 @@ "eslint": "^9.36.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-storybook": "10.0.0", "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jest": "^30.1.3", "mermaid": "^11.12.0", @@ -146,9 +149,7 @@ "typescript-eslint": "^8.45.0", "vite": "^7.1.11", "vite-plugin-svgr": "^4.5.0", - "vite-plugin-top-level-await": "^1.6.0", - "eslint-plugin-storybook": "10.0.0", - "@storybook/addon-docs": "^10.0.0" + "vite-plugin-top-level-await": "^1.6.0" }, "files": [ "dist/**/*.js", diff --git a/src/main-server.ts b/src/main-server.ts index 8789b9d09..8d12f2c5d 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -1,11 +1,6 @@ /** * HTTP/WebSocket Server for cmux * Allows accessing cmux backend from mobile devices - * - * Usage: node dist/main.js server [-h HOST] [-p PORT] - * Options: - * -h HOST Bind to specific host (default: 0.0.0.0) - * -p PORT Bind to specific port (default: 3000) */ import { Config } from "./config"; import { IPC_CHANNELS } from "@/constants/ipc-constants"; @@ -17,22 +12,21 @@ import * as http from "http"; import * as path from "path"; import type { RawData } from "ws"; import { WebSocket, WebSocketServer } from "ws"; +import { Command } from "commander"; // Parse command line arguments -const args = process.argv.slice(3); // Skip node, script, and "server" -let HOST = "0.0.0.0"; -let PORT = 3000; - -for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-h" && i + 1 < args.length) { - HOST = args[i + 1]; - i++; // Skip next arg since we consumed it - } else if (arg === "-p" && i + 1 < args.length) { - PORT = parseInt(args[i + 1], 10); - i++; - } -} +const program = new Command(); + +program + .name("cmux-server") + .description("HTTP/WebSocket server for cmux - allows accessing cmux backend from mobile devices") + .option("-h, --host ", "bind to specific host", "0.0.0.0") + .option("-p, --port ", "bind to specific port", "3000") + .parse(process.argv); + +const options = program.opts(); +const HOST = options.host as string; +const PORT = parseInt(options.port as string, 10); // Mock Electron's ipcMain for HTTP class HttpIpcMainAdapter { From 1aa32e8f22bae7e13615eb6ad2a937f521f96817 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 17:55:15 +0000 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20emit=20stream-error?= =?UTF-8?q?=20for=20SendMessageError=20to=20display=20on=20page=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes issue where errors (like api_key_not_found) are invisible on page reload. Previously, when resumeStream failed during auto-retry, the error was silently swallowed and users only saw "Retrying" without knowing why. ## Problem SendMessageError (validation errors before streaming starts) were never emitted as stream-error events, so they: - Were not persisted to partial.json - Were not visible on page reload - Left users confused during auto-retry loops ## Solution Emit all SendMessageErrors as stream-error events in streamWithHistory: - Both sendMessage and resumeStream now emit errors consistently - Errors persist via partial.json and survive page reloads - ChatInput still shows toast for immediate feedback (in addition to stream-error) ## Parity Before: - sendMessage: validation fails → returns error → toast shown (ephemeral) - resumeStream: validation fails → returns error → silently retries (no UI) After: - sendMessage: validation fails → returns error + emits stream-error → toast + persisted - resumeStream: validation fails → returns error + emits stream-error → persisted Both paths now emit errors for consistent UI feedback and persistence. _Generated with `cmux`_ --- src/services/agentSession.ts | 56 +++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index 4d01bec95..feaec203e 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -360,19 +360,25 @@ export class AgentSession { await loadTokenizerForModel(modelString); } catch (error) { const reason = error instanceof Error ? error.message : String(error); - return Err( - createUnknownSendMessageError(`Failed to preload tokenizer for ${modelString}: ${reason}`) + const sendError = createUnknownSendMessageError( + `Failed to preload tokenizer for ${modelString}: ${reason}` ); + this.emitSendMessageError(sendError); + return Err(sendError); } const commitResult = await this.partialService.commitToHistory(this.workspaceId); if (!commitResult.success) { - return Err(createUnknownSendMessageError(commitResult.error)); + const sendError = createUnknownSendMessageError(commitResult.error); + this.emitSendMessageError(sendError); + return Err(sendError); } const historyResult = await this.historyService.getHistory(this.workspaceId); if (!historyResult.success) { - return Err(createUnknownSendMessageError(historyResult.error)); + const sendError = createUnknownSendMessageError(historyResult.error); + this.emitSendMessageError(sendError); + return Err(sendError); } // Enforce thinking policy for the specified model (single source of truth) @@ -394,9 +400,51 @@ export class AgentSession { options?.mode ); + // If streamMessage returns a SendMessageError (pre-stream validation failure), + // emit it as a stream-error event so it's visible in the UI + if (!streamResult.success) { + this.emitSendMessageError(streamResult.error); + } + return streamResult; } + /** + * Convert SendMessageError to StreamErrorMessage and emit it + * This ensures validation errors are visible in the chat UI and persist across reloads + */ + private emitSendMessageError(error: SendMessageError): void { + let errorMessage: string; + let errorType: StreamErrorMessage["errorType"]; + + switch (error.type) { + case "api_key_not_found": + errorMessage = `API key not found for ${error.provider}. Please configure your API key.`; + errorType = "authentication"; + break; + case "provider_not_supported": + errorMessage = `Provider ${error.provider} is not supported.`; + errorType = "unknown"; + break; + case "invalid_model_string": + errorMessage = error.message; + errorType = "unknown"; + break; + case "unknown": + errorMessage = error.raw; + errorType = "unknown"; + break; + } + + const streamError: StreamErrorMessage = { + type: "stream-error", + messageId: `error-${Date.now()}`, + error: errorMessage, + errorType, + }; + this.emitChatEvent(streamError); + } + private attachAiListeners(): void { const forward = (event: string, handler: (payload: WorkspaceChatMessage) => void) => { const wrapped = (...args: unknown[]) => { From 40a365e7d8506d96de533782070b2de37189dade Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 17:59:31 +0000 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20hot-reload?= =?UTF-8?q?=20dev=20mode=20for=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `make dev-server` for rapid iteration on server mode changes. Setup: - Watches TypeScript files in src/ and rebuilds main process - Watches dist/main.js and dist/main-server.js - Auto-restarts server on changes (500ms debounce) - Uses nodemon for process management Usage: make dev-server Server runs on http://localhost:3000 with automatic reload on file changes. Dependencies: Added nodemon@3.1.10 _Generated with `cmux`_ --- .gitignore | 2 ++ Makefile | 8 ++++++++ bun.lock | 17 +++++++++++++++++ package.json | 1 + 4 files changed, 28 insertions(+) diff --git a/.gitignore b/.gitignore index ef6b70066..0cc7415ad 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,5 @@ storybook-static/ *.tgz src/test-workspaces/ terminal-bench-results/ +nodemon.json +test_hot_reload.sh diff --git a/Makefile b/Makefile index d9d94def9..7d765266e 100644 --- a/Makefile +++ b/Makefile @@ -96,6 +96,14 @@ dev: node_modules/.installed build-main ## Start development server (Vite + tsgo "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ "vite" +dev-server: node_modules/.installed build-main ## Start server mode with hot reload + @echo "Starting server with hot reload (http://localhost:3000)..." + @bun x concurrently -k \ + "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ + "bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'node dist/main.js server'" + + + start: node_modules/.installed build-main build-preload build-static ## Build and start Electron app @bun x electron --remote-debugging-port=9222 . diff --git a/bun.lock b/bun.lock index 89a4b1dc9..78d71cffc 100644 --- a/bun.lock +++ b/bun.lock @@ -85,6 +85,7 @@ "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jest": "^30.1.3", "mermaid": "^11.12.0", + "nodemon": "^3.1.10", "playwright": "^1.56.0", "postcss": "^8.5.6", "posthog-js": "^1.276.0", @@ -1727,6 +1728,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -2213,6 +2216,8 @@ "node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="], + "nodemon": ["nodemon@3.1.10", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], @@ -2361,6 +2366,8 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -2677,6 +2684,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -2727,6 +2736,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], + "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -3185,6 +3196,10 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "nodemon/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "nodemon/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "nyc/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], "nyc/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -3527,6 +3542,8 @@ "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "nodemon/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "nyc/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "nyc/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], diff --git a/package.json b/package.json index 43f48d727..7ba750248 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "eslint-plugin-tailwindcss": "4.0.0-beta.0", "jest": "^30.1.3", "mermaid": "^11.12.0", + "nodemon": "^3.1.10", "playwright": "^1.56.0", "postcss": "^8.5.6", "posthog-js": "^1.276.0", From d6d70633d180e44d195b1d8dd30c832cbe18e849 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 18:58:53 +0000 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20infinite?= =?UTF-8?q?=20retry=20loop=20for=20non-retryable=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split retry eligibility logic: - hasInterruptedStream() shows UI for ALL errors - isEligibleForAutoRetry() filters non-retryable errors - isNonRetryableSendError() checks SendMessageError types - Fixed localStorage event synchronization: - useResumeManager now uses updatePersistedState() - Dispatches custom events for usePersistedState listeners - RetryBarrier receives updates immediately - Made RetryBarrier self-contained: - Accepts only workspaceId (like AgentStatusIndicator) - Manages own state and event handlers - Computes effectiveAutoRetry internally - Removed bloat from AIView.tsx - Fixed manual retry button: - Added isManual flag to bypass eligibility checks - User clicks = immediate retry regardless of error type - Auto-retry still respects eligibility rules - Centralized event type system: - CustomEventPayloads interface for all events - createCustomEvent() helper for type-safe dispatch - CustomEventType for type-safe listeners - Single source of truth for event payloads Tests: All 30 tests pass in retryEligibility.test.ts _Generated with `cmux`_ --- Makefile | 11 +- src/browser/api.ts | 4 +- src/components/AIView.tsx | 26 +-- src/components/CommandPalette.tsx | 6 +- .../Messages/ChatBarrier/RetryBarrier.tsx | 164 ++++++++++++------ src/constants/events.ts | 63 ++++++- src/hooks/useResumeManager.ts | 50 ++++-- src/main-server.ts | 6 +- src/services/agentSession.ts | 60 +------ src/stores/WorkspaceStore.ts | 8 +- src/utils/commands/sources.ts | 4 +- src/utils/messages/retryEligibility.test.ts | 37 +++- src/utils/messages/retryEligibility.ts | 20 ++- vite.config.ts | 7 +- 14 files changed, 292 insertions(+), 174 deletions(-) diff --git a/Makefile b/Makefile index 7d765266e..24cd13165 100644 --- a/Makefile +++ b/Makefile @@ -96,11 +96,16 @@ dev: node_modules/.installed build-main ## Start development server (Vite + tsgo "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ "vite" -dev-server: node_modules/.installed build-main ## Start server mode with hot reload - @echo "Starting server with hot reload (http://localhost:3000)..." +dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 for remote access + @echo "Starting dev-server..." + @echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000)" + @echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)" + @echo "" + @echo "For remote access: make dev-server VITE_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0" @bun x concurrently -k \ "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ - "bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'node dist/main.js server'" + "bun x nodemon --watch dist/main.js --watch dist/main-server.js --delay 500ms --exec 'node dist/main.js server --host $(or $(BACKEND_HOST),localhost) --port $(or $(BACKEND_PORT),3000)'" \ + "CMUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1) CMUX_VITE_PORT=$(or $(VITE_PORT),5173) VITE_BACKEND_URL=http://$(or $(BACKEND_HOST),localhost):$(or $(BACKEND_PORT),3000) vite" diff --git a/src/browser/api.ts b/src/browser/api.ts index 4be41e43d..16295be17 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -4,7 +4,9 @@ import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants"; import type { IPCApi } from "@/types/ipc"; -const API_BASE = window.location.origin; +// Backend URL - defaults to same origin, but can be overridden via VITE_BACKEND_URL +// This allows frontend (Vite :8080) to connect to backend (:3000) in dev mode +const API_BASE = import.meta.env.VITE_BACKEND_URL || window.location.origin; const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://"); interface InvokeResponse { diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index cefc027cc..477cbd2c2 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -74,19 +74,12 @@ const AIViewInner: React.FC = ({ undefined ); - // Auto-retry state (persisted per workspace, with cross-component sync) - // Semantics: - // true (default): System errors should auto-retry - // false: User stopped this (Ctrl+C), don't auto-retry until user re-engages - // State transitions are EXPLICIT only: - // - User presses Ctrl+C → false - // - User sends a message → true (clear intent: "I'm using this workspace") - // - User clicks manual retry button → true - // No automatic resets on stream events - prevents initialization bugs - const [autoRetry, setAutoRetry] = usePersistedState( + // Auto-retry state - minimal setter for keybinds and message sent handler + // RetryBarrier manages its own state, but we need this for Ctrl+C keybind + const [, setAutoRetry] = usePersistedState( getAutoRetryKey(workspaceId), - true, // Default to true - { listener: true } // Enable cross-component synchronization + true, + { listener: true } ); // Use auto-scroll hook for scroll management @@ -408,14 +401,7 @@ const AIViewInner: React.FC = ({ ); })} {/* Show RetryBarrier after the last message if needed */} - {showRetryBarrier && ( - setAutoRetry(false)} - onResetAutoRetry={() => setAutoRetry(true)} - /> - )} + {showRetryBarrier && } )} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 06da96b9c..307b20b73 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -4,7 +4,7 @@ import { useCommandRegistry } from "@/contexts/CommandRegistryContext"; import type { CommandAction } from "@/contexts/CommandRegistryContext"; import { formatKeybind, KEYBINDS, isEditableElement, matchesKeybind } from "@/utils/ui/keybinds"; import { getSlashCommandSuggestions } from "@/utils/slashCommands/suggestions"; -import { CUSTOM_EVENTS } from "@/constants/events"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; interface CommandPaletteProps { getSlashContext?: () => { providerNames: string[]; workspaceId?: string }; @@ -189,9 +189,7 @@ export const CommandPalette: React.FC = ({ getSlashContext shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`, run: () => { const text = s.replacement; - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { detail: { text } }) - ); + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { text })); }, })), }, diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 1be095f8b..85d5cef03 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,37 +1,37 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { usePersistedState } from "@/hooks/usePersistedState"; -import { getRetryStateKey } from "@/constants/storage"; -import { CUSTOM_EVENTS } from "@/constants/events"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { usePersistedState, updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; +import { getRetryStateKey, getAutoRetryKey } from "@/constants/storage"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import { cn } from "@/lib/utils"; +import type { SendMessageError } from "@/types/errors"; +import type { RetryState } from "@/hooks/useResumeManager"; +import { useWorkspaceState } from "@/stores/WorkspaceStore"; +import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; interface RetryBarrierProps { workspaceId: string; - autoRetry: boolean; - onStopAutoRetry: () => void; - onResetAutoRetry: () => void; className?: string; } const INITIAL_DELAY = 1000; // 1 second const MAX_DELAY = 60000; // 60 seconds (cap for exponential backoff) -interface RetryState { - attempt: number; - retryStartTime: number; -} - const defaultRetryState: RetryState = { attempt: 0, retryStartTime: Date.now(), }; -export const RetryBarrier: React.FC = ({ - workspaceId, - autoRetry, - onStopAutoRetry, - onResetAutoRetry, - className, -}) => { +export const RetryBarrier: React.FC = ({ workspaceId, className }) => { + // Get workspace state for computing effective autoRetry + const workspaceState = useWorkspaceState(workspaceId); + + // Read autoRetry preference from localStorage + const [autoRetry, setAutoRetry] = usePersistedState( + getAutoRetryKey(workspaceId), + true, // Default to true + { listener: true } + ); + // Use persisted state for retry tracking (survives workspace switches) // Read retry state (managed by useResumeManager) const [retryState] = usePersistedState( @@ -40,7 +40,26 @@ export const RetryBarrier: React.FC = ({ { listener: true } ); - const { attempt, retryStartTime } = retryState; + const { attempt, retryStartTime, lastError } = retryState || defaultRetryState; + + // Compute effective autoRetry state: user preference AND error is retryable + // This ensures UI shows "Retry" button (not "Retrying...") for non-retryable errors + const effectiveAutoRetry = useMemo(() => { + if (!autoRetry || !workspaceState) return false; + + // Check if current state is eligible for auto-retry + const messagesEligible = isEligibleForAutoRetry( + workspaceState.messages, + workspaceState.pendingStreamStartTime + ); + + // Also check RetryState for SendMessageErrors (from resumeStream failures) + if (lastError && isNonRetryableSendError(lastError)) { + return false; // Non-retryable SendMessageError + } + + return messagesEligible; + }, [autoRetry, workspaceState, lastError]); // Local state for UI const [countdown, setCountdown] = useState(0); @@ -71,16 +90,18 @@ export const RetryBarrier: React.FC = ({ // Emits event to useResumeManager instead of calling resumeStream directly // This keeps all retry logic centralized in one place const handleManualRetry = () => { - onResetAutoRetry(); // Re-enable auto-retry for next failure + setAutoRetry(true); // Re-enable auto-retry for next failure // Clear retry state to make workspace immediately eligible for resume - // (no retryState = defaults to immediately eligible in useResumeManager) - localStorage.removeItem(getRetryStateKey(workspaceId)); + // Use updatePersistedState to ensure listener-enabled hooks receive the update + updatePersistedState(getRetryStateKey(workspaceId), null); // Emit event to useResumeManager - it will handle the actual resume + // Pass isManual flag to bypass eligibility checks (user explicitly wants to retry) window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { - detail: { workspaceId }, + createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { + workspaceId, + isManual: true, }) ); }; @@ -88,39 +109,61 @@ export const RetryBarrier: React.FC = ({ // Stop auto-retry handler const handleStopAutoRetry = () => { setCountdown(0); - onStopAutoRetry(); + setAutoRetry(false); + }; + + // Format error message for display + const getErrorMessage = (error: SendMessageError): string => { + switch (error.type) { + case "api_key_not_found": + return `API key not found for ${error.provider}. Configure with /providers set ${error.provider} apiKey YOUR_KEY`; + case "provider_not_supported": + return `Provider ${error.provider} is not supported yet.`; + case "invalid_model_string": + return error.message; + case "unknown": + default: + return error.raw || "Unknown error occurred"; + } }; - if (autoRetry) { + if (effectiveAutoRetry) { // Auto-retry mode: Show countdown and stop button // useResumeManager handles the actual retry logic return (
-
- 🔄 -
- {countdown === 0 ? ( - <>Retrying... (attempt {attempt + 1}) - ) : ( - <> - Retrying in{" "} - {countdown}s (attempt{" "} - {attempt + 1}) - - )} +
+
+ 🔄 +
+ {countdown === 0 ? ( + <>Retrying... (attempt {attempt + 1}) + ) : ( + <> + Retrying in{" "} + {countdown}s{" "} + (attempt {attempt + 1}) + + )} +
+
- + {lastError && ( +
+ Error: {getErrorMessage(lastError)} +
+ )}
); } else { @@ -128,22 +171,29 @@ export const RetryBarrier: React.FC = ({ return (
-
- ⚠️ -
- Stream interrupted +
+
+ ⚠️ +
+ Stream interrupted +
+
- + {lastError && ( +
+ Error: {getErrorMessage(lastError)} +
+ )}
); } diff --git a/src/constants/events.ts b/src/constants/events.ts index 9f91f2e59..427cedddc 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -1,8 +1,12 @@ /** - * Custom Event Constants + * Custom Event Constants & Types * These are window-level custom events used for cross-component communication + * + * Each event has a corresponding type in CustomEventPayloads for type safety */ +import type { ThinkingLevel } from "@/types/thinking"; + export const CUSTOM_EVENTS = { /** * Event to show a toast notification when thinking level changes @@ -48,6 +52,63 @@ export const CUSTOM_EVENTS = { EXECUTE_COMMAND: "cmux:executeCommand", } as const; +/** + * Payload types for custom events + * Maps event names to their detail payload structure + */ +export interface CustomEventPayloads { + [CUSTOM_EVENTS.THINKING_LEVEL_TOAST]: { + workspaceId: string; + level: ThinkingLevel; + }; + [CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT]: { + text: string; + }; + [CUSTOM_EVENTS.OPEN_MODEL_SELECTOR]: never; // No payload + [CUSTOM_EVENTS.RESUME_CHECK_REQUESTED]: { + workspaceId: string; + isManual?: boolean; // true when user explicitly clicks retry (bypasses eligibility checks) + }; + [CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH]: { + workspaceId: string; + projectPath: string; + projectName: string; + workspacePath: string; + branch: string; + }; + [CUSTOM_EVENTS.EXECUTE_COMMAND]: { + commandId: string; + }; +} + +/** + * Type-safe custom event type + * Usage: CustomEventType + */ +export type CustomEventType = CustomEvent< + CustomEventPayloads[K] +>; + +/** + * Helper to create a typed custom event + * + * @example + * ```typescript + * const event = createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { + * workspaceId: 'abc123', + * isManual: true + * }); + * window.dispatchEvent(event); + * ``` + */ +export function createCustomEvent( + eventName: K, + ...args: CustomEventPayloads[K] extends never ? [] : [detail: CustomEventPayloads[K]] +): CustomEvent { + const [detail] = args; + return new CustomEvent(eventName, { detail } as CustomEventInit); +} + /** * Helper to create a storage change event name for a specific key * Used by usePersistedState for same-tab synchronization diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 4c33fdfba..20ab58f7c 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -1,15 +1,17 @@ import { useEffect, useRef } from "react"; import { useWorkspaceStoreRaw, type WorkspaceState } from "@/stores/WorkspaceStore"; -import { CUSTOM_EVENTS } from "@/constants/events"; +import { CUSTOM_EVENTS, type CustomEventType } from "@/constants/events"; import { getAutoRetryKey, getRetryStateKey } from "@/constants/storage"; import { getSendOptionsFromStorage } from "@/utils/messages/sendOptions"; -import { readPersistedState } from "./usePersistedState"; -import { isEligibleForAutoRetry } from "@/utils/messages/retryEligibility"; +import { readPersistedState, updatePersistedState } from "./usePersistedState"; +import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; import { applyCompactionOverrides } from "@/utils/messages/compactionOptions"; +import type { SendMessageError } from "@/types/errors"; -interface RetryState { +export interface RetryState { attempt: number; retryStartTime: number; + lastError?: SendMessageError; } const INITIAL_DELAY = 1000; // 1 second @@ -106,12 +108,19 @@ export function useResumeManager() { // 3. Must not already be retrying if (retryingRef.current.has(workspaceId)) return false; - // 4. Check exponential backoff timer + // 4. Check if previous error was non-retryable (e.g., api_key_not_found) const retryState = readPersistedState( getRetryStateKey(workspaceId), { attempt: 0, retryStartTime: Date.now() - INITIAL_DELAY } // Make immediately eligible on first check ); + if (retryState.lastError && isNonRetryableSendError(retryState.lastError)) { + // Don't auto-retry errors that require user action + // Manual retry is still available via RetryBarrier + return false; + } + + // 5. Check exponential backoff timer const { attempt, retryStartTime } = retryState; const delay = Math.min(INITIAL_DELAY * Math.pow(2, attempt), MAX_DELAY); const timeSinceLastRetry = Date.now() - retryStartTime; @@ -124,9 +133,13 @@ export function useResumeManager() { /** * Attempt to resume a workspace stream * Polling will check eligibility every 1 second + * + * @param workspaceId - The workspace to resume + * @param isManual - If true, bypass eligibility checks (user explicitly clicked retry) */ - const attemptResume = async (workspaceId: string) => { - if (!isEligibleForResume(workspaceId)) return; + const attemptResume = async (workspaceId: string, isManual = false) => { + // Skip eligibility checks for manual retries (user explicitly wants to retry) + if (!isManual && !isEligibleForResume(workspaceId)) return; // Mark as retrying retryingRef.current.add(workspaceId); @@ -157,24 +170,29 @@ export function useResumeManager() { const result = await window.api.workspace.resumeStream(workspaceId, options); if (!result.success) { - // Increment attempt and reset timer for next retry + // Store error in retry state so RetryBarrier can display it const newState: RetryState = { attempt: attempt + 1, retryStartTime: Date.now(), + lastError: result.error, }; - localStorage.setItem(getRetryStateKey(workspaceId), JSON.stringify(newState)); + updatePersistedState(getRetryStateKey(workspaceId), newState); } else { // Success - clear retry state entirely // If stream fails again, we'll start fresh (immediately eligible) - localStorage.removeItem(getRetryStateKey(workspaceId)); + updatePersistedState(getRetryStateKey(workspaceId), null); } - } catch { - // Increment attempt on error + } catch (error) { + // Store error in retry state for display const newState: RetryState = { attempt: attempt + 1, retryStartTime: Date.now(), + lastError: { + type: "unknown", + raw: error instanceof Error ? error.message : "Failed to resume stream", + }, }; - localStorage.setItem(getRetryStateKey(workspaceId), JSON.stringify(newState)); + updatePersistedState(getRetryStateKey(workspaceId), newState); } finally { // Always clear retrying flag retryingRef.current.delete(workspaceId); @@ -189,9 +207,9 @@ export function useResumeManager() { // Listen for resume check requests (primary mechanism) const handleResumeCheck = (event: Event) => { - const customEvent = event as CustomEvent<{ workspaceId: string }>; - const { workspaceId } = customEvent.detail; - void attemptResume(workspaceId); + const customEvent = event as CustomEventType; + const { workspaceId, isManual = false } = customEvent.detail; + void attemptResume(workspaceId, isManual); }; window.addEventListener(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, handleResumeCheck); diff --git a/src/main-server.ts b/src/main-server.ts index 8d12f2c5d..ba5f3e4d8 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -20,7 +20,7 @@ const program = new Command(); program .name("cmux-server") .description("HTTP/WebSocket server for cmux - allows accessing cmux backend from mobile devices") - .option("-h, --host ", "bind to specific host", "0.0.0.0") + .option("-h, --host ", "bind to specific host", "localhost") .option("-p, --port ", "bind to specific port", "3000") .parse(process.argv); @@ -149,7 +149,7 @@ app.get("/health", (req, res) => { // Fallback to index.html for SPA routes (use middleware instead of deprecated wildcard) app.use((req, res, next) => { if (!req.path.startsWith("/ipc") && !req.path.startsWith("/ws")) { - res.sendFile(path.join(__dirname, ".")); + res.sendFile(path.join(__dirname, "index.html")); } else { next(); } @@ -245,7 +245,7 @@ wss.on("connection", (ws) => { ws.on("error", (error) => { console.error("WebSocket error:", error); }); -}); + }); server.listen(PORT, HOST, () => { console.log(`Server is running on http://${HOST}:${PORT}`); diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index feaec203e..15706a2b1 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -360,25 +360,19 @@ export class AgentSession { await loadTokenizerForModel(modelString); } catch (error) { const reason = error instanceof Error ? error.message : String(error); - const sendError = createUnknownSendMessageError( - `Failed to preload tokenizer for ${modelString}: ${reason}` + return Err( + createUnknownSendMessageError(`Failed to preload tokenizer for ${modelString}: ${reason}`) ); - this.emitSendMessageError(sendError); - return Err(sendError); } const commitResult = await this.partialService.commitToHistory(this.workspaceId); if (!commitResult.success) { - const sendError = createUnknownSendMessageError(commitResult.error); - this.emitSendMessageError(sendError); - return Err(sendError); + return Err(createUnknownSendMessageError(commitResult.error)); } const historyResult = await this.historyService.getHistory(this.workspaceId); if (!historyResult.success) { - const sendError = createUnknownSendMessageError(historyResult.error); - this.emitSendMessageError(sendError); - return Err(sendError); + return Err(createUnknownSendMessageError(historyResult.error)); } // Enforce thinking policy for the specified model (single source of truth) @@ -387,7 +381,7 @@ export class AgentSession { ? enforceThinkingPolicy(modelString, options.thinkingLevel) : undefined; - const streamResult = await this.aiService.streamMessage( + return this.aiService.streamMessage( historyResult.data, this.workspaceId, modelString, @@ -399,50 +393,6 @@ export class AgentSession { options?.providerOptions, options?.mode ); - - // If streamMessage returns a SendMessageError (pre-stream validation failure), - // emit it as a stream-error event so it's visible in the UI - if (!streamResult.success) { - this.emitSendMessageError(streamResult.error); - } - - return streamResult; - } - - /** - * Convert SendMessageError to StreamErrorMessage and emit it - * This ensures validation errors are visible in the chat UI and persist across reloads - */ - private emitSendMessageError(error: SendMessageError): void { - let errorMessage: string; - let errorType: StreamErrorMessage["errorType"]; - - switch (error.type) { - case "api_key_not_found": - errorMessage = `API key not found for ${error.provider}. Please configure your API key.`; - errorType = "authentication"; - break; - case "provider_not_supported": - errorMessage = `Provider ${error.provider} is not supported.`; - errorType = "unknown"; - break; - case "invalid_model_string": - errorMessage = error.message; - errorType = "unknown"; - break; - case "unknown": - errorMessage = error.raw; - errorType = "unknown"; - break; - } - - const streamError: StreamErrorMessage = { - type: "stream-error", - messageId: `error-${Date.now()}`, - error: errorMessage, - errorType, - }; - this.emitChatEvent(streamError); } private attachAiListeners(): void { diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 73e598acc..48fcbe49a 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -7,7 +7,7 @@ import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; import { updatePersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey } from "@/constants/storage"; -import { CUSTOM_EVENTS } from "@/constants/events"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import { useSyncExternalStore } from "react"; import { isCaughtUpMessage, isStreamError, isDeleteMessage, isCmuxMessage } from "@/types/ipc"; import { MapStore } from "./MapStore"; @@ -263,11 +263,7 @@ export class WorkspaceStore { * Triggers useResumeManager to check if interrupted stream can be resumed. */ private dispatchResumeCheck(workspaceId: string): void { - window.dispatchEvent( - new CustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { - detail: { workspaceId }, - }) - ); + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { workspaceId })); } /** diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 6a24e2644..99fa6ba1a 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -1,7 +1,7 @@ import type { CommandAction } from "@/contexts/CommandRegistryContext"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import type { ThinkingLevel } from "@/types/thinking"; -import { CUSTOM_EVENTS } from "@/constants/events"; +import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import type { ProjectConfig } from "@/config"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; @@ -357,7 +357,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi section: section.mode, shortcutHint: formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR), run: () => { - window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR)); + window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR)); }, }, ]; diff --git a/src/utils/messages/retryEligibility.test.ts b/src/utils/messages/retryEligibility.test.ts index f1b2985f0..28f5a7c04 100644 --- a/src/utils/messages/retryEligibility.test.ts +++ b/src/utils/messages/retryEligibility.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "@jest/globals"; -import { hasInterruptedStream, isEligibleForAutoRetry } from "./retryEligibility"; +import { hasInterruptedStream, isEligibleForAutoRetry, isNonRetryableSendError } from "./retryEligibility"; import type { DisplayedMessage } from "@/types/message"; +import type { SendMessageError } from "@/types/errors"; describe("hasInterruptedStream", () => { it("returns false for empty messages", () => { @@ -555,3 +556,37 @@ describe("isEligibleForAutoRetry", () => { }); }); }); + +describe("isNonRetryableSendError", () => { + it("returns true for api_key_not_found error", () => { + const error: SendMessageError = { + type: "api_key_not_found", + provider: "anthropic", + }; + expect(isNonRetryableSendError(error)).toBe(true); + }); + + it("returns true for provider_not_supported error", () => { + const error: SendMessageError = { + type: "provider_not_supported", + provider: "unknown-provider", + }; + expect(isNonRetryableSendError(error)).toBe(true); + }); + + it("returns true for invalid_model_string error", () => { + const error: SendMessageError = { + type: "invalid_model_string", + message: "Invalid model format", + }; + expect(isNonRetryableSendError(error)).toBe(true); + }); + + it("returns false for unknown error", () => { + const error: SendMessageError = { + type: "unknown", + raw: "Some transient error", + }; + expect(isNonRetryableSendError(error)).toBe(false); + }); +}); diff --git a/src/utils/messages/retryEligibility.ts b/src/utils/messages/retryEligibility.ts index 6686744e3..199ba92d0 100644 --- a/src/utils/messages/retryEligibility.ts +++ b/src/utils/messages/retryEligibility.ts @@ -1,11 +1,11 @@ import type { DisplayedMessage } from "@/types/message"; -import type { StreamErrorType } from "@/types/errors"; +import type { StreamErrorType, SendMessageError } from "@/types/errors"; /** * Error types that should NOT be auto-retried because they require user action * These errors won't resolve on their own - the user must fix the underlying issue */ -const NON_RETRYABLE_ERRORS: StreamErrorType[] = [ +const NON_RETRYABLE_STREAM_ERRORS: StreamErrorType[] = [ "authentication", // Bad API key - user must fix credentials "quota", // Billing/usage limits - user must upgrade or wait for reset "model_not_found", // Invalid model - user must select different model @@ -13,6 +13,20 @@ const NON_RETRYABLE_ERRORS: StreamErrorType[] = [ "aborted", // User cancelled - should not auto-retry ]; +/** + * Check if a SendMessageError (from resumeStream failures) is non-retryable + */ +export function isNonRetryableSendError(error: SendMessageError): boolean { + switch (error.type) { + case "api_key_not_found": // Missing API key - user must configure + case "provider_not_supported": // Unsupported provider - user must switch + case "invalid_model_string": // Bad model format - user must fix + return true; + case "unknown": + return false; // Unknown errors might be transient + } +} + /** * Check if messages contain an interrupted stream * @@ -77,7 +91,7 @@ export function isEligibleForAutoRetry( // (but manual retry is still available via hasInterruptedStream) const lastMessage = messages[messages.length - 1]; if (lastMessage.type === "stream-error") { - return !NON_RETRYABLE_ERRORS.includes(lastMessage.errorType); + return !NON_RETRYABLE_STREAM_ERRORS.includes(lastMessage.errorType); } // Other interrupted states (partial messages, user messages) are auto-retryable diff --git a/vite.config.ts b/vite.config.ts index df1f27413..c881b9388 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,9 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const disableMermaid = process.env.VITE_DISABLE_MERMAID === "1"; + +// Vite server configuration (for dev-server remote access) +const devServerHost = process.env.CMUX_VITE_HOST ?? "127.0.0.1"; // Secure by default const devServerPort = Number(process.env.CMUX_VITE_PORT ?? "5173"); const previewPort = Number(process.env.CMUX_VITE_PREVIEW_PORT ?? "4173"); @@ -81,10 +84,10 @@ export default defineConfig(({ mode }) => ({ plugins: [topLevelAwait()], }, server: { - host: "127.0.0.1", + host: devServerHost, // Configurable via CMUX_VITE_HOST (defaults to 127.0.0.1 for security) port: devServerPort, strictPort: true, - allowedHosts: ["localhost", "127.0.0.1"], + allowedHosts: devServerHost === "0.0.0.0" ? undefined : ["localhost", "127.0.0.1"], sourcemapIgnoreList: () => false, // Show all sources in DevTools }, preview: { From 21b21950ef15563a0dd922edd99f7524abf015c2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 19:01:46 +0000 Subject: [PATCH 07/11] fix: lint errors and formatting --- src/browser/api.ts | 2 +- src/components/AIView.tsx | 8 +++----- .../Messages/ChatBarrier/RetryBarrier.tsx | 14 +++++++------- src/constants/events.ts | 4 ++-- src/hooks/useResumeManager.ts | 2 +- src/main-server.ts | 2 +- src/utils/messages/retryEligibility.test.ts | 6 +++++- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/browser/api.ts b/src/browser/api.ts index 16295be17..75e1c08e8 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -6,7 +6,7 @@ import type { IPCApi } from "@/types/ipc"; // Backend URL - defaults to same origin, but can be overridden via VITE_BACKEND_URL // This allows frontend (Vite :8080) to connect to backend (:3000) in dev mode -const API_BASE = import.meta.env.VITE_BACKEND_URL || window.location.origin; +const API_BASE = import.meta.env.VITE_BACKEND_URL ?? window.location.origin; const WS_BASE = API_BASE.replace("http://", "ws://").replace("https://", "wss://"); interface InvokeResponse { diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 477cbd2c2..13991ffeb 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -76,11 +76,9 @@ const AIViewInner: React.FC = ({ // Auto-retry state - minimal setter for keybinds and message sent handler // RetryBarrier manages its own state, but we need this for Ctrl+C keybind - const [, setAutoRetry] = usePersistedState( - getAutoRetryKey(workspaceId), - true, - { listener: true } - ); + const [, setAutoRetry] = usePersistedState(getAutoRetryKey(workspaceId), true, { + listener: true, + }); // Use auto-scroll hook for scroll management const { diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index 85d5cef03..d7f7b9f81 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { usePersistedState, updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; +import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey, getAutoRetryKey } from "@/constants/storage"; import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import { cn } from "@/lib/utils"; @@ -137,7 +137,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa className )} > -
+
🔄
@@ -160,8 +160,8 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
{lastError && ( -
- Error: {getErrorMessage(lastError)} +
+ Error: {getErrorMessage(lastError)}
)}
@@ -175,7 +175,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa className )} > -
+
⚠️
@@ -190,8 +190,8 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
{lastError && ( -
- Error: {getErrorMessage(lastError)} +
+ Error: {getErrorMessage(lastError)}
)}
diff --git a/src/constants/events.ts b/src/constants/events.ts index 427cedddc..844d5e3f5 100644 --- a/src/constants/events.ts +++ b/src/constants/events.ts @@ -1,7 +1,7 @@ /** * Custom Event Constants & Types * These are window-level custom events used for cross-component communication - * + * * Each event has a corresponding type in CustomEventPayloads for type safety */ @@ -91,7 +91,7 @@ export type CustomEventType = CustomEvent< /** * Helper to create a typed custom event - * + * * @example * ```typescript * const event = createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { diff --git a/src/hooks/useResumeManager.ts b/src/hooks/useResumeManager.ts index 20ab58f7c..b9eea7e41 100644 --- a/src/hooks/useResumeManager.ts +++ b/src/hooks/useResumeManager.ts @@ -133,7 +133,7 @@ export function useResumeManager() { /** * Attempt to resume a workspace stream * Polling will check eligibility every 1 second - * + * * @param workspaceId - The workspace to resume * @param isManual - If true, bypass eligibility checks (user explicitly clicked retry) */ diff --git a/src/main-server.ts b/src/main-server.ts index ba5f3e4d8..3be56ecef 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -245,7 +245,7 @@ wss.on("connection", (ws) => { ws.on("error", (error) => { console.error("WebSocket error:", error); }); - }); +}); server.listen(PORT, HOST, () => { console.log(`Server is running on http://${HOST}:${PORT}`); diff --git a/src/utils/messages/retryEligibility.test.ts b/src/utils/messages/retryEligibility.test.ts index 28f5a7c04..9719b4a76 100644 --- a/src/utils/messages/retryEligibility.test.ts +++ b/src/utils/messages/retryEligibility.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "@jest/globals"; -import { hasInterruptedStream, isEligibleForAutoRetry, isNonRetryableSendError } from "./retryEligibility"; +import { + hasInterruptedStream, + isEligibleForAutoRetry, + isNonRetryableSendError, +} from "./retryEligibility"; import type { DisplayedMessage } from "@/types/message"; import type { SendMessageError } from "@/types/errors"; From dea14c84ce0dc91f4935df40094f1f9d94b76957 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 19:04:41 +0000 Subject: [PATCH 08/11] refactor: centralize error message formatting Created src/utils/errors/formatSendError.ts as single source of truth for SendMessageError formatting. Both RetryBarrier and ChatInputToasts now use formatSendMessageError() helper, eliminating duplicate logic for provider commands and error messages. - formatSendMessageError() returns message + optional providerCommand - RetryBarrier combines them: "message. Configure with command" - ChatInputToasts uses providerCommand in solution section - All provider-related strings now defined in one place --- src/components/ChatInputToasts.tsx | 29 +++++++++----- .../Messages/ChatBarrier/RetryBarrier.tsx | 23 +++++------ src/utils/errors/formatSendError.ts | 40 +++++++++++++++++++ 3 files changed, 68 insertions(+), 24 deletions(-) create mode 100644 src/utils/errors/formatSendError.ts diff --git a/src/components/ChatInputToasts.tsx b/src/components/ChatInputToasts.tsx index 88f6b8abc..c073cab23 100644 --- a/src/components/ChatInputToasts.tsx +++ b/src/components/ChatInputToasts.tsx @@ -3,6 +3,7 @@ import type { Toast } from "./ChatInputToast"; import { SolutionLabel } from "./ChatInputToast"; import type { ParsedCommand } from "@/utils/slashCommands/types"; import type { SendMessageError as SendMessageErrorType } from "@/types/errors"; +import { formatSendMessageError } from "@/utils/errors/formatSendError"; /** * Creates a toast message for command-related errors and help messages @@ -146,26 +147,29 @@ export const createCommandToast = (parsed: ParsedCommand): Toast | null => { */ export const createErrorToast = (error: SendMessageErrorType): Toast => { switch (error.type) { - case "api_key_not_found": + case "api_key_not_found": { + const formatted = formatSendMessageError(error); return { id: Date.now().toString(), type: "error", title: "API Key Not Found", message: `The ${error.provider} provider requires an API key to function.`, - solution: ( + solution: formatted.providerCommand ? ( <> Quick Fix: - /providers set {error.provider} apiKey YOUR_API_KEY + {formatted.providerCommand} - ), + ) : undefined, }; + } - case "provider_not_supported": + case "provider_not_supported": { + const formatted = formatSendMessageError(error); return { id: Date.now().toString(), type: "error", title: "Provider Not Supported", - message: `The ${error.provider} provider is not supported yet.`, + message: formatted.message, solution: ( <> Try This: @@ -173,13 +177,15 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { ), }; + } - case "invalid_model_string": + case "invalid_model_string": { + const formatted = formatSendMessageError(error); return { id: Date.now().toString(), type: "error", title: "Invalid Model Format", - message: error.message, + message: formatted.message, solution: ( <> Expected Format: @@ -187,14 +193,17 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { ), }; + } case "unknown": - default: + default: { + const formatted = formatSendMessageError(error); return { id: Date.now().toString(), type: "error", title: "Message Send Failed", - message: error.raw || "An unexpected error occurred while sending your message.", + message: formatted.message, }; + } } }; diff --git a/src/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/components/Messages/ChatBarrier/RetryBarrier.tsx index d7f7b9f81..27c58e91e 100644 --- a/src/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -3,10 +3,10 @@ import { usePersistedState, updatePersistedState } from "@/hooks/usePersistedSta import { getRetryStateKey, getAutoRetryKey } from "@/constants/storage"; import { CUSTOM_EVENTS, createCustomEvent } from "@/constants/events"; import { cn } from "@/lib/utils"; -import type { SendMessageError } from "@/types/errors"; import type { RetryState } from "@/hooks/useResumeManager"; import { useWorkspaceState } from "@/stores/WorkspaceStore"; import { isEligibleForAutoRetry, isNonRetryableSendError } from "@/utils/messages/retryEligibility"; +import { formatSendMessageError } from "@/utils/errors/formatSendError"; interface RetryBarrierProps { workspaceId: string; @@ -112,19 +112,14 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa setAutoRetry(false); }; - // Format error message for display - const getErrorMessage = (error: SendMessageError): string => { - switch (error.type) { - case "api_key_not_found": - return `API key not found for ${error.provider}. Configure with /providers set ${error.provider} apiKey YOUR_KEY`; - case "provider_not_supported": - return `Provider ${error.provider} is not supported yet.`; - case "invalid_model_string": - return error.message; - case "unknown": - default: - return error.raw || "Unknown error occurred"; - } + // Format error message for display (centralized logic) + const getErrorMessage = (error: typeof lastError): string => { + if (!error) return ""; + const formatted = formatSendMessageError(error); + // Combine message with command if available + return formatted.providerCommand + ? `${formatted.message} Configure with ${formatted.providerCommand}` + : formatted.message; }; if (effectiveAutoRetry) { diff --git a/src/utils/errors/formatSendError.ts b/src/utils/errors/formatSendError.ts new file mode 100644 index 000000000..caf276c1b --- /dev/null +++ b/src/utils/errors/formatSendError.ts @@ -0,0 +1,40 @@ +/** + * Centralized error message formatting for SendMessageError types + * Used by both RetryBarrier and ChatInputToasts + */ + +import type { SendMessageError } from "@/types/errors"; + +export interface FormattedError { + message: string; + providerCommand?: string; // e.g., "/providers set anthropic apiKey YOUR_KEY" +} + +/** + * Format a SendMessageError into a user-friendly message + * Returns both the message and an optional command suggestion + */ +export function formatSendMessageError(error: SendMessageError): FormattedError { + switch (error.type) { + case "api_key_not_found": + return { + message: `API key not found for ${error.provider}.`, + providerCommand: `/providers set ${error.provider} apiKey YOUR_API_KEY`, + }; + + case "provider_not_supported": + return { + message: `Provider ${error.provider} is not supported yet.`, + }; + + case "invalid_model_string": + return { + message: error.message, + }; + + case "unknown": + return { + message: error.raw || "Unknown error occurred", + }; + } +} From 8e3cf0ea74726bd010040357d9d9f3404de1049a Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 19:06:16 +0000 Subject: [PATCH 09/11] fix: match expected error message in tests --- src/utils/errors/formatSendError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/errors/formatSendError.ts b/src/utils/errors/formatSendError.ts index caf276c1b..b9e7d680e 100644 --- a/src/utils/errors/formatSendError.ts +++ b/src/utils/errors/formatSendError.ts @@ -34,7 +34,7 @@ export function formatSendMessageError(error: SendMessageError): FormattedError case "unknown": return { - message: error.raw || "Unknown error occurred", + message: error.raw || "An unexpected error occurred", }; } } From a42519c0e8044ef02dab80cd2ec79391e0ea2d41 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 19:08:44 +0000 Subject: [PATCH 10/11] test: clarify distinction between UI display and auto-retry Added tests that explicitly demonstrate the key distinction: - hasInterruptedStream = Should RetryBarrier UI be shown? - isEligibleForAutoRetry = Should system auto-retry? For non-retryable errors (authentication, quota): - hasInterruptedStream returns true (show UI) - isEligibleForAutoRetry returns false (don't auto-retry) - Result: User sees manual Retry button, not 'Retrying...' For retryable errors (network, server): - Both return true - Result: User sees 'Retrying...' with countdown and exponential backoff This prevents confusion about why we have two similar-sounding functions. --- src/utils/messages/retryEligibility.test.ts | 108 ++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/utils/messages/retryEligibility.test.ts b/src/utils/messages/retryEligibility.test.ts index 9719b4a76..7148ad4bb 100644 --- a/src/utils/messages/retryEligibility.test.ts +++ b/src/utils/messages/retryEligibility.test.ts @@ -286,6 +286,114 @@ describe("hasInterruptedStream", () => { }); describe("isEligibleForAutoRetry", () => { + describe("hasInterruptedStream vs isEligibleForAutoRetry distinction", () => { + // These tests demonstrate the key distinction in our retry system: + // - hasInterruptedStream: Should UI show retry barrier? (YES for all errors) + // - isEligibleForAutoRetry: Should system auto-retry? (NO for non-retryable errors) + + describe("authentication errors", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Invalid API key", + errorType: "authentication", + historySequence: 2, + }, + ]; + + it("hasInterruptedStream returns true (show UI)", () => { + expect(hasInterruptedStream(messages)).toBe(true); + }); + + it("isEligibleForAutoRetry returns false (don't auto-retry)", () => { + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + + it("behavior: UI shows Retry button, not 'Retrying...'", () => { + // This combination means: + // - User sees RetryBarrier with manual "Retry" button + // - System does NOT auto-retry (infinite loop prevention) + // - User can manually retry after fixing API key + expect(hasInterruptedStream(messages)).toBe(true); + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + }); + + describe("quota errors", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Usage quota exceeded", + errorType: "quota", + historySequence: 2, + }, + ]; + + it("hasInterruptedStream returns true (show UI)", () => { + expect(hasInterruptedStream(messages)).toBe(true); + }); + + it("isEligibleForAutoRetry returns false (don't auto-retry)", () => { + expect(isEligibleForAutoRetry(messages)).toBe(false); + }); + }); + + describe("network errors (retryable)", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "user-1", + historyId: "user-1", + content: "Hello", + historySequence: 1, + }, + { + type: "stream-error", + id: "error-1", + historyId: "assistant-1", + error: "Network connection failed", + errorType: "network", + historySequence: 2, + }, + ]; + + it("hasInterruptedStream returns true (show UI)", () => { + expect(hasInterruptedStream(messages)).toBe(true); + }); + + it("isEligibleForAutoRetry returns true (auto-retry with backoff)", () => { + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); + + it("behavior: UI shows 'Retrying...' with countdown", () => { + // This combination means: + // - User sees RetryBarrier with "Retrying... (attempt N)" + // - System auto-retries with exponential backoff + // - User can stop with Ctrl+C + expect(hasInterruptedStream(messages)).toBe(true); + expect(isEligibleForAutoRetry(messages)).toBe(true); + }); + }); + }); + it("returns false for empty messages", () => { expect(isEligibleForAutoRetry([])).toBe(false); }); From 5a03fe00e478aed26dc53fa112a9634e9f8de621 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 29 Oct 2025 19:09:40 +0000 Subject: [PATCH 11/11] test: remove trivial hasInterruptedStream tests Removed tests that only checked 'does stream-error mean interrupted?' without testing actual retry logic. These were misleading because: - They don't test retry behavior - They're trivial (of course errors mean interrupted) - Test names implied retry testing but just checked message detection Kept only tests that verify isEligibleForAutoRetry correctly filters non-retryable errors, which is the actual important logic. --- src/utils/messages/retryEligibility.test.ts | 108 -------------------- 1 file changed, 108 deletions(-) diff --git a/src/utils/messages/retryEligibility.test.ts b/src/utils/messages/retryEligibility.test.ts index 7148ad4bb..9719b4a76 100644 --- a/src/utils/messages/retryEligibility.test.ts +++ b/src/utils/messages/retryEligibility.test.ts @@ -286,114 +286,6 @@ describe("hasInterruptedStream", () => { }); describe("isEligibleForAutoRetry", () => { - describe("hasInterruptedStream vs isEligibleForAutoRetry distinction", () => { - // These tests demonstrate the key distinction in our retry system: - // - hasInterruptedStream: Should UI show retry barrier? (YES for all errors) - // - isEligibleForAutoRetry: Should system auto-retry? (NO for non-retryable errors) - - describe("authentication errors", () => { - const messages: DisplayedMessage[] = [ - { - type: "user", - id: "user-1", - historyId: "user-1", - content: "Hello", - historySequence: 1, - }, - { - type: "stream-error", - id: "error-1", - historyId: "assistant-1", - error: "Invalid API key", - errorType: "authentication", - historySequence: 2, - }, - ]; - - it("hasInterruptedStream returns true (show UI)", () => { - expect(hasInterruptedStream(messages)).toBe(true); - }); - - it("isEligibleForAutoRetry returns false (don't auto-retry)", () => { - expect(isEligibleForAutoRetry(messages)).toBe(false); - }); - - it("behavior: UI shows Retry button, not 'Retrying...'", () => { - // This combination means: - // - User sees RetryBarrier with manual "Retry" button - // - System does NOT auto-retry (infinite loop prevention) - // - User can manually retry after fixing API key - expect(hasInterruptedStream(messages)).toBe(true); - expect(isEligibleForAutoRetry(messages)).toBe(false); - }); - }); - - describe("quota errors", () => { - const messages: DisplayedMessage[] = [ - { - type: "user", - id: "user-1", - historyId: "user-1", - content: "Hello", - historySequence: 1, - }, - { - type: "stream-error", - id: "error-1", - historyId: "assistant-1", - error: "Usage quota exceeded", - errorType: "quota", - historySequence: 2, - }, - ]; - - it("hasInterruptedStream returns true (show UI)", () => { - expect(hasInterruptedStream(messages)).toBe(true); - }); - - it("isEligibleForAutoRetry returns false (don't auto-retry)", () => { - expect(isEligibleForAutoRetry(messages)).toBe(false); - }); - }); - - describe("network errors (retryable)", () => { - const messages: DisplayedMessage[] = [ - { - type: "user", - id: "user-1", - historyId: "user-1", - content: "Hello", - historySequence: 1, - }, - { - type: "stream-error", - id: "error-1", - historyId: "assistant-1", - error: "Network connection failed", - errorType: "network", - historySequence: 2, - }, - ]; - - it("hasInterruptedStream returns true (show UI)", () => { - expect(hasInterruptedStream(messages)).toBe(true); - }); - - it("isEligibleForAutoRetry returns true (auto-retry with backoff)", () => { - expect(isEligibleForAutoRetry(messages)).toBe(true); - }); - - it("behavior: UI shows 'Retrying...' with countdown", () => { - // This combination means: - // - User sees RetryBarrier with "Retrying... (attempt N)" - // - System auto-retries with exponential backoff - // - User can stop with Ctrl+C - expect(hasInterruptedStream(messages)).toBe(true); - expect(isEligibleForAutoRetry(messages)).toBe(true); - }); - }); - }); - it("returns false for empty messages", () => { expect(isEligibleForAutoRetry([])).toBe(false); });