Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a107bed
feat(theme): add OpenHands-Neo theme with white primary buttons
FraterCCCLXIII May 22, 2026
4303125
fix(ui): standardize form controls to 36px rounded-lg styling
FraterCCCLXIII May 22, 2026
3fa3a06
fix(ui): indent switch helper text under labels
FraterCCCLXIII May 22, 2026
18b7656
fix(ui): unify toggle switch and polish settings navigation
FraterCCCLXIII May 22, 2026
c7a9189
fix(ui): use font-medium for page and modal headings
FraterCCCLXIII May 22, 2026
24c1873
fix(ui): unify connected status green with sidebar indicator
FraterCCCLXIII May 22, 2026
16f316a
fix(ui): round toast corners to match button radius
FraterCCCLXIII May 22, 2026
4008fdf
fix(ui): unify combobox carets and polish error toasts
FraterCCCLXIII May 22, 2026
bd30d3b
fix(ui): remove hover delay on nav icons and dropdown carets
FraterCCCLXIII May 22, 2026
d00b537
fix(ui): unify settings list styling and secrets header layout
FraterCCCLXIII May 22, 2026
fc9195a
fix(ui): polish settings lists, profile menu, and error indicators
FraterCCCLXIII May 22, 2026
e0efe5d
fix(ui): tighten conversation list row spacing to 2px
FraterCCCLXIII May 22, 2026
ea8a407
fix(ui): cap grouped conversation folders at five rows
FraterCCCLXIII May 22, 2026
1cc793c
fix(ui): polish conversation card metadata and launcher buttons
FraterCCCLXIII May 22, 2026
61835ad
fix(ui): size collapsed sidebar nav rows to 36px height
FraterCCCLXIII May 22, 2026
795a1fd
fix(ui): prevent sidebar logo flicker when collapsing
FraterCCCLXIII May 22, 2026
e457ac2
fix(ui): refine LLM profile editor header layout
FraterCCCLXIII May 22, 2026
1f7cb05
fix(ui): refine secrets add/edit form header layout
FraterCCCLXIII May 22, 2026
7b69667
fix(ui): normalize button font weight to regular across the app
FraterCCCLXIII May 22, 2026
a580aad
fix(ui): align error toast styling with chat error banner
FraterCCCLXIII May 22, 2026
cff80a1
fix(ui): improve error toast icon alignment with message text
FraterCCCLXIII May 22, 2026
c7b53b3
fix(ui): shrink sidebar collapse toggle to match filter button
FraterCCCLXIII May 22, 2026
d203908
fix(ui): polish conversation empty state, status menu, and logo
FraterCCCLXIII May 22, 2026
9e823f9
refactor: resolve merge conflicts
hieptl May 23, 2026
8232cb7
fix: failing tests
hieptl May 23, 2026
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
7 changes: 7 additions & 0 deletions __tests__/components/chat/error-message-banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ describe("ErrorMessageBanner", () => {
expect(onRetry).toHaveBeenCalledTimes(1);
});

it("shows a red error icon beside the message", () => {
render(<ErrorMessageBanner message="Something went wrong" />);

const icon = screen.getByTestId("error-message-banner-icon");
expect(icon).toHaveStyle({ color: "var(--oh-status-error)" });
});

it("uses greyscale theme tokens instead of red error styling", () => {
render(<ErrorMessageBanner message="Something went wrong" />);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
getGroupConversationPreview,
groupConversations,
GROUP_CONVERSATIONS_PREVIEW_LIMIT,
parseConversationTimeMs,
sortConversationsByField,
} from "#/components/features/conversation-panel/conversation-panel-list-helpers";
Expand Down Expand Up @@ -212,6 +214,48 @@ describe("conversation-panel-list-helpers", () => {
]);
});

