feat: theme family system with 12 color palettes + NavRail redesign
Theme system:
- Two-layer architecture: mode (light/dark/system via next-themes) + theme family (color palette via CSS variables)
- 12 theme families: Default, GitHub, Rosé Pine, Tokyo Night, Everforest, Nord, Kanagawa, Night Owl, Poimandres, Vesper, Horizon, SynthWave '84
- Per-family code block highlighting for Prism, HLJS, and Shiki (no more generic oneLight/oneDark fallback)
- Anti-FOUC: synchronous script in <head> reads localStorage and sets data-theme-family before paint
- Theme JSON files in themes/ directory, auto-discovered by server-side loader
- OKLCH color space for all values; CSS specificity html[data-theme-family] beats :root
UI changes:
- NavRail: moved Settings to bottom (replacing theme toggle), removed light/dark toggle button
- Settings: merged General controls into one card with dividers; Appearance section with live Shiki code preview
- Replaced hardcoded blue-500/blue-600 with primary/primary-foreground across 19 components
- Appearance settings: pill mode selector (light/dark/system), theme family dropdown, live code preview
New files:
- src/lib/theme/{types,loader,render-css,context,code-themes}.ts — core theme infrastructure
- src/components/layout/ThemeFamilyProvider.tsx — client context provider
- src/components/settings/AppearanceSection.tsx — appearance settings UI
- src/hooks/useAppTheme.ts — combined theme hook
- themes/*.json — 12 theme family definitions
- docs/handover/theme-system.md — handover documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ad66f82c4
commit
4da188bfe5
53 files changed
+2446
-144
No files matched your search
@@ -18,6 +18,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThemeFamily } from "@/lib/theme/context";
|
||||
import { resolveShikiTheme, resolveShikiThemes, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK } from "@/lib/theme/code-themes";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
@@ -121,7 +123,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
// Highlighter cache keyed by "lang:lightTheme:darkTheme"
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
@@ -133,36 +135,45 @@ const tokensCache = new Map<string, TokenizedCode>();
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage, lightTheme: BundledTheme, darkTheme: BundledTheme) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
return `${language}:${lightTheme}:${darkTheme}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const isBundledLanguage = (lang: string): lang is BundledLanguage =>
|
||||
lang in bundledLanguages || lang === "text" || lang === "plaintext";
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
language: BundledLanguage,
|
||||
lightTheme: BundledTheme = SHIKI_DEFAULT_LIGHT,
|
||||
darkTheme: BundledTheme = SHIKI_DEFAULT_DARK,
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
// Normalize unknown languages to "text" before hitting Shiki
|
||||
const safeLang = isBundledLanguage(language) ? language : ("text" as BundledLanguage);
|
||||
const cacheKey = `${safeLang}:${lightTheme}:${darkTheme}`;
|
||||
|
||||
const cached = highlighterCache.get(safeLang);
|
||||
const cached = highlighterCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [safeLang],
|
||||
themes: ["github-light", "github-dark"],
|
||||
themes: [lightTheme, darkTheme],
|
||||
}).catch(() => {
|
||||
// Language not supported by Shiki — fall back to plain text
|
||||
highlighterCache.delete(safeLang);
|
||||
return getHighlighter("text" as BundledLanguage);
|
||||
// Language or theme not supported — fall back to plain text + default themes.
|
||||
// Using default themes avoids infinite retry if the *theme* was the problem.
|
||||
highlighterCache.delete(cacheKey);
|
||||
const useFallbackThemes =
|
||||
lightTheme !== SHIKI_DEFAULT_LIGHT || darkTheme !== SHIKI_DEFAULT_DARK;
|
||||
if (useFallbackThemes) {
|
||||
return getHighlighter("text" as BundledLanguage, SHIKI_DEFAULT_LIGHT, SHIKI_DEFAULT_DARK);
|
||||
}
|
||||
return getHighlighter("text" as BundledLanguage, lightTheme, darkTheme);
|
||||
});
|
||||
|
||||
highlighterCache.set(safeLang, highlighterPromise);
|
||||
highlighterCache.set(cacheKey, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
@@ -187,9 +198,11 @@ export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
callback?: (result: TokenizedCode) => void,
|
||||
lightTheme: BundledTheme = SHIKI_DEFAULT_LIGHT,
|
||||
darkTheme: BundledTheme = SHIKI_DEFAULT_DARK,
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
const tokensCacheKey = getTokensCacheKey(code, language, lightTheme, darkTheme);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
@@ -206,7 +219,7 @@ export const highlightCode = (
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
getHighlighter(language, lightTheme, darkTheme)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
@@ -215,8 +228,8 @@ export const highlightCode = (
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
dark: darkTheme,
|
||||
light: lightTheme,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -387,6 +400,13 @@ export const CodeBlockActions = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Resolve Shiki theme pair from the current theme family. */
|
||||
function useShikiThemes(): { light: BundledTheme; dark: BundledTheme } {
|
||||
const { family, families } = useThemeFamily();
|
||||
const shikiTheme = resolveShikiTheme(families, family);
|
||||
return resolveShikiThemes(shikiTheme);
|
||||
}
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
@@ -396,18 +416,20 @@ export const CodeBlockContent = ({
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
const { light: lightTheme, dark: darkTheme } = useShikiThemes();
|
||||
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Try to get cached result synchronously, otherwise use raw tokens
|
||||
const syncTokenized = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
() => highlightCode(code, language, undefined, lightTheme, darkTheme) ?? rawTokens,
|
||||
[code, language, rawTokens, lightTheme, darkTheme]
|
||||
);
|
||||
|
||||
// Track async highlighting results keyed by code+language to avoid stale state
|
||||
// Track async highlighting results keyed by code+language+themes to avoid stale state
|
||||
const [asyncResult, setAsyncResult] = useState<{ key: string; tokens: TokenizedCode } | null>(null);
|
||||
const resultKey = `${code}:${language}`;
|
||||
const resultKey = `${code}:${language}:${lightTheme}:${darkTheme}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -415,16 +437,16 @@ export const CodeBlockContent = ({
|
||||
// Subscribe to async highlighting result
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncResult({ key: `${code}:${language}`, tokens: result });
|
||||
setAsyncResult({ key: `${code}:${language}:${lightTheme}:${darkTheme}`, tokens: result });
|
||||
}
|
||||
});
|
||||
}, lightTheme, darkTheme);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
}, [code, language, lightTheme, darkTheme]);
|
||||
|
||||
// Only use async result if it matches the current code+language
|
||||
// Only use async result if it matches the current code+language+themes
|
||||
const tokenized = (asyncResult && asyncResult.key === resultKey) ? asyncResult.tokens : syncTokenized;
|
||||
|
||||
return (
|
||||
|
||||
@@ -257,7 +257,7 @@ export function BridgeSection() {
|
||||
<div
|
||||
className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${
|
||||
isEnabled
|
||||
? "border-blue-500/50 bg-blue-500/5"
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "border-border/50"
|
||||
}`}
|
||||
>
|
||||
@@ -275,8 +275,8 @@ export function BridgeSection() {
|
||||
/>
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-xs text-primary">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
{t("bridge.activeHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark, vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useThemeFamily } from '@/lib/theme/context';
|
||||
import { resolveCodeTheme, resolvePrismStyle } from '@/lib/theme/code-themes';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import {
|
||||
@@ -41,6 +44,14 @@ interface CodeBlockProps {
|
||||
maxCollapsedLines?: number;
|
||||
}
|
||||
|
||||
/** Resolve the Prism syntax highlighting style for CodeBlock. */
|
||||
function useCodeBlockTheme(isTerminal: boolean, isDark: boolean) {
|
||||
const { family, families } = useThemeFamily();
|
||||
if (isTerminal) return vscDarkPlus;
|
||||
const codeTheme = resolveCodeTheme(families, family);
|
||||
return resolvePrismStyle(codeTheme, isDark);
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = 'text',
|
||||
@@ -53,6 +64,8 @@ export function CodeBlock({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const codeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [animatingHeight, setAnimatingHeight] = useState<string | undefined>(undefined);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
const lines = useMemo(() => code.split('\n'), [code]);
|
||||
const totalLines = lines.length;
|
||||
@@ -93,17 +106,14 @@ export function CodeBlock({
|
||||
}
|
||||
const currentHeight = container.scrollHeight;
|
||||
if (!expanded) {
|
||||
// Expanding: set current height, then switch to auto after transition
|
||||
setAnimatingHeight(`${currentHeight}px`);
|
||||
setExpanded(true);
|
||||
requestAnimationFrame(() => {
|
||||
// Measure the full height after content change
|
||||
const fullHeight = container.scrollHeight;
|
||||
setAnimatingHeight(`${fullHeight}px`);
|
||||
setTimeout(() => setAnimatingHeight(undefined), 300);
|
||||
});
|
||||
} else {
|
||||
// Collapsing: set current height, then reduce
|
||||
setAnimatingHeight(`${currentHeight}px`);
|
||||
requestAnimationFrame(() => {
|
||||
const collapsedH = maxCollapsedLines * 1.5 + 1.5;
|
||||
@@ -118,35 +128,50 @@ export function CodeBlock({
|
||||
|
||||
const languageIconData = getLanguageIcon(language);
|
||||
|
||||
const theme = isTerminal ? vscDarkPlus : oneDark;
|
||||
const theme = useCodeBlockTheme(isTerminal, isDark);
|
||||
|
||||
return (
|
||||
<div className="relative group not-prose my-3 rounded-lg overflow-hidden border border-zinc-700/50">
|
||||
<div className={cn(
|
||||
"relative group not-prose my-3 rounded-lg overflow-hidden",
|
||||
isTerminal
|
||||
? "border border-zinc-700/50"
|
||||
: "border border-border"
|
||||
)}>
|
||||
{/* Header bar */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-4 py-1.5 text-xs",
|
||||
isTerminal
|
||||
? "bg-zinc-950 text-zinc-400"
|
||||
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<HugeiconsIcon icon={languageIconData} className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isTerminal ? "text-green-400" : "text-zinc-400",
|
||||
isTerminal ? "text-green-400" : "text-muted-foreground",
|
||||
)} />
|
||||
{filename && (
|
||||
<span className="truncate text-zinc-300 font-medium">{filename}</span>
|
||||
<span className={cn(
|
||||
"truncate font-medium",
|
||||
isTerminal ? "text-zinc-300" : "text-foreground"
|
||||
)}>{filename}</span>
|
||||
)}
|
||||
{filename && <span className="text-zinc-600">|</span>}
|
||||
{filename && <span className="text-muted-foreground/50">|</span>}
|
||||
<span className={cn(
|
||||
"bg-zinc-700/50 rounded px-1.5 py-0.5",
|
||||
isTerminal && "text-green-400",
|
||||
"rounded px-1.5 py-0.5",
|
||||
isTerminal
|
||||
? "bg-zinc-700/50 text-green-400"
|
||||
: "bg-accent text-accent-foreground"
|
||||
)}>{language.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
|
||||
isTerminal
|
||||
? "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -163,7 +188,12 @@ export function CodeBlock({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors"
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
|
||||
isTerminal
|
||||
? "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
title="Copy as Markdown"
|
||||
>
|
||||
{copiedMarkdown ? (
|
||||
@@ -197,7 +227,8 @@ export function CodeBlock({
|
||||
lineNumberStyle={{
|
||||
minWidth: '2.5em',
|
||||
paddingRight: '1em',
|
||||
color: '#3a3a48',
|
||||
color: 'var(--muted-foreground)',
|
||||
opacity: 0.4,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
customStyle={{
|
||||
@@ -220,7 +251,7 @@ export function CodeBlock({
|
||||
"absolute bottom-0 left-0 right-0 h-16 pointer-events-none",
|
||||
isTerminal
|
||||
? "bg-gradient-to-t from-[#0a0a0a] to-transparent"
|
||||
: "bg-gradient-to-t from-[#282c34] to-transparent"
|
||||
: "bg-gradient-to-t from-muted to-transparent"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
@@ -233,7 +264,7 @@ export function CodeBlock({
|
||||
"flex w-full items-center justify-center gap-1.5 py-1.5 text-xs transition-colors",
|
||||
isTerminal
|
||||
? "bg-zinc-950 text-zinc-400 hover:text-zinc-200"
|
||||
: "bg-zinc-800 dark:bg-zinc-900 text-zinc-400 hover:text-zinc-200"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
|
||||
@@ -178,7 +178,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => handleNavigate(dir.path)}
|
||||
>
|
||||
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<HugeiconsIcon icon={Folder01Icon} className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="truncate">{dir.name}</span>
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -287,7 +287,7 @@ export function ImageGenConfirmation({
|
||||
'px-2.5 py-1 text-xs font-medium rounded-md border transition-colors',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
aspectRatio === ratio
|
||||
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
|
||||
)}
|
||||
>
|
||||
@@ -313,7 +313,7 @@ export function ImageGenConfirmation({
|
||||
'px-3 py-1 text-xs font-medium rounded-md border transition-colors',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
resolution === res
|
||||
? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border/60 text-muted-foreground hover:text-foreground hover:border-foreground/30'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ImageGenToggle() {
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
|
||||
state.enabled
|
||||
? 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30'
|
||||
? 'bg-primary/15 text-primary border-primary/30'
|
||||
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1133,15 +1133,15 @@ export function MessageInput({
|
||||
{/* Command badge */}
|
||||
{badge && (
|
||||
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-blue-500/20">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 text-primary pl-2.5 pr-1.5 py-1 text-xs font-medium border border-primary/20">
|
||||
<span className="font-mono">{badge.command}</span>
|
||||
{badge.description && (
|
||||
<span className="text-blue-500/60 dark:text-blue-400/60 text-[10px]">{badge.description}</span>
|
||||
<span className="text-primary/60 text-[10px]">{badge.description}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeBadge}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-500/20 transition-colors"
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 3l6 6M9 3l-6 6" />
|
||||
|
||||
@@ -106,7 +106,7 @@ function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMes
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRewind}
|
||||
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
|
||||
className="text-[10px] text-primary hover:underline"
|
||||
>
|
||||
{t('messageList.rewindConfirm' as TranslationKey)}
|
||||
</button>
|
||||
|
||||
@@ -100,8 +100,8 @@ function StatusIndicator({ status }: { status: ToolStatus }) {
|
||||
case 'running':
|
||||
return (
|
||||
<span className="relative flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-30" />
|
||||
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-blue-500" />
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-30" />
|
||||
<HugeiconsIcon icon={Loading02Icon} className="relative h-3.5 w-3.5 animate-spin text-primary" />
|
||||
</span>
|
||||
);
|
||||
case 'success':
|
||||
@@ -282,13 +282,13 @@ export function ToolCallBlock({
|
||||
};
|
||||
|
||||
const statusBorderColor = {
|
||||
running: 'border-blue-500/70',
|
||||
running: 'border-primary/70',
|
||||
success: 'border-green-500/50',
|
||||
error: 'border-red-500/60',
|
||||
}[status];
|
||||
|
||||
const statusBgColor = {
|
||||
running: 'bg-blue-500/[0.03] dark:bg-blue-500/[0.05]',
|
||||
running: 'bg-primary/[0.03] dark:bg-primary/[0.05]',
|
||||
success: 'bg-transparent',
|
||||
error: 'bg-red-500/[0.03] dark:bg-red-500/[0.05]',
|
||||
}[status];
|
||||
@@ -309,11 +309,11 @@ export function ToolCallBlock({
|
||||
)}
|
||||
<HugeiconsIcon icon={toolIconData} className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
category === 'read' && "text-blue-500",
|
||||
category === 'read' && "text-primary",
|
||||
category === 'write' && "text-amber-500",
|
||||
category === 'bash' && "text-green-500",
|
||||
category === 'search' && "text-indigo-500",
|
||||
category === 'other' && "text-zinc-500",
|
||||
category === 'search' && "text-primary",
|
||||
category === 'other' && "text-muted-foreground",
|
||||
)} />
|
||||
<span className="font-mono text-xs truncate flex-1 text-foreground/80">{summary}</span>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
|
||||
@@ -13,7 +13,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
|
||||
|
||||
const statusColor = {
|
||||
pending: 'text-muted-foreground',
|
||||
processing: 'text-blue-500',
|
||||
processing: 'text-primary',
|
||||
completed: 'text-green-500',
|
||||
failed: 'text-red-500',
|
||||
cancelled: 'text-muted-foreground',
|
||||
@@ -37,7 +37,7 @@ export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
|
||||
{/* Status indicator */}
|
||||
<div className="shrink-0">
|
||||
{item.status === 'processing' ? (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
) : item.status === 'completed' ? (
|
||||
<div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<svg className="w-2.5 h-2.5 text-green-500" viewBox="0 0 12 12" fill="none">
|
||||
|
||||
@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Cancel01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useThemeFamily } from "@/lib/theme/context";
|
||||
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
@@ -216,13 +216,21 @@ function ViewModeToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve hljs style from the current theme family + mode. */
|
||||
function useDocCodeTheme(isDark: boolean) {
|
||||
const { family, families } = useThemeFamily();
|
||||
const codeTheme = resolveCodeTheme(families, family);
|
||||
return resolveHljsStyle(codeTheme, isDark);
|
||||
}
|
||||
|
||||
/** Source code view using react-syntax-highlighter */
|
||||
function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) {
|
||||
const hljsStyle = useDocCodeTheme(isDark);
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<SyntaxHighlighter
|
||||
language={preview.language}
|
||||
style={isDark ? atomOneDark : atomOneLight}
|
||||
style={hljsStyle}
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
@@ -235,7 +243,8 @@ function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boo
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.5em",
|
||||
paddingRight: "8px",
|
||||
color: isDark ? "#636d83" : "#9ca3af",
|
||||
color: "var(--muted-foreground)",
|
||||
opacity: 0.5,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -70,7 +70,7 @@ function StepIcon({ status }: { status: string }) {
|
||||
case "success":
|
||||
return <HugeiconsIcon icon={Tick01Icon} className="size-4 text-emerald-500" />;
|
||||
case "running":
|
||||
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-blue-500 animate-spin" />;
|
||||
return <HugeiconsIcon icon={Loading02Icon} className="size-4 text-primary animate-spin" />;
|
||||
case "failed":
|
||||
return <HugeiconsIcon icon={Cancel01Icon} className="size-4 text-red-500" />;
|
||||
case "skipped":
|
||||
@@ -223,11 +223,11 @@ export function InstallWizard({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase("checking"); // eslint-disable-line react-hooks/set-state-in-effect -- reset state before async check
|
||||
setLogs([]); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setCopiedBrew(false); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setPrereqs(null); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setLogs([]);
|
||||
setProgress(null);
|
||||
setCopied(false);
|
||||
setCopiedBrew(false);
|
||||
setPrereqs(null);
|
||||
checkPrereqs();
|
||||
}
|
||||
return () => {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Message02Icon,
|
||||
@@ -12,8 +10,6 @@ import {
|
||||
Image01Icon,
|
||||
Settings02Icon,
|
||||
Wifi01Icon,
|
||||
Moon02Icon,
|
||||
Sun02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -40,13 +36,11 @@ const navItems = [
|
||||
{ href: "/mcp", label: "MCP", icon: Plug01Icon },
|
||||
{ href: "/gallery", label: "Gallery", icon: Image01Icon },
|
||||
{ href: "/bridge", label: "Bridge", icon: Wifi01Icon },
|
||||
{ href: "/settings", label: "Settings", icon: Settings02Icon },
|
||||
] as const;
|
||||
|
||||
export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermissionsActive }: NavRailProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const navLabelKeys: Record<string, TranslationKey> = {
|
||||
'Chats': 'nav.chats',
|
||||
@@ -54,11 +48,9 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
'MCP': 'extensions.mcpServers',
|
||||
'Gallery': 'gallery.title',
|
||||
'Bridge': 'nav.bridge',
|
||||
'Settings': 'nav.settings',
|
||||
};
|
||||
const emptySubscribe = useCallback(() => () => {}, []);
|
||||
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
|
||||
const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/");
|
||||
const isSettingsActive = pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
|
||||
return (
|
||||
<aside className="flex w-14 shrink-0 flex-col items-center bg-sidebar pb-3 pt-10">
|
||||
@@ -110,12 +102,6 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
<span className="sr-only">{t(navLabelKeys[item.label] ?? item.label as TranslationKey)}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
{item.href === "/settings" && hasUpdate && (
|
||||
<span className={cn(
|
||||
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
|
||||
readyToInstall ? "bg-green-500 animate-pulse" : "bg-blue-500"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
@@ -125,7 +111,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom: skip-permissions indicator + theme toggle */}
|
||||
{/* Bottom: skip-permissions indicator + settings */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
{skipPermissionsActive && (
|
||||
<Tooltip>
|
||||
@@ -140,28 +126,33 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
<TooltipContent side="right">{t('nav.autoApproveOn')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{mounted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<HugeiconsIcon icon={Sun02Icon} className="h-4 w-4" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Moon02Icon} className="h-4 w-4" />
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
isSettingsActive && "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
)}
|
||||
<span className="sr-only">{t('nav.toggleTheme')}</span>
|
||||
>
|
||||
<Link href="/settings">
|
||||
<HugeiconsIcon icon={Settings02Icon} className="h-4 w-4" />
|
||||
<span className="sr-only">{t('nav.settings')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{theme === "dark" ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasUpdate && (
|
||||
<span className={cn(
|
||||
"absolute top-0.5 right-0.5 h-2 w-2 rounded-full",
|
||||
readyToInstall ? "bg-green-500 animate-pulse" : "bg-primary"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('nav.settings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
|
||||
isActive ? "border-blue-500" : "border-transparent"
|
||||
isActive ? "border-primary" : "border-transparent"
|
||||
)}
|
||||
onClick={onFocus}
|
||||
>
|
||||
@@ -129,7 +129,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
|
||||
isActive ? "border-blue-500" : "border-transparent"
|
||||
isActive ? "border-primary" : "border-transparent"
|
||||
)}
|
||||
onClick={onFocus}
|
||||
>
|
||||
@@ -144,7 +144,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
|
||||
isActive ? "border-blue-500" : "border-transparent"
|
||||
isActive ? "border-primary" : "border-transparent"
|
||||
)}
|
||||
onClick={onFocus}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { ThemeFamilyContext } from '@/lib/theme/context';
|
||||
import type { ThemeFamilyMeta } from '@/lib/theme/types';
|
||||
|
||||
const STORAGE_KEY = 'codepilot_theme_family';
|
||||
|
||||
function getInitialFamily(): string {
|
||||
// Read from DOM attribute (set by anti-FOUC script before hydration)
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.dataset.themeFamily || 'default';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
interface ThemeFamilyProviderProps {
|
||||
families: ThemeFamilyMeta[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeFamilyProvider({ families, children }: ThemeFamilyProviderProps) {
|
||||
const [rawFamily, setFamilyState] = useState(getInitialFamily);
|
||||
|
||||
// Derive validated family: if stored ID no longer exists in loaded themes, use 'default'.
|
||||
const isValid = families.length === 0 || families.some((f) => f.id === rawFamily);
|
||||
const family = isValid ? rawFamily : 'default';
|
||||
|
||||
// When the derived family differs from raw (i.e. fallback occurred),
|
||||
// sync DOM attribute and localStorage. This effect does NOT call setState,
|
||||
// avoiding the cascading-render lint error.
|
||||
useEffect(() => {
|
||||
if (family !== rawFamily) {
|
||||
document.documentElement.setAttribute('data-theme-family', family);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, family);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
}, [family, rawFamily]);
|
||||
|
||||
const setFamily = useCallback((id: string) => {
|
||||
setFamilyState(id);
|
||||
// Sync to DOM immediately for instant visual update
|
||||
document.documentElement.setAttribute('data-theme-family', id);
|
||||
// Persist to localStorage (sole source of truth for theme family)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeFamilyContext.Provider value={{ family, setFamily, families }}>
|
||||
{children}
|
||||
</ThemeFamilyContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export function UpdateBanner() {
|
||||
if (!updateInfo?.isNativeUpdate || !updateInfo.readyToInstall) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 border-b border-blue-500/20 bg-blue-500/10 px-4 py-1.5 text-sm">
|
||||
<div className="flex items-center justify-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-1.5 text-sm">
|
||||
<span>{t('update.readyToInstall', { version: updateInfo.latestVersion })}</span>
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs" onClick={quitAndInstall}>
|
||||
{t('update.restartNow')}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UpdateDialog() {
|
||||
ol: ({ children }) => <ol className="mb-2 list-decimal pl-4 text-sm">{children}</ol>,
|
||||
li: ({ children }) => <li className="mb-0.5">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 underline">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
@@ -88,7 +88,7 @@ export function UpdateDialog() {
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all"
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(downloadProgress!, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +277,7 @@ export function McpManager() {
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${
|
||||
s.status === 'connected' ? 'bg-green-500' :
|
||||
s.status === 'failed' ? 'bg-red-500' :
|
||||
s.status === 'pending' ? 'bg-blue-500' :
|
||||
s.status === 'pending' ? 'bg-primary' :
|
||||
s.status === 'disabled' ? 'bg-gray-400' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
|
||||
@@ -28,7 +28,7 @@ function getServerTypeInfo(server: MCPServer) {
|
||||
const type = server.type || 'stdio';
|
||||
switch (type) {
|
||||
case 'sse':
|
||||
return { label: 'SSE', icon: Wifi01Icon, color: 'text-blue-500' };
|
||||
return { label: 'SSE', icon: Wifi01Icon, color: 'text-primary' };
|
||||
case 'http':
|
||||
return { label: 'HTTP', icon: GlobeIcon, color: 'text-green-500' };
|
||||
default:
|
||||
@@ -45,7 +45,7 @@ function getStatusBadge(status: McpRuntimeStatus['status']) {
|
||||
case 'needs-auth':
|
||||
return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' };
|
||||
case 'pending':
|
||||
return { label: 'Pending', className: 'bg-blue-500/10 text-blue-600 border-blue-500/20' };
|
||||
return { label: 'Pending', className: 'bg-primary/10 text-primary border-primary/20' };
|
||||
case 'disabled':
|
||||
return { label: 'Disabled', className: 'bg-gray-500/10 text-gray-500 border-gray-500/20' };
|
||||
default:
|
||||
|
||||
@@ -6,9 +6,19 @@ import { ArrowLeft01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeico
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useThemeFamily } from "@/lib/theme/context";
|
||||
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
|
||||
import { usePanel } from "@/hooks/usePanel";
|
||||
|
||||
function useFilePreviewCodeTheme() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { family, families } = useThemeFamily();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const codeTheme = resolveCodeTheme(families, family);
|
||||
return resolveHljsStyle(codeTheme, isDark);
|
||||
}
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { FilePreview as FilePreviewType } from "@/types";
|
||||
|
||||
@@ -20,6 +30,7 @@ interface FilePreviewProps {
|
||||
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
|
||||
const { workingDirectory } = usePanel();
|
||||
const { t } = useTranslation();
|
||||
const hljsStyle = useFilePreviewCodeTheme();
|
||||
const [preview, setPreview] = useState<FilePreviewType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -119,7 +130,7 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
|
||||
<div className="rounded-md border border-border text-xs">
|
||||
<SyntaxHighlighter
|
||||
language={preview.language}
|
||||
style={atomOneDark}
|
||||
style={hljsStyle}
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
@@ -131,7 +142,8 @@ export function FilePreview({ filePath, onBack }: FilePreviewProps) {
|
||||
lineNumberStyle={{
|
||||
minWidth: "2.5em",
|
||||
paddingRight: "8px",
|
||||
color: "#636d83",
|
||||
color: "var(--muted-foreground)",
|
||||
opacity: 0.5,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface TaskCardProps {
|
||||
|
||||
const statusColors: Record<TaskStatus, string> = {
|
||||
pending: "bg-yellow-500",
|
||||
in_progress: "bg-blue-500",
|
||||
in_progress: "bg-primary",
|
||||
completed: "bg-green-500",
|
||||
failed: "bg-red-500",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { codeToHtml, type BundledTheme } from "shiki";
|
||||
import { useThemeFamily } from "@/lib/theme/context";
|
||||
import {
|
||||
resolveShikiTheme,
|
||||
resolveShikiThemes,
|
||||
} from "@/lib/theme/code-themes";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Sun02Icon, Moon02Icon, ComputerDesk01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Theme Mode Pill Selector ────────────────────────────────────────
|
||||
|
||||
const MODE_OPTIONS = [
|
||||
{ value: "light", icon: Sun02Icon, labelKey: "settings.modeLight" as const },
|
||||
{ value: "dark", icon: Moon02Icon, labelKey: "settings.modeDark" as const },
|
||||
{ value: "system", icon: ComputerDesk01Icon, labelKey: "settings.modeSystem" as const },
|
||||
] as const;
|
||||
|
||||
function ThemeModePills({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border border-border/50 p-1 gap-1" role="radiogroup">
|
||||
{MODE_OPTIONS.map((opt) => {
|
||||
const selected = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={opt.icon} className="h-3.5 w-3.5" />
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shiki Code Preview ──────────────────────────────────────────────
|
||||
|
||||
const PREVIEW_CODE = `function greet(name: string) {
|
||||
const time = new Date().getHours();
|
||||
if (time < 12) return \`Good morning, \${name}\`;
|
||||
return \`Hello, \${name}\`;
|
||||
}`;
|
||||
|
||||
function ShikiCodePreview({ isDark }: { isDark: boolean }) {
|
||||
const { family, families } = useThemeFamily();
|
||||
const shikiMapping = resolveShikiTheme(families, family);
|
||||
const { light, dark } = resolveShikiThemes(shikiMapping);
|
||||
const theme: BundledTheme = isDark ? dark : light;
|
||||
const [html, setHtml] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
codeToHtml(PREVIEW_CODE, {
|
||||
lang: "typescript",
|
||||
theme,
|
||||
}).then((result) => {
|
||||
if (!cancelled) setHtml(result);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-xs bg-muted text-muted-foreground">
|
||||
<span className="font-medium">preview.ts</span>
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-accent-foreground">TypeScript</span>
|
||||
</div>
|
||||
{html ? (
|
||||
<div
|
||||
className="shiki-preview [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!text-xs [&_pre]:!leading-relaxed [&_pre]:!p-2 [&_code]:!text-xs"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UI Token Preview ────────────────────────────────────────────────
|
||||
|
||||
function UIPreview() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">
|
||||
Primary
|
||||
</button>
|
||||
<button className="rounded-md bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground">
|
||||
Secondary
|
||||
</button>
|
||||
<button className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-primary-foreground">
|
||||
Destructive
|
||||
</button>
|
||||
<span className="inline-flex items-center rounded-full bg-accent px-2.5 py-0.5 text-[10px] font-medium text-accent-foreground">
|
||||
Badge
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-card px-2.5 py-0.5 text-[10px] text-card-foreground">
|
||||
Card
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
Muted
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Appearance Section ─────────────────────────────────────────
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const { family, setFamily, families } = useThemeFamily();
|
||||
const { t } = useTranslation();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section header — outside card */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t("settings.appearance")}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.appearanceDesc")}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
|
||||
{/* Mode */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xs font-medium">{t("settings.themeMode")}</h3>
|
||||
<p className="text-[11px] text-muted-foreground">{t("settings.themeModeDesc")}</p>
|
||||
</div>
|
||||
<ThemeModePills value={theme || "system"} onChange={setTheme} />
|
||||
</div>
|
||||
|
||||
{theme === "system" && resolvedTheme && (
|
||||
<p className="text-[11px] text-muted-foreground pl-1">
|
||||
{resolvedTheme === "dark" ? t("settings.modeDark") : t("settings.modeLight")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Family */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xs font-medium">{t("settings.themeFamily")}</h3>
|
||||
<p className="text-[11px] text-muted-foreground">{t("settings.themeFamilyDesc")}</p>
|
||||
</div>
|
||||
<Select value={family} onValueChange={setFamily}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{families.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
{f.previewColors && (
|
||||
<span className="flex gap-0.5">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full border border-border/30"
|
||||
style={{ background: f.previewColors.primaryLight }}
|
||||
/>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full border border-border/30"
|
||||
style={{ background: f.previewColors.primaryDark }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs">{f.label}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Preview</h3>
|
||||
<UIPreview />
|
||||
<ShikiCodePreview isDark={isDark} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AppearanceSection } from "./AppearanceSection";
|
||||
|
||||
function UpdateCard() {
|
||||
const { updateInfo, checking, checkForUpdates, downloadUpdate, quitAndInstall, setShowDialog } = useUpdate();
|
||||
@@ -75,7 +76,7 @@ function UpdateCard() {
|
||||
{updateInfo.updateAvailable ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-blue-500'}`} />
|
||||
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-green-500' : isDownloading ? 'bg-yellow-500 animate-pulse' : 'bg-primary'}`} />
|
||||
<span className="text-sm">
|
||||
{updateInfo.readyToInstall
|
||||
? t('update.readyToInstall', { version: updateInfo.latestVersion })
|
||||
@@ -98,7 +99,7 @@ function UpdateCard() {
|
||||
{isDownloading && (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all"
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(updateInfo.downloadProgress!, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -209,8 +210,9 @@ export function GeneralSection() {
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<UpdateCard />
|
||||
|
||||
{/* Auto-approve toggle */}
|
||||
<div className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
|
||||
{/* General settings card */}
|
||||
<div className={`rounded-lg border p-4 space-y-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
|
||||
{/* Auto-approve toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t('settings.autoApproveTitle')}</h2>
|
||||
@@ -225,15 +227,15 @@ export function GeneralSection() {
|
||||
/>
|
||||
</div>
|
||||
{skipPermissions && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
<div className="flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
|
||||
{t('settings.autoApproveWarning')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language picker */}
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Language picker */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t('settings.language')}</h2>
|
||||
@@ -250,10 +252,10 @@ export function GeneralSection() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thinking mode */}
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Thinking mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t('settings.thinkingMode' as TranslationKey)}</h2>
|
||||
@@ -272,6 +274,9 @@ export function GeneralSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<AppearanceSection />
|
||||
|
||||
{/* Account info */}
|
||||
{accountInfo && (
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
|
||||
@@ -134,7 +134,7 @@ export function CreateSkillDialog({
|
||||
className={cn(
|
||||
"flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
|
||||
scope === "project"
|
||||
? "border-blue-500/50 bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
? "border-primary/50 bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function InstallProgressDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{phase === "running" && (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-blue-500" />
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-primary" />
|
||||
)}
|
||||
{phase === "success" && (
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-5 w-5 text-green-500" />
|
||||
|
||||
@@ -126,8 +126,8 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) {
|
||||
: skill.source === "installed"
|
||||
? "border-orange-500/40 text-orange-600 dark:text-orange-400"
|
||||
: skill.source === "plugin"
|
||||
? "border-indigo-500/40 text-indigo-600 dark:text-indigo-400"
|
||||
: "border-blue-500/40 text-blue-600 dark:text-blue-400"
|
||||
? "border-primary/40 text-primary"
|
||||
: "border-primary/40 text-primary"
|
||||
)}
|
||||
>
|
||||
{skill.source === "global" ? (
|
||||
|
||||
Reference in new issue
Block a user