Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/config/sharedConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@ declare global {
displayRawMarkdown?: boolean;
showChatScrollbar?: boolean;
codeWrap?: boolean;
rainbowEffectEnabled?: boolean;
}

interface ContextMenuConfig {
Expand Down
1 change: 1 addition & 0 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,7 @@ export interface ContinueUIConfig {
codeWrap?: boolean;
showSessionTabs?: boolean;
continueAfterToolRejection?: boolean;
rainbowEffectEnabled?: boolean;
}

export interface ContextMenuConfig {
Expand Down
12 changes: 8 additions & 4 deletions gui/src/components/mainInput/ContinueInputBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -107,6 +110,8 @@ function ContinueInputBox(props: ContinueInputBoxProps) {

const { appliedRules = [], contextItems = [] } = props;

const isLoading = isStreaming && (props.isLastUserInput || isInEdit);

return (
<div
className={`${props.hidden ? "hidden" : ""}`}
Expand All @@ -115,12 +120,11 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
<div className={`relative flex flex-col px-2`}>
{props.isMainInput && <Lump />}
<GradientBorder
loading={isStreaming && (props.isLastUserInput || isInEdit) ? 1 : 0}
loading={isLoading ? 1 : 0}
borderColor={
isStreaming && (props.isLastUserInput || isInEdit)
? undefined
: vscBackground
isLoading && rainbowEffectEnabled ? undefined : vscBackground
}
rainbowEffectEnabled={rainbowEffectEnabled}
borderRadius={defaultBorderRadius}
>
<TipTapEditor
Expand Down
84 changes: 84 additions & 0 deletions gui/src/components/mainInput/GradientBorder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { GradientBorder } from "./GradientBorder";

// styled-components injects generated rules into document stylesheets,
// so inspect the css rules matching the rendered element's class names
// (whitespace stripped to tolerate cssText serialization differences)
function getCssForElement(element: HTMLElement): string {
const classNames = Array.from(element.classList);
const cssTexts: string[] = [];
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
continue;
}
for (const rule of Array.from(rules)) {
const styleRule = rule as CSSStyleRule;
if (
styleRule.selectorText &&
classNames.some((name) => 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(
<GradientBorder loading={1}>
<div>content</div>
</GradientBorder>,
);
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(
<GradientBorder loading={1} rainbowEffectEnabled={true}>
<div>content</div>
</GradientBorder>,
);
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(
<GradientBorder loading={1} rainbowEffectEnabled={false}>
<div>content</div>
</GradientBorder>,
);
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(
<GradientBorder loading={0} borderColor="#1e1e1e">
<div>content</div>
</GradientBorder>,
);
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(
<GradientBorder loading={1} rainbowEffectEnabled={false}>
<div>content</div>
</GradientBorder>,
);
expect(getByText("content")).toBeInTheDocument();
});
});
8 changes: 7 additions & 1 deletion gui/src/components/mainInput/GradientBorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
116 changes: 116 additions & 0 deletions gui/src/pages/config/sections/UserSettingsSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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<any>("../../../components/ui");
return {
...actual,
useFontSize: () => 14,
};
});

import { useAppSelector } from "../../../redux/hooks";

const mockUseAppSelector = useAppSelector as any;

const mockConfig = {
ui: {} as Record<string, unknown>,
tabAutocompleteOptions: {},
experimental: {},
allowAnonymousTelemetry: true,
disableSessionTitles: false,
};

function renderSection() {
return render(<UserSettingsSection />);
}

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");
});
});
10 changes: 10 additions & 0 deletions gui/src/pages/config/sections/UserSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -164,6 +165,15 @@ export function UserSettingsSection() {
min={7}
max={50}
/>
<UserSetting
type="toggle"
title="Enable Rainbow Effect"
description="Show the animated rainbow border around the input box while Continue is processing."
value={rainbowEffectEnabled}
onChange={(value) =>
handleUpdate({ rainbowEffectEnabled: value })
}
/>
</div>
</Card>
</div>
Expand Down
Loading