feat: introduced a token-based theming system across the UI

* feat: added themes system

* feat: smart sidebar auto-hide for files/diff tabs + lower files sidebar threshold

* feat: added Checkbox component and update theming

- Add reusable Checkbox component for toggles across UI
- Replace several inputs with Checkbox in settings and commands panels
- Add DiffIcon and apply surface/border theming to key UI areas

* feat: Add convert-vscode-theme.cjs to convert VS Code themes to OpenChamber format

* refactor: remove unused permission logic from ChatInput

- Remove unused permission rules parsing logic from ChatInput
- Memoize renderTheme in DiffWorkerProvider to avoid unnecessary recalculations
- Remove forceOpaque helper in vscode theme adapter

* fix: guard VSCode theme loading in MarkdownRenderer

* feat: add custom user themes loading and reload

- Load user themes from ~/.config/openchamber/themes at runtime
- Expose /api/config/themes to fetch custom themes
- Allow theme reloading from Settings → Theme → Reload themes in the UI
This commit is contained in:
Bohdan Triapitsyn
2026-02-01 18:29:34 +02:00
committed by GitHub
parent 5bb2c56bc0
commit ddedc02687
147 changed files with 9853 additions and 2435 deletions
+14 -131
View File
@@ -11,8 +11,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import type { AttachedFile, EditPermissionMode } from '@/stores/types/sessionTypes';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import { AttachedFilesList } from './FileAttachment';
import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
@@ -40,7 +39,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useContextStore } from '@/stores/contextStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
@@ -52,53 +51,6 @@ interface ChatInputProps {
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
type PermissionAction = 'allow' | 'ask' | 'deny';
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) {
return null;
}
const rules: PermissionRule[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const candidate = entry as Partial<PermissionRule>;
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
continue;
}
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
continue;
}
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
}
return rules;
};
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
const rules = asPermissionRuleset(ruleset);
if (!rules || rules.length === 0) {
return undefined;
}
for (let i = rules.length - 1; i >= 0; i -= 1) {
const rule = rules[i];
if (rule.permission === permission && rule.pattern === '*') {
return rule.action;
}
}
for (let i = rules.length - 1; i >= 0; i -= 1) {
const rule = rules[i];
if (rule.permission === '*' && rule.pattern === '*') {
return rule.action;
}
}
return undefined;
};
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const [message, setMessage] = React.useState('');
const [isDragging, setIsDragging] = React.useState(false);
@@ -140,6 +92,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const agents = getVisibleAgents();
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius } = useUIStore();
const { working } = useAssistantStatus();
const { currentTheme } = useThemeSystem();
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWasAbortedRef = React.useRef(false);
@@ -270,79 +223,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [pendingInputText, consumePendingInputText]);
const currentAgent = React.useMemo(() => {
const selectedName = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || currentAgentName)
: currentAgentName;
if (!selectedName) {
return undefined;
}
return agents.find((agent) => agent.name === selectedName);
}, [agents, currentAgentName, currentSessionId]);
const agentEditAction = React.useMemo<EditPermissionMode>(() => {
if (!currentAgent) {
return 'deny';
}
return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
}, [currentAgent]);
const sessionEditMode = useContextStore(
React.useCallback((state) => {
if (!currentAgentName) {
return undefined;
}
const sessionId = currentSessionId ?? '__global__';
return state.getSessionAgentEditMode(sessionId, currentAgentName, 'ask');
}, [currentAgentName, currentSessionId])
);
const selectionContextReady = Boolean(currentSessionId && currentAgentName);
const effectiveEditPermission = React.useMemo<EditPermissionMode>(() => {
// Only show accent when edits are effectively allowed.
if (agentEditAction === 'allow') {
return 'allow';
}
if (agentEditAction !== 'ask') {
return 'ask';
}
const sessionMode = selectionContextReady ? (sessionEditMode ?? 'ask') : 'ask';
return (sessionMode === 'allow' || sessionMode === 'full') ? 'allow' : 'ask';
}, [agentEditAction, selectionContextReady, sessionEditMode]);
const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]);
// VS Code webviews tend to have stronger status border colors; in web/desktop themes the same
// border tokens can already be subtle, so avoid double-softening there.
const softenBorderColor = React.useCallback((color: string) => (
isVSCodeRuntime()
? `color-mix(in srgb, ${color} 55%, transparent)`
: color
), []);
const chatInputWrapperStyle = React.useMemo<React.CSSProperties | undefined>(() => {
// Keep border width stable so toggling modes doesn't shift layout.
const baseBorderWidth = isVSCodeRuntime() ? 1 : 2;
const baseStyle: React.CSSProperties = {
borderRadius: cornerRadius,
};
if (!chatInputAccent) {
return { ...baseStyle, borderWidth: baseBorderWidth };
}
const borderColor = chatInputAccent.border ?? chatInputAccent.text;
return {
...baseStyle,
borderColor: softenBorderColor(borderColor),
borderWidth: baseBorderWidth,
};
}, [chatInputAccent, softenBorderColor, cornerRadius]);
const hasContent = message.trim() || attachedFiles.length > 0;
const hasQueuedMessages = queuedMessages.length > 0;
const canSend = hasContent || hasQueuedMessages;
@@ -1378,7 +1258,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<form
onSubmit={handleSubmit}
className={cn(
"relative pt-0 pb-2 md:pb-4",
"relative pt-0 pb-4",
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
)}
data-keyboard-avoid="true"
@@ -1438,10 +1318,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
/>
<div
className={cn(
"border border-border/80 bg-input/10 dark:bg-input/30",
"flex flex-col relative overflow-visible"
"flex flex-col relative overflow-visible",
"border border-border/80",
"focus-within:ring-1 focus-within:ring-primary/50"
)}
style={chatInputWrapperStyle}
style={{
borderRadius: cornerRadius,
backgroundColor: currentTheme?.colors?.surface?.subtle,
}}
>
{stopButton}
{}
@@ -1493,12 +1377,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
? "# for agents; @ for files; / for commands"
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
outerClassName="focus-within:ring-0"
className={cn(
'min-h-[52px] resize-none border-0 px-3 shadow-none rounded-b-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent',
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
isMobile ? "py-2.5" : "pt-4 pb-2",
canAbort && 'pr-10',
"focus-visible:outline-none focus-visible:ring-0"
canAbort && 'pr-10'
)}
style={{
flex: 'none',