feat(ui): add dynamic window title and sprite-based project/file icons (#529)
* feat(ui): add dynamic titles and sprite-based project/file icons * feat(files): add viewer syntax fallback and tab file icons * fix(files): restore file viewer highlighting and add diff file icons * feat(git): add file icons and async file-viewer syntax fallback * fix(files): force codemirror token colors in file viewer * feat(files): add shiki view mode for file viewer * fix(files): force codemirror parse after programmatic content updates * feat(files): support markdown frontmatter preview * feat(chat): use pierre diffs for tool previews * feat(chat): add configurable beautiful-mermaid rendering * feat(perf): virtualize chat rendering and add react-scan toggle * feat(build): enable React Compiler in Vite React apps * fix(chat): reduce rerenders from tooltips and streamed activity * fix(ui): make MessageList React Compiler safe * chore(ui): batch commit remaining pending ui updates * fix: polish chat and diff preview rendering - Keep Mermaid action buttons fixed while diagram content scrolls - Align Diff All Files headers and match Git-style path truncation - Default chat tool diffs to unified view with lightweight indicators disabled * fix: preserve file tree expansion and delay git action label collapse * fix: refine project icon controls in settings --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d6b8f28e6f
commit
1d8ff97c95
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
Reference in New Issue
Block a user