feat(ui): light/dark theme toggle and light mode color support
Add ThemeContext with localStorage persistence and FOUC-preventing inline script. Add theme toggle button in sidebar. Update status badges, toast notifications, live indicators, and approval cards with dark: prefixed classes for proper light mode rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,10 @@ import { timeAgo } from "../lib/timeAgo";
|
||||
import type { Approval, Agent } from "@paperclip/shared";
|
||||
|
||||
function statusIcon(status: string) {
|
||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-400" />;
|
||||
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-400" />;
|
||||
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-400" />;
|
||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
|
||||
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />;
|
||||
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />;
|
||||
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { BookOpen, Moon, Sun } from "lucide-react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
@@ -19,20 +19,24 @@ import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { panelContent, closePanel } = usePanel();
|
||||
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
@@ -168,7 +172,25 @@ export function Layout() {
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarNavItem
|
||||
to="/docs"
|
||||
label="Documentation"
|
||||
icon={BookOpen}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -185,7 +207,25 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarNavItem
|
||||
to="/docs"
|
||||
label="Documentation"
|
||||
icon={BookOpen}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -344,7 +344,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
<button
|
||||
onClick={() => handleCancelRun(headerRun.id)}
|
||||
disabled={cancellingRunIds.has(headerRun.id)}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
||||
>
|
||||
<Square className="h-2 w-2" fill="currentColor" />
|
||||
{cancellingRunIds.has(headerRun.id) ? "Stopping…" : "Stop"}
|
||||
@@ -352,7 +352,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
)}
|
||||
<Link
|
||||
to={`/agents/${headerRun.agentId}/runs/${headerRun.id}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
|
||||
>
|
||||
Open run
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
@@ -376,13 +376,13 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
<span className="text-[10px] text-muted-foreground">{relativeTime(item.ts)}</span>
|
||||
<div className={cn(
|
||||
"min-w-0",
|
||||
item.tone === "error" && "text-red-300",
|
||||
item.tone === "warn" && "text-amber-300",
|
||||
item.tone === "assistant" && "text-emerald-200",
|
||||
item.tone === "tool" && "text-cyan-300",
|
||||
item.tone === "error" && "text-red-600 dark:text-red-300",
|
||||
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
|
||||
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
|
||||
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
|
||||
item.tone === "info" && "text-foreground/80",
|
||||
)}>
|
||||
<Identity name={item.agentName} size="sm" className="text-cyan-400" />
|
||||
<Identity name={item.agentName} size="sm" className="text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-muted-foreground"> [{item.runId.slice(0, 8)}] </span>
|
||||
<span className="break-words">{item.text}</span>
|
||||
</div>
|
||||
@@ -396,7 +396,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
<div key={run.id} className="inline-flex items-center gap-1.5">
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
|
||||
>
|
||||
<Identity name={run.agentName} size="sm" /> {run.id.slice(0, 8)}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
@@ -8,10 +9,12 @@ interface MarkdownBodyProps {
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all",
|
||||
"prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all",
|
||||
theme === "dark" && "prose-invert",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function SidebarAgents() {
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{runCount} live
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface SidebarNavItemProps {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
end?: boolean;
|
||||
className?: string;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
alert?: boolean;
|
||||
@@ -19,6 +20,7 @@ export function SidebarNavItem({
|
||||
label,
|
||||
icon: Icon,
|
||||
end,
|
||||
className,
|
||||
badge,
|
||||
badgeTone = "default",
|
||||
alert = false,
|
||||
@@ -36,7 +38,8 @@ export function SidebarNavItem({
|
||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
className,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -53,7 +56,7 @@ export function SidebarNavItem({
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">{liveCount} live</span>
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||
</span>
|
||||
)}
|
||||
{badge != null && badge > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user