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,6 +1,7 @@
import React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiLoader4Line, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { createPortal } from 'react-dom';
@@ -8,6 +9,9 @@ import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { toolDisplayStyles } from '@/lib/typography';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import {
renderTodoOutput,
renderListOutput,
@@ -15,11 +19,7 @@ import {
renderGlobOutput,
renderWebSearchOutput,
formatInputForDisplay,
parseDiffToUnified,
parseReadToolOutput,
type UnifiedDiffHunk,
type SideBySideDiffHunk,
type SideBySideDiffLine,
} from './toolRenderers';
import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
@@ -92,6 +92,45 @@ const MERMAID_DIALOG_HEADER_HEIGHT = 40;
const MERMAID_ASPECT_RETRY_DELAY_MS = 120;
const MERMAID_ASPECT_MAX_RETRIES = 3;
type PierreThemeConfig = {
theme: { light: string; dark: string };
themeType: 'light' | 'dark';
};
const usePierreThemeConfig = (): PierreThemeConfig => {
const themeSystem = useOptionalThemeSystem();
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
const availableThemes = React.useMemo(
() => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
[fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
[availableThemes, fallbackLightTheme, lightThemeId],
);
const darkTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
[availableThemes, darkThemeId, fallbackDarkTheme],
);
React.useEffect(() => {
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
}, [darkTheme, lightTheme]);
const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
return {
theme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
themeType: currentVariant === 'dark' ? 'dark' : 'light',
};
};
type ViewportSize = { width: number; height: number };
const getWindowViewport = (): ViewportSize => ({
@@ -452,48 +491,28 @@ const ImagePreviewDialog: React.FC<{
const DialogUnifiedDiff: React.FC<{
popup: ToolPopupContent;
syntaxTheme: Record<string, React.CSSProperties>;
isMobile: boolean;
}> = React.memo(({ popup, syntaxTheme, isMobile }) => {
const hunks = React.useMemo(() => parseDiffToUnified(popup.content), [popup.content]);
diffViewMode: DiffViewMode;
pierreThemeConfig: PierreThemeConfig;
}> = React.memo(({ popup, diffViewMode, pierreThemeConfig }) => {
const patchContent = popup.content || '';
return (
<div className="typography-code">
{hunks.map((hunk, hunkIdx) => {
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
: null;
const fileStr = typeof inputFile === 'string' ? inputFile : '';
const hunkFileStr = typeof hunk.file === 'string' ? hunk.file : '';
const lang = getLanguageFromExtension(fileStr || hunkFileStr || '') || 'text';
const codeLines: CodeLine[] = hunk.lines.map((line) => ({
text: line.content,
lineNumber: line.lineNumber || null,
type: line.type as CodeLine['type'],
}));
return (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<VirtualizedCodeBlock
lines={codeLines}
language={lang}
syntaxTheme={syntaxTheme}
maxHeight="70vh"
lineStyles={(line) =>
line.type === 'removed'
? { backgroundColor: 'var(--tools-edit-removed-bg)', color: 'var(--tools-edit-removed)' }
: line.type === 'added'
? { backgroundColor: 'var(--tools-edit-added-bg)', color: 'var(--tools-edit-added)' }
: undefined
}
/>
</div>
);
})}
<PatchDiff
patch={patchContent}
options={{
diffStyle: diffViewMode === 'unified' ? 'unified' : 'split',
diffIndicators: 'none',
hunkSeparators: 'line-info-basic',
lineDiffType: 'none',
maxLineDiffLength: 1000,
expansionLineCount: 20,
overflow: 'wrap',
theme: pierreThemeConfig.theme,
themeType: pierreThemeConfig.themeType,
}}
className="block w-full"
/>
</div>
);
});
@@ -503,19 +522,36 @@ DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
const DialogReadContent: React.FC<{
popup: ToolPopupContent;
syntaxTheme: Record<string, React.CSSProperties>;
}> = React.memo(({ popup, syntaxTheme }) => {
pierreThemeConfig: PierreThemeConfig;
}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
const codeLines: CodeLine[] = React.useMemo(() => {
const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
let fallbackLineCursor = offset;
const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
const filePath =
typeof inputObj.file_path === 'string'
? inputObj.file_path
: typeof inputObj.filePath === 'string'
? inputObj.filePath
: typeof inputObj.path === 'string'
? inputObj.path
: 'read-output';
return parsedReadOutput.lines.map((line) => {
const fileContents = React.useMemo(() => parsedReadOutput.lines.map((line) => line.text).join('\n'), [parsedReadOutput]);
const detectedLanguage = React.useMemo(
() => popup.language || getLanguageFromExtension(filePath) || 'text',
[filePath, popup.language],
);
const codeLines: CodeLine[] = React.useMemo(() => {
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
const result: CodeLine[] = [];
let nextLineNumber = offset;
for (const line of parsedReadOutput.lines) {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
nextLineNumber = line.lineNumber;
}
const shouldAssignFallback =
parsedReadOutput.type === 'file'
@@ -523,21 +559,45 @@ const DialogReadContent: React.FC<{
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
? (fallbackLineCursor += 1)
? (nextLineNumber + 1)
: null);
if (typeof effectiveLineNumber === 'number') {
nextLineNumber = effectiveLineNumber;
}
return {
result.push({
text: line.text,
lineNumber: effectiveLineNumber,
isInfo: line.isInfo,
};
});
}, [parsedReadOutput, popup.metadata?.input]);
});
}
return result;
}, [offset, parsedReadOutput]);
if (parsedReadOutput.type === 'file') {
return (
<PierreFile
file={{
name: filePath,
contents: fileContents,
lang: detectedLanguage || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreThemeConfig.theme,
themeType: pierreThemeConfig.themeType,
}}
className="block w-full"
/>
);
}
return (
<VirtualizedCodeBlock
lines={codeLines}
language={popup.language || 'text'}
language={detectedLanguage}
syntaxTheme={syntaxTheme}
maxHeight="70vh"
/>
@@ -583,21 +643,28 @@ const MermaidPreviewDialog: React.FC<{
return /^[A-Za-z]:\//.test(normalized);
};
try {
let pathname = decodeURIComponent(new URL(input).pathname || '');
const decodeLoose = (value: string): string => {
return value.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex: string) => {
const codePoint = Number.parseInt(hex, 16);
return Number.isFinite(codePoint) ? String.fromCharCode(codePoint) : `%${hex}`;
});
};
const canParse = typeof URL.canParse === 'function'
? URL.canParse(input)
: false;
if (canParse) {
let pathname = decodeLoose(new URL(input).pathname || '');
if (/^\/[A-Za-z]:\//.test(pathname)) {
pathname = pathname.slice(1);
}
return isSafeLocalPath(pathname) ? pathname : null;
} catch {
const stripped = input.replace(/^file:\/\//i, '');
try {
const decoded = decodeURIComponent(stripped);
return isSafeLocalPath(decoded) ? decoded : null;
} catch {
return isSafeLocalPath(stripped) ? stripped : null;
}
}
const stripped = input.replace(/^file:\/\//i, '');
const decoded = decodeLoose(stripped);
return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null);
}, []);
const decodeDataUrl = React.useCallback((value: string): string => {
@@ -635,46 +702,57 @@ const MermaidPreviewDialog: React.FC<{
setStatus('loading');
setErrorMessage('');
try {
let resolvedSource = '';
if (target.url.startsWith('data:')) {
resolvedSource = decodeDataUrl(target.url);
} else if (target.url.toLowerCase().startsWith('file://')) {
const normalizedPath = normalizeFilePath(target.url);
if (!normalizedPath) {
throw new Error('Invalid local file path for Mermaid preview.');
}
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`);
if (!response.ok) {
throw new Error(`Failed to read diagram file (${response.status})`);
}
resolvedSource = await response.text();
let sourcePromise: Promise<string>;
if (target.url.startsWith('data:')) {
sourcePromise = Promise.resolve(decodeDataUrl(target.url));
} else if (target.url.toLowerCase().startsWith('file://')) {
const normalizedPath = normalizeFilePath(target.url);
if (!normalizedPath) {
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
} else {
const resolvedUrl = new URL(target.url, window.location.origin);
if (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:') {
throw new Error('Unsupported Mermaid URL protocol.');
}
const response = await fetch(resolvedUrl.toString());
if (!response.ok) {
throw new Error(`Failed to load diagram (${response.status})`);
}
resolvedSource = await response.text();
sourcePromise = fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`)
.then((response) => {
if (!response.ok) {
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
}
return response.text();
});
}
} else {
const canParse = typeof URL.canParse === 'function'
? URL.canParse(target.url, window.location.origin)
: false;
const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
if (requestIdRef.current !== requestId) {
return;
if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
} else {
sourcePromise = fetch(resolvedUrl.toString())
.then((response) => {
if (!response.ok) {
return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
}
return response.text();
});
}
setSource(resolvedSource);
setStatus('ready');
} catch (error) {
if (requestIdRef.current !== requestId) {
return;
}
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
}
await sourcePromise
.then((resolvedSource) => {
if (requestIdRef.current !== requestId) {
return;
}
setSource(resolvedSource);
setStatus('ready');
})
.catch((error) => {
if (requestIdRef.current !== requestId) {
return;
}
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.');
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid]);
React.useEffect(() => {
@@ -883,7 +961,13 @@ const MermaidPreviewDialog: React.FC<{
};
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const pierreThemeConfig = usePierreThemeConfig();
React.useEffect(() => {
if (!popup.open) return;
setDiffViewMode('unified');
}, [popup.open, popup.title]);
if (popup.image) {
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
@@ -966,16 +1050,20 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
) : meta.tool === 'write' && getInputValue('content') ? (
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || 'text'}
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
wrapLongLines
>
{getInputValue('content')!}
</SyntaxHighlighter>
<PierreFile
file={{
name: getInputValue('filePath') || getInputValue('file_path') || 'new-file',
contents: getInputValue('content')!,
lang: getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreThemeConfig.theme,
themeType: pierreThemeConfig.themeType,
}}
className="block w-full"
/>
</div>
) : (
<div
@@ -990,136 +1078,12 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
})() : null}
{popup.isDiff ? (
diffViewMode === 'unified' ? (
<DialogUnifiedDiff popup={popup} syntaxTheme={syntaxTheme} isMobile={isMobile} />
) : popup.diffHunks ? (
<div className="typography-code">
{popup.diffHunks.map((hunk, hunkIdx) => (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<div>
{(hunk as unknown as SideBySideDiffHunk).lines.map((line: SideBySideDiffLine, lineIdx: number) => (
<div key={lineIdx} className="grid grid-cols-2 divide-x divide-border/20">
<div
className={cn(
'typography-code font-mono px-3 py-0.5 overflow-hidden',
line.leftLine.type === 'context' && 'bg-transparent',
line.leftLine.type === 'empty' && 'bg-transparent'
)}
style={{
lineHeight: '1.1',
...(line.leftLine.type === 'removed' ? { backgroundColor: 'var(--tools-edit-removed-bg)' } : {}),
}}
>
<div className="flex">
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
{line.leftLine.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
{line.leftLine.content && (
<SyntaxHighlighter
style={syntaxTheme}
language={(() => {
const input = popup.metadata?.input;
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
const filePath = inputObj.file_path || inputObj.filePath;
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || 'text';
})()}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
{line.leftLine.content}
</SyntaxHighlighter>
)}
</div>
</div>
</div>
<div
className={cn(
'typography-code font-mono px-3 py-0.5 overflow-hidden',
line.rightLine.type === 'context' && 'bg-transparent',
line.rightLine.type === 'empty' && 'bg-transparent'
)}
style={{
lineHeight: '1.1',
...(line.rightLine.type === 'added' ? { backgroundColor: 'var(--tools-edit-added-bg)' } : {}),
}}
>
<div className="flex">
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
{line.rightLine.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
{line.rightLine.content && (
<SyntaxHighlighter
style={syntaxTheme}
language={(() => {
const input = popup.metadata?.input;
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
const filePath = inputObj.file_path || inputObj.filePath;
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || 'text';
})()}
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
{line.rightLine.content}
</SyntaxHighlighter>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
) : null
) : popup.content ? (
<DialogUnifiedDiff
popup={popup}
diffViewMode={diffViewMode}
pierreThemeConfig={pierreThemeConfig}
/>
) : popup.content ? (
<div className="p-4">
{(() => {
const tool = popup.metadata?.tool;
@@ -1197,7 +1161,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}
if (tool === 'read') {
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} />;
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
}
return (