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 })
+ }
+ />