From 1afdbbbb47232c72a06edaf25f8eb64eb883b3f4 Mon Sep 17 00:00:00 2001 From: Fusion Worker Date: Sun, 7 Jun 2026 00:15:48 +0700 Subject: [PATCH 1/3] feat(engine): add structured error types and general-purpose retry with exponential backoff Add domain-specific error classes (engine-errors.ts) replacing generic catch blocks with typed, classifiable errors: - EngineError base with code/retryable/details fields - TransientError hierarchy: NetworkError, ServiceUnavailableError, TimeoutError - PermanentError hierarchy: ConfigurationError, ValidationError - RateLimitError (triggers global pause, not local retry) - classifyThrownError() bridge from legacy string-based detection Add general-purpose retry with exponential backoff (retry-with-backoff.ts): - withRetry() wraps async ops with configurable retries for transient errors - Exponential backoff: delay = min(baseMs * 2^attempt, maxMs) - Three jitter strategies: full (default), equal, none - Per-attempt timeout support via timeoutMs option - AbortSignal integration for task pause/cancel/shutdown - Custom isRetryable check for domain-specific retry logic - withRetryResult() variant returning retry metadata - Never retries rate-limit errors (delegates to global pause) 53 new tests covering error hierarchy, backoff math, cancellable sleep, retry on transient errors, non-retryable fail-fast, rate-limit bypass, abort cancellation, per-attempt timeout, custom predicates. --- .../src/__tests__/retry-with-backoff.test.ts | 621 ++++++++++++++++++ packages/engine/src/engine-errors.ts | 269 ++++++++ packages/engine/src/retry-with-backoff.ts | 344 ++++++++++ 3 files changed, 1234 insertions(+) create mode 100644 packages/engine/src/__tests__/retry-with-backoff.test.ts create mode 100644 packages/engine/src/engine-errors.ts create mode 100644 packages/engine/src/retry-with-backoff.ts diff --git a/packages/engine/src/__tests__/retry-with-backoff.test.ts b/packages/engine/src/__tests__/retry-with-backoff.test.ts new file mode 100644 index 0000000000..4fde5a3fdd --- /dev/null +++ b/packages/engine/src/__tests__/retry-with-backoff.test.ts @@ -0,0 +1,621 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + withRetry, + withRetryResult, + computeBackoff, + cancellableSleep, + type JitterStrategy, + type RetryOptions, +} from "../retry-with-backoff.js"; +import { + EngineError, + TransientError, + NetworkError, + ServiceUnavailableError, + TimeoutError, + PermanentError, + ConfigurationError, + ValidationError, + RateLimitError, + classifyThrownError, + isRetryableError, +} from "../engine-errors.js"; + +// ── engine-errors.ts tests ────────────────────────────────────────────── + +describe("engine-errors", () => { + describe("error hierarchy", () => { + it("TransientError is retryable EngineError", () => { + const err = new TransientError("blip"); + expect(err).toBeInstanceOf(EngineError); + expect(err).toBeInstanceOf(TransientError); + expect(err.retryable).toBe(true); + expect(err.code).toBe("TRANSIENT"); + expect(err.message).toBe("blip"); + }); + + it("NetworkError is a TransientError", () => { + const err = new NetworkError("ECONNREFUSED"); + expect(err).toBeInstanceOf(TransientError); + expect(err).toBeInstanceOf(NetworkError); + expect(err.retryable).toBe(true); + expect(err.code).toBe("NETWORK"); + }); + + it("ServiceUnavailableError carries statusCode", () => { + const err = new ServiceUnavailableError("overloaded", 503); + expect(err).toBeInstanceOf(TransientError); + expect(err.statusCode).toBe(503); + expect(err.details?.statusCode).toBe(503); + }); + + it("TimeoutError carries timeoutMs", () => { + const err = new TimeoutError("timed out", 5000); + expect(err).toBeInstanceOf(TransientError); + expect(err.timeoutMs).toBe(5000); + }); + + it("PermanentError is non-retryable", () => { + const err = new PermanentError("bad code"); + expect(err).toBeInstanceOf(EngineError); + expect(err.retryable).toBe(false); + expect(err.code).toBe("PERMANENT"); + }); + + it("ConfigurationError is a PermanentError", () => { + const err = new ConfigurationError("missing API key"); + expect(err).toBeInstanceOf(PermanentError); + expect(err.code).toBe("CONFIGURATION"); + }); + + it("ValidationError is a PermanentError", () => { + const err = new ValidationError("invalid input"); + expect(err).toBeInstanceOf(PermanentError); + expect(err.code).toBe("VALIDATION"); + }); + + it("RateLimitError is non-retryable EngineError", () => { + const err = new RateLimitError("429", 5000); + expect(err).toBeInstanceOf(EngineError); + expect(err.retryable).toBe(false); + expect(err.code).toBe("RATE_LIMIT"); + expect(err.retryAfterMs).toBe(5000); + }); + + it("error cause chain is preserved", () => { + const cause = new Error("root cause"); + const err = new NetworkError("wrapped", undefined, cause); + expect(err.cause).toBe(cause); + }); + }); + + describe("classifyThrownError", () => { + it("passes through existing EngineError instances", () => { + const original = new NetworkError("existing"); + expect(classifyThrownError(original)).toBe(original); + }); + + it("classifies rate-limit errors", () => { + const err = classifyThrownError(new Error("rate limit exceeded")); + expect(err).toBeInstanceOf(RateLimitError); + }); + + it("classifies network errors", () => { + const err = classifyThrownError(new Error("ECONNREFUSED 127.0.0.1:443")); + expect(err).toBeInstanceOf(NetworkError); + }); + + it("classifies timeout errors", () => { + const err = classifyThrownError(new Error("ETIMEDOUT connection timed out")); + expect(err).toBeInstanceOf(TimeoutError); + }); + + it("classifies upstream service errors", () => { + const err = classifyThrownError(new Error("upstream connect error")); + expect(err).toBeInstanceOf(ServiceUnavailableError); + }); + + it("classifies server_error JSON payloads", () => { + const err = classifyThrownError(new Error('{"type":"server_error","code":"server_error"}')); + expect(err).toBeInstanceOf(ServiceUnavailableError); + }); + + it("classifies WebSocket errors as transient", () => { + const err = classifyThrownError(new Error("WebSocket error")); + expect(err).toBeInstanceOf(TransientError); + expect(err.retryable).toBe(true); + }); + + it("classifies unknown errors as permanent", () => { + const err = classifyThrownError(new Error("something unexpected")); + expect(err).toBeInstanceOf(PermanentError); + expect(err.code).toBe("UNKNOWN"); + }); + + it("handles string thrown values", () => { + const err = classifyThrownError("plain string error"); + expect(err).toBeInstanceOf(PermanentError); + }); + + it("handles null/undefined thrown values", () => { + const err = classifyThrownError(null); + expect(err).toBeInstanceOf(PermanentError); + }); + }); + + describe("isRetryableError", () => { + it("returns true for TransientError subclasses", () => { + expect(isRetryableError(new NetworkError("net"))).toBe(true); + expect(isRetryableError(new ServiceUnavailableError("svc"))).toBe(true); + expect(isRetryableError(new TimeoutError("tmr"))).toBe(true); + expect(isRetryableError(new TransientError("gen"))).toBe(true); + }); + + it("returns false for PermanentError", () => { + expect(isRetryableError(new PermanentError("perm"))).toBe(false); + }); + + it("returns false for RateLimitError", () => { + expect(isRetryableError(new RateLimitError("rl"))).toBe(false); + }); + + it("falls back to string detection for untyped errors", () => { + expect(isRetryableError(new Error("ECONNREFUSED"))).toBe(true); + expect(isRetryableError(new Error("socket hang up"))).toBe(true); + expect(isRetryableError(new Error("bad code"))).toBe(false); + }); + }); +}); + +// ── retry-with-backoff.ts tests ───────────────────────────────────────── + +describe("computeBackoff", () => { + it("returns raw delay with jitter=none", () => { + expect(computeBackoff(0, 1000, 30000, "none")).toBe(1000); + expect(computeBackoff(1, 1000, 30000, "none")).toBe(2000); + expect(computeBackoff(2, 1000, 30000, "none")).toBe(4000); + expect(computeBackoff(3, 1000, 30000, "none")).toBe(8000); + }); + + it("caps delay at maxDelayMs", () => { + expect(computeBackoff(10, 1000, 5000, "none")).toBe(5000); + }); + + it("full jitter returns value in [0, rawDelay]", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const delay = computeBackoff(0, 1000, 30000, "full"); + expect(delay).toBe(500); // floor(0.5 * 1000) + vi.restoreAllMocks(); + }); + + it("equal jitter returns value in [rawDelay/2, rawDelay]", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const delay = computeBackoff(0, 1000, 30000, "equal"); + expect(delay).toBe(750); // floor(500 + 0.5 * 500) + vi.restoreAllMocks(); + }); + + it("exponential growth is correct with no jitter", () => { + const delays = [0, 1, 2, 3, 4].map((a) => computeBackoff(a, 500, 100000, "none")); + expect(delays).toEqual([500, 1000, 2000, 4000, 8000]); + }); +}); + +describe("cancellableSleep", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("resolves after the specified delay", async () => { + const promise = cancellableSleep(1000); + await vi.advanceTimersByTimeAsync(1000); + await expect(promise).resolves.toBeUndefined(); + }); + + it("rejects immediately if signal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(new Error("Already done")); + await expect(cancellableSleep(1000, ac.signal)).rejects.toThrow("Already done"); + }); + + it("rejects when signal fires during sleep", async () => { + const ac = new AbortController(); + const promise = cancellableSleep(10000, ac.signal); + await vi.advanceTimersByTimeAsync(100); + ac.abort(new Error("Cancelled")); + await expect(promise).rejects.toThrow("Cancelled"); + }); +}); + +describe("withRetry", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns the result when fn succeeds on first call", async () => { + const fn = vi.fn().mockResolvedValue("ok"); + const result = await withRetry(fn); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries on TransientError and succeeds", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new NetworkError("ECONNREFUSED")) + .mockResolvedValueOnce("recovered"); + + const onRetry = vi.fn(); + const promise = withRetry(fn, { + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + onRetry, + }); + + await vi.advanceTimersByTimeAsync(200); + const result = await promise; + + expect(result).toBe("recovered"); + expect(fn).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith(1, 100, expect.any(NetworkError)); + }); + + it("retries on raw transient error strings (untyped)", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("socket hang up")) + .mockResolvedValueOnce("ok"); + + const promise = withRetry(fn, { + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + }); + + await vi.advanceTimersByTimeAsync(200); + const result = await promise; + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("re-throws non-retryable errors immediately without retry", async () => { + const fn = vi.fn().mockRejectedValue(new Error("ENOENT: file not found")); + const onRetry = vi.fn(); + + await expect( + withRetry(fn, { baseDelayMs: 100, onRetry }), + ).rejects.toThrow("ENOENT: file not found"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("re-throws PermanentError immediately", async () => { + const fn = vi.fn().mockRejectedValue(new PermanentError("bad config")); + const onRetry = vi.fn(); + + await expect( + withRetry(fn, { baseDelayMs: 100, onRetry }), + ).rejects.toThrow("bad config"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("never retries rate-limit errors", async () => { + const fn = vi.fn().mockRejectedValue(new Error("rate limit exceeded")); + const onRetry = vi.fn(); + + await expect( + withRetry(fn, { baseDelayMs: 100, onRetry }), + ).rejects.toThrow("rate limit exceeded"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("never retries RateLimitError instances", async () => { + const fn = vi.fn().mockRejectedValue(new RateLimitError("429")); + const onRetry = vi.fn(); + + await expect( + withRetry(fn, { baseDelayMs: 100, onRetry }), + ).rejects.toThrow("429"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("applies exponential backoff with increasing delays", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new NetworkError("net-1")) + .mockRejectedValueOnce(new NetworkError("net-2")) + .mockResolvedValueOnce("ok"); + + const delays: number[] = []; + const onRetry = (_attempt: number, delayMs: number) => delays.push(delayMs); + + const promise = withRetry(fn, { + baseDelayMs: 1000, + maxDelayMs: 10000, + jitter: "none", + onRetry, + }); + + await vi.advanceTimersByTimeAsync(1100); // 1st delay: 1000ms + await vi.advanceTimersByTimeAsync(2100); // 2nd delay: 2000ms + await promise; + + expect(delays).toEqual([1000, 2000]); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("caps delay at maxDelayMs", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new TimeoutError("slow")) + .mockResolvedValueOnce("ok"); + + const delays: number[] = []; + const promise = withRetry(fn, { + baseDelayMs: 100000, + maxDelayMs: 5000, + jitter: "none", + onRetry: (_a, d) => delays.push(d), + }); + + await vi.advanceTimersByTimeAsync(6000); + await promise; + + expect(delays[0]).toBe(5000); + }); + + it("throws after all retries are exhausted", async () => { + const fn = vi.fn().mockRejectedValue(new NetworkError("always fails")); + const onRetry = vi.fn(); + + const promise = withRetry(fn, { + maxRetries: 2, + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + onRetry, + }); + + const assertion = expect(promise).rejects.toThrow("always fails"); + + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(500); + } + + await assertion; + expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it("cancels backoff sleep when abort signal fires", async () => { + const fn = vi.fn().mockRejectedValue(new NetworkError("net")); + const ac = new AbortController(); + + const promise = withRetry(fn, { + baseDelayMs: 60000, + maxDelayMs: 120000, + jitter: "none", + signal: ac.signal, + }); + + // Let first call fail and start sleeping + await vi.advanceTimersByTimeAsync(10); + ac.abort(new Error("Task paused")); + + await expect(promise).rejects.toThrow("Task paused"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("does not retry if abort signal is already aborted at start", async () => { + const fn = vi.fn().mockRejectedValue(new NetworkError("net")); + const ac = new AbortController(); + ac.abort(new Error("Already cancelled")); + + await expect( + withRetry(fn, { signal: ac.signal }), + ).rejects.toThrow("Aborted before first attempt"); + + expect(fn).toHaveBeenCalledTimes(0); // never called — aborted before first attempt + }); + + it("supports custom isRetryable check", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("custom-retryable")) + .mockResolvedValueOnce("ok"); + + const promise = withRetry(fn, { + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + isRetryable: (err) => err instanceof Error && err.message === "custom-retryable", + }); + + await vi.advanceTimersByTimeAsync(200); + const result = await promise; + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("custom isRetryable returning false prevents retry", async () => { + const fn = vi.fn().mockRejectedValue(new Error("custom-retryable")); + const onRetry = vi.fn(); + + await expect( + withRetry(fn, { + baseDelayMs: 100, + onRetry, + isRetryable: () => false, + }), + ).rejects.toThrow("custom-retryable"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("handles simulated 5xx errors correctly", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new ServiceUnavailableError("502 Bad Gateway", 502)) + .mockRejectedValueOnce(new ServiceUnavailableError("503 Service Unavailable", 503)) + .mockResolvedValueOnce("recovered"); + + const onRetry = vi.fn(); + const promise = withRetry(fn, { + maxRetries: 3, + baseDelayMs: 100, + maxDelayMs: 5000, + jitter: "none", + onRetry, + }); + + await vi.advanceTimersByTimeAsync(200); // 1st delay: 100ms + await vi.advanceTimersByTimeAsync(400); // 2nd delay: 200ms + const result = await promise; + + expect(result).toBe("recovered"); + expect(fn).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + + // Verify the errors carry status codes + const retryCalls = onRetry.mock.calls; + expect((retryCalls[0][2] as ServiceUnavailableError).statusCode).toBe(502); + expect((retryCalls[1][2] as ServiceUnavailableError).statusCode).toBe(503); + }); + + it("handles simulated timeout errors correctly", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new TimeoutError("request timed out", 5000)) + .mockResolvedValueOnce("ok"); + + const promise = withRetry(fn, { + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + }); + + await vi.advanceTimersByTimeAsync(200); + const result = await promise; + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("non-retryable errors fail fast without blocking", async () => { + const fn = vi.fn().mockRejectedValue(new ValidationError("invalid schema")); + const onRetry = vi.fn(); + + const start = Date.now(); + await expect( + withRetry(fn, { baseDelayMs: 10000, onRetry }), + ).rejects.toThrow("invalid schema"); + + // Should resolve immediately — no sleep for non-retryable errors + // (fake timers don't advance real Date.now, but fn call count proves it) + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("handles non-Error thrown values", async () => { + const fn = vi.fn().mockRejectedValue("string error"); + const onRetry = vi.fn(); + + await expect( + withRetry(fn, { baseDelayMs: 100, onRetry }), + ).rejects.toThrow("string error"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("uses default options when none provided", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new NetworkError("net")) + .mockResolvedValueOnce("ok"); + + const promise = withRetry(fn); + // Default baseDelayMs=1000, jitter="full" → delay is random in [0,1000] + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + expect(result).toBe("ok"); + }); + + it("respects per-attempt timeout", async () => { + // Simulate a slow operation that exceeds the per-attempt timeout. + // On first call, fn returns a promise that never settles (simulating a hang). + // On second call (after retry), fn resolves successfully. + const fn = vi + .fn() + .mockImplementationOnce( + () => new Promise(() => {}), // hangs forever — triggers timeout + ) + .mockResolvedValueOnce("recovered"); + + const onRetry = vi.fn(); + const promise = withRetry(fn, { + maxRetries: 1, + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + timeoutMs: 500, + onRetry, + }); + + // Let first attempt timeout (500ms) + await vi.advanceTimersByTimeAsync(600); + // Let retry backoff pass (100ms) + await vi.advanceTimersByTimeAsync(200); + // Second attempt resolves immediately (fn is mockResolvedValueOnce) + + const result = await promise; + expect(result).toBe("recovered"); + expect(fn).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(1); + // Verify the retry was triggered by a timeout classification + const retryErr = onRetry.mock.calls[0][2]; + expect(retryErr.code).toBe("TIMEOUT"); + }); +}); + +describe("withRetryResult", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns metadata with retry count and elapsed time", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new NetworkError("net")) + .mockResolvedValueOnce("ok"); + + const promise = withRetryResult(fn, { + baseDelayMs: 100, + maxDelayMs: 1000, + jitter: "none", + }); + + await vi.advanceTimersByTimeAsync(200); + const result = await promise; + + expect(result.value).toBe("ok"); + expect(result.retries).toBe(1); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("reports 0 retries on first-attempt success", async () => { + const fn = vi.fn().mockResolvedValue("instant"); + const result = await withRetryResult(fn); + expect(result.value).toBe("instant"); + expect(result.retries).toBe(0); + }); +}); diff --git a/packages/engine/src/engine-errors.ts b/packages/engine/src/engine-errors.ts new file mode 100644 index 0000000000..c5368e3c25 --- /dev/null +++ b/packages/engine/src/engine-errors.ts @@ -0,0 +1,269 @@ +/** + * Structured Engine Error Types — domain-specific error classes for the Fusion engine. + * + * These error types replace generic `catch (err)` blocks with typed, classifiable + * errors that callers can match on for domain-specific handling (retry, fail-fast, + * alerting, etc.). + * + * ## Hierarchy + * + * ``` + * EngineError (base) + * ├── TransientError — temporary, retryable (network blip, 5xx, timeout) + * │ ├── NetworkError — connection refused/reset, DNS failure, socket hang-up + * │ ├── ServiceUnavailableError — upstream 5xx, overloaded, maintenance mode + * │ └── TimeoutError — request/operation exceeded deadline + * ├── PermanentError — non-retryable, task-defect or config error + * │ ├── ConfigurationError — bad env, missing keys, invalid settings + * │ └── ValidationError — schema violations, invalid inputs + * └── RateLimitError — quota/rate-limit, needs global pause (not local retry) + * ``` + * + * ## Usage + * + * ```ts + * catch (err) { + * if (err instanceof TransientError) { + * // Move task to todo for retry + * } else if (err instanceof RateLimitError) { + * // Trigger global usage-limit pause + * } else if (err instanceof PermanentError) { + * // Mark task as failed + * } + * } + * ``` + */ + +import { isUsageLimitError } from "./usage-limit-detector.js"; +import { isTransientError } from "./transient-error-detector.js"; + +// ── Base Error ────────────────────────────────────────────────────────── + +/** + * Base class for all structured engine errors. + * + * Adds a `code` (machine-readable string) and optional `cause` chain to the + * standard Error. Subclasses set `retryable` to indicate whether the operation + * should be retried by the caller. + */ +export abstract class EngineError extends Error { + /** Machine-readable error code for programmatic matching. */ + public readonly code: string; + /** Whether the caller should retry the operation. */ + public readonly retryable: boolean; + /** Optional structured metadata for logging/metrics. */ + public readonly details?: Record; + + constructor( + message: string, + code: string, + retryable: boolean, + details?: Record, + cause?: Error, + ) { + super(message, { cause }); + this.name = this.constructor.name; + this.code = code; + this.retryable = retryable; + this.details = details; + } +} + +// ── Transient Errors (retryable) ──────────────────────────────────────── + +/** + * A transient error — the operation failed due to a temporary condition + * that is expected to resolve on its own (network blip, brief service + * unavailability, timeout). Callers should retry with backoff. + */ +export class TransientError extends EngineError { + constructor( + message: string, + code: string = "TRANSIENT", + details?: Record, + cause?: Error, + ) { + super(message, code, true, details, cause); + } +} + +/** + * Network-level error — connection refused, DNS resolution failure, + * socket hang-up, TLS handshake failure, etc. + */ +export class NetworkError extends TransientError { + constructor( + message: string, + details?: Record, + cause?: Error, + ) { + super(message, "NETWORK", details, cause); + } +} + +/** + * Upstream service returned a 5xx or is temporarily unavailable + * (overloaded, maintenance mode). + */ +export class ServiceUnavailableError extends TransientError { + /** HTTP status code if available. */ + public readonly statusCode?: number; + + constructor( + message: string, + statusCode?: number, + details?: Record, + cause?: Error, + ) { + super(message, "SERVICE_UNAVAILABLE", { ...details, statusCode }, cause); + this.statusCode = statusCode; + } +} + +/** + * Operation or request exceeded its deadline / timeout. + */ +export class TimeoutError extends TransientError { + /** Configured timeout in milliseconds. */ + public readonly timeoutMs?: number; + + constructor( + message: string, + timeoutMs?: number, + details?: Record, + cause?: Error, + ) { + super(message, "TIMEOUT", { ...details, timeoutMs }, cause); + this.timeoutMs = timeoutMs; + } +} + +// ── Permanent Errors (non-retryable) ──────────────────────────────────── + +/** + * A permanent error — the operation failed due to a defect in the task, + * configuration, or input. Retrying will not help. + */ +export class PermanentError extends EngineError { + constructor( + message: string, + code: string = "PERMANENT", + details?: Record, + cause?: Error, + ) { + super(message, code, false, details, cause); + } +} + +/** + * Configuration error — missing env vars, invalid settings, bad keys. + */ +export class ConfigurationError extends PermanentError { + constructor( + message: string, + details?: Record, + cause?: Error, + ) { + super(message, "CONFIGURATION", details, cause); + } +} + +/** + * Validation error — schema violations, invalid inputs, malformed data. + */ +export class ValidationError extends PermanentError { + constructor( + message: string, + details?: Record, + cause?: Error, + ) { + super(message, "VALIDATION", details, cause); + } +} + +// ── Rate Limit Error (special — global pause, not local retry) ────────── + +/** + * Rate-limit / usage-limit error. Unlike transient errors, these should + * NOT be retried locally — instead they trigger a global pause via + * UsageLimitPauser so all agents back off simultaneously. + */ +export class RateLimitError extends EngineError { + /** Suggested retry-after in milliseconds (from Retry-After header or heuristic). */ + public readonly retryAfterMs?: number; + + constructor( + message: string, + retryAfterMs?: number, + details?: Record, + cause?: Error, + ) { + super(message, "RATE_LIMIT", false, { ...details, retryAfterMs }, cause); + this.retryAfterMs = retryAfterMs; + } +} + +// ── Classification helpers ────────────────────────────────────────────── + +/** + * Classify a raw error into a structured EngineError subtype. + * + * This bridges the gap between legacy string-based error classification + * (transient-error-detector, usage-limit-detector) and the new typed system. + * New code should throw typed errors directly; this function upgrades + * untyped errors from external libraries. + * + * @param err - The raw thrown value + * @returns A structured EngineError instance + */ +export function classifyThrownError(err: unknown): EngineError { + // Already structured — return as-is + if (err instanceof EngineError) { + return err; + } + + const message = err instanceof Error ? err.message : String(err ?? ""); + + // Rate-limit (triggers global pause) + if (isUsageLimitError(message)) { + return new RateLimitError(message, undefined, undefined, err instanceof Error ? err : undefined); + } + + // Network / connection errors + if (/ECONNREFUSED|connection refused|connection reset|socket hang up|EHOSTUNREACH|ENETUNREACH/i.test(message)) { + return new NetworkError(message, undefined, err instanceof Error ? err : undefined); + } + + // Timeout errors + if (/ETIMEDOUT|timeout.*connection|connection.*timeout|deadline exceeded|timed out after \d+ms/i.test(message)) { + return new TimeoutError(message, undefined, undefined, err instanceof Error ? err : undefined); + } + + // 5xx / service unavailable + if (/upstream connect error|disconnect\/reset before headers|remote connection failure|transport failure/i.test(message)) { + return new ServiceUnavailableError(message, undefined, undefined, err instanceof Error ? err : undefined); + } + + if (/"type":"server_error"|\"code\":\"server_error\"/i.test(message)) { + return new ServiceUnavailableError(message, 500, undefined, err instanceof Error ? err : undefined); + } + + // Generic transient (WebSocket errors, provider aborts, etc.) + if (isTransientError(message)) { + return new TransientError(message, "TRANSIENT", undefined, err instanceof Error ? err : undefined); + } + + // Default: permanent + return new PermanentError(message, "UNKNOWN", undefined, err instanceof Error ? err : undefined); +} + +/** + * Type guard: is the error retryable (transient)? + */ +export function isRetryableError(err: unknown): err is TransientError { + if (err instanceof TransientError) return true; + if (err instanceof EngineError) return err.retryable; + // Fall back to string-based detection for untyped errors + const message = err instanceof Error ? err.message : String(err ?? ""); + return isTransientError(message); +} diff --git a/packages/engine/src/retry-with-backoff.ts b/packages/engine/src/retry-with-backoff.ts new file mode 100644 index 0000000000..286c28ab51 --- /dev/null +++ b/packages/engine/src/retry-with-backoff.ts @@ -0,0 +1,344 @@ +/** + * Retry with Exponential Backoff — general-purpose retry wrapper for + * transient network and external service failures. + * + * This extends the retry pattern established in `rate-limit-retry.ts` to + * cover ALL transient errors (network blips, 5xx, timeouts, WebSocket drops) + * — not just rate-limit / usage-limit errors. + * + * ## Strategy + * + * **Backoff:** `delay = min(baseDelayMs × 2^attempt, maxDelayMs)` with + * configurable jitter to avoid thundering-herd effects across concurrent agents. + * + * **Jitter modes:** + * - `"full"` (default): `random(0, delay)` — spreads retries uniformly + * - `"equal"`: `base + random(0, base)` where `base = delay/2` — tighter clustering + * - `"none"`: no jitter — deterministic (useful for tests) + * + * **Retryable check:** Uses the structured error types from `engine-errors.ts` + * when available, falling back to `transient-error-detector.ts` for untyped errors. + * + * **Abort support:** An optional `AbortSignal` cancels pending retries when a + * task is paused, cancelled, or the engine shuts down. + * + * **Non-blocking:** Backoff sleeps yield to the event loop, never blocking the + * main thread. + * + * @example + * ```ts + * const result = await withRetry(() => fetchExternalService(url), { + * maxRetries: 3, + * baseDelayMs: 1000, + * maxDelayMs: 30_000, + * timeoutMs: 60_000, + * onRetry: (attempt, delayMs, err) => { + * logger.warn(`Retry ${attempt} after ${delayMs}ms: ${err.message}`); + * }, + * signal: abortController.signal, + * }); + * ``` + */ + +import { classifyThrownError, isRetryableError, type EngineError } from "./engine-errors.js"; +import { isUsageLimitError } from "./usage-limit-detector.js"; + +// ── Types ─────────────────────────────────────────────────────────────── + +/** Jitter strategy for backoff delay randomization. */ +export type JitterStrategy = "full" | "equal" | "none"; + +/** Configuration for retry behavior. */ +export interface RetryOptions { + /** Maximum number of retry attempts before re-throwing (default: 3). */ + maxRetries?: number; + /** Initial backoff delay in milliseconds (default: 1 000 — 1 s). */ + baseDelayMs?: number; + /** Upper bound on backoff delay in milliseconds (default: 30 000 — 30 s). */ + maxDelayMs?: number; + /** + * Per-attempt timeout in milliseconds. When set, each call to `fn()` is + * wrapped in a deadline. If the deadline fires before `fn()` resolves, a + * TimeoutError is thrown (which is retryable). Default: undefined (no timeout). + */ + timeoutMs?: number; + /** + * Jitter strategy for randomizing backoff delays (default: "full"). + * - `"full"`: random(0, delay) — best spread, recommended for production + * - `"equal"`: base ± random(0, base/2) — tighter clustering + * - `"none"`: no jitter — deterministic, useful for tests + */ + jitter?: JitterStrategy; + /** + * Called before each retry with the attempt number (1-based), the + * computed delay, and the error that triggered the retry. + */ + onRetry?: (attempt: number, delayMs: number, error: EngineError) => void; + /** + * Abort signal that cancels pending retries and re-throws immediately. + */ + signal?: AbortSignal; + /** + * Custom retryable check. When provided, this function is called instead + * of the default `isRetryableError` check. Return `true` to retry, `false` + * to re-throw immediately. + */ + isRetryable?: (err: unknown) => boolean; +} + +/** Result of a successful retry operation, including retry metadata. */ +export interface RetryResult { + /** The successful return value. */ + value: T; + /** Total number of retries that occurred (0 = succeeded on first attempt). */ + retries: number; + /** Total elapsed time in milliseconds including all backoff sleeps. */ + elapsedMs: number; +} + +// ── Backoff Calculation ───────────────────────────────────────────────── + +/** + * Compute the backoff delay for a given attempt with the chosen jitter strategy. + * + * @param attempt - 0-based attempt index + * @param baseDelayMs - Base delay in milliseconds + * @param maxDelayMs - Maximum delay cap in milliseconds + * @param jitter - Jitter strategy + * @returns Delay in milliseconds (always >= 0) + */ +export function computeBackoff( + attempt: number, + baseDelayMs: number, + maxDelayMs: number, + jitter: JitterStrategy = "full", +): number { + const rawDelay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); + + switch (jitter) { + case "full": + return Math.floor(Math.random() * rawDelay); + case "equal": { + const half = rawDelay / 2; + return Math.floor(half + Math.random() * half); + } + case "none": + return rawDelay; + } +} + +// ── Sleep with Abort ──────────────────────────────────────────────────── + +/** + * Sleep for `ms` milliseconds, cancellable via an `AbortSignal`. + * Yields to the event loop — never blocks the main thread. + */ +export function cancellableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("Aborted")); + return; + } + + const timer = setTimeout(resolve, ms); + + if (signal) { + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason ?? new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + // Clean up listener when timer fires normally + const origResolve = resolve; + resolve = () => { + signal.removeEventListener("abort", onAbort); + origResolve(); + }; + } + }); +} + +// ── Timeout Wrapper ───────────────────────────────────────────────────── + +/** + * Wrap an async function with a deadline timeout. + * + * If `fn()` does not settle within `timeoutMs`, the promise is rejected + * with a TimeoutError and any underlying resources are cleaned up via + * the AbortController. + */ +function withTimeout( + fn: () => Promise, + timeoutMs: number, + parentSignal?: AbortSignal, +): Promise { + let settled = false; + return new Promise((resolve, reject) => { + const ac = new AbortController(); + + // Link parent signal — if parent aborts, we abort too + const onParentAbort = () => { + if (settled) return; + settled = true; + ac.abort(parentSignal?.reason ?? new Error("Aborted")); + reject(parentSignal?.reason ?? new Error("Aborted")); + }; + parentSignal?.addEventListener("abort", onParentAbort, { once: true }); + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + ac.abort(new Error(`Operation timed out after ${timeoutMs}ms`)); + reject(new Error(`Operation timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + fn() + .then((result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + parentSignal?.removeEventListener("abort", onParentAbort); + resolve(result); + }) + .catch((err: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + parentSignal?.removeEventListener("abort", onParentAbort); + reject(err); + }); + }); +} + +// ── Main Retry Function ───────────────────────────────────────────────── + +/** + * Wrap an async function with exponential backoff retry for transient errors. + * + * The wrapper calls `fn()`. If it throws a retryable error (transient network + * or service errors), it sleeps with exponential backoff and retries up to + * `maxRetries` times. Non-retryable errors are re-thrown immediately. + * + * Rate-limit / usage-limit errors are NEVER retried by this function — they + * should be handled by `withRateLimitRetry` or trigger a global pause. + * + * After all retries are exhausted, the original error is thrown. + * + * @param fn - The async function to execute + * @param options - Retry configuration + * @returns The return value of `fn()` + */ +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxRetries = 3, + baseDelayMs = 1_000, + maxDelayMs = 30_000, + timeoutMs, + jitter = "full", + onRetry, + signal, + isRetryable: customIsRetryable, + } = options; + + const startTime = Date.now(); + let lastError: EngineError | undefined; + let retryCount = 0; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + // Check abort before each attempt + if (signal?.aborted) { + throw lastError ?? new Error("Aborted before first attempt"); + } + + try { + // Wrap with timeout if configured + const result = timeoutMs + ? await withTimeout(fn, timeoutMs, signal) + : await fn(); + return result; + } catch (err: unknown) { + // Classify the error into a structured type + const classified = classifyThrownError(err); + + // Rate-limit errors: never retry locally — re-throw immediately + if (isUsageLimitError(classified.message)) { + throw classified; + } + + // Use custom retryable check if provided, otherwise use default + const shouldRetry = customIsRetryable + ? customIsRetryable(err) + : isRetryableError(classified); + + // Non-retryable error: re-throw immediately + if (!shouldRetry) { + throw classified; + } + + lastError = classified; + + // All retries exhausted — throw the last error + if (attempt >= maxRetries) { + throw lastError; + } + + // Check abort before sleeping + if (signal?.aborted) { + throw lastError; + } + + // Compute backoff delay + const delay = computeBackoff(attempt, baseDelayMs, maxDelayMs, jitter); + + onRetry?.(attempt + 1, delay, classified); + + // Sleep with cancellation support + await cancellableSleep(delay, signal); + + retryCount++; + } + } + + // Unreachable, but satisfies TypeScript + throw lastError ?? new Error("withRetry: unexpected state"); +} + +/** + * Wrap an async function with retry and return extended metadata. + * + * Same as `withRetry` but returns a `RetryResult` with the value plus + * retry count and elapsed time — useful for logging and metrics. + * + * @param fn - The async function to execute + * @param options - Retry configuration + * @returns A `RetryResult` with value and retry metadata + */ +export async function withRetryResult( + fn: () => Promise, + options: RetryOptions = {}, +): Promise> { + const startTime = Date.now(); + let retries = 0; + + const result = await withRetry(async () => { + if (retries > 0) { + // We're in a retry — count it + } + return fn(); + }, { + ...options, + onRetry: (attempt, delayMs, err) => { + retries = attempt; + options.onRetry?.(attempt, delayMs, err); + }, + }); + + return { + value: result, + retries, + elapsedMs: Date.now() - startTime, + }; +} From cb455bf757625d0492a2e42724d1518cb0c5b28e Mon Sep 17 00:00:00 2001 From: buihongduc132 Date: Sun, 7 Jun 2026 02:26:00 +0700 Subject: [PATCH 2/3] feat(dashboard): scrollable, autocomplete, bookmarkable project selector - Fix: ProjectSelector dropdown now scrolls (max-height + overflow-y) when project list exceeds visible area, with scrollIntoView on keyboard navigation - Feat: Always-visible search input with type-ahead filtering, HighlightMatch text highlighting, exact match detection + auto-select on Enter - Feat: Project bookmarking via localStorage (useProjectBookmarks hook), star toggle on each item, bookmarked section shown at top of dropdown - Refactor: Header.tsx now imports standalone ProjectSelector instead of using an inline copy that lacked these features - Tests: 131 tests passing across ProjectSelector + useProjectBookmarks --- packages/dashboard/app/components/Header.tsx | 128 +---- .../app/components/ProjectSelector.css | 104 ++++ .../app/components/ProjectSelector.tsx | 347 +++++++++++--- .../__tests__/ProjectSelector.test.tsx | 446 +++++++++++++++++- .../__tests__/useProjectBookmarks.test.ts | 120 +++++ .../app/hooks/useProjectBookmarks.ts | 52 ++ 6 files changed, 1000 insertions(+), 197 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useProjectBookmarks.test.ts create mode 100644 packages/dashboard/app/hooks/useProjectBookmarks.ts diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 034afa89a4..4437c31e83 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -2,8 +2,9 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent a import { useTranslation } from "react-i18next"; import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react"; import "./Header.css"; -// Header renders an inline ProjectSelector dropdown using project-selector-* classes. +// ProjectSelector styles used by the imported standalone component. import "./ProjectSelector.css"; +import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector"; import type { ProjectInfo } from "../api"; import type { NodeConfig, ProjectStatus } from "@fusion/core"; import { fetchScripts } from "../api"; @@ -30,125 +31,8 @@ const PROJECT_STATUS_CONFIG: Record = { initializing: { color: "var(--info)" }, }; -/** - * ProjectSelector - A component for project navigation. - * Shows project dropdown for switching projects and navigating to project management. - */ -function ProjectSelector({ - projects, - currentProject, - onViewAll, - onSelectProject, -}: { - projects: ProjectInfo[]; - currentProject: ProjectInfo | null; - onViewAll: () => void; - onSelectProject?: (project: ProjectInfo) => void; -}) { - const { t } = useTranslation("app"); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - // Close dropdown on outside click - useEffect(() => { - if (!isOpen) return; - const handleClickOutside = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen]); - - // Close on Escape - useEffect(() => { - if (!isOpen) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - setIsOpen(false); - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen]); - - const handleSelectProject = useCallback( - (project: ProjectInfo) => { - onSelectProject?.(project); - setIsOpen(false); - }, - [onSelectProject] - ); - - return ( -
- {projects.length > 0 && ( - <> - - {isOpen && ( -
- {projects.map((project) => { - const isCurrent = currentProject?.id === project.id; - const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color; - return ( - - ); - })} -
- -
- )} - - )} -
- ); -} +// Inline ProjectSelector removed — now imports StandaloneProjectSelector from ./ProjectSelector +// which has scroll fix, autocomplete, and bookmarking features. // GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility function GitHubLogo({ size = 16 }: { size?: number }) { @@ -953,11 +837,11 @@ export function Header({ {/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */} {!isMobile && projects.length >= 1 && onViewAllProjects && ( - )} diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index ca5d969b1b..157ffeff20 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -71,11 +71,29 @@ z-index: 100; min-width: 240px; max-width: 360px; + max-height: min(480px, calc(100vh - 120px)); + overflow-y: auto; + overscroll-behavior: contain; padding: var(--space-sm); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-lg); + scrollbar-width: thin; + scrollbar-color: var(--text-dim) transparent; +} + +.project-selector__dropdown::-webkit-scrollbar { + width: 6px; +} + +.project-selector__dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.project-selector__dropdown::-webkit-scrollbar-thumb { + background-color: var(--text-dim); + border-radius: 3px; } .project-selector-item, @@ -199,11 +217,59 @@ } .project-selector__no-results { + display: flex; + align-items: center; + gap: var(--space-xs); padding: var(--space-sm) calc(var(--space-sm) + var(--space-xs)); color: var(--text-muted); font-size: 13px; } +.project-selector__no-results-icon { + flex-shrink: 0; + opacity: 0.5; +} + +/* Autocomplete highlight — marks the matched text substring */ +.project-selector__highlight { + background: transparent; + color: inherit; + font-weight: 700; + text-decoration: underline; + text-decoration-color: var(--todo); + text-underline-offset: 2px; + text-decoration-thickness: 2px; +} + +/* Exact match indicator banner */ +.project-selector__exact-match { + padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs)); + margin-bottom: var(--space-xs); + font-size: 12px; + color: var(--todo); + background: color-mix(in srgb, var(--todo) 8%, transparent); + border-radius: var(--radius-md); + text-align: center; +} + +/* Exact match badge shown on item */ +.project-selector__exact-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--todo) 15%, transparent); + color: var(--todo); + flex-shrink: 0; +} + +/* Exact match item subtle highlight */ +.project-selector__item.exact-match { + background: color-mix(in srgb, var(--todo) 5%, transparent); +} + .project-selector__footer { margin-top: var(--space-xs); padding-top: var(--space-xs); @@ -279,6 +345,44 @@ color: var(--todo); } +/* Bookmark star toggle */ +.project-selector__bookmark { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-dim); + cursor: pointer; + user-select: none; + opacity: 0; + transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast); +} + +/* Show star on row hover or when bookmarked */ +.project-selector__item:hover .project-selector__bookmark, +.project-selector__item.highlighted .project-selector__bookmark, +.project-selector__bookmark.bookmarked { + opacity: 1; +} + +.project-selector__bookmark:hover { + color: var(--todo); + background: var(--card-hover); +} + +.project-selector__bookmark.bookmarked { + color: var(--todo); +} + +.project-selector__bookmark:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + /* Light theme overrides for project selector */ [data-theme="light"] .project-selector-trigger:hover { background: var(--card-hover); diff --git a/packages/dashboard/app/components/ProjectSelector.tsx b/packages/dashboard/app/components/ProjectSelector.tsx index 564b075a7f..a0baa15619 100644 --- a/packages/dashboard/app/components/ProjectSelector.tsx +++ b/packages/dashboard/app/components/ProjectSelector.tsx @@ -1,5 +1,5 @@ import "./ProjectSelector.css"; -import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, @@ -8,12 +8,14 @@ import { Grid3X3, Search, Clock, + Star, X, } from "lucide-react"; import type { ProjectInfo } from "../api"; import type { ProjectStatus } from "@fusion/core"; import { getTrailingPath } from "../utils/pathDisplay"; import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig"; +import { useProjectBookmarks } from "../hooks/useProjectBookmarks"; export interface ProjectSelectorProps { projects: ProjectInfo[]; @@ -24,14 +26,50 @@ export interface ProjectSelectorProps { } /** - * ProjectSelector - Project switcher dropdown with keyboard navigation + * HighlightMatch — Renders text with matching substring highlighted (bold + accent underline). + * Used to show which part of a project name/path matches the autocomplete query. + */ +function HighlightMatch({ + text, + query, +}: { + text: string; + query: string; +}): ReactNode { + if (!query.trim()) return <>{text}; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const matchIndex = lowerText.indexOf(lowerQuery); + + if (matchIndex === -1) return <>{text}; + + const before = text.slice(0, matchIndex); + const match = text.slice(matchIndex, matchIndex + query.length); + const after = text.slice(matchIndex + query.length); + + return ( + <> + {before} + {match} + {after} + + ); +} + +/** + * ProjectSelector - Project switcher dropdown with autocomplete/type-ahead * * Features: * - Dropdown trigger showing current project name + chevron + * - Always-visible search input with type-ahead filtering + * - Text highlighting showing matched portions of project names/paths * - Dropdown menu with project list, status icons, "View All Projects" option * - Keyboard navigation: arrow keys, enter to select, escape to close - * - Search/filter when 5+ projects - * - Recent projects section at top (last 3 accessed) + * - Recent projects section (last 3 accessed) + * - Bookmarked projects section (star toggle, persisted in localStorage) + * - Exact match detection: auto-highlights and Enter-selects the exact match + * - No matches state with clear messaging */ export function ProjectSelector({ projects, @@ -47,6 +85,8 @@ export function ProjectSelector({ const dropdownRef = useRef(null); const triggerRef = useRef(null); const searchInputRef = useRef(null); + const itemRefs = useRef>(new Map()); + const { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks(); // Close dropdown on outside click useEffect(() => { @@ -60,6 +100,7 @@ export function ProjectSelector({ !triggerRef.current.contains(e.target as Node) ) { setIsOpen(false); + setSearchQuery(""); } }; @@ -74,6 +115,7 @@ export function ProjectSelector({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setIsOpen(false); + setSearchQuery(""); triggerRef.current?.focus(); } }; @@ -82,12 +124,12 @@ export function ProjectSelector({ return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen]); - // Focus search input when dropdown opens (if search is visible) + // Focus search input when dropdown opens (always visible for autocomplete) useEffect(() => { - if (isOpen && projects.length >= 5) { + if (isOpen) { setTimeout(() => searchInputRef.current?.focus(), 0); } - }, [isOpen, projects.length]); + }, [isOpen]); // Get recent projects const recentProjects = useMemo(() => { @@ -108,28 +150,53 @@ export function ProjectSelector({ ); }, [projects, searchQuery]); - // Organize projects for display: recent first, then others + // Detect exact match (case-insensitive name match) + const exactMatch = useMemo((): ProjectInfo | null => { + if (!searchQuery.trim()) return null; + const query = searchQuery.toLowerCase(); + const nameMatches = filteredProjects.filter( + (p) => p.name.toLowerCase() === query + ); + if (nameMatches.length === 1) return nameMatches[0]; + return null; + }, [filteredProjects, searchQuery]); + + // Organize projects for display: bookmarked first, then recent, then others const displayProjects = useMemo(() => { const recentIds = new Set(recentProjects.map((p) => p.id)); const currentId = currentProject?.id; - // Exclude current project from list + // Bookmarked projects (excluding current) + const bookmarked = filteredProjects.filter( + (p) => + p.id !== currentId && + bookmarkedIds.has(p.id) && + !recentIds.has(p.id) + ); + + // Exclude current, bookmarked, and recent from "others" + const bookmarkedAndRecentIds = new Set([ + ...bookmarked.map((p) => p.id), + ...recentIds, + ]); const others = filteredProjects.filter( - (p) => p.id !== currentId && !recentIds.has(p.id) + (p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id) ); return { + bookmarked: searchQuery.trim() ? [] : bookmarked, recent: searchQuery.trim() ? [] : recentProjects, others, }; - }, [filteredProjects, recentProjects, currentProject, searchQuery]); + }, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]); // Calculate total items for keyboard navigation const totalItems = useMemo(() => { + const bookmarkedCount = displayProjects.bookmarked.length; const recentCount = displayProjects.recent.length; const othersCount = displayProjects.others.length; const viewAllCount = 1; - return recentCount + othersCount + viewAllCount; + return bookmarkedCount + recentCount + othersCount + viewAllCount; }, [displayProjects]); // Handle keyboard navigation within dropdown @@ -151,21 +218,30 @@ export function ProjectSelector({ case "Enter": e.preventDefault(); if (highlightedIndex >= 0) { + const bookmarkedCount = displayProjects.bookmarked.length; const recentCount = displayProjects.recent.length; const othersCount = displayProjects.others.length; - if (highlightedIndex < recentCount) { + if (highlightedIndex < bookmarkedCount) { + // Select bookmarked project + onSelect(displayProjects.bookmarked[highlightedIndex]); + } else if (highlightedIndex < bookmarkedCount + recentCount) { // Select recent project - onSelect(displayProjects.recent[highlightedIndex]); - } else if (highlightedIndex < recentCount + othersCount) { + onSelect(displayProjects.recent[highlightedIndex - bookmarkedCount]); + } else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) { // Select other project - onSelect(displayProjects.others[highlightedIndex - recentCount]); + onSelect(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]); } else { // View All onViewAll(); } setIsOpen(false); setSearchQuery(""); + } else if (exactMatch) { + // Auto-select exact match on Enter when nothing is highlighted + onSelect(exactMatch); + setIsOpen(false); + setSearchQuery(""); } break; case "Home": @@ -178,15 +254,41 @@ export function ProjectSelector({ break; } }, - [highlightedIndex, totalItems, displayProjects, onSelect, onViewAll] + [highlightedIndex, totalItems, displayProjects, onSelect, onViewAll, exactMatch] ); - // Reset highlight when dropdown opens or search changes + // Auto-highlight first result when filtering (type-ahead behavior) useEffect(() => { - if (isOpen) { + if (isOpen && searchQuery.trim()) { + if (exactMatch) { + // Auto-highlight the exact match item + const bookmarkedCount = displayProjects.bookmarked.length; + const recentCount = displayProjects.recent.length; + const matchIdx = displayProjects.others.findIndex( + (p) => p.id === exactMatch.id + ); + if (matchIdx >= 0) { + setHighlightedIndex(bookmarkedCount + recentCount + matchIdx); + } + } else if (displayProjects.others.length > 0) { + // Highlight first item in others section + setHighlightedIndex(displayProjects.bookmarked.length + displayProjects.recent.length); + } else { + setHighlightedIndex(-1); + } + } else if (isOpen && !searchQuery.trim()) { setHighlightedIndex(-1); } - }, [isOpen, searchQuery]); + }, [isOpen, searchQuery, exactMatch, displayProjects]); + + // Scroll highlighted item into view for keyboard navigation + useEffect(() => { + if (highlightedIndex < 0) return; + const el = itemRefs.current.get(highlightedIndex); + if (el) { + el.scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex]); // Handle project selection const handleSelectProject = useCallback( @@ -207,11 +309,14 @@ export function ProjectSelector({ // Toggle dropdown const toggleDropdown = useCallback(() => { - setIsOpen((prev) => !prev); - if (isOpen) { - setSearchQuery(""); - } - }, [isOpen]); + setIsOpen((prev) => { + if (!prev) { + // Opening — always clear search for a fresh type-ahead + setSearchQuery(""); + } + return !prev; + }); + }, []); // Render status icon const renderStatusIcon = (status: ProjectStatus) => { @@ -226,6 +331,36 @@ export function ProjectSelector({ ); }; + // Render bookmark star toggle (span to avoid nested - )} + {/* Search input — always visible for autocomplete/type-ahead */} +
+ + setSearchQuery(e.target.value)} + className="project-selector__search-input" + data-testid="project-selector-search-input" + aria-label={t("projectSelector.searchAriaLabel", "Type to search projects")} + /> + {searchQuery && ( + + )} +
+ + {/* Exact match indicator */} + {exactMatch && ( +
+ {t("projectSelector.exactMatch", "Exact match — press Enter to select")}
)} - {/* Recent projects section */} - {displayProjects.recent.length > 0 && ( + {/* Bookmarked projects section */} + {displayProjects.bookmarked.length > 0 && (
- - {t("projectSelector.recent", "Recent")} + + {t("projectSelector.bookmarked", "Bookmarked")}
- {displayProjects.recent.map((project, index) => ( + {displayProjects.bookmarked.map((project, index) => (
)} - {/* All projects section */} -
- {displayProjects.recent.length > 0 && ( + {/* Recent projects section */} + {displayProjects.recent.length > 0 && ( +
- - {t("projectSelector.allProjects", "All Projects")} + + {t("projectSelector.recent", "Recent")}
- )} - - {displayProjects.others.length === 0 && searchQuery ? ( -
- {t("projectSelector.noResults", "No projects match your search")} -
- ) : ( - displayProjects.others.map((project, index) => { - const actualIndex = displayProjects.recent.length + index; + {displayProjects.recent.map((project, index) => { + const actualIndex = displayProjects.bookmarked.length + index; return ( + ); + })} +
+ )} + + {/* All projects section */} +
+ {(displayProjects.bookmarked.length > 0 || displayProjects.recent.length > 0) && ( +
+ + {t("projectSelector.allProjects", "All Projects")} +
+ )} + + {displayProjects.others.length === 0 && searchQuery ? ( +
+ + + {t("projectSelector.noResults", "No projects match \"{{query}}\"", { query: searchQuery })} + +
+ ) : ( + displayProjects.others.map((project, index) => { + const actualIndex = displayProjects.bookmarked.length + displayProjects.recent.length + index; + const isExactMatch = exactMatch?.id === project.id; + return ( +