diff --git a/core/config/sharedConfig.ts b/core/config/sharedConfig.ts index 87306a2aed8..ca72c84704d 100644 --- a/core/config/sharedConfig.ts +++ b/core/config/sharedConfig.ts @@ -32,6 +32,7 @@ export const sharedConfigSchema = z displayRawMarkdown: z.boolean(), showChatScrollbar: z.boolean(), continueAfterToolRejection: z.boolean(), + rainbowEffectEnabled: z.boolean(), // `tabAutocompleteOptions` in `ContinueConfig` useAutocompleteCache: z.boolean(), @@ -140,6 +141,10 @@ export function modifyAnyConfigWithSharedConfig< configCopy.ui.showChatScrollbar = sharedConfig.showChatScrollbar; } + if (sharedConfig.rainbowEffectEnabled !== undefined) { + configCopy.ui.rainbowEffectEnabled = sharedConfig.rainbowEffectEnabled; + } + if (sharedConfig.allowAnonymousTelemetry !== undefined) { configCopy.allowAnonymousTelemetry = sharedConfig.allowAnonymousTelemetry; } diff --git a/core/config/types.ts b/core/config/types.ts index 2500042e887..1c784ca53ca 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -1039,6 +1039,7 @@ declare global { displayRawMarkdown?: boolean; showChatScrollbar?: boolean; codeWrap?: boolean; + rainbowEffectEnabled?: boolean; } interface ContextMenuConfig { diff --git a/core/index.d.ts b/core/index.d.ts index bec3e0e0ff8..4899ebb3547 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -1453,6 +1453,7 @@ export interface ContinueUIConfig { codeWrap?: boolean; showSessionTabs?: boolean; continueAfterToolRejection?: boolean; + rainbowEffectEnabled?: boolean; } export interface ContextMenuConfig { diff --git a/gui/src/components/mainInput/ContinueInputBox.tsx b/gui/src/components/mainInput/ContinueInputBox.tsx index c57dbd9d009..8caf651c99e 100644 --- a/gui/src/components/mainInput/ContinueInputBox.tsx +++ b/gui/src/components/mainInput/ContinueInputBox.tsx @@ -62,6 +62,9 @@ function ContinueInputBox(props: ContinueInputBoxProps) { ); const isInEdit = useAppSelector((store) => store.session.isInEdit); const editModeState = useAppSelector((state) => state.editModeState); + const rainbowEffectEnabled = useAppSelector( + (state) => state.config.config.ui?.rainbowEffectEnabled ?? true, + ); const filteredSlashCommands = useMemo(() => { if (isInEdit) { @@ -107,6 +110,8 @@ function ContinueInputBox(props: ContinueInputBoxProps) { const { appliedRules = [], contextItems = [] } = props; + const isLoading = isStreaming && (props.isLastUserInput || isInEdit); + return (
{props.isMainInput && } styleRule.selectorText.includes(name)) + ) { + cssTexts.push(styleRule.cssText); + } + } + } + return cssTexts.join("\n").replace(/\s+/g, ""); +} + +describe("GradientBorder", () => { + it("runs the rainbow animation while loading by default (no config provided)", () => { + const { container } = render( + +
content
+
, + ); + const css = getCssForElement(container.firstChild as HTMLElement); + expect(css).toContain("infinite"); + expect(css).not.toContain("animation-name:none"); + }); + + it("runs the rainbow animation when loading and rainbowEffectEnabled is true", () => { + const { container } = render( + +
content
+
, + ); + const css = getCssForElement(container.firstChild as HTMLElement); + expect(css).toContain("infinite"); + expect(css).not.toContain("animation-name:none"); + }); + + it("does not run the rainbow animation when rainbowEffectEnabled is false, even while loading", () => { + const { container } = render( + +
content
+
, + ); + const css = getCssForElement(container.firstChild as HTMLElement); + expect(css).not.toContain("infinite"); + expect(css).toContain("animation-name:none"); + }); + + it("does not run the rainbow animation when not loading", () => { + const { container } = render( + +
content
+
, + ); + const css = getCssForElement(container.firstChild as HTMLElement); + expect(css).not.toContain("infinite"); + expect(css).toContain("animation-name:none"); + }); + + it("still renders its children when the rainbow effect is disabled", () => { + const { getByText } = render( + +
content
+
, + ); + expect(getByText("content")).toBeInTheDocument(); + }); +}); diff --git a/gui/src/components/mainInput/GradientBorder.tsx b/gui/src/components/mainInput/GradientBorder.tsx index c3b80ccca34..19a81be1802 100644 --- a/gui/src/components/mainInput/GradientBorder.tsx +++ b/gui/src/components/mainInput/GradientBorder.tsx @@ -13,6 +13,7 @@ export const GradientBorder = styled.div<{ borderRadius?: string; borderColor?: string; loading: 0 | 1; + rainbowEffectEnabled?: boolean; }>` border-radius: ${(props) => props.borderRadius || "0"}; padding: 1px; @@ -29,7 +30,12 @@ export const GradientBorder = styled.div<{ #331BBE 85%, #1BBE84 99% )`}; - animation: ${(props) => (props.loading ? gradient : "")} 6s linear infinite; + animation-name: ${(props) => + props.loading && props.rainbowEffectEnabled !== false ? gradient : "none"}; + animation-duration: 6s; + animation-timing-function: linear; + animation-iteration-count: ${(props) => + props.loading && props.rainbowEffectEnabled !== false ? "infinite" : "1"}; background-size: 200% 200%; width: 100%; display: flex; diff --git a/gui/src/pages/config/sections/UserSettingsSection.test.tsx b/gui/src/pages/config/sections/UserSettingsSection.test.tsx new file mode 100644 index 00000000000..2261db88050 --- /dev/null +++ b/gui/src/pages/config/sections/UserSettingsSection.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UserSettingsSection } from "./UserSettingsSection"; + +// Same mock as renderWithProviders (headlessui needs ResizeObserver) +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +const mockPost = vi.fn(); +const mockDispatch = vi.fn(); + +// Mock the dependencies, same style as FindAndReplace.test.tsx +vi.mock("../../../context/IdeMessenger", () => ({ + IdeMessengerContext: { + _currentValue: { post: (...args: any[]) => mockPost(...args) }, + }, +})); + +vi.mock("../../../redux/hooks", () => ({ + useAppSelector: vi.fn(), + useAppDispatch: () => mockDispatch, +})); + +vi.mock("../../../components/ui", async () => { + const actual = await vi.importActual("../../../components/ui"); + return { + ...actual, + useFontSize: () => 14, + }; +}); + +import { useAppSelector } from "../../../redux/hooks"; + +const mockUseAppSelector = useAppSelector as any; + +const mockConfig = { + ui: {} as Record, + tabAutocompleteOptions: {}, + experimental: {}, + allowAnonymousTelemetry: true, + disableSessionTitles: false, +}; + +function renderSection() { + return render(); +} + +function getRainbowToggle() { + // The toggle track (click target) is the sibling element of the setting title, + // the knob inside it carries the on/off styling + const title = screen.getByText("Enable Rainbow Effect"); + const row = title.closest("div.gap-4") as HTMLElement; + const track = row.querySelector(".rounded-full") as HTMLElement; + const knob = track.querySelector("div") as HTMLElement; + return { track, knob }; +} + +describe("UserSettingsSection - Enable Rainbow Effect", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfig.ui = {}; + mockUseAppSelector.mockImplementation((selector: any) => + selector({ config: { config: mockConfig } }), + ); + }); + + it("shows the toggle enabled by default when no config value is provided", () => { + renderSection(); + + expect(screen.getByText("Enable Rainbow Effect")).toBeInTheDocument(); + expect( + screen.getByText( + "Show the animated rainbow border around the input box while Continue is processing.", + ), + ).toBeInTheDocument(); + + // Default true => toggle knob has the "on" styling + expect(getRainbowToggle().knob.className).toContain("brightness-150"); + }); + + it("posts rainbowEffectEnabled: false to shared config when toggled off", () => { + renderSection(); + + fireEvent.click(getRainbowToggle().track); + + expect(mockPost).toHaveBeenCalledWith("config/updateSharedConfig", { + rainbowEffectEnabled: false, + }); + // Optimistic redux update is dispatched as well + expect(mockDispatch).toHaveBeenCalled(); + }); + + it("posts rainbowEffectEnabled: true when toggled back on", () => { + mockConfig.ui = { rainbowEffectEnabled: false }; + + renderSection(); + + fireEvent.click(getRainbowToggle().track); + + expect(mockPost).toHaveBeenCalledWith("config/updateSharedConfig", { + rainbowEffectEnabled: true, + }); + }); + + it("reflects an existing disabled value from config", () => { + mockConfig.ui = { rainbowEffectEnabled: false }; + + renderSection(); + + // Default false => toggle knob has the "off" styling + expect(getRainbowToggle().knob.className).toContain("brightness-75"); + }); +}); diff --git a/gui/src/pages/config/sections/UserSettingsSection.tsx b/gui/src/pages/config/sections/UserSettingsSection.tsx index 0b5549f5a08..3f904159607 100644 --- a/gui/src/pages/config/sections/UserSettingsSection.tsx +++ b/gui/src/pages/config/sections/UserSettingsSection.tsx @@ -52,6 +52,7 @@ export function UserSettingsSection() { config.ui?.continueAfterToolRejection ?? false; const codeWrap = config.ui?.codeWrap ?? false; const showChatScrollbar = config.ui?.showChatScrollbar ?? false; + const rainbowEffectEnabled = config.ui?.rainbowEffectEnabled ?? true; const readResponseTTS = config.experimental?.readResponseTTS ?? false; const displayRawMarkdown = config.ui?.displayRawMarkdown ?? false; const disableSessionTitles = config.disableSessionTitles ?? false; @@ -164,6 +165,15 @@ export function UserSettingsSection() { min={7} max={50} /> + + handleUpdate({ rainbowEffectEnabled: value }) + } + />