Skip to content

feat(ui): introduce flexible UI configuration system with theme and branding support #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
17 changes: 10 additions & 7 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { UIConfigProvider } from "@/lib/ui-config-provider";
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import React from "react";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import React from "react";
import "./globals.css";

const inter = Inter({
subsets: ["latin"],
Expand All @@ -21,10 +22,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
<UIConfigProvider>
<html lang="en">
<body className={inter.className}>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
</UIConfigProvider>
);
}
25 changes: 25 additions & 0 deletions src/components/dark-mode-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { Moon, Sun } from "lucide-react";
import { useUIConfig } from "@/hooks/useUIConfig";

export const ThemeToggleButton = () => {
const { theme, setTheme } = useUIConfig();

const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};

const isDark = theme === "dark";

return (
<button
onClick={toggleTheme}
className="cursor-pointer rounded-full p-0 transition"
aria-label="Toggle Theme"
title={`Switch to ${isDark ? "Light" : "Dark"} Mode`}
>
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
);
};
151 changes: 86 additions & 65 deletions src/components/thread/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { useUIConfig } from "@/hooks/useUIConfig";
import { ThemeToggleButton } from "../dark-mode-switcher";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { GitHubSVG } from "../icons/github";
Expand Down Expand Up @@ -114,6 +116,7 @@ export function Thread() {
const [input, setInput] = useState("");
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
const { config } = useUIConfig();

const stream = useStreamContext();
const messages = stream.messages;
Expand Down Expand Up @@ -212,30 +215,32 @@ export function Thread() {

return (
<div className="flex h-screen w-full overflow-hidden">
<div className="relative hidden lg:flex">
<motion.div
className="absolute z-20 h-full overflow-hidden border-r bg-white"
style={{ width: 300 }}
animate={
isLargeScreen
? { x: chatHistoryOpen ? 0 : -300 }
: { x: chatHistoryOpen ? 0 : -300 }
}
initial={{ x: -300 }}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
<div
className="relative h-full"
{!config.layout?.hideThreadHistory && (
<div className="relative hidden lg:flex">
<motion.div
className="absolute z-20 h-full overflow-hidden border-r bg-white"
style={{ width: 300 }}
animate={
isLargeScreen
? { x: chatHistoryOpen ? 0 : -300 }
: { x: chatHistoryOpen ? 0 : -300 }
}
initial={{ x: -300 }}
transition={
isLargeScreen
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
>
<ThreadHistory />
</div>
</motion.div>
</div>
<div
className="relative h-full"
style={{ width: 300 }}
>
<ThreadHistory />
</div>
</motion.div>
</div>
)}
<motion.div
className={cn(
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
Expand All @@ -258,23 +263,26 @@ export function Thread() {
>
{!chatStarted && (
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
{!config.layout?.hideThreadHistory && (
<div>
{(!chatHistoryOpen || !isLargeScreen) && (
<Button
className="hover:bg-gray-100"
variant="ghost"
onClick={() => setChatHistoryOpen((p) => !p)}
>
{chatHistoryOpen ? (
<PanelRightOpen className="size-5" />
) : (
<PanelRightClose className="size-5" />
)}
</Button>
)}
</div>
)}
<div className="absolute top-2 right-4 flex items-center gap-4">
{config.layout?.showThemeToggle && <ThemeToggleButton />}
{!config.layout?.hideGithubButton && <OpenGitHubRepo />}
</div>
</div>
)}
Expand Down Expand Up @@ -308,20 +316,29 @@ export function Thread() {
damping: 30,
}}
>
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
{config.brand.brandHeaderTopLeft ? (
config.brand.brandHeaderTopLeft()
) : (
<>
<LangGraphLogoSVG
width={32}
height={32}
/>
<span className="text-xl font-semibold tracking-tight">
Agent Chat
</span>
</>
)}
</motion.button>
</div>

<div className="flex items-center gap-4">
<div className="flex items-center">
<OpenGitHubRepo />
</div>
{!config.layout?.hideGithubButton && (
<div className="flex items-center">
<OpenGitHubRepo />
</div>
)}
{config.layout?.showThemeToggle && <ThemeToggleButton />}
<TooltipIconButton
size="lg"
className="p-4"
Expand Down Expand Up @@ -381,15 +398,17 @@ export function Thread() {
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
{!chatStarted && (
<div className="sticky bottom-0 flex flex-col items-center gap-8">
{!chatStarted && config.brand.brandHeaderWelcome ? (
config.brand.brandHeaderWelcome()
) : !config.layout?.hideBrandHeaderAboveChatBox ? (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be really nice to display my custom brandHeader above the chat when it started as well. I configured something like this, but this could probably be simplified:

{!chatStarted && config.brand.brandHeaderWelcome ? (
  config.brand.brandHeaderWelcome()
) : !config.layout?.hideBrandHeaderAboveChatBox ? (
  config.brand.brandHeaderWelcome ? (
    config.brand.brandHeaderWelcome()
  ) : (
    <div className="flex items-center gap-3">
      <LangGraphLogoSVG className="h-8 flex-shrink-0" />
      <h1 className="text-2xl font-semibold tracking-tight">
        Agent Chat
      </h1>
    </div>
  )
) : null}

<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
<h1 className="text-2xl font-semibold tracking-tight">
Agent Chat
</h1>
</div>
)}
) : null}

<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />

Expand Down Expand Up @@ -420,19 +439,21 @@ export function Thread() {

<div className="flex items-center justify-between p-2 pt-4">
<div>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
{!config.layout?.hideToolCallsToggleButton && (
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
)}
</div>
{stream.isLoading ? (
<Button
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/useUIConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useContext } from "react";
import UIConfigContext from "@/lib/ui-config-provider";

export const useUIConfig = () => {
const ctx = useContext(UIConfigContext);
if (!ctx)
throw new Error("useUIConfig must be used within a UIConfigProvider");
return ctx;
};
3 changes: 3 additions & 0 deletions src/lib/custom-ui-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { UIConfig } from "@/lib/ui-config";

export const uiConfig: Partial<UIConfig> = {};
15 changes: 15 additions & 0 deletions src/lib/get-ui-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defaultUIConfig, UIConfig } from "./ui-config";
import { uiConfig as custom } from "./custom-ui-config";

export const uiConfig: UIConfig = {
...defaultUIConfig,
...custom,
brand: {
...defaultUIConfig.brand,
...(custom.brand || {}),
},
layout: {
...defaultUIConfig.layout,
...(custom.layout || {}),
},
};
48 changes: 48 additions & 0 deletions src/lib/ui-config-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import React, { createContext, useCallback, useEffect, useState } from "react";
import { uiConfig as mergedConfig } from "./get-ui-config";

type ThemeMode = "light" | "dark";

type UIContextValue = {
config: typeof mergedConfig;
theme: ThemeMode;
setTheme: (mode: ThemeMode) => void;
};

const UIConfigContext = createContext<UIContextValue | undefined>(undefined);

export const UIConfigProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [theme, setThemeState] = useState<ThemeMode>("light");

const setTheme = useCallback((mode: ThemeMode) => {
setThemeState(mode);
document.documentElement.classList.toggle("dark", mode === "dark");
applyCSSVars(mode);
}, []);

const applyCSSVars = (mode: ThemeMode) => {
const root = document.documentElement;
const colors = mergedConfig.brand.colors?.[mode] || {};
if (colors.background)
root.style.setProperty("--background", colors.background);
if (colors.foreground)
root.style.setProperty("--foreground", colors.foreground);
if (colors.primary) root.style.setProperty("--primary", colors.primary);
if (mergedConfig.brand.radius)
root.style.setProperty("--radius", mergedConfig.brand.radius);
};

return (
<UIConfigContext.Provider value={{ config: mergedConfig, theme, setTheme }}>
{children}
</UIConfigContext.Provider>
);
};

export default UIConfigContext;
45 changes: 45 additions & 0 deletions src/lib/ui-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";

export type UIConfig = {
brand: {
colors?: {
light?: {
background?: string;
foreground?: string;
primary?: string;
};
dark?: {
background?: string;
foreground?: string;
primary?: string;
};
};
brandHeaderWelcome?: () => ReactNode;
brandHeaderTopLeft?: () => ReactNode;
radius?: string;
};
layout?: {
hideThreadHistory?: boolean;
hideGithubButton?: boolean;
hideBrandHeaderAboveChatBox?: boolean;
showThemeToggle?: boolean;
hideToolCallsToggleButton?: boolean;
};
};

export const defaultUIConfig: UIConfig = {
brand: {
colors: {
light: {
background: "#ffffff",
foreground: "#000000",
primary: "#007bff",
},
dark: {
background: "#000000",
foreground: "#ffffff",
primary: "#007bff",
},
},
},
};