feat(ui): add dynamic window title and sprite-based project/file icons (#529)

* feat(ui): add dynamic titles and sprite-based project/file icons

* feat(files): add viewer syntax fallback and tab file icons

* fix(files): restore file viewer highlighting and add diff file icons

* feat(git): add file icons and async file-viewer syntax fallback

* fix(files): force codemirror token colors in file viewer

* feat(files): add shiki view mode for file viewer

* fix(files): force codemirror parse after programmatic content updates

* feat(files): support markdown frontmatter preview

* feat(chat): use pierre diffs for tool previews

* feat(chat): add configurable beautiful-mermaid rendering

* feat(perf): virtualize chat rendering and add react-scan toggle

* feat(build): enable React Compiler in Vite React apps

* fix(chat): reduce rerenders from tooltips and streamed activity

* fix(ui): make MessageList React Compiler safe

* chore(ui): batch commit remaining pending ui updates

* fix: polish chat and diff preview rendering

- Keep Mermaid action buttons fixed while diagram content scrolls
- Align Diff All Files headers and match Git-style path truncation
- Default chat tool diffs to unified view with lightweight indicators disabled

* fix: preserve file tree expansion and delay git action label collapse

* fix: refine project icon controls in settings

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
shekohex
2026-02-27 20:03:42 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d6b8f28e6f
commit 1d8ff97c95
1134 changed files with 14091 additions and 2005 deletions
@@ -104,7 +104,7 @@ const VisualSectionContent: React.FC = () => {
// Chat section: Default Tool Output, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />;
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'mermaidRendering', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />;
};
// Sessions section: Default model & agent, Session retention, Memory limits
@@ -83,7 +83,20 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [
},
];
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [
{
id: 'svg',
label: 'SVG',
description: 'Render diagrams as scalable graphics.',
},
{
id: 'ascii',
label: 'ASCII',
description: 'Render diagrams as text blocks.',
},
];
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -99,6 +112,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setShowTextJustificationActivity = useUIStore(state => state.setShowTextJustificationActivity);
const toolCallExpansion = useUIStore(state => state.toolCallExpansion);
const setToolCallExpansion = useUIStore(state => state.setToolCallExpansion);
const mermaidRenderingMode = useUIStore(state => state.mermaidRenderingMode);
const setMermaidRenderingMode = useUIStore(state => state.setMermaidRenderingMode);
const fontSize = useUIStore(state => state.fontSize);
const setFontSize = useUIStore(state => state.setFontSize);
const terminalFontSize = useUIStore(state => state.terminalFontSize);
@@ -174,6 +189,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
const hasBehaviorSettings = shouldShow('toolOutput')
|| shouldShow('mermaidRendering')
|| shouldShow('diffLayout')
|| (shouldShow('mobileStatusBar') && isMobile)
|| shouldShow('dotfiles')
@@ -249,20 +265,19 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<button
type="button"
disabled={customThemesLoading || themesReloading}
onClick={async () => {
onClick={() => {
const startedAt = Date.now();
setThemesReloading(true);
try {
await reloadCustomThemes();
} finally {
void reloadCustomThemes().finally(() => {
const elapsed = Date.now() - startedAt;
if (elapsed < 500) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 500 - elapsed);
});
window.setTimeout(() => {
setThemesReloading(false);
}, 500 - elapsed);
return;
}
setThemesReloading(false);
}
});
}}
className="inline-flex items-center typography-ui-label font-normal text-foreground underline decoration-[1px] underline-offset-2 hover:text-foreground/80 disabled:cursor-not-allowed disabled:text-muted-foreground/60"
>
@@ -558,6 +573,42 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section>
)}
{shouldShow('mermaidRendering') && (
<section className="p-2">
<h4 className="typography-ui-header font-medium text-foreground">Mermaid Rendering</h4>
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-1 space-y-0">
{MERMAID_RENDERING_OPTIONS.map((option) => {
const selected = mermaidRenderingMode === option.id;
return (
<div
key={option.id}
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={() => setMermaidRenderingMode(option.id)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setMermaidRenderingMode(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
>
<Radio
checked={selected}
onChange={() => setMermaidRenderingMode(option.id)}
ariaLabel={`Mermaid rendering: ${option.label}`}
/>
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
{option.label}
</span>
</div>
);
})}
</div>
</section>
)}
{shouldShow('diffLayout') && !isVSCodeRuntime() && (
<section className="p-2">
<h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
@@ -2,16 +2,20 @@ import React from 'react';
import { Input } from '@/components/ui/input';
import { ButtonSmall } from '@/components/ui/button-small';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP } from '@/lib/projectMeta';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { RiCloseLine } from '@remixicon/react';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
export const ProjectsPage: React.FC = () => {
const projects = useProjectsStore((state) => state.projects);
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
@@ -34,29 +38,107 @@ export const ProjectsPage: React.FC = () => {
const [name, setName] = React.useState('');
const [icon, setIcon] = React.useState<string | null>(null);
const [color, setColor] = React.useState<string | null>(null);
const [iconBackground, setIconBackground] = React.useState<string | null>(null);
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
if (!selectedProject) {
setName('');
setIcon(null);
setColor(null);
setIconBackground(null);
return;
}
setName(selectedProject.label ?? '');
setIcon(selectedProject.icon ?? null);
setColor(selectedProject.color ?? null);
setIconBackground(selectedProject.iconBackground ?? null);
setPreviewImageFailed(false);
}, [selectedProject]);
const hasChanges = Boolean(selectedProject) && (
name.trim() !== (selectedProject?.label ?? '').trim()
|| icon !== (selectedProject?.icon ?? null)
|| color !== (selectedProject?.color ?? null)
|| iconBackground !== (selectedProject?.iconBackground ?? null)
);
const handleSave = React.useCallback(() => {
if (!selectedProject) return;
updateProjectMeta(selectedProject.id, { label: name.trim(), icon, color });
}, [color, icon, name, selectedProject, updateProjectMeta]);
updateProjectMeta(selectedProject.id, { label: name.trim(), icon, color, iconBackground });
}, [color, icon, iconBackground, name, selectedProject, updateProjectMeta]);
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
const hasImageIcon = Boolean(selectedProject?.iconImage);
const hasCustomIcon = selectedProject?.iconImage?.source === 'custom';
const iconPreviewUrl = selectedProject && hasImageIcon && !previewImageFailed
? getProjectIconImageUrl(selectedProject)
: null;
const handleUploadIcon = React.useCallback(async (file: File | null) => {
if (!selectedProject || !file || isUploadingIcon) {
return;
}
setIsUploadingIcon(true);
void uploadProjectIcon(selectedProject.id, file)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to upload project icon');
return;
}
toast.success('Project icon updated');
})
.finally(() => {
setIsUploadingIcon(false);
});
}, [isUploadingIcon, selectedProject, uploadProjectIcon]);
const handleRemoveCustomIcon = React.useCallback(async () => {
if (!selectedProject || !hasCustomIcon || isRemovingCustomIcon) {
return;
}
setIsRemovingCustomIcon(true);
void removeProjectIcon(selectedProject.id)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to remove project icon');
return;
}
toast.success('Custom project icon removed');
})
.finally(() => {
setIsRemovingCustomIcon(false);
});
}, [hasCustomIcon, isRemovingCustomIcon, removeProjectIcon, selectedProject]);
const handleDiscoverIcon = React.useCallback(async () => {
if (!selectedProject || isDiscoveringIcon) {
return;
}
setIsDiscoveringIcon(true);
void discoverProjectIcon(selectedProject.id)
.then((result) => {
if (!result.ok) {
toast.error(result.error || 'Failed to discover project icon');
return;
}
if (result.skipped) {
toast.success('Custom icon already set for this project');
return;
}
toast.success('Project icon discovered');
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [discoverProjectIcon, isDiscoveringIcon, selectedProject]);
if (!selectedProject) {
return (
@@ -68,8 +150,6 @@ export const ProjectsPage: React.FC = () => {
);
}
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
@@ -147,6 +227,17 @@ export const ProjectsPage: React.FC = () => {
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Project Icon</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,.png,.jpg,.jpeg,.svg"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadIcon(file);
event.currentTarget.value = '';
}}
/>
<div className="mt-1.5 flex max-w-[22rem] flex-wrap items-center gap-2">
<button
type="button"
@@ -181,6 +272,88 @@ export const ProjectsPage: React.FC = () => {
);
})}
</div>
{hasImageIcon && iconPreviewUrl && (
<div className="mt-2 flex items-center gap-2">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
<img
src={iconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
</span>
</span>
</div>
)}
{hasImageIcon && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
type="color"
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label="Project icon background color"
/>
<Input
value={iconBackground ?? ''}
onChange={(event) => setIconBackground(event.target.value)}
placeholder="#000000"
className="h-7 w-[8rem]"
/>
<ButtonSmall
type="button"
size="xs"
variant="outline"
onClick={() => setIconBackground(null)}
className="h-7 w-7 p-0"
aria-label="Clear icon background"
title="Clear background"
disabled={!iconBackground}
>
<RiCloseLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
{!hasCustomIcon && (
<>
<ButtonSmall
size="xs"
className="h-6 !font-normal"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
>
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
</ButtonSmall>
<ButtonSmall
size="xs"
className="h-6 !font-normal"
variant="outline"
onClick={() => void handleDiscoverIcon()}
disabled={isDiscoveringIcon}
>
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
</ButtonSmall>
</>
)}
{hasCustomIcon && (
<ButtonSmall
size="xs"
className="!font-normal"
variant="outline"
onClick={() => void handleRemoveCustomIcon()}
disabled={isRemovingCustomIcon}
>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Custom Icon'}
</ButtonSmall>
)}
</div>
</div>
</section>
@@ -4,10 +4,10 @@ import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP } from '@/lib/projectMeta';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import { RiAddLine, RiFolderLine } from '@remixicon/react';
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { toast } from '@/components/ui';
@@ -16,6 +16,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
const addProject = useProjectsStore((state) => state.addProject);
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
@@ -26,8 +27,7 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
@@ -90,8 +90,34 @@ export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onIte
{projects.map((project) => {
const selected = project.id === selectedId;
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
const imageUrl = brokenIconIds.has(imageFailureKey) ? null : getProjectIconImageUrl(project);
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const icon = Icon
const icon = imageUrl
? (
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={imageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => {
setBrokenIconIds((prev) => {
if (prev.has(imageFailureKey)) {
return prev;
}
const next = new Set(prev);
next.add(imageFailureKey);
return next;
});
}}
/>
</span>
)
: Icon
? (
<Icon className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
)