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
@@ -8,7 +8,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
import MessageList from './MessageList';
import MessageList, { type MessageListHandle } from './MessageList';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device';
@@ -166,6 +166,7 @@ export const ChatContainer: React.FC = () => {
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
const isDesktopExpandedInput = isExpandedInput && !isMobile;
const messageListRef = React.useRef<MessageListHandle | null>(null);
React.useEffect(() => {
if (!currentSessionId && !draftOpen) {
@@ -176,6 +177,7 @@ export const ChatContainer: React.FC = () => {
const [turnStart, setTurnStart] = React.useState(0);
const turnHandleRef = React.useRef<number | null>(null);
const turnIdleRef = React.useRef(false);
const initializedTurnStartSessionRef = React.useRef<string | null>(null);
const TURN_INIT = 5;
const TURN_BATCH = 8;
@@ -286,6 +288,16 @@ export const ChatContainer: React.FC = () => {
React.useEffect(() => {
cancelTurnBackfill();
if (!currentSessionId) {
initializedTurnStartSessionRef.current = null;
setTurnStart(0);
return;
}
if (initializedTurnStartSessionRef.current === currentSessionId) {
return;
}
if (sessionMessages.length === 0) {
setTurnStart(0);
return;
}
@@ -293,14 +305,21 @@ export const ChatContainer: React.FC = () => {
const turnCount = userTurnIndexes.length;
const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0;
setTurnStart(start);
}, [cancelTurnBackfill, currentSessionId, userTurnIndexes.length]);
initializedTurnStartSessionRef.current = currentSessionId;
}, [cancelTurnBackfill, currentSessionId, sessionMessages.length, userTurnIndexes.length]);
const isSessionActive = sessionStatusForCurrent.type === 'busy' || sessionStatusForCurrent.type === 'retry';
React.useEffect(() => {
if (isSessionActive) {
cancelTurnBackfill();
return;
}
scheduleTurnBackfill();
return () => {
cancelTurnBackfill();
};
}, [cancelTurnBackfill, scheduleTurnBackfill, turnStart]);
}, [cancelTurnBackfill, isSessionActive, scheduleTurnBackfill, turnStart]);
const hasMoreAbove = React.useMemo(() => {
if (!memoryState) {
@@ -344,19 +363,22 @@ export const ChatContainer: React.FC = () => {
setTurnStart(0);
const container = scrollRef.current;
const anchor = messageListRef.current?.captureViewportAnchor() ?? null;
const prevHeight = container?.scrollHeight ?? null;
const prevTop = container?.scrollTop ?? null;
setIsLoadingOlder(true);
try {
await loadMoreMessages(currentSessionId, 'up');
if (container && prevHeight !== null && prevTop !== null) {
const heightDiff = container.scrollHeight - prevHeight;
scrollToPosition(prevTop + heightDiff, { instant: true });
}
} finally {
setIsLoadingOlder(false);
}
void loadMoreMessages(currentSessionId, 'up')
.then(() => {
const restored = anchor ? (messageListRef.current?.restoreViewportAnchor(anchor) ?? false) : false;
if (!restored && container && prevHeight !== null && prevTop !== null) {
const heightDiff = container.scrollHeight - prevHeight;
scrollToPosition(prevTop + heightDiff, { instant: true });
}
})
.finally(() => {
setIsLoadingOlder(false);
});
}, [cancelTurnBackfill, currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
const handleRenderEarlier = React.useCallback(() => {
@@ -366,6 +388,10 @@ export const ChatContainer: React.FC = () => {
// Scroll to a specific message by ID (for timeline dialog)
const scrollToMessage = React.useCallback((messageId: string) => {
if (messageListRef.current?.scrollToMessageId(messageId, { behavior: 'smooth' })) {
return;
}
const container = scrollRef.current;
if (!container) return;
@@ -396,12 +422,9 @@ export const ChatContainer: React.FC = () => {
}
const load = async () => {
try {
await loadMessages(currentSessionId);
} finally {
await loadMessages(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
// When pinned and active, scroll is already maintained automatically
const shouldSkipScroll = isActivePhase && isPinned;
if (!shouldSkipScroll) {
@@ -413,7 +436,7 @@ export const ChatContainer: React.FC = () => {
});
}
}
}
});
};
void load();
@@ -536,6 +559,7 @@ export const ChatContainer: React.FC = () => {
>
<div className="relative z-0 min-h-full">
<MessageList
ref={messageListRef}
messages={renderedSessionMessages}
permissions={sessionPermissions}
questions={sessionQuestions}
@@ -547,6 +571,7 @@ export const ChatContainer: React.FC = () => {
hasRenderEarlier={turnStart > 0}
onRenderEarlier={handleRenderEarlier}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
/>
</div>
</ScrollShadow>
+62 -31
View File
@@ -30,20 +30,37 @@ import { copyTextToClipboard } from '@/lib/clipboard';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']);
const EXPANDED_TOOLS_CACHE_MAX = 4000;
const expandedToolsStateCache = new Map<string, Set<string>>();
const readExpandedToolsCache = (messageId: string): Set<string> => {
const cached = expandedToolsStateCache.get(messageId);
return cached ? new Set(cached) : new Set();
};
const writeExpandedToolsCache = (messageId: string, value: Set<string>): void => {
if (expandedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !expandedToolsStateCache.has(messageId)) {
const oldest = expandedToolsStateCache.keys().next().value;
if (typeof oldest === 'string') {
expandedToolsStateCache.delete(oldest);
}
}
expandedToolsStateCache.set(messageId, new Set(value));
};
const isDetailedDefaultTool = (toolName: unknown): boolean =>
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined });
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
if (value !== undefined && value !== null) {
if (!ref.current.hasValue || ref.current.value !== value) {
ref.current = { hasValue: true, value };
React.useEffect(() => {
if (value !== undefined && value !== null) {
setStickyValue(value);
}
}
}, [value]);
return ref.current.hasValue ? ref.current.value : value;
return value ?? stickyValue;
}
const getMessageInfoProp = (info: unknown, key: string): unknown => {
@@ -129,7 +146,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
const [copiedMessage, setCopiedMessage] = React.useState(false);
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(new Set());
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
const [popupContent, setPopupContent] = React.useState<ToolPopupContent>({
open: false,
title: '',
@@ -137,8 +154,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
React.useEffect(() => {
setExpandedTools(readExpandedToolsCache(message.info.id));
}, [message.info.id]);
React.useEffect(() => {
expandedToolsStateCache.clear();
setExpandedTools(new Set());
}, [message.info.id, toolCallExpansion]);
}, [toolCallExpansion]);
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
@@ -517,8 +539,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
}, [message.info, currentSessionId, isUser]);
// Track if this message should show header to prevent flickering
const shouldShowHeaderRef = React.useRef(false);
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
const previousRole = React.useMemo(() => {
if (!previousMessage) return null;
@@ -546,6 +567,22 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return isStreamingMessage ? 'streaming' : 'completed';
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
React.useEffect(() => {
setHasStartedStreamingHeader(false);
}, [message.info.id]);
React.useEffect(() => {
const headerMessageId = turnGroupingContext?.headerMessageId;
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
return;
}
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
if (isCurrentlyStreaming) {
setHasStartedStreamingHeader(true);
}
}, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
const shouldShowHeader = React.useMemo(() => {
if (isUser) return true;
@@ -563,15 +600,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
// For streaming messages: show header when streaming starts and keep it visible
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
const hasStartedStreaming = shouldShowHeaderRef.current;
// Update the ref when streaming starts
if (isCurrentlyStreaming && !hasStartedStreaming) {
shouldShowHeaderRef.current = true;
}
// Show header if streaming has started or is currently active
return hasStartedStreaming || isCurrentlyStreaming;
return hasStartedStreamingHeader || isCurrentlyStreaming;
}
// For non-first assistant messages, don't show header
@@ -581,7 +610,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
// Fallback to original logic when turn grouping is not available
if (!previousRole) return true;
return previousRole.isUser;
}, [isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
}, [hasStartedStreamingHeader, isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
const handleCopyCode = React.useCallback((code: string) => {
void copyTextToClipboard(code).then((result) => {
@@ -626,16 +655,17 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
? turnGroupingContext.summaryBody
: assistantSummaryFromStore;
const assistantSummaryRef = React.useRef<string | undefined>(undefined);
if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) {
assistantSummaryRef.current = assistantSummaryCandidate;
}
const prevUserMessageIdForCopy = React.useRef(userMessageIdForTurn);
if (prevUserMessageIdForCopy.current !== userMessageIdForTurn) {
prevUserMessageIdForCopy.current = userMessageIdForTurn;
assistantSummaryRef.current = undefined;
}
const assistantSummaryForCopy = assistantSummaryRef.current;
const [assistantSummaryForCopy, setAssistantSummaryForCopy] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
setAssistantSummaryForCopy(undefined);
}, [userMessageIdForTurn]);
React.useEffect(() => {
if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) {
setAssistantSummaryForCopy(assistantSummaryCandidate);
}
}, [assistantSummaryCandidate]);
const assistantErrorText = React.useMemo(() => {
if (isUser) {
@@ -742,9 +772,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
} else {
next.add(toolId);
}
writeExpandedToolsCache(message.info.id, next);
return next;
});
}, []);
}, [message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
@@ -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>
);
+484 -137
View File
@@ -1,6 +1,9 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { flushSync } from 'react-dom';
import { elementScroll, observeElementOffset, observeElementRect, Virtualizer } from '@tanstack/react-virtual';
import { useShallow } from 'zustand/react/shallow';
import type { ReactVirtualizerOptions, VirtualItem } from '@tanstack/react-virtual';
import ChatMessage from './ChatMessage';
import { PermissionCard } from './PermissionCard';
@@ -10,9 +13,57 @@ import type { QuestionRequest } from '@/types/question';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic } from './contexts/TurnGroupingContext';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDeviceInfo } from '@/lib/device';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
const MESSAGE_VIRTUALIZE_THRESHOLD = 40;
const MESSAGE_VIRTUAL_OVERSCAN_MOBILE = 2;
const MESSAGE_VIRTUAL_OVERSCAN_DESKTOP = 4;
type MessageListVirtualizerOptions<TItemElement extends Element> = Omit<
ReactVirtualizerOptions<HTMLElement, TItemElement>,
'scrollToFn' | 'observeElementRect' | 'observeElementOffset'
>
const useMessageListVirtualizer = <TItemElement extends Element>(
options: MessageListVirtualizerOptions<TItemElement>,
): Virtualizer<HTMLElement, TItemElement> => {
const [, forceRender] = React.useReducer(() => ({}), {});
const { useFlushSync = true, onChange, ...baseOptions } = options;
const handleChange = React.useCallback((instance: Virtualizer<HTMLElement, TItemElement>, sync: boolean) => {
if (useFlushSync && sync) {
flushSync(forceRender);
} else {
forceRender();
}
onChange?.(instance, sync);
}, [onChange, useFlushSync]);
const [virtualizer] = React.useState(() => new Virtualizer<HTMLElement, TItemElement>({
...baseOptions,
onChange: handleChange,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
}));
virtualizer.setOptions({
...baseOptions,
onChange: handleChange,
observeElementRect,
observeElementOffset,
scrollToFn: elementScroll,
});
React.useLayoutEffect(() => virtualizer._didMount(), [virtualizer]);
React.useLayoutEffect(() => virtualizer._willUpdate(), [virtualizer]);
return virtualizer;
};
interface ChatMessageEntry {
info: Message;
@@ -45,38 +96,6 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
};
const hasSameTurnStructure = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
if (prev === next) {
return true;
}
if (prev.length !== next.length) {
return false;
}
for (let index = 0; index < prev.length; index += 1) {
const prevMessage = prev[index];
const nextMessage = next[index];
if (prevMessage !== nextMessage) {
return false;
}
if (prevMessage.info.id !== nextMessage.info.id) {
return false;
}
if (resolveMessageRole(prevMessage) !== resolveMessageRole(nextMessage)) {
return false;
}
if (getMessageParentId(prevMessage) !== getMessageParentId(nextMessage)) {
return false;
}
}
return true;
};
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
@@ -281,6 +300,16 @@ interface MessageListProps {
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
export interface MessageListHandle {
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean;
captureViewportAnchor: () => { messageId: string; offsetTop: number } | null;
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean;
}
type RenderEntry =
| { kind: 'ungrouped'; key: string; message: ChatMessageEntry; isInLastTurn: boolean }
| { kind: 'turn'; key: string; turn: Turn; isLastTurn: boolean };
interface MessageRowProps {
message: ChatMessageEntry;
onContentChange: (reason?: ContentChangeReason) => void;
@@ -342,23 +371,25 @@ DynamicMessageRow.displayName = 'DynamicMessageRow';
interface TurnBlockProps {
turn: Turn;
isLastTurn: boolean;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
stickyUserHeader?: boolean;
}
const TurnBlock: React.FC<TurnBlockProps> = ({
turn,
isLastTurn,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
stickyUserHeader = true,
}) => {
const lastTurnMessageIds = useLastTurnMessageIds();
const renderMessage = React.useCallback(
(message: ChatMessageEntry) => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id);
const isInLastTurn = role !== 'user' && isLastTurn;
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
@@ -371,20 +402,24 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
/>
);
},
[getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom]
[getAnimationHandlers, isLastTurn, onMessageContentChange, scrollToBottom]
);
return (
<section className="relative w-full" data-turn-id={turn.turnId}>
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
{stickyUserHeader ? (
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-8 bg-gradient-to-b from-[var(--surface-background)] to-transparent"
/>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-8 bg-gradient-to-b from-[var(--surface-background)] to-transparent"
/>
</div>
) : (
renderMessage(turn.userMessage)
)}
<div className="relative z-0">
{turn.assistantMessages.map((message) => renderMessage(message))}
@@ -395,53 +430,122 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
TurnBlock.displayName = 'TurnBlock';
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
turns: Turn[];
ungroupedMessages: ChatMessageEntry[];
interface UngroupedMessageRowProps {
message: ChatMessageEntry;
isInLastTurn: boolean;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ turns, ungroupedMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
const lastTurnMessageIds = useLastTurnMessageIds();
}
const renderUngroupedMessage = React.useCallback(
(message: ChatMessageEntry) => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
const UngroupedMessageRow: React.FC<UngroupedMessageRowProps> = React.memo(({
message,
isInLastTurn,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
}) => {
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom]
return (
<RowComponent
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
});
UngroupedMessageRow.displayName = 'UngroupedMessageRow';
interface MessageListEntryProps {
entry: RenderEntry;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
stickyUserHeader?: boolean;
}
const MessageListEntry: React.FC<MessageListEntryProps> = React.memo(({
entry,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
stickyUserHeader,
}) => {
if (entry.kind === 'ungrouped') {
return (
<UngroupedMessageRow
message={entry.message}
isInLastTurn={entry.isInLastTurn}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
);
}
return (
<TurnBlock
turn={entry.turn}
isLastTurn={entry.isLastTurn}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
/>
);
}, areMessageListEntryPropsEqual);
MessageListEntry.displayName = 'MessageListEntry';
function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextProps: MessageListEntryProps): boolean {
if (prevProps.stickyUserHeader !== nextProps.stickyUserHeader) return false;
if (prevProps.onMessageContentChange !== nextProps.onMessageContentChange) return false;
if (prevProps.getAnimationHandlers !== nextProps.getAnimationHandlers) return false;
if (prevProps.scrollToBottom !== nextProps.scrollToBottom) return false;
const prevEntry = prevProps.entry;
const nextEntry = nextProps.entry;
if (prevEntry.kind !== nextEntry.kind) return false;
if (prevEntry.key !== nextEntry.key) return false;
if (prevEntry.kind === 'turn' && nextEntry.kind === 'turn') {
return prevEntry.turn === nextEntry.turn && prevEntry.isLastTurn === nextEntry.isLastTurn;
}
if (prevEntry.kind === 'ungrouped' && nextEntry.kind === 'ungrouped') {
return prevEntry.message === nextEntry.message && prevEntry.isInLastTurn === nextEntry.isInLastTurn;
}
return false;
}
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
entries: RenderEntry[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
return (
<>
{ungroupedMessages.map((message) => renderUngroupedMessage(message))}
{turns.map((turn) => (
<TurnBlock
key={turn.turnId}
turn={turn}
{entries.map((entry) => (
<MessageListEntry
key={entry.key}
entry={entry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader
/>
))}
</>
);
};
const MessageList: React.FC<MessageListProps> = ({
const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
messages,
permissions,
questions,
@@ -453,14 +557,9 @@ const MessageList: React.FC<MessageListProps> = ({
hasRenderEarlier,
onRenderEarlier,
scrollToBottom,
}) => {
scrollRef,
}, ref) => {
const { isMobile } = useDeviceInfo();
const turnStructureCacheRef = React.useRef<{
messages: ChatMessageEntry[];
turns: Turn[];
ungroupedMessages: ChatMessageEntry[];
} | null>(null);
const normalizedMessageCacheRef = React.useRef<Map<string, { source: ChatMessageEntry; normalized: ChatMessageEntry }>>(new Map());
React.useEffect(() => {
if (permissions.length === 0 && questions.length === 0) {
@@ -471,7 +570,6 @@ const MessageList: React.FC<MessageListProps> = ({
const baseDisplayMessages = React.useMemo(() => {
const seenIdsFromTail = new Set<string>();
const nextNormalizedCache = new Map<string, { source: ChatMessageEntry; normalized: ChatMessageEntry }>();
const dedupedMessages: ChatMessageEntry[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
@@ -488,17 +586,7 @@ const MessageList: React.FC<MessageListProps> = ({
dedupedMessages.reverse();
const normalizedMessages = dedupedMessages
.map((message, index) => {
const messageId = typeof message.info?.id === 'string' && message.info.id.length > 0
? message.info.id
: `__idx_${index}`;
const cacheKey = `${messageId}:${resolveMessageRole(message) ?? 'unknown'}`;
const cached = normalizedMessageCacheRef.current.get(cacheKey);
if (cached && cached.source === message) {
nextNormalizedCache.set(cacheKey, cached);
return cached.normalized;
}
.map((message) => {
const filteredParts = filterSyntheticParts(message.parts);
const normalized = filteredParts === message.parts
? message
@@ -506,12 +594,9 @@ const MessageList: React.FC<MessageListProps> = ({
...message,
parts: filteredParts,
};
nextNormalizedCache.set(cacheKey, { source: message, normalized });
return normalized;
});
normalizedMessageCacheRef.current = nextNormalizedCache;
const output: ChatMessageEntry[] = [];
for (let index = 0; index < normalizedMessages.length; index += 1) {
@@ -555,15 +640,41 @@ const MessageList: React.FC<MessageListProps> = ({
})
);
const activeRetrySessionId = activeRetryStatus?.sessionId ?? null;
const activeRetryMessage = activeRetryStatus?.message
?? 'Quota limit reached. Retrying automatically.';
const activeRetryConfirmedAt = activeRetryStatus?.confirmedAt;
const [fallbackRetryTimestamp, setFallbackRetryTimestamp] = React.useState<number>(0);
const fallbackRetrySessionRef = React.useRef<string | null>(null);
const [scrollContainer, setScrollContainer] = React.useState<HTMLDivElement | null>(null);
React.useLayoutEffect(() => {
setScrollContainer(scrollRef?.current ?? null);
}, [scrollRef]);
React.useEffect(() => {
if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
fallbackRetrySessionRef.current = null;
setFallbackRetryTimestamp(0);
return;
}
if (fallbackRetrySessionRef.current !== activeRetryStatus.sessionId) {
fallbackRetrySessionRef.current = activeRetryStatus.sessionId;
setFallbackRetryTimestamp(Date.now());
}
}, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]);
const displayMessages = React.useMemo(() => {
if (!activeRetryStatus) {
if (!activeRetrySessionId) {
return baseDisplayMessages;
}
const retryError = {
name: 'SessionRetry',
message: activeRetryStatus.message,
data: { message: activeRetryStatus.message },
message: activeRetryMessage,
data: { message: activeRetryMessage },
};
let lastUserIndex = -1;
@@ -609,12 +720,12 @@ const MessageList: React.FC<MessageListProps> = ({
});
}
const eventTime = typeof activeRetryStatus.confirmedAt === 'number' ? activeRetryStatus.confirmedAt : Date.now();
const syntheticId = `synthetic_retry_notice_${activeRetryStatus.sessionId}`;
const eventTime = typeof activeRetryConfirmedAt === 'number' ? activeRetryConfirmedAt : fallbackRetryTimestamp;
const syntheticId = `synthetic_retry_notice_${activeRetrySessionId}`;
const synthetic: ChatMessageEntry = {
info: {
id: syntheticId,
sessionID: activeRetryStatus.sessionId,
sessionID: activeRetrySessionId,
role: 'assistant',
time: { created: eventTime, completed: eventTime },
finish: 'stop',
@@ -626,41 +737,242 @@ const MessageList: React.FC<MessageListProps> = ({
const next = baseDisplayMessages.slice();
next.splice(lastUserIndex + 1, 0, synthetic);
return next;
}, [activeRetryStatus, baseDisplayMessages]);
}, [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]);
const { turns, ungroupedMessages } = React.useMemo(() => {
const cached = turnStructureCacheRef.current;
if (cached && hasSameTurnStructure(cached.messages, displayMessages)) {
return {
turns: cached.turns,
ungroupedMessages: cached.ungroupedMessages,
};
const turns = React.useMemo(() => detectTurns(displayMessages), [displayMessages]);
const renderEntries = React.useMemo<RenderEntry[]>(() => {
const entries: RenderEntry[] = [];
const turnByUserId = new Map<string, Turn>();
const groupedAssistantIds = new Set<string>();
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
const lastTurnId = lastTurn?.turnId ?? null;
const lastTurnMessageIds = new Set<string>();
if (lastTurn) {
lastTurnMessageIds.add(lastTurn.userMessage.info.id);
lastTurn.assistantMessages.forEach((assistantMessage: ChatMessageEntry) => {
lastTurnMessageIds.add(assistantMessage.info.id);
});
}
const groupedTurns = detectTurns(displayMessages);
const groupedMessageIds = new Set<string>();
groupedTurns.forEach((turn) => {
groupedMessageIds.add(turn.userMessage.info.id);
turn.assistantMessages.forEach((message) => {
groupedMessageIds.add(message.info.id);
turns.forEach((turn: Turn) => {
turnByUserId.set(turn.userMessage.info.id, turn);
turn.assistantMessages.forEach((assistantMessage: ChatMessageEntry) => {
groupedAssistantIds.add(assistantMessage.info.id);
});
});
const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id));
const nextValue = {
turns: groupedTurns,
ungroupedMessages: ungrouped,
displayMessages.forEach((message: ChatMessageEntry) => {
const turn = turnByUserId.get(message.info.id);
if (turn) {
entries.push({
kind: 'turn',
key: `turn:${turn.turnId}`,
turn,
isLastTurn: turn.turnId === lastTurnId,
});
return;
}
if (groupedAssistantIds.has(message.info.id)) {
return;
}
entries.push({
kind: 'ungrouped',
key: `msg:${message.info.id}`,
message,
isInLastTurn: lastTurnMessageIds.has(message.info.id),
});
});
return entries;
}, [displayMessages, turns]);
const shouldVirtualize = Boolean(scrollContainer) && renderEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD;
const estimateEntrySize = React.useCallback(
(index: number): number => {
const entry = renderEntries[index];
if (!entry) {
return 300;
}
if (entry.kind === 'turn') {
const assistantCount = entry.turn.assistantMessages.length;
return Math.min(3600, 140 + assistantCount * 260);
}
const role = resolveMessageRole(entry.message);
return role === 'user' ? 120 : 280;
},
[renderEntries]
);
const virtualizer = useMessageListVirtualizer<Element>({
count: renderEntries.length,
getScrollElement: () => scrollContainer,
estimateSize: estimateEntrySize,
overscan: isMobile ? MESSAGE_VIRTUAL_OVERSCAN_MOBILE : MESSAGE_VIRTUAL_OVERSCAN_DESKTOP,
getItemKey: (index: number) => renderEntries[index]?.key ?? index,
enabled: shouldVirtualize,
useFlushSync: false,
});
const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems() : [];
const scrollVirtualizerToIndex = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
if (!virtualizer) {
return;
}
const normalizedBehavior: 'auto' | 'smooth' = behavior === 'instant' ? 'auto' : behavior;
virtualizer.scrollToIndex(index, { align: 'start', behavior: normalizedBehavior });
}, [virtualizer]);
const messageIndexMap = React.useMemo(() => {
const indexMap = new Map<string, number>();
renderEntries.forEach((entry, index) => {
if (entry.kind === 'ungrouped') {
indexMap.set(entry.message.info.id, index);
return;
}
indexMap.set(entry.turn.userMessage.info.id, index);
entry.turn.assistantMessages.forEach((message) => {
indexMap.set(message.info.id, index);
});
});
return indexMap;
}, [renderEntries]);
const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => {
const container = scrollContainer;
if (!container) {
return null;
}
return container.querySelector(`[data-message-id="${messageId}"]`);
}, [scrollContainer]);
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
const container = scrollContainer;
if (!container) {
return false;
}
const messageElement = findMessageElement(messageId);
if (!messageElement) {
return false;
}
const containerRect = container.getBoundingClientRect();
const messageRect = messageElement.getBoundingClientRect();
const offset = 50;
const top = messageRect.top - containerRect.top + container.scrollTop - offset;
container.scrollTo({ top, behavior });
return true;
}, [findMessageElement, scrollContainer]);
React.useLayoutEffect(() => {
if (!ref) {
return;
}
const handle: MessageListHandle = {
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
const behavior = options?.behavior ?? 'auto';
const index = messageIndexMap.get(messageId);
if (index === undefined) {
return false;
}
if (shouldVirtualize) {
scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior);
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scrollMessageElementIntoView(messageId, behavior);
});
});
}
return true;
}
return scrollMessageElementIntoView(messageId, behavior);
},
captureViewportAnchor: () => {
const container = scrollContainer;
if (!container) {
return null;
}
const containerRect = container.getBoundingClientRect();
const nodes = Array.from(container.querySelectorAll<HTMLElement>('[data-message-id]'));
const firstVisible = nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1);
if (!firstVisible) {
return null;
}
const messageId = firstVisible.dataset.messageId;
if (!messageId) {
return null;
}
return {
messageId,
offsetTop: firstVisible.getBoundingClientRect().top - containerRect.top,
};
},
restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => {
const container = scrollContainer;
if (!container) {
return false;
}
const index = messageIndexMap.get(anchor.messageId);
if (index === undefined) {
return false;
}
if (shouldVirtualize) {
scrollVirtualizerToIndex(index, 'auto');
}
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
const element = findMessageElement(anchor.messageId);
if (!element) {
return;
}
const containerRect = container.getBoundingClientRect();
const targetTop = element.getBoundingClientRect().top - containerRect.top;
const delta = targetTop - anchor.offsetTop;
if (delta !== 0) {
container.scrollTop += delta;
}
});
});
}
return true;
},
};
turnStructureCacheRef.current = {
messages: displayMessages,
turns: groupedTurns,
ungroupedMessages: ungrouped,
};
if (typeof ref === 'function') {
ref(handle);
return () => {
ref(null);
};
}
return nextValue;
}, [displayMessages]);
const objectRef = ref;
objectRef.current = handle;
return () => {
objectRef.current = null;
};
}, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, scrollContainer, scrollVirtualizerToIndex, shouldVirtualize, ref]);
const disableFadeIn = shouldVirtualize && virtualizer.isScrolling;
return (
<TurnGroupingProvider messages={displayMessages}>
@@ -695,13 +1007,46 @@ const MessageList: React.FC<MessageListProps> = ({
</div>
)}
<MessageListContent
turns={turns}
ungroupedMessages={ungroupedMessages}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
<FadeInDisabledProvider disabled={disableFadeIn}>
{shouldVirtualize ? (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualRows.map((virtualRow: VirtualItem) => {
const entry = renderEntries[virtualRow.index];
if (!entry) {
return null;
}
return (
<div
key={entry.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full [overflow-anchor:none]"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<MessageListEntry
entry={entry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={false}
/>
</div>
);
})}
</div>
) : (
<MessageListContent
entries={renderEntries}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
)}
</FadeInDisabledProvider>
{(questions.length > 0 || permissions.length > 0) && (
<div>
@@ -719,6 +1064,8 @@ const MessageList: React.FC<MessageListProps> = ({
</div>
</TurnGroupingProvider>
);
};
});
MessageList.displayName = 'MessageList';
export default React.memo(MessageList);
@@ -9,10 +9,10 @@ import { cn, formatDirectoryName } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors';
import { RiLoader4Line, RiAddLine } from '@remixicon/react';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui';
import { isTauriShell, isDesktopLocalOriginActive } from '@/lib/desktop';
import { isTauriShell, isDesktopLocalOriginActive, requestDirectoryAccess } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import {
Dialog,
@@ -184,72 +184,61 @@ function useProjectStatus(
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const projectStatusMap = React.useMemo(() => {
const result = new Map<string, { hasRunning: boolean; hasUnread: boolean }>();
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.get(sessionId);
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
};
return (projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
const cached = result.get(projectPath);
if (cached) return cached;
const projectRoot = normalize(projectPath);
if (!projectRoot) {
return { hasRunning: false, hasUnread: false };
}
const projectRoot = normalize(projectPath);
if (!projectRoot) {
const empty = { hasRunning: false, hasUnread: false };
result.set(projectPath, empty);
return empty;
}
const dirs: string[] = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized && normalized !== projectRoot) {
dirs.push(normalized);
}
const dirs: string[] = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized && normalized !== projectRoot) {
dirs.push(normalized);
}
}
}
const seen = new Set<string>();
let hasRunning = false;
let hasUnread = false;
const seen = new Set<string>();
let hasRunning = false;
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) {
continue;
}
seen.add(session.id);
const statusType = getStatusType(session.id);
if (statusType === 'busy' || statusType === 'retry') {
hasRunning = true;
}
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
hasUnread = true;
}
if (hasRunning && hasUnread) {
break;
}
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) {
continue;
}
seen.add(session.id);
const statusType = getStatusType(session.id);
if (statusType === 'busy' || statusType === 'retry') {
hasRunning = true;
}
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
hasUnread = true;
}
if (hasRunning && hasUnread) {
break;
}
}
if (hasRunning && hasUnread) {
break;
}
}
const status = { hasRunning, hasUnread };
result.set(projectPath, status);
return status;
};
return { hasRunning, hasUnread };
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
return projectStatusMap;
@@ -390,6 +379,8 @@ interface SessionStatusHeaderProps {
currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectIconImageUrl?: string | null;
currentProjectIconBackground?: string | null;
currentProjectColor?: string | null;
onToggle: () => void;
isExpanded?: boolean;
@@ -400,15 +391,23 @@ function SessionStatusHeader({
currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectIconImageUrl,
currentProjectIconBackground,
currentProjectColor,
onToggle,
isExpanded = false,
childIndicators = []
}: SessionStatusHeaderProps) {
const [imageFailed, setImageFailed] = React.useState(false);
const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
const imageUrl = !imageFailed ? currentProjectIconImageUrl : null;
const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0;
React.useEffect(() => {
setImageFailed(false);
}, [currentProjectIconImageUrl]);
return (
<button
type="button"
@@ -418,7 +417,20 @@ function SessionStatusHeader({
{!isExpanded && currentProjectLabel && (
<div className="flex flex-col items-start">
<div className="flex items-center gap-1 leading-none">
{ProjectIcon && (
{imageUrl ? (
<span
className="inline-flex h-2.5 w-2.5 items-center justify-center overflow-hidden rounded-[1px]"
style={currentProjectIconBackground ? { backgroundColor: currentProjectIconBackground } : undefined}
>
<img
src={imageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon && (
<ProjectIcon
className="h-2.5 w-2.5"
style={projectColorVar ? { color: projectColorVar } : undefined}
@@ -534,7 +546,13 @@ function ProjectButton({
onRemoveProject,
formatProjectLabel,
}: ProjectButtonProps) {
const [imageFailed, setImageFailed] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectIconImageUrl = !imageFailed ? getProjectIconImageUrl(project) : null;
React.useEffect(() => {
setImageFailed(false);
}, [project.id, project.iconImage?.updatedAt]);
const longPressHandlers = useLongPress(
() => {
@@ -569,7 +587,20 @@ function ProjectButton({
</div>
{/* Icon */}
{ProjectIcon && (
{projectIconImageUrl ? (
<span
className="inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[2px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<img
src={projectIconImageUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setImageFailed(true)}
/>
</span>
) : ProjectIcon && (
<ProjectIcon
className="h-3.5 w-3.5"
style={projectColorVar ? { color: projectColorVar } : undefined}
@@ -752,6 +783,8 @@ function CollapsedView({
currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectIconImageUrl,
currentProjectIconBackground,
currentProjectColor,
onToggle,
onNewSession,
@@ -764,6 +797,8 @@ function CollapsedView({
currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectIconImageUrl?: string | null;
currentProjectIconBackground?: string | null;
currentProjectColor?: string | null;
onToggle: () => void;
onNewSession: () => void;
@@ -789,6 +824,8 @@ function CollapsedView({
currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectIconImageUrl={currentProjectIconImageUrl}
currentProjectIconBackground={currentProjectIconBackground}
currentProjectColor={currentProjectColor}
onToggle={onToggle}
childIndicators={childIndicators}
@@ -827,6 +864,8 @@ function ExpandedView({
currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectIconImageUrl,
currentProjectIconBackground,
currentProjectColor,
isExpanded,
onToggleCollapse,
@@ -854,6 +893,8 @@ function ExpandedView({
currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectIconImageUrl?: string | null;
currentProjectIconBackground?: string | null;
currentProjectColor?: string | null;
isExpanded: boolean;
onToggleCollapse: () => void;
@@ -936,6 +977,8 @@ function ExpandedView({
currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectIconImageUrl={currentProjectIconImageUrl}
currentProjectIconBackground={currentProjectIconBackground}
currentProjectColor={currentProjectColor}
onToggle={onToggleCollapse}
isExpanded={true}
@@ -1053,6 +1096,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const activeProject = getActiveProject();
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
const currentProjectIcon = activeProject?.icon;
const currentProjectIconImageUrl = activeProject ? getProjectIconImageUrl(activeProject) : null;
const currentProjectIconBackground = activeProject?.iconBackground ?? null;
const currentProjectColor = activeProject?.color;
// Calculate token usage for current session
@@ -1097,8 +1142,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
sessionEvents.requestDirectoryDialog();
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
@@ -1127,6 +1171,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectIconImageUrl={currentProjectIconImageUrl}
currentProjectIconBackground={currentProjectIconBackground}
currentProjectColor={currentProjectColor}
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
onNewSession={handleCreateSession}
@@ -1146,6 +1192,8 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectIconImageUrl={currentProjectIconImageUrl}
currentProjectIconBackground={currentProjectIconBackground}
currentProjectColor={currentProjectColor}
isExpanded={isExpanded}
onToggleCollapse={() => {
@@ -8,7 +8,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RiCloseLine, RiCodeLine, RiFileImageLine, RiFileTextLine, RiFolder6Line, RiSearchLine } from '@remixicon/react';
import { RiCloseLine, RiFolder6Line, RiSearchLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, truncatePathMiddle } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -19,6 +19,7 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
interface FileInfo {
name: string;
path: string;
@@ -62,6 +63,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileInfo[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const [inFlightDirs, setInFlightDirs] = React.useState<Set<string>>(new Set());
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
const [searching, setSearching] = React.useState(false);
const [loading, setLoading] = React.useState(false);
@@ -71,6 +73,11 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const updateInFlightDirs = React.useCallback((next: Set<string>) => {
inFlightDirsRef.current = next;
setInFlightDirs(next);
}, []);
const sortDirectoryItems = React.useCallback((items: FileInfo[]) => (
items.slice().sort((a, b) => {
if (a.type !== b.type) {
@@ -101,28 +108,30 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const loadDirectory = React.useCallback(async (dirPath: string) => {
setLoading(true);
setError(null);
try {
const entries = await opencodeClient.listLocalDirectory(dirPath, { respectGitignore: !showGitignored });
const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
await opencodeClient.listLocalDirectory(dirPath, { respectGitignore: !showGitignored })
.then((entries) => {
const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
loadedDirsRef.current = new Set([dirPath]);
inFlightDirsRef.current = new Set();
setChildrenByDir({ [dirPath]: items });
setExpandedDirs(new Set());
} catch {
setError('Failed to load directory contents');
loadedDirsRef.current = new Set([dirPath]);
inFlightDirsRef.current = new Set();
setChildrenByDir({ [dirPath]: [] });
setExpandedDirs(new Set());
} finally {
setLoading(false);
}
}, [mapFilesystemEntries, showGitignored]);
loadedDirsRef.current = new Set([dirPath]);
updateInFlightDirs(new Set());
setChildrenByDir({ [dirPath]: items });
setExpandedDirs(new Set());
})
.catch(() => {
setError('Failed to load directory contents');
loadedDirsRef.current = new Set([dirPath]);
updateInFlightDirs(new Set());
setChildrenByDir({ [dirPath]: [] });
setExpandedDirs(new Set());
})
.finally(() => {
setLoading(false);
});
}, [mapFilesystemEntries, showGitignored, updateInFlightDirs]);
const loadDirectoryChildren = React.useCallback(async (dirPath: string) => {
const normalizedDir = dirPath.trim();
@@ -137,39 +146,42 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
return;
}
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.add(cacheKey);
const nextInFlight = new Set(inFlightDirsRef.current);
nextInFlight.add(cacheKey);
updateInFlightDirs(nextInFlight);
try {
const entries = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: !showGitignored });
const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: !showGitignored })
.then((entries) => {
const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
name: entry.name,
path: entry.path,
isDirectory: entry.isDirectory,
})));
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(cacheKey);
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: items,
}));
} catch {
// Keep it unloadded so the user can retry expanding the directory.
setChildrenByDir((prev) => {
if (prev[normalizedDir]) {
return prev;
}
return {
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(cacheKey);
setChildrenByDir((prev) => ({
...prev,
[normalizedDir]: [],
};
[normalizedDir]: items,
}));
})
.catch(() => {
setChildrenByDir((prev) => {
if (prev[normalizedDir]) {
return prev;
}
return {
...prev,
[normalizedDir]: [],
};
});
})
.finally(() => {
const updatedInFlightDirs = new Set(inFlightDirsRef.current);
updatedInFlightDirs.delete(cacheKey);
updateInFlightDirs(updatedInFlightDirs);
});
} finally {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(cacheKey);
}
}, [mapFilesystemEntries, showGitignored, cacheNonce]);
}, [mapFilesystemEntries, showGitignored, cacheNonce, updateInFlightDirs]);
React.useEffect(() => {
if ((open || mobileOpen) && currentDirectory) {
@@ -244,11 +256,11 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
setSearchResults([]);
setSearching(false);
loadedDirsRef.current = new Set();
inFlightDirsRef.current = new Set();
updateInFlightDirs(new Set());
setChildrenByDir({});
setExpandedDirs(new Set());
}
}, [open, mobileOpen]);
}, [open, mobileOpen, updateInFlightDirs]);
const getFileIcon = (file: FileInfo) => {
if (file.type === 'directory') {
@@ -259,31 +271,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
);
}
const ext = file.extension?.toLowerCase();
switch (ext) {
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
case 'html':
case 'css':
case 'scss':
case 'less':
return <RiCodeLine className="h-3.5 w-3.5 text-[var(--status-info)]" />;
case 'json':
return <RiCodeLine className="h-3.5 w-3.5 text-[var(--status-warning)]" />;
case 'md':
case 'mdx':
return <RiFileTextLine className="h-3.5 w-3.5 text-muted-foreground" />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'svg':
return <RiFileImageLine className="h-3.5 w-3.5 text-[var(--status-success)]" />;
default:
return <RiFileTextLine className="h-3.5 w-3.5 text-muted-foreground" />;
}
return <FileTypeIcon filePath={file.path} extension={file.extension} className="h-3.5 w-3.5" />;
};
const toggleDirectory = async (dirPath: string) => {
@@ -338,14 +326,15 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
.filter((file): file is FileInfo => Boolean(file));
setAttaching(true);
try {
await onFilesSelected(selected);
setSelectedFiles(new Set());
setOpen(false);
setMobileOpen(false);
} finally {
setAttaching(false);
}
await Promise.resolve(onFilesSelected(selected))
.then(() => {
setSelectedFiles(new Set());
setOpen(false);
setMobileOpen(false);
})
.finally(() => {
setAttaching(false);
});
};
const rootItems = React.useMemo(() => {
@@ -425,7 +414,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
const isDirectory = file.type === 'directory';
const children = isDirectory ? getChildItems(file.path) : [];
const isExpanded = expandedDirs.has(file.path);
const isLoadingChildren = isDirectory && isExpanded && inFlightDirsRef.current.has(file.path) && children.length === 0;
const isLoadingChildren = isDirectory && isExpanded && inFlightDirs.has(file.path) && children.length === 0;
return (
<div key={file.path}>
@@ -33,6 +33,7 @@ interface NeighborInfo {
// Static data that only changes when messages change
interface TurnGroupingStaticData {
structureKey: string;
turns: Turn[];
messageToTurn: Map<string, Turn>;
turnActivityInfo: Map<string, TurnActivityInfo>;
@@ -60,8 +61,6 @@ const TurnGroupingStaticContext = React.createContext<TurnGroupingStaticData | n
const TurnGroupingUiStateContext = React.createContext<TurnGroupingUiStateData | null>(null);
const TurnGroupingStreamingContext = React.createContext<TurnGroupingStreamingData | null>(null);
// Track staticData reference to clear cache when it changes
let lastStaticDataRef: TurnGroupingStaticData | null = null;
const contextCache = new Map<string, TurnGroupingContextType>();
export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupingContextType | undefined => {
@@ -71,13 +70,7 @@ export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupin
return React.useMemo(() => {
if (!staticData || !uiStateData || !streamingData) return undefined;
// Clear cache when staticData changes (new messages arrived)
if (lastStaticDataRef !== staticData) {
contextCache.clear();
lastStaticDataRef = staticData;
}
const turn = staticData.messageToTurn.get(messageId);
if (!turn) return undefined;
@@ -88,7 +81,7 @@ export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupin
const isLastTurn = staticData.lastTurnId === turn.turnId;
const lastTurnActivityVersion = isLastTurn
? `${streamingData.lastTurnActivityInfo?.activityParts.length ?? 0}:${streamingData.lastTurnActivityInfo?.summaryBody ?? ''}`
? `${streamingData.lastTurnActivityInfo?.activityParts.length ?? 0}:${streamingData.lastTurnActivityInfo?.activityGroupSegments.length ?? 0}:${streamingData.lastTurnActivityInfo?.hasTools ? 1 : 0}:${streamingData.lastTurnActivityInfo?.hasReasoning ? 1 : 0}`
: '';
// Get UI state early - needed for cache key to ensure expand/collapse updates propagate
@@ -99,9 +92,9 @@ export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupin
// - messageId: identifies the specific message
// - isExpanded: UI state for this turn's activity group
// - sessionIsWorking (last turn only): streaming state affects "working" indicator
const cacheKey = isLastTurn
? `${messageId}-${isExpanded}-${streamingData.sessionIsWorking}-${lastTurnActivityVersion}`
: `${messageId}-${isExpanded}`;
const cacheKey = isLastTurn
? `${staticData.structureKey}:${messageId}-${isExpanded}-${streamingData.sessionIsWorking}-${lastTurnActivityVersion}`
: `${staticData.structureKey}:${messageId}-${isExpanded}`;
const cached = contextCache.get(cacheKey);
if (cached) return cached;
@@ -510,29 +503,6 @@ const getMessageRole = (message: ChatMessageEntry): string => {
return typeof role === 'string' ? role : '';
};
const hasSameTurnStructure = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
if (prev === next) {
return true;
}
if (prev.length !== next.length) {
return false;
}
for (let index = 0; index < prev.length; index += 1) {
if (prev[index] !== next[index]) {
return false;
}
if (prev[index]?.info?.id !== next[index]?.info?.id) {
return false;
}
if (getMessageRole(prev[index]) !== getMessageRole(next[index])) {
return false;
}
}
return true;
};
const getStructureKey = (messages: ChatMessageEntry[]): string => {
if (messages.length === 0) return '';
return messages
@@ -540,211 +510,29 @@ const getStructureKey = (messages: ChatMessageEntry[]): string => {
.join('|');
};
const isAppendOnlyChange = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
if (prev.length > next.length) {
return false;
}
for (let index = 0; index < prev.length; index += 1) {
if (prev[index] !== next[index]) {
return false;
}
if (prev[index]?.info?.id !== next[index]?.info?.id) {
return false;
}
if (getMessageRole(prev[index]) !== getMessageRole(next[index])) {
return false;
}
}
return true;
};
const appendTurnsIncremental = (prevTurns: Turn[], appendedMessages: ChatMessageEntry[]): Turn[] => {
if (appendedMessages.length === 0) {
return prevTurns;
}
const nextTurns = prevTurns.length > 0
? [
...prevTurns.slice(0, -1),
{
...prevTurns[prevTurns.length - 1],
assistantMessages: [...prevTurns[prevTurns.length - 1].assistantMessages],
},
]
: [];
let currentTurn = nextTurns.length > 0 ? nextTurns[nextTurns.length - 1] : null;
appendedMessages.forEach((message) => {
const role = getMessageRole(message);
if (role === 'user') {
currentTurn = {
turnId: message.info.id,
userMessage: message,
assistantMessages: [],
};
nextTurns.push(currentTurn);
return;
}
if (role === 'assistant' && currentTurn) {
currentTurn.assistantMessages.push(message);
}
});
return nextTurns;
};
const appendNeighborsIncremental = (
prevNeighbors: Map<string, NeighborInfo>,
prevMessages: ChatMessageEntry[],
nextMessages: ChatMessageEntry[],
): Map<string, NeighborInfo> => {
if (nextMessages.length <= prevMessages.length) {
return prevNeighbors;
}
const appended = nextMessages.slice(prevMessages.length);
if (appended.length === 0) {
return prevNeighbors;
}
const nextNeighbors = new Map(prevNeighbors);
const previousTail = prevMessages.length > 0 ? prevMessages[prevMessages.length - 1] : undefined;
if (previousTail) {
nextNeighbors.set(previousTail.info.id, {
previousMessage: prevMessages.length > 1 ? prevMessages[prevMessages.length - 2] : undefined,
nextMessage: appended[0],
});
}
appended.forEach((message, index) => {
const previousMessage = index === 0 ? previousTail : appended[index - 1];
const nextMessage = index < appended.length - 1 ? appended[index + 1] : undefined;
nextNeighbors.set(message.info.id, {
previousMessage,
nextMessage,
});
});
return nextNeighbors;
};
export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => {
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
const structureKeyCacheRef = React.useRef<{ messages: ChatMessageEntry[]; key: string } | null>(null);
const structureKey = React.useMemo(() => {
const cached = structureKeyCacheRef.current;
if (cached && hasSameTurnStructure(cached.messages, messages)) {
return cached.key;
}
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
const [structuredMessages, setStructuredMessages] = React.useState<ChatMessageEntry[]>(messages);
const key = getStructureKey(messages);
structureKeyCacheRef.current = {
messages,
key,
};
return key;
}, [messages]);
const staticCacheRef = React.useRef<{
messages: ChatMessageEntry[];
structureKey: string;
defaultActivityExpanded: boolean;
showTextJustificationActivity: boolean;
value: TurnGroupingStaticData;
} | null>(null);
React.useEffect(() => {
setStructuredMessages((previous) => {
if (getStructureKey(previous) === structureKey) {
return previous;
}
return messages;
});
}, [messages, structureKey]);
const staticStructureKey = React.useMemo(() => getStructureKey(structuredMessages), [structuredMessages]);
// Static data - avoid identity churn while assistant streams within existing turn structure.
const staticValue = React.useMemo<TurnGroupingStaticData>(() => {
const cached = staticCacheRef.current;
if (
cached &&
hasSameTurnStructure(cached.messages, messages) &&
cached.structureKey === structureKey &&
cached.defaultActivityExpanded === defaultActivityExpanded &&
cached.showTextJustificationActivity === showTextJustificationActivity
) {
return cached.value;
}
if (
cached &&
cached.defaultActivityExpanded === defaultActivityExpanded &&
cached.showTextJustificationActivity === showTextJustificationActivity &&
isAppendOnlyChange(cached.messages, messages)
) {
const appendedMessages = messages.slice(cached.messages.length);
const turns = appendTurnsIncremental(cached.value.turns, appendedMessages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
const messageToTurn = new Map(cached.value.messageToTurn);
let currentTurn = turns.length > 0 ? turns[turns.length - 1] : null;
appendedMessages.forEach((message) => {
const role = getMessageRole(message);
if (role === 'user') {
currentTurn = turns.find((turn) => turn.turnId === message.info.id) ?? null;
if (currentTurn) {
messageToTurn.set(message.info.id, currentTurn);
}
return;
}
if (role === 'assistant' && currentTurn) {
messageToTurn.set(message.info.id, currentTurn);
}
});
const turnActivityInfo = new Map(cached.value.turnActivityInfo);
const previousLastTurnId = cached.value.lastTurnId;
if (previousLastTurnId && previousLastTurnId !== lastTurnId) {
const finalizedTurn = turns.find((turn) => turn.turnId === previousLastTurnId);
if (finalizedTurn) {
turnActivityInfo.set(previousLastTurnId, getTurnActivityInfo(finalizedTurn, showTextJustificationActivity));
}
}
if (lastTurnId) {
turnActivityInfo.delete(lastTurnId);
}
const messageNeighbors = appendNeighborsIncremental(cached.value.messageNeighbors, cached.messages, messages);
const lastTurnMessageIds = new Set<string>();
if (turns.length > 0) {
const lastTurn = turns[turns.length - 1]!;
lastTurnMessageIds.add(lastTurn.userMessage.info.id);
lastTurn.assistantMessages.forEach((msg) => {
lastTurnMessageIds.add(msg.info.id);
});
}
const value: TurnGroupingStaticData = {
turns,
messageToTurn,
turnActivityInfo,
lastTurnId,
lastTurnMessageIds,
defaultActivityExpanded,
messageNeighbors,
};
staticCacheRef.current = {
messages,
structureKey,
defaultActivityExpanded,
showTextJustificationActivity,
value,
};
return value;
}
const turns = detectTurns(messages);
const turns = detectTurns(structuredMessages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
const messageToTurn = new Map<string, Turn>();
turns.forEach((turn) => {
messageToTurn.set(turn.userMessage.info.id, turn);
@@ -759,9 +547,8 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
});
const messageNeighbors = buildNeighborMap(messages);
const messageNeighbors = buildNeighborMap(structuredMessages);
// Build set of message IDs belonging to the last turn
const lastTurnMessageIds = new Set<string>();
if (turns.length > 0) {
const lastTurn = turns[turns.length - 1]!;
@@ -771,7 +558,8 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
});
}
const value: TurnGroupingStaticData = {
return {
structureKey: staticStructureKey,
turns,
messageToTurn,
turnActivityInfo,
@@ -780,17 +568,7 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
defaultActivityExpanded,
messageNeighbors,
};
staticCacheRef.current = {
messages,
structureKey,
defaultActivityExpanded,
showTextJustificationActivity,
value,
};
return value;
}, [defaultActivityExpanded, messages, showTextJustificationActivity, structureKey]);
}, [defaultActivityExpanded, showTextJustificationActivity, staticStructureKey, structuredMessages]);
const lastTurnActivityInfo = React.useMemo<TurnActivityInfo | undefined>(() => {
const lastTurnId = staticValue.lastTurnId;
@@ -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 (
@@ -2,12 +2,14 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -15,6 +17,8 @@ import { opencodeClient } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ToolPopupContent } from '../types';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import {
renderListOutput,
@@ -22,12 +26,12 @@ import {
renderGlobOutput,
renderTodoOutput,
renderWebSearchOutput,
parseDiffToUnified,
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
parseReadToolOutput,
} from '../toolRenderers';
import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
@@ -167,6 +171,40 @@ const getRelativePath = (absolutePath: string, currentDirectory: string, isMobil
return absolutePath;
};
const usePierreThemeConfig = () => {
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 {
pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
};
};
// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
@@ -627,49 +665,29 @@ const TaskToolSummary: React.FC<{
interface DiffPreviewProps {
diff: string;
syntaxTheme: { [key: string]: React.CSSProperties };
input?: ToolStateWithMetadata['input'];
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
diffViewMode: DiffViewMode;
}
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => {
const hunks = React.useMemo(() => parseDiffToUnified(diff), [diff]);
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
return (
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
{hunks.map((hunk, hunkIdx) => {
const lang = getLanguageFromExtension(
typeof input?.file_path === 'string' ? input.file_path
: typeof input?.filePath === 'string' ? input.filePath
: hunk.file
) || '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="-mx-1 px-1 last:border-b-0" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground break-words -mx-1" style={{ borderBottomWidth: '1px', borderBottomColor: 'var(--tools-border)' }}>
{`${hunk.file} (line ${hunk.oldStart})`}
</div>
<VirtualizedCodeBlock
lines={codeLines}
language={lang}
syntaxTheme={syntaxTheme}
maxHeight="50vh"
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>
);
})}
<div className="typography-code px-1 pb-1 pt-0">
<PatchDiff
patch={diff}
options={{
diffStyle: diffViewMode === 'side-by-side' ? 'split' : 'unified',
diffIndicators: 'none',
hunkSeparators: 'line-info-basic',
lineDiffType: 'none',
maxLineDiffLength: 1000,
expansionLineCount: 20,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>
</div>
);
});
@@ -678,26 +696,25 @@ DiffPreview.displayName = 'DiffPreview';
interface WriteInputPreviewProps {
content: string;
syntaxTheme: { [key: string]: React.CSSProperties };
filePath?: string;
displayPath: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ content, syntaxTheme, filePath, displayPath }) => {
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({
content,
filePath,
displayPath,
pierreTheme,
pierreThemeType,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
[content, filePath]
);
const codeLines: CodeLine[] = React.useMemo(() => {
const rawLines = content.split('\n');
return rawLines.map((text, idx) => ({
text: text || ' ',
lineNumber: idx + 1,
}));
}, [content]);
const lineCount = Math.max(codeLines.length, 1);
const lineCount = Math.max(content.split('\n').length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return (
@@ -705,11 +722,19 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ conten
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{`${displayPath} (${headerLineLabel})`}
</div>
<VirtualizedCodeBlock
lines={codeLines}
language={language || 'text'}
syntaxTheme={syntaxTheme}
maxHeight="50vh"
<PierreFile
file={{
name: displayPath,
contents: content,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>
</div>
);
@@ -723,6 +748,8 @@ interface ReadToolVirtualizedProps {
input?: Record<string, unknown>;
syntaxTheme: { [key: string]: React.CSSProperties };
toolName: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
renderScrollableBlock: (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
@@ -734,41 +761,53 @@ const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
input,
syntaxTheme,
toolName,
pierreTheme,
pierreThemeType,
renderScrollableBlock,
}) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]);
const offset = typeof input?.offset === 'number' ? input.offset : 0;
const codeLines: CodeLine[] = React.useMemo(() => {
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
let fallbackLineCursor = offset;
return parsedReadOutput.lines.map((line) => {
if (line.lineNumber !== null) {
fallbackLineCursor = line.lineNumber;
}
const shouldAssignFallback =
parsedReadOutput.type === 'file'
&& !hasExplicitLineNumbers
&& line.lineNumber === null
&& !line.isInfo;
const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallback
? (fallbackLineCursor += 1)
: null);
return {
text: line.text,
lineNumber: effectiveLineNumber,
isInfo: line.isInfo,
};
});
}, [parsedReadOutput, offset]);
const language = React.useMemo(() => {
const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n');
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
}, [parsedReadOutput, toolName, input]);
const filePath =
typeof input?.filePath === 'string'
? input.filePath
: typeof input?.file_path === 'string'
? input.file_path
: typeof input?.path === 'string'
? input.path
: 'read-output';
const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({
text: line.text,
lineNumber: line.lineNumber,
isInfo: line.isInfo,
})), [parsedReadOutput]);
if (parsedReadOutput.type === 'file') {
const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n');
return renderScrollableBlock(
<PierreFile
file={{
name: filePath,
contents: fileContent,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>,
{ className: 'p-1' }
) as React.ReactElement;
}
return renderScrollableBlock(
<VirtualizedCodeBlock
lines={codeLines}
@@ -845,6 +884,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
hasPrevTool,
hasNextTool,
}) => {
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
@@ -892,6 +933,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}, [input, part.tool]);
const hasInputText = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0;
React.useEffect(() => {
setDiffViewMode('unified');
}, [part.id]);
const renderScrollableBlock = (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
@@ -1042,7 +1087,12 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent) {
return renderScrollableBlock(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
<DiffPreview
diff={diffContent}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
diffViewMode={diffViewMode}
/>,
{ className: 'p-1' }
);
}
@@ -1054,6 +1104,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
input={input}
syntaxTheme={syntaxTheme}
toolName={part.tool}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
renderScrollableBlock={renderScrollableBlock}
/>;
}
@@ -1122,9 +1174,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{renderScrollableBlock(
<WriteInputPreview
content={writeInputContent as string}
syntaxTheme={syntaxTheme}
filePath={writeFilePath}
displayPath={writeDisplayPath ?? 'New file'}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
/>
)}
</div>
@@ -1141,8 +1194,17 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
{part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
<div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">
Result:
<div className="mb-1 flex items-center justify-between gap-2">
<div className="typography-meta font-medium text-muted-foreground/80">
Result:
</div>
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? (
<DiffViewToggle
mode={diffViewMode}
onModeChange={setDiffViewMode}
className="h-5 w-5 p-0"
/>
) : null}
</div>
{renderResultContent()}
</div>
@@ -1190,49 +1252,53 @@ const ToolPart: React.FC<ToolPartProps> = ({
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
React.useEffect(() => {
if (!isFinalized && !isTaskTool) {
if (!shouldNotifyStructuralChange) {
return;
}
if (previousExpandedRef.current === isExpanded) {
return;
}
previousExpandedRef.current = isExpanded;
if (typeof isExpanded === 'boolean') {
onContentChange?.('structural');
}
}, [isExpanded, isFinalized, isTaskTool, onContentChange]);
}, [isExpanded, onContentChange, shouldNotifyStructuralChange]);
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const time = stateWithData.time;
// Pin start/end so a server-side time reset doesn't reset UI duration.
const pinnedTaskTimeRef = React.useRef<{ start?: number; end?: number }>({});
const lastPinnedTaskIdRef = React.useRef<string>(part.id);
const [pinnedTaskTime, setPinnedTaskTime] = React.useState<{ start?: number; end?: number }>({});
if (lastPinnedTaskIdRef.current !== part.id) {
lastPinnedTaskIdRef.current = part.id;
pinnedTaskTimeRef.current = {};
}
React.useEffect(() => {
setPinnedTaskTime({});
}, [part.id]);
if (isTaskTool) {
if (typeof time?.start === 'number') {
const pinnedStart = pinnedTaskTimeRef.current.start;
if (typeof pinnedStart !== 'number' || time.start < pinnedStart) {
pinnedTaskTimeRef.current.start = time.start;
React.useEffect(() => {
if (!isTaskTool) {
return;
}
setPinnedTaskTime((prev) => {
const next = { ...prev };
let changed = false;
if (typeof time?.start === 'number' && (typeof prev.start !== 'number' || time.start < prev.start)) {
next.start = time.start;
changed = true;
}
}
if (typeof time?.end === 'number') {
pinnedTaskTimeRef.current.end = time.end;
}
}
const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start;
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end;
if (typeof time?.end === 'number' && prev.end !== time.end) {
next.end = time.end;
changed = true;
}
return changed ? next : prev;
});
}, [isTaskTool, time?.end, time?.start]);
const effectiveTimeStart = isTaskTool ? (pinnedTaskTime.start ?? time?.start) : time?.start;
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTime.end ?? time?.end) : time?.end;
const taskOutputString = React.useMemo(() => {
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
@@ -1371,7 +1437,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
const runtime = React.useContext(RuntimeAPIContext);
const handleMainClick = (e: React.MouseEvent) => {
const handleMainClick = (e: { stopPropagation: () => void }) => {
if (isTaskTool || !runtime?.editor) {
onToggle(part.id);
return;
@@ -1400,6 +1466,14 @@ const ToolPart: React.FC<ToolPartProps> = ({
}
};
const handleMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
handleMainClick(event);
};
if (!isFinalized && !isTaskTool) {
return null;
}
@@ -1412,10 +1486,18 @@ const ToolPart: React.FC<ToolPartProps> = ({
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
)}
onClick={handleMainClick}
onKeyDown={handleMainKeyDown}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2 flex-shrink-0">
{}
<div className="relative h-3.5 w-3.5 flex-shrink-0" onClick={(e) => { e.stopPropagation(); onToggle(part.id); }}>
<button
type="button"
className="relative h-3.5 w-3.5 flex-shrink-0"
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
aria-label={isExpanded ? 'Collapse tool details' : 'Expand tool details'}
>
{}
<div
className={cn(
@@ -1438,7 +1520,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
</div>
</div>
</button>
<span
className="typography-meta font-medium"
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}