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
@@ -1,7 +1,7 @@
import React from 'react';
import { Streamdown } from 'streamdown';
import { code } from '@streamdown/code';
import { mermaid } from '@streamdown/mermaid';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import 'streamdown/styles.css';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { Part } from '@opencode-ai/sdk/v2';
@@ -15,6 +15,8 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { ToolPopupContent } from './message/types';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
const withStableStringId = <T extends object>(value: T, id: string): T => {
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
@@ -122,47 +124,15 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] =
return isVSCode ? themes : fallbackThemes;
};
const useStreamdownMermaidOptions = () => {
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
const currentTheme = themeSystem?.currentTheme
return themeSystem?.currentTheme
?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? fallbackDark
: fallbackLight);
const MERMAID_CONFIG_VERSION = 'sequence-nowrap-v1';
const mermaidRenderKey = `${currentTheme.metadata.id}:${currentTheme.metadata.variant}:${themeSystem?.themeMode ?? 'fallback'}:${MERMAID_CONFIG_VERSION}`;
const options = React.useMemo(() => {
const isDark = currentTheme.metadata.variant === 'dark';
return {
config: {
theme: isDark ? 'dark' : 'base',
sequence: {
useMaxWidth: false,
},
themeVariables: {
primaryColor: currentTheme.colors.surface.elevated,
primaryTextColor: currentTheme.colors.surface.foreground,
primaryBorderColor: currentTheme.colors.interactive.border,
lineColor: currentTheme.colors.interactive.border,
secondaryColor: currentTheme.colors.surface.muted,
tertiaryColor: currentTheme.colors.surface.subtle,
background: currentTheme.colors.surface.background,
mainBkg: currentTheme.colors.surface.elevated,
nodeTextColor: currentTheme.colors.surface.foreground,
edgeLabelBackground: currentTheme.colors.surface.background,
},
},
};
}, [currentTheme]);
return React.useMemo(
() => ({ options, mermaidRenderKey }),
[mermaidRenderKey, options],
);
};
// Table utility functions
@@ -338,21 +308,17 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
}, []);
const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
try {
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
toast.success(`Table downloaded as ${format.toUpperCase()}`);
} catch (err) {
console.error('Failed to download table:', err);
}
};
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
toast.success(`Table downloaded as ${format.toUpperCase()}`);
};
return (
<div className="relative" ref={menuRef}>
@@ -423,10 +389,165 @@ const getMermaidInfo = (children: React.ReactNode): { isMermaid: boolean; source
return { isMermaid: true, source };
};
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const currentTheme = useCurrentMermaidTheme();
const { isMobile } = useDeviceInfo();
const [copied, setCopied] = React.useState(false);
const [downloaded, setDownloaded] = React.useState(false);
const svg = React.useMemo(() => {
if (mode !== 'svg') return '';
try {
return renderMermaidSVG(source, {
bg: currentTheme.colors.surface.elevated,
fg: currentTheme.colors.surface.foreground,
line: currentTheme.colors.interactive.border,
accent: currentTheme.colors.primary.base,
muted: currentTheme.colors.surface.mutedForeground,
surface: currentTheme.colors.surface.muted,
border: currentTheme.colors.interactive.border,
transparent: true,
font: 'IBM Plex Sans, sans-serif',
});
} catch {
return '';
}
}, [currentTheme, mode, source]);
const ascii = React.useMemo(() => {
if (mode !== 'ascii') return '';
try {
return renderMermaidASCII(source);
} catch {
return '';
}
}, [mode, source]);
const copyVisibilityClass = isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
const handleCopyAscii = async (asciiText: string) => {
if (!asciiText) return;
const result = await copyTextToClipboard(asciiText);
if (result.ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCopyMermaidSource = async () => {
if (!source) return;
const result = await copyTextToClipboard(source);
if (result.ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleDownloadSvg = () => {
if (!svg) return;
try {
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `diagram-${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setDownloaded(true);
setTimeout(() => setDownloaded(false), 2000);
} catch {
toast.error('Failed to download diagram');
}
};
if (mode === 'ascii') {
const asciiText = ascii || source;
return (
<div data-streamdown="mermaid-block" className="group">
<div data-streamdown="mermaid-scroll">
<pre data-streamdown="mermaid-ascii">{asciiText}</pre>
</div>
<div
className={cn(
'absolute top-1 right-2 transition-opacity',
copyVisibilityClass,
)}
>
<button
onClick={() => handleCopyAscii(asciiText)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
</div>
);
}
if (!svg) {
return (
<div data-streamdown="mermaid-block" className="group">
<div data-streamdown="mermaid-scroll">
<pre data-streamdown="mermaid-ascii">{source}</pre>
</div>
<div
className={cn(
'absolute top-1 right-2 transition-opacity',
copyVisibilityClass,
)}
>
<button
onClick={() => handleCopyAscii(source)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
</div>
</div>
);
}
return (
<div data-streamdown="mermaid-block" className="group">
<div data-streamdown="mermaid-scroll">
<div data-streamdown="mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
<div
className={cn(
'absolute top-1 right-2 flex items-center gap-1 transition-opacity',
copyVisibilityClass,
)}
>
<button
onClick={handleCopyMermaidSource}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Copy source"
>
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
</button>
<button
onClick={handleDownloadSvg}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download SVG"
>
{downloaded ? <RiCheckLine className="size-3.5" /> : <RiDownloadLine className="size-3.5" />}
</button>
</div>
</div>
);
};
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
const [copied, setCopied] = React.useState(false);
const codeRef = React.useRef<HTMLDivElement>(null);
const { isMobile } = useDeviceInfo();
const mermaidInfo = getMermaidInfo(children);
const mermaidRenderingMode = useUIStore((state) => state.mermaidRenderingMode);
const codeChild = React.useMemo(
() => (
React.isValidElement(children)
@@ -480,9 +601,8 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
return next;
}, [style]);
// Mermaid blocks are handled by Streamdown Mermaid controls.
if (mermaidInfo.isMermaid) {
return codeChild;
return <MermaidBlock source={mermaidInfo.source} mode={mermaidRenderingMode} />;
}
const getCodeContent = (): string => {
@@ -513,7 +633,12 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
>
{codeChild}
</pre>
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div
className={cn(
'absolute top-1 right-2 transition-opacity',
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
@@ -533,18 +658,11 @@ const streamdownComponents = {
const streamdownPlugins = {
code,
mermaid,
};
const streamdownControls = {
code: false,
table: false,
mermaid: {
download: true,
copy: true,
fullscreen: false,
panZoom: false,
},
};
type MermaidControlOptions = {
@@ -568,6 +686,18 @@ const extractMermaidBlocks = (markdown: string): string[] => {
return blocks;
};
const stripLeadingFrontmatter = (markdown: string): string => {
const frontmatterMatch = markdown.match(
/^(?:\uFEFF)?(---|\+\+\+)[^\S\r\n]*\r?\n[\s\S]*?\r?\n\1[^\S\r\n]*(?:\r?\n|$)/,
);
if (!frontmatterMatch) {
return markdown;
}
return markdown.slice(frontmatterMatch[0].length);
};
export type MarkdownVariant = 'assistant' | 'tool';
interface MarkdownRendererProps {
@@ -691,11 +821,8 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
const shikiThemes = useMarkdownShikiThemes();
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
const componentKey = React.useMemo(() => {
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
return `markdown-${signature}`;
}, [messageId, part?.id]);
const currentMermaidTheme = useCurrentMermaidTheme();
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
@@ -704,13 +831,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const markdownContent = (
<div className={cn('break-words', className)} ref={streamdownContainerRef}>
<Streamdown
key={`streamdown-${componentKey}-${mermaidRenderKey}`}
key={`streamdown-${componentKey}-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
mode={isStreaming ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={streamdownControls}
plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents}
>
{content}
@@ -734,12 +860,25 @@ export const SimpleMarkdownRenderer: React.FC<{
className?: string;
variant?: MarkdownVariant;
disableLinkSafety?: boolean;
stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
mermaidControls?: MermaidControlOptions;
allowMermaidWheelZoom?: boolean;
}> = ({ content, className, variant = 'assistant', disableLinkSafety, onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => {
}> = ({
content,
className,
variant = 'assistant',
disableLinkSafety,
stripFrontmatter = false,
onShowPopup,
allowMermaidWheelZoom = false,
}) => {
const renderedContent = React.useMemo(
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
[content, stripFrontmatter],
);
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
useMermaidInlineInteractions({
containerRef: streamdownContainerRef,
mermaidBlocks,
@@ -748,7 +887,7 @@ export const SimpleMarkdownRenderer: React.FC<{
});
const shikiThemes = useMarkdownShikiThemes();
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
const currentMermaidTheme = useCurrentMermaidTheme();
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
@@ -757,21 +896,17 @@ export const SimpleMarkdownRenderer: React.FC<{
return (
<div className={cn('break-words', className)} ref={streamdownContainerRef}>
<Streamdown
key={`streamdown-simple-${mermaidRenderKey}`}
key={`streamdown-simple-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
mode="static"
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={{
...streamdownControls,
mermaid: mermaidControls ?? streamdownControls.mermaid,
}}
controls={streamdownControls}
plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={disableLinkSafety ? { enabled: false } : undefined}
>
{content}
{renderedContent}
</Streamdown>
</div>
);