it("limits grouped folder previews to five conversations with an expand path", () => {
const conversations = Array.from({ length: 6 }, (_, index) => ({
...base,
id: `c-${index}`,
title: `Conversation ${index}`,
updated_at: `2024-01-0${index + 1}T00:00:00.000Z`,
}));

const truncated = getGroupConversationPreview(conversations, {
expanded: false,
});
expect(truncated.visibleConversations.map((c) => c.id)).toEqual([
"c-0",
"c-1",
"c-2",
"c-3",
"c-4",
]);
expect(truncated.isPreviewTruncated).toBe(true);
expect(truncated.isShowingAll).toBe(false);

const expanded = getGroupConversationPreview(conversations, {
expanded: true,
});
expect(expanded.visibleConversations).toHaveLength(6);
expect(expanded.isPreviewTruncated).toBe(true);
expect(expanded.isShowingAll).toBe(true);

const withActiveBeyondPreview = getGroupConversationPreview(conversations, {
expanded: false,
activeConversationId: "c-5",
});
expect(withActiveBeyondPreview.visibleConversations.map((c) => c.id)).toEqual([
"c-0",
"c-1",
"c-2",
"c-3",
"c-5",
]);
expect(GROUP_CONVERSATIONS_PREVIEW_LIMIT).toBe(5);
});

it("groups local conversations by selected_workspace, collapsing per-conversation worktree paths", () => {
// Two conversations launched against the same workspace but with
// different per-conversation worktree dirs (the agent-server runs
Expand Down
13 changes: 11 additions & 2 deletions __tests__/components/features/home/home-chat-launcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser

const mockNavigate = vi.fn();
const mockUseActiveBackend = vi.fn();
const mockDisplayErrorToast = vi.fn();

vi.mock("#/utils/custom-toast-handlers", async (importOriginal) => {
const actual =
await importOriginal<typeof import("#/utils/custom-toast-handlers")>();
return {
...actual,
displayErrorToast: (...args: unknown[]) => mockDisplayErrorToast(...args),
};
});

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
Expand Down Expand Up @@ -294,7 +304,6 @@ describe("HomeChatLauncher", () => {
});

it("surfaces a toast and skips navigation when conversation creation fails", async () => {
const toastErrorSpy = vi.spyOn(toast, "error");
vi.spyOn(AgentServerConversationService, "createConversation").mockRejectedValue(
new Error("Network down"),
);
Expand All @@ -303,7 +312,7 @@ describe("HomeChatLauncher", () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("stub-chat-submit"));

await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
await waitFor(() => expect(mockDisplayErrorToast).toHaveBeenCalled());
expect(mockNavigate).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,12 @@ describe("ProfileActionsMenu", () => {
expect(menuItems).toHaveLength(4);
});

it("marks the Delete menu item as destructive", () => {
it("styles Delete like other menu items", () => {
render(<ProfileActionsMenu {...defaultProps} />);

const deleteButton = screen.getByTestId("profile-delete");
expect(deleteButton).toHaveAttribute("data-destructive", "true");
expect(deleteButton).not.toHaveAttribute("data-destructive");
expect(deleteButton.className).not.toMatch(/text-red/);
});

it("does not call onClose when clicking inside the menu container", () => {
Expand Down
70 changes: 70 additions & 0 deletions __tests__/themes/color-themes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AgentServerUIRoot } from "#/components/providers/agent-server-ui-root";
import {
AVAILABLE_COLOR_THEMES,
COLOR_THEMES,
applyColorTheme,
} from "#/themes/color-themes";

describe("color themes", () => {
it("includes OpenHands-Neo as a neutral-based theme with white button tokens", () => {
const neo = COLOR_THEMES["openhands-neo"];

expect(neo.label).toBe("OpenHands-Neo");
expect(neo.scale).toEqual(COLOR_THEMES["openhands-neutral"].scale);
expect(neo.heroui).toEqual(COLOR_THEMES["openhands-neutral"].heroui);
expect(neo.tokens?.["--oh-color-primary"]).toBe("#ffffff");
expect(neo.tokens?.["--oh-accent"]).toBe("#ffffff");
});

it("exposes Neo in the settings theme picker", () => {
expect(AVAILABLE_COLOR_THEMES.map((theme) => theme.key)).toContain(
"openhands-neo",
);
expect(
AVAILABLE_COLOR_THEMES.find((theme) => theme.key === "openhands-neo")
?.label,
).toBe("OpenHands-Neo");
});

it("injects white primary tokens when applying OpenHands-Neo", () => {
document.body.setAttribute("data-agent-server-ui", "");

applyColorTheme("openhands-neo");

const styleEl = document.getElementById("oh-color-theme-override");
expect(styleEl?.textContent).toContain("--oh-color-primary: #ffffff;");
expect(styleEl?.textContent).toContain("--oh-accent: #ffffff;");

styleEl?.remove();
document.body.removeAttribute("data-agent-server-ui");
document.body.style.removeProperty("--oh-color-primary");
document.body.style.removeProperty("--oh-accent");
document.body.style.removeProperty("--oh-warning");
});

it("applies Neo button tokens on the scoped UI root used by primary buttons", () => {
render(
<AgentServerUIRoot>
<button type="button" data-testid="primary-button">
Save
</button>
</AgentServerUIRoot>,
);

applyColorTheme("openhands-neo");

const scopeRoot = screen.getByTestId("primary-button").closest(
"[data-agent-server-ui]",
) as HTMLElement;

expect(scopeRoot.style.getPropertyValue("--oh-color-primary")).toBe(
"#ffffff",
);

applyColorTheme("openhands-neutral");

expect(scopeRoot.style.getPropertyValue("--oh-color-primary")).toBe("");
});
});
34 changes: 23 additions & 11 deletions __tests__/utils/custom-toast-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import {
displayErrorToast,
} from "#/utils/custom-toast-handlers";

// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
const { toastMock } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
},
loading: vi.fn(),
dismiss: vi.fn(),
}),
}));

// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: toastMock,
}));

describe("custom-toast-handlers", () => {
Expand All @@ -29,6 +35,7 @@ describe("custom-toast-handlers", () => {
duration: 5000, // Should use minimum duration of 5000ms
position: "top-right",
style: expect.objectContaining({
borderRadius: "var(--oh-radius)",
maxWidth: "400px",
wordBreak: "break-word",
}),
Expand All @@ -47,6 +54,7 @@ describe("custom-toast-handlers", () => {
duration: expect.any(Number),
position: "top-right",
style: expect.objectContaining({
borderRadius: "var(--oh-radius)",
maxWidth: "400px",
wordBreak: "break-word",
}),
Expand All @@ -67,44 +75,48 @@ describe("custom-toast-handlers", () => {
});

describe("displayErrorToast", () => {
it("should call toast.error with calculated duration for short message", () => {
it("should call toast with calculated duration for short message", () => {
const shortMessage = "Error occurred";
displayErrorToast(shortMessage);

expect(toast.error).toHaveBeenCalledWith(
expect(toastMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
duration: 4000, // Should use minimum duration of 4000ms for errors
position: "top-right",
icon: null,
style: expect.objectContaining({
borderRadius: "var(--oh-radius)",
maxWidth: "400px",
wordBreak: "break-word",
color: "var(--oh-muted)",
}),
}),
);
});

it("should call toast.error with longer duration for long error message", () => {
it("should call toast with longer duration for long error message", () => {
const longMessage =
"A very long error message that should take more time to read and understand what went wrong with the operation.";
displayErrorToast(longMessage);

expect(toast.error).toHaveBeenCalledWith(
expect(toastMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
duration: expect.any(Number),
position: "top-right",
icon: null,
style: expect.objectContaining({
borderRadius: "var(--oh-radius)",
maxWidth: "400px",
wordBreak: "break-word",
color: "var(--oh-muted)",
}),
}),
);

// Get the actual duration that was passed
const callArgs = (
toast.error as unknown as { mock: { calls: unknown[][] } }
).mock.calls[0][1] as { duration: number };
const callArgs = toastMock.mock.calls[0][1] as { duration: number };
const actualDuration = callArgs.duration;

// For a long message, duration should be more than the minimum 4000ms
Expand Down
22 changes: 22 additions & 0 deletions __tests__/utils/form-control-classes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
formControlButtonClassName,
formControlFieldClassName,
formControlShellClassName,
} from "#/utils/form-control-classes";

describe("formControlClasses", () => {
it("standardizes fields, shells, and buttons to 36px with rounded-lg", () => {
expect(formControlFieldClassName).toContain("h-9");
expect(formControlFieldClassName).toContain("rounded-lg");
expect(formControlFieldClassName).toContain("border-[var(--oh-border)]");
expect(formControlFieldClassName).toContain("bg-base-secondary");

expect(formControlShellClassName).toContain("h-9");
expect(formControlShellClassName).toContain("rounded-lg");
expect(formControlShellClassName).toContain("focus-within:ring-1");

expect(formControlButtonClassName).toContain("h-9");
expect(formControlButtonClassName).toContain("rounded-lg");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function SkillReadyContentList({ items }: SkillReadyContentListProps) {
<ChevronRight size={14} />
)}
</Typography.Text>
<Typography.Text className="font-semibold text-[var(--oh-foreground)] text-sm">
<Typography.Text className="font-normal text-[var(--oh-foreground)] text-sm">
{item.name}
</Typography.Text>
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/components/features/automations/automation-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function AutomationGroup({
return (
<section>
<div className="flex items-center">
<h2 className="text-sm font-semibold text-white">{title}</h2>
<h2 className="text-sm font-medium text-white">{title}</h2>
<StatusBadge count={count} />
</div>
<div className="mt-3 flex flex-col gap-3">
Expand Down
10 changes: 6 additions & 4 deletions src/components/features/automations/backend-not-configured.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react";
import { BrandButton } from "#/components/features/settings/brand-button";

interface BackendUnavailableProps {
onRetry: () => void;
Expand All @@ -12,19 +13,20 @@ export function BackendUnavailable({ onRetry }: BackendUnavailableProps) {
return (
<div className="flex flex-col items-center justify-center py-20 px-4">
<ExclamationCircleIcon className="size-12 text-[var(--oh-warning)]" />
<h2 className="mt-4 text-lg font-semibold text-content">
<h2 className="mt-4 text-lg font-medium text-content">
{t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE)}
</h2>
<p className="mt-2 text-sm text-muted text-center max-w-md">
{t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE)}
</p>
<button
<BrandButton
type="button"
variant="secondary"
className="mt-6"
onClick={onRetry}
className="mt-6 rounded-lg border border-[var(--oh-border)] px-4 py-2 text-sm text-white hover:bg-surface-raised"
>
{t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY)}
</button>
</BrandButton>
</div>
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/features/automations/create-instructions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function CreateInstructions({
</p>
<NavigationLink
to={NEW_CONVERSATION_URL}
className="mt-2 inline-flex items-center gap-1 rounded-md bg-surface-raised px-3 py-2 text-xs font-medium text-content hover:bg-surface-raised transition-colors"
className="mt-2 inline-flex items-center gap-1 rounded-md bg-surface-raised px-3 py-2 text-xs font-normal text-content hover:bg-surface-raised transition-colors"
>
{t(I18nKey.AUTOMATIONS$EMPTY_START_CONVERSATION)}
<span aria-hidden="true">→</span>
Expand Down Expand Up @@ -96,7 +96,7 @@ export function CreateInstructions({
aria-expanded={isExpanded}
className="flex w-full items-center justify-between p-4 text-left hover:bg-surface-raised transition-colors rounded-lg"
>
<span className="text-sm font-medium text-content">
<span className="text-sm font-normal text-content">
{t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}
</span>
<ChevronDownIcon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function DeleteConfirmationModal({
<XMarkIcon className="size-5" />
</button>

<h2 className="text-lg font-semibold text-content-2">
<h2 className="text-lg font-medium text-white">
{t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_TITLE)}
</h2>
<p className="mt-2 text-sm text-muted">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function DetailHeader({
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-content">
<h1 className="text-xl font-medium text-content">
{automation.name}
</h1>
<ActiveStatusBadge active={automation.enabled} />
Expand Down
Loading
Loading