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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d6b8f28e6f
commit
1d8ff97c95
@@ -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} />
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user