diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index aed1670a..d64d86d4 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react'; +import { RiArrowLeftLine } from '@remixicon/react'; import { useShallow } from 'zustand/react/shallow'; import type { Message, Part } from '@opencode-ai/sdk/v2'; @@ -9,87 +9,83 @@ import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import MessageList, { type MessageListHandle } from './MessageList'; +import ScrollToBottomButton from './components/ScrollToBottomButton'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager } from '@/hooks/useChatScrollManager'; +import { useChatTimelineController } from './hooks/useChatTimelineController'; +import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useDeviceInfo } from '@/lib/device'; -import { getMemoryLimits } from '@/stores/types/sessionTypes'; -import { Button } from '@/components/ui/button'; import { ButtonSmall } from '@/components/ui/button-small'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { TimelineDialog } from './TimelineDialog'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { cn } from '@/lib/utils'; +import { + collectVisibleSessionIdsForBlockingRequests, + flattenBlockingRequests, +} from './lib/blockingRequests'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; -const collectVisibleSessionIdsForBlockingRequests = ( - sessions: Array<{ id: string; parentID?: string }> | undefined, - currentSessionId: string | null -): string[] => { - if (!currentSessionId) return []; - if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId]; - - const current = sessions.find((session) => session.id === currentSessionId); - if (!current) return [currentSessionId]; - - // Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread. - if (current.parentID) { - return []; - } - - const childIds = sessions - .filter((session) => session.parentID === currentSessionId) - .map((session) => session.id); - - return [currentSessionId, ...childIds]; +type HydratingToolSkeletonRow = { + id: string; + titleWidth: string; + detailWidth: string; }; -const flattenBlockingRequests = ( - source: Map, - sessionIds: string[] -): T[] => { - if (sessionIds.length === 0) return []; - const seen = new Set(); - const result: T[] = []; - - for (const sessionId of sessionIds) { - const entries = source.get(sessionId); - if (!entries || entries.length === 0) continue; - for (const entry of entries) { - if (seen.has(entry.id)) continue; - seen.add(entry.id); - result.push(entry); - } - } - - return result; -}; +const HYDRATING_SKELETON_ITEMS: Array<{ + id: number; + toolRows: HydratingToolSkeletonRow[]; + textWidths: [string, string, string]; +}> = [ + { + id: 1, + toolRows: [ + { id: 'search', titleWidth: 'w-24', detailWidth: 'w-52' }, + { id: 'read', titleWidth: 'w-20', detailWidth: 'w-36' }, + { id: 'edit', titleWidth: 'w-24', detailWidth: 'w-64' }, + ], + textWidths: ['w-24', 'w-[92%]', 'w-[78%]'], + }, + { + id: 2, + toolRows: [ + { id: 'read', titleWidth: 'w-20', detailWidth: 'w-40' }, + { id: 'search', titleWidth: 'w-24', detailWidth: 'w-48' }, + ], + textWidths: ['w-20', 'w-[88%]', 'w-[70%]'], + }, + { + id: 3, + toolRows: [ + { id: 'shell', titleWidth: 'w-28', detailWidth: 'w-44' }, + { id: 'edit', titleWidth: 'w-24', detailWidth: 'w-56' }, + ], + textWidths: ['w-24', 'w-[84%]', 'w-[64%]'], + }, +]; export const ChatContainer: React.FC = () => { const { currentSessionId, - isLoading, loadMessages, loadMoreMessages, updateViewportAnchor, openNewSessionDraft, setCurrentSession, - trimToViewportWindow, newSessionDraft, } = useSessionStore( useShallow((state) => ({ currentSessionId: state.currentSessionId, - isLoading: state.isLoading, loadMessages: state.loadMessages, loadMoreMessages: state.loadMoreMessages, updateViewportAnchor: state.updateViewportAnchor, openNewSessionDraft: state.openNewSessionDraft, setCurrentSession: state.setCurrentSession, - trimToViewportWindow: state.trimToViewportWindow, newSessionDraft: state.newSessionDraft, })) ); @@ -107,6 +103,7 @@ export const ChatContainer: React.FC = () => { setTimelineDialogOpen, isExpandedInput, stickyUserHeader, + chatRenderMode, } = useUIStore(); const sessionMessages = useSessionStore( @@ -144,9 +141,9 @@ export const ChatContainer: React.FC = () => { return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds); }, [blockingRequestState.questions, scopedSessionIds]); - const memoryState = useSessionStore( + const historyMeta = useSessionStore( React.useCallback( - (state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null), + (state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null), [currentSessionId] ) ); @@ -216,115 +213,60 @@ export const ChatContainer: React.FC = () => { } }, [currentSessionId, draftOpen, openNewSessionDraft]); - const [turnStart, setTurnStart] = React.useState(0); - const turnHandleRef = React.useRef(null); - const turnIdleRef = React.useRef(false); - const initializedTurnStartSessionRef = React.useRef(null); - const TURN_INIT = 5; - const TURN_BATCH = 8; - - const userTurnIndexes = React.useMemo(() => { - const indexes: number[] = []; - for (let i = 0; i < sessionMessages.length; i += 1) { - const message = sessionMessages[i]; - const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role; - if (role === 'user') { - indexes.push(i); - } - } - return indexes; - }, [sessionMessages]); - - const cancelTurnBackfill = React.useCallback(() => { - const handle = turnHandleRef.current; - if (handle === null) { - return; - } - turnHandleRef.current = null; - if (turnIdleRef.current && typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') { - window.cancelIdleCallback(handle); - return; - } - if (typeof window !== 'undefined') { - window.clearTimeout(handle); - } - }, []); - - const renderedSessionMessages = React.useMemo(() => { - if (turnStart <= 0 || userTurnIndexes.length === 0) { - return sessionMessages; - } - const startIndex = userTurnIndexes[turnStart] ?? 0; - return sessionMessages.slice(startIndex); - }, [sessionMessages, turnStart, userTurnIndexes]); - - const backfillTurns = React.useCallback(() => { - if (turnStart <= 0) { - return; - } - - const container = typeof document !== 'undefined' - ? (document.querySelector('[data-scrollbar="chat"]') as HTMLDivElement | null) - : null; - const beforeTop = container?.scrollTop ?? null; - const beforeHeight = container?.scrollHeight ?? null; - - setTurnStart((prev) => (prev - TURN_BATCH > 0 ? prev - TURN_BATCH : 0)); - - if (container && beforeTop !== null && beforeHeight !== null) { - window.requestAnimationFrame(() => { - const delta = container.scrollHeight - beforeHeight; - if (delta !== 0) { - container.scrollTop = beforeTop + delta; - } - }); - } - }, [turnStart]); - - const scheduleTurnBackfill = React.useCallback(() => { - if (turnHandleRef.current !== null || turnStart <= 0) { - return; - } - - if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { - turnIdleRef.current = true; - turnHandleRef.current = window.requestIdleCallback(() => { - turnHandleRef.current = null; - backfillTurns(); - }); - return; - } - - turnIdleRef.current = false; - turnHandleRef.current = window.setTimeout(() => { - turnHandleRef.current = null; - backfillTurns(); - }, 0); - }, [backfillTurns, turnStart]); - const sessionBlockingCards = React.useMemo(() => { return [...sessionPermissions, ...sessionQuestions]; }, [sessionPermissions, sessionQuestions]); + const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {}); + const { scrollRef, handleMessageContentChange, getAnimationHandlers, - showScrollButton, scrollToBottom, - scrollToPosition, + releasePinnedScroll, isPinned, + isOverflowing, + isProgrammaticFollowActive, } = useChatScrollManager({ currentSessionId, - sessionMessages: renderedSessionMessages, + sessionMessages, streamingMessageId, sessionMemoryState: sessionMemoryStateMap, updateViewportAnchor, isSyncing, isMobile, + chatRenderMode, messageStreamStates, sessionPermissions: sessionBlockingCards, - trimToViewportWindow, + onActiveTurnChange: (turnId) => { + activeTurnChangeRef.current(turnId); + }, + }); + + const timelineController = useChatTimelineController({ + sessionId: currentSessionId, + messages: sessionMessages, + historyMeta, + scrollRef, + messageListRef, + loadMoreMessages, + scrollToBottom, + isPinned, + isOverflowing, + }); + + React.useEffect(() => { + activeTurnChangeRef.current = timelineController.handleActiveTurnChange; + }, [timelineController.handleActiveTurnChange]); + + const navigation = useChatTurnNavigation({ + sessionId: currentSessionId, + turnIds: timelineController.turnIds, + activeTurnId: timelineController.activeTurnId, + scrollToTurn: timelineController.scrollToTurn, + scrollToMessage: timelineController.scrollToMessage, + resumeToBottom: timelineController.resumeToBottom, }); React.useLayoutEffect(() => { @@ -354,131 +296,13 @@ export const ChatContainer: React.FC = () => { }; }, [currentSessionId, isDesktopExpandedInput, scrollRef]); - React.useEffect(() => { - cancelTurnBackfill(); - if (!currentSessionId) { - initializedTurnStartSessionRef.current = null; - setTurnStart(0); - return; - } - - if (initializedTurnStartSessionRef.current === currentSessionId) { - return; - } - - if (sessionMessages.length === 0) { - setTurnStart(0); - return; - } - - const turnCount = userTurnIndexes.length; - const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0; - setTurnStart(start); - 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, isSessionActive, scheduleTurnBackfill, turnStart]); - - const hasMoreAbove = React.useMemo(() => { - if (!memoryState) { - return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES; - } - if (memoryState.historyComplete === true) { - return false; - } - if (memoryState.hasMoreAbove) { - return true; - } - if (memoryState.historyComplete === false) { - return true; - } - - // Backward compatibility: older persisted sessions may miss history flags. - if (memoryState.hasMoreAbove === undefined && memoryState.historyComplete === undefined) { - return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES; - } - - return false; - }, [memoryState, sessionMessages.length]); - const hasHistoryMetadata = React.useMemo(() => { - if (!memoryState) { - return false; - } - return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined; - }, [memoryState]); - const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); - React.useEffect(() => { - setIsLoadingOlder(false); - }, [currentSessionId]); + return Boolean(historyMeta); + }, [historyMeta]); - const handleLoadOlder = React.useCallback(async () => { - if (!currentSessionId || isLoadingOlder) { - return; - } - - cancelTurnBackfill(); - setTurnStart(0); - - const container = scrollRef.current; - const anchor = messageListRef.current?.captureViewportAnchor() ?? null; - const prevHeight = container?.scrollHeight ?? null; - const prevTop = container?.scrollTop ?? null; - - setIsLoadingOlder(true); - 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(() => { - cancelTurnBackfill(); - setTurnStart(0); - }, [cancelTurnBackfill]); - - // 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; - - // Find the message element by looking for data-message-id attribute - const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement; - if (messageElement) { - // Scroll to the message with some padding (50px from top) - const containerRect = container.getBoundingClientRect(); - const messageRect = messageElement.getBoundingClientRect(); - const offset = 50; - - const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset; - container.scrollTo({ - top: scrollTop, - behavior: 'smooth' - }); - } - }, [scrollRef]); + const isSessionHydrating = + Boolean(currentSessionId) + && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true); React.useEffect(() => { if (!currentSessionId) { @@ -494,7 +318,8 @@ export const ChatContainer: React.FC = () => { await loadMessages(currentSessionId).finally(() => { const statusType = sessionStatusForCurrent.type ?? 'idle'; const isActivePhase = statusType === 'busy' || statusType === 'retry'; - const shouldSkipScroll = isActivePhase && isPinned; + const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; + const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget; if (!shouldSkipScroll) { if (typeof window === 'undefined') { @@ -551,32 +376,44 @@ export const ChatContainer: React.FC = () => { return null; } - if (isLoading && sessionMessages.length === 0 && !streamingMessageId) { - const hasMessagesEntry = hasSessionMessagesEntry; - if (!hasMessagesEntry) { - return ( -
- {returnToParentButton} -
-
- {[1, 2, 3].map((i) => ( -
- -
- - + if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) { + return ( +
+ {returnToParentButton} +
+
+ {HYDRATING_SKELETON_ITEMS.map((item) => ( +
+
+
+
+ {item.toolRows.map((row) => { + return ( +
+ + + +
+ ); + })} +
+
+ + + +
- ))} -
+
+ ))}
-
- ); - } + +
+ ); } if (sessionMessages.length === 0 && !streamingMessageId) { @@ -632,22 +469,25 @@ export const ChatContainer: React.FC = () => {
0} - onRenderEarlier={handleRenderEarlier} + hasMoreAbove={timelineController.historySignals.hasMoreAboveTurns} + isLoadingOlder={timelineController.isLoadingOlder} + onLoadOlder={() => { + void timelineController.loadEarlier(); + }} scrollToBottom={scrollToBottom} scrollRef={scrollRef} />
- +
@@ -659,19 +499,11 @@ export const ChatContainer: React.FC = () => { : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80' )} > - {!isDesktopExpandedInput && showScrollButton && sessionMessages.length > 0 && ( -
- -
+ {!isDesktopExpandedInput && sessionMessages.length > 0 && ( + )}
@@ -679,7 +511,15 @@ export const ChatContainer: React.FC = () => { { + releasePinnedScroll(); + return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false }); + }} + onScrollByTurnOffset={(offset) => { + releasePinnedScroll(); + void navigation.scrollByTurnOffset(offset); + }} + onResumeToLatest={navigation.resumeToLatest} />
); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 05671223..e4fbb54c 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1224,8 +1224,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo clearAbortPrompt(); startAbortIndicator(); - void abortCurrentOperation(); - }, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]); + void abortCurrentOperation(currentSessionId || undefined); + }, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]); const handleCycleAgent = React.useCallback(() => { if (primaryAgents.length <= 1) return; @@ -2381,19 +2381,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo data-keyboard-avoid="true" style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} > - {/* Absolute positioned above input - no layout shift */} -
- -
= ({ onOpenSettings, scrollToBo
)} +
import('./message/ToolOutputDialog')); -const TOOL_DEFAULT_EXPANSION_BY_MODE = { - detailed: new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']), - changes: new Set(['edit', 'multiedit', 'write', 'apply_patch']), -} as const; - -type DefaultExpandedToolMode = keyof typeof TOOL_DEFAULT_EXPANSION_BY_MODE; const EXPANDED_TOOLS_CACHE_MAX = 4000; const expandedToolsStateCache = new Map>(); +const collapsedToolsStateCache = new Map>(); + +const BASH_TOOL_NAMES = new Set(['bash', 'shell', 'cmd', 'terminal']); +const EDIT_TOOL_NAMES = new Set([ + 'apply_patch', + 'edit', + 'write', + 'multiedit', + 'str_replace', + 'str_replace_based_edit_tool', + 'create', + 'file_write', +]); + +const normalizeToolName = (toolName: unknown): string => { + if (typeof toolName !== 'string') return ''; + const trimmed = toolName.trim().toLowerCase(); + if (!trimmed) return ''; + const withoutIndex = trimmed.replace(/:\d+$/, ''); + if (!withoutIndex.includes('.')) { + return withoutIndex; + } + const parts = withoutIndex.split('.').filter(Boolean); + return parts[parts.length - 1] ?? withoutIndex; +}; const readExpandedToolsCache = (messageId: string): Set => { const cached = expandedToolsStateCache.get(messageId); @@ -53,8 +72,20 @@ const writeExpandedToolsCache = (messageId: string, value: Set): void => expandedToolsStateCache.set(messageId, new Set(value)); }; -const isDefaultExpandedTool = (toolName: unknown, mode: DefaultExpandedToolMode): boolean => - typeof toolName === 'string' && TOOL_DEFAULT_EXPANSION_BY_MODE[mode].has(toolName.toLowerCase()); +const readCollapsedToolsCache = (messageId: string): Set => { + const cached = collapsedToolsStateCache.get(messageId); + return cached ? new Set(cached) : new Set(); +}; + +const writeCollapsedToolsCache = (messageId: string, value: Set): void => { + if (collapsedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !collapsedToolsStateCache.has(messageId)) { + const oldest = collapsedToolsStateCache.keys().next().value; + if (typeof oldest === 'string') { + collapsedToolsStateCache.delete(oldest); + } + } + collapsedToolsStateCache.set(messageId, new Set(value)); +}; function useStickyDisplayValue(value: T | null | undefined): T | null | undefined { const [stickyValue, setStickyValue] = React.useState(value); @@ -92,6 +123,8 @@ interface ChatMessageProps { animationHandlers?: AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; turnGroupingContext?: TurnGroupingContext; + animateUserOnMount?: boolean; + onUserAnimationConsumed?: (messageId: string) => void; } const ChatMessage: React.FC = ({ @@ -101,6 +134,8 @@ const ChatMessage: React.FC = ({ onContentChange, animationHandlers, turnGroupingContext, + animateUserOnMount = false, + onUserAnimationConsumed, }) => { const { isMobile, hasTouchInput } = useDeviceInfo(); const { currentTheme } = useThemeSystem(); @@ -136,11 +171,13 @@ const ChatMessage: React.FC = ({ } = sessionState; const providers = useConfigStore((state) => state.providers); - const { showReasoningTraces, toolCallExpansion, stickyUserHeader } = useUIStore( + const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore( useShallow((state) => ({ showReasoningTraces: state.showReasoningTraces, - toolCallExpansion: state.toolCallExpansion, stickyUserHeader: state.stickyUserHeader, + chatRenderMode: state.chatRenderMode, + showExpandedBashTools: state.showExpandedBashTools, + showExpandedEditTools: state.showExpandedEditTools, })) ); @@ -153,6 +190,7 @@ const ChatMessage: React.FC = ({ const [copiedCode, setCopiedCode] = React.useState(null); const [copiedMessage, setCopiedMessage] = React.useState(false); const [expandedTools, setExpandedTools] = React.useState>(() => readExpandedToolsCache(message.info.id)); + const [collapsedTools, setCollapsedTools] = React.useState>(() => readCollapsedToolsCache(message.info.id)); const [popupContent, setPopupContent] = React.useState({ open: false, title: '', @@ -161,12 +199,10 @@ const ChatMessage: React.FC = ({ React.useEffect(() => { setExpandedTools(readExpandedToolsCache(message.info.id)); + setCollapsedTools(readCollapsedToolsCache(message.info.id)); }, [message.info.id]); - React.useEffect(() => { - expandedToolsStateCache.clear(); - setExpandedTools(new Set()); - }, [toolCallExpansion]); + const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]); const isUser = messageRole.isUser; @@ -188,35 +224,7 @@ const ChatMessage: React.FC = ({ return message.parts; } - const keepSyntheticUserText = (text: string): boolean => { - const trimmed = text.trim(); - if (trimmed.startsWith('User has requested to enter plan mode')) return true; - if (trimmed.startsWith('The plan at ')) return true; - if (trimmed.startsWith('The following tool was executed by the user')) return true; - return false; - }; - - return message.parts - .filter((part) => { - const synthetic = (part as unknown as { synthetic?: boolean })?.synthetic === true; - if (!synthetic) return true; - if (part.type !== 'text') return false; - const text = (part as unknown as { text?: unknown })?.text; - return typeof text === 'string' ? keepSyntheticUserText(text) : false; - }) - .map((part) => { - const rawPart = part as Record; - if (rawPart.type === 'compaction') { - return { type: 'text', text: '/compact' } as Part; - } - if (rawPart.type === 'text') { - const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : ''; - if (text.startsWith('The following tool was executed by the user')) { - return { type: 'text', text: '/shell' } as Part; - } - } - return part; - }); + return normalizeUserDisplayParts(message.parts); }, [isUser, message.parts]); const previousUserMetadata = React.useMemo(() => { @@ -425,8 +433,12 @@ const ChatMessage: React.FC = ({ return visibleParts; } - return isMessageCompleted ? visibleParts : []; - }, [isUser, isMessageCompleted, visibleParts]); + if (!isMessageCompleted && chatRenderMode === 'sorted') { + return []; + } + + return visibleParts; + }, [chatRenderMode, isMessageCompleted, isUser, visibleParts]); const assistantTextParts = React.useMemo(() => { @@ -444,65 +456,57 @@ const ChatMessage: React.FC = ({ return filtered; }, [isUser, visibleParts]); + const turnActivityToolParts = React.useMemo(() => { + if (isUser) { + return [] as Part[]; + } + const records = turnGroupingContext?.activityParts ?? []; + return records + .filter((record) => record.kind === 'tool') + .map((record) => record.part) + .filter((part): part is Part => part.type === 'tool'); + }, [isUser, turnGroupingContext?.activityParts]); + + const defaultOpenToolIds = React.useMemo(() => { + if (!showExpandedBashTools && !showExpandedEditTools) { + return new Set(); + } + + const next = new Set(); + for (const part of [...toolParts, ...turnActivityToolParts]) { + const toolId = typeof part?.id === 'string' ? part.id : ''; + if (!toolId) continue; + const toolName = normalizeToolName((part as { tool?: string }).tool); + if (!toolName) continue; + + if (showExpandedBashTools && BASH_TOOL_NAMES.has(toolName)) { + next.add(toolId); + continue; + } + if (showExpandedEditTools && EDIT_TOOL_NAMES.has(toolName)) { + next.add(toolId); + } + } + + return next; + }, [showExpandedBashTools, showExpandedEditTools, toolParts, turnActivityToolParts]); + const effectiveExpandedTools = React.useMemo(() => { - // 'collapsed': Activity and tools start collapsed - // 'activity': Activity expanded, tools collapsed - // 'detailed': Activity expanded, only key tools expanded - // 'changes': Activity expanded, only edit/diff tools expanded - - if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') { - // Tools default collapsed: expandedTools contains IDs of tools that ARE expanded + if (defaultOpenToolIds.size === 0 && collapsedTools.size === 0) { return expandedTools; } - const defaultExpansionMode = - toolCallExpansion === 'detailed' || toolCallExpansion === 'changes' - ? toolCallExpansion - : null; - - if (!defaultExpansionMode) { - return expandedTools; - } - - // 'detailed'/'changes': expand only allowlisted tools by default. - // expandedTools acts as a "toggled" set (XOR with defaults). - const defaultExpandedToolIds = new Set(); - - for (const part of toolParts) { - const toolName = (part as { tool?: unknown }).tool; - if (part.id && isDefaultExpandedTool(toolName, defaultExpansionMode)) { - defaultExpandedToolIds.add(part.id); + const next = new Set(expandedTools); + defaultOpenToolIds.forEach((toolId) => { + if (!collapsedTools.has(toolId)) { + next.add(toolId); } - } - - if (turnGroupingContext?.isFirstAssistantInTurn) { - for (const activity of turnGroupingContext.activityParts) { - if (activity.kind !== 'tool') { - continue; - } - - const toolPart = activity.part as unknown as { id?: string; tool?: unknown }; - if (isDefaultExpandedTool(toolPart.tool, defaultExpansionMode)) { - if (toolPart.id) { - defaultExpandedToolIds.add(toolPart.id); - } - if (activity.id) { - defaultExpandedToolIds.add(activity.id); - } - } - } - } - - const effective = new Set(defaultExpandedToolIds); - for (const id of expandedTools) { - if (effective.has(id)) { - effective.delete(id); - } else { - effective.add(id); - } - } - return effective; - }, [expandedTools, toolCallExpansion, toolParts, turnGroupingContext]); + }); + collapsedTools.forEach((toolId) => { + next.delete(toolId); + }); + return next; + }, [collapsedTools, defaultOpenToolIds, expandedTools]); const agentMention = React.useMemo(() => { if (!isUser) { @@ -564,21 +568,22 @@ const ChatMessage: React.FC = ({ const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false); - const previousRole = React.useMemo(() => { - if (!previousMessage) return null; - return deriveMessageRole(previousMessage.info); - }, [previousMessage]); - const nextRole = React.useMemo(() => { if (!nextMessage) return null; return deriveMessageRole(nextMessage.info); }, [nextMessage]); + const hasTurnGrouping = Boolean(turnGroupingContext); + const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; + const isFollowedByAssistant = React.useMemo(() => { if (isUser) return false; + if (hasTurnGrouping) { + return !isLastAssistantInTurn; + } if (!nextRole) return false; return !nextRole.isUser && nextRole.role === 'assistant'; - }, [isUser, nextRole]); + }, [hasTurnGrouping, isLastAssistantInTurn, isUser, nextRole]); const streamPhase: StreamPhase = React.useMemo(() => { if (isMessageCompleted) { @@ -590,6 +595,13 @@ const ChatMessage: React.FC = ({ return isStreamingMessage ? 'streaming' : 'completed'; }, [isMessageCompleted, lifecyclePhase, isStreamingMessage]); + React.useEffect(() => { + if (!isUser || !animateUserOnMount) { + return; + } + onUserAnimationConsumed?.(message.info.id); + }, [animateUserOnMount, isUser, message.info.id, onUserAnimationConsumed]); + React.useEffect(() => { setHasStartedStreamingHeader(false); }, [message.info.id]); @@ -630,10 +642,9 @@ const ChatMessage: React.FC = ({ return false; } - // Fallback to original logic when turn grouping is not available - if (!previousRole) return true; - return previousRole.isUser; - }, [hasStartedStreamingHeader, isUser, previousRole, turnGroupingContext, streamPhase, message.info]); + // Ungrouped fallback path: always show assistant header. + return true; + }, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]); const handleCopyCode = React.useCallback((code: string) => { void copyTextToClipboard(code).then((result) => { @@ -645,50 +656,11 @@ const ChatMessage: React.FC = ({ }); }, []); - const userMessageIdForTurn = turnGroupingContext?.turnId; - const { assistantSummaryFromStore, variantFromTurnStore } = useMessageStore( - useShallow((state) => { - if (!userMessageIdForTurn || !message.info.sessionID) { - return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; - } - const sessionMessages = state.messages.get(message.info.sessionID); - if (!sessionMessages) { - return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; - } - const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn); - if (!userMsg) { - return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined }; - } - const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary; - const body = summary?.body; - const variant = (userMsg.info as { variant?: unknown }).variant; - return { - assistantSummaryFromStore: typeof body === 'string' && body.trim().length > 0 ? body : undefined, - variantFromTurnStore: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined, - }; - }) - ); - - const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined; + const headerVariantRaw = !isUser ? (turnGroupingContext?.userMessageVariant ?? previousUserMetadata?.variant) : undefined; const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined; - const assistantSummaryCandidate = - typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0 - ? turnGroupingContext.summaryBody - : assistantSummaryFromStore; - - const [assistantSummaryForCopy, setAssistantSummaryForCopy] = React.useState(undefined); - - React.useEffect(() => { - setAssistantSummaryForCopy(undefined); - }, [userMessageIdForTurn]); - - React.useEffect(() => { - if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) { - setAssistantSummaryForCopy(assistantSummaryCandidate); - } - }, [assistantSummaryCandidate]); + // Summary body removed — flat rendering means text is always inline. const assistantErrorText = React.useMemo(() => { if (isUser) { @@ -758,12 +730,8 @@ const ChatMessage: React.FC = ({ return assistantErrorText; } - if (assistantSummaryForCopy && assistantSummaryForCopy.trim().length > 0) { - return assistantSummaryForCopy; - } - return flattenAssistantTextParts(displayParts); - }, [assistantErrorText, assistantSummaryForCopy, displayParts, isUser]); + }, [assistantErrorText, displayParts, isUser]); const hasTextContent = messageTextContent.length > 0; @@ -788,6 +756,32 @@ const ChatMessage: React.FC = ({ }, [sessionId, message.info.id, forkFromMessage]); const handleToggleTool = React.useCallback((toolId: string) => { + const isDefaultOpen = defaultOpenToolIds.has(toolId); + const isCurrentlyExpanded = effectiveExpandedTools.has(toolId); + + if (isDefaultOpen) { + setCollapsedTools((prev) => { + const next = new Set(prev); + if (isCurrentlyExpanded) { + next.add(toolId); + } else { + next.delete(toolId); + } + writeCollapsedToolsCache(message.info.id, next); + return next; + }); + + if (!isCurrentlyExpanded) { + setExpandedTools((prev) => { + const next = new Set(prev); + next.delete(toolId); + writeExpandedToolsCache(message.info.id, next); + return next; + }); + } + return; + } + setExpandedTools((prev) => { const next = new Set(prev); if (next.has(toolId)) { @@ -798,7 +792,17 @@ const ChatMessage: React.FC = ({ writeExpandedToolsCache(message.info.id, next); return next; }); - }, [message.info.id]); + + setCollapsedTools((prev) => { + if (!prev.has(toolId)) { + return prev; + } + const next = new Set(prev); + next.delete(toolId); + writeCollapsedToolsCache(message.info.id, next); + return next; + }); + }, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]); const resolvedAnimationHandlers = animationHandlers ?? null; const hasAnnouncedAuxiliaryScrollRef = React.useRef(false); @@ -807,6 +811,7 @@ const ChatMessage: React.FC = ({ const hasRequestedReservationRef = React.useRef(false); const animationStartNotifiedRef = React.useRef(false); const hasTriggeredReservationOnceRef = React.useRef(false); + const hasEverStreamedRef = React.useRef(false); React.useEffect(() => { animationCompletedRef.current = false; @@ -814,6 +819,7 @@ const ChatMessage: React.FC = ({ animationStartNotifiedRef.current = false; hasTriggeredReservationOnceRef.current = false; hasAnnouncedAuxiliaryScrollRef.current = false; + hasEverStreamedRef.current = false; }, [message.info.id]); const handleAuxiliaryContentComplete = React.useCallback(() => { @@ -843,7 +849,11 @@ const ChatMessage: React.FC = ({ }, [setImagePreviewOpen]); const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled')); - const isStreamingPhase = streamPhase === 'streaming'; + const isStreamingPhase = streamPhase === 'streaming' || streamPhase === 'cooldown'; + + if (isStreamingPhase) { + hasEverStreamedRef.current = true; + } const hasReasoningParts = React.useMemo(() => { if (isUser) { @@ -852,7 +862,7 @@ const ChatMessage: React.FC = ({ return visibleParts.some((part) => part.type === 'reasoning'); }, [isUser, visibleParts]); - const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase; + const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current; const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering; React.useEffect(() => { @@ -970,83 +980,89 @@ const ChatMessage: React.FC = ({ isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass, isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8' )} + id={`message-${message.info.id}`} data-message-id={message.info.id} ref={messageContainerRef} >
{isUser ? ( displayParts.length === 0 ? null : ( - -
-
-
- + +
+
+
+ +
+ {useExternalUserActionsRow ? ( + + ) : null}
- {useExternalUserActionsRow ? ( - - ) : null} + {showStickyInlineHoverRow ? - {showStickyInlineHoverRow ? - + ) ) : (
diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index cbc624a9..384df646 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -671,15 +671,17 @@ const stripLeadingFrontmatter = (markdown: string): string => { return markdown.slice(frontmatterMatch[0].length); }; -export type MarkdownVariant = 'assistant' | 'tool'; +export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning'; interface MarkdownRendererProps { content: string; part?: Part; messageId: string; isAnimated?: boolean; + skipFadeIn?: boolean; className?: string; isStreaming?: boolean; + disableStreamAnimation?: boolean; variant?: MarkdownVariant; onShowPopup?: (content: ToolPopupContent) => void; } @@ -1325,8 +1327,10 @@ export const MarkdownRenderer: React.FC = ({ part, messageId, isAnimated = true, + skipFadeIn = false, className, isStreaming = false, + disableStreamAnimation = false, variant = 'assistant', onShowPopup, }) => { @@ -1350,18 +1354,27 @@ export const MarkdownRenderer: React.FC = ({ const streamdownClassName = variant === 'tool' ? 'streamdown-content streamdown-tool' - : 'streamdown-content'; + : variant === 'reasoning' + ? 'streamdown-content streamdown-reasoning' + : 'streamdown-content'; + + const streamdownAnimated = React.useMemo( + () => ({ animation: 'blurIn' as const, duration: 150, easing: 'ease-out' }), + [], + ); const markdownContent = (
- {content} @@ -1370,7 +1383,7 @@ export const MarkdownRenderer: React.FC = ({ if (isAnimated) { return ( - + {markdownContent} ); @@ -1425,7 +1438,9 @@ export const SimpleMarkdownRenderer: React.FC<{ const streamdownClassName = variant === 'tool' ? 'streamdown-content streamdown-tool' - : 'streamdown-content'; + : variant === 'reasoning' + ? 'streamdown-content streamdown-reasoning' + : 'streamdown-content'; return (
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index deb48e8f..fe529c74 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { Message, Part } from '@opencode-ai/sdk/v2'; +import type { 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'; @@ -8,20 +8,41 @@ import type { ReactVirtualizerOptions, VirtualItem } from '@tanstack/react-virtu import ChatMessage from './ChatMessage'; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; +import TurnItem from './components/TurnItem'; +import TurnList from './components/TurnList'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; +import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; -import { detectTurns, type Turn } from './hooks/useTurnGrouping'; -import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic } from './contexts/TurnGroupingContext'; +import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; +import { useTurnRecords } from './hooks/useTurnRecords'; +import { useStageTurns } from './lib/turns/stageTurns'; +import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; +import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; +import { useAssistantStatus } from '@/hooks/useAssistantStatus'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { StatusRow } from './StatusRow'; const MESSAGE_VIRTUALIZE_THRESHOLD = 40; const MESSAGE_VIRTUAL_OVERSCAN_MOBILE = 2; const MESSAGE_VIRTUAL_OVERSCAN_DESKTOP = 4; +const TURN_ESTIMATE_BASE_PX = 120; +const TURN_ESTIMATE_PER_ASSISTANT_PX = 120; +const TURN_ESTIMATE_MAX_PX = 1400; + +const useStableEvent = (handler: (...args: TArgs) => TResult) => { + const handlerRef = React.useRef(handler); + React.useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + return React.useCallback((...args: TArgs) => handlerRef.current(...args), []); +}; type MessageListVirtualizerOptions = Omit< ReactVirtualizerOptions, @@ -66,11 +87,6 @@ const useMessageListVirtualizer = ( return virtualizer; }; -interface ChatMessageEntry { - info: Message; - parts: Part[]; -} - const USER_SHELL_MARKER = 'The following tool was executed by the user'; const resolveMessageRole = (message: ChatMessageEntry): string | null => { @@ -80,6 +96,19 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => { ?? null; }; +const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => { + const info = message.info as { time?: { completed?: unknown }; status?: unknown }; + const completed = info.time?.completed; + const status = info.status; + if (typeof completed !== 'number' || completed <= 0) { + return false; + } + if (typeof status === 'string') { + return status === 'completed'; + } + return true; +}; + const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => { if (!message) return false; if (resolveMessageRole(message) !== 'user') return false; @@ -167,23 +196,26 @@ const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParen const readTaskSessionId = (toolPart: Part): string | null => { const partRecord = toolPart as unknown as { state?: { - metadata?: { sessionId?: unknown; sessionID?: unknown }; + metadata?: { + sessionId?: unknown; + sessionID?: unknown; + }; output?: unknown; }; }; const metadata = partRecord.state?.metadata; const fromMetadata = - (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0 - ? metadata.sessionId.trim() - : null) - ?? (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0 + (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0 ? metadata.sessionID.trim() + : null) + ?? (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0 + ? metadata.sessionId.trim() : null); if (fromMetadata) return fromMetadata; const output = partRecord.state?.output; if (typeof output === 'string') { - const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/); + const match = output.match(/task_id\s*:\s*([^\s<"']+)/i); if (match?.[1]) { return match[1]; } @@ -287,6 +319,9 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD }; interface MessageListProps { + sessionKey: string; + turnStart: number; + disableStaging?: boolean; messages: ChatMessageEntry[]; permissions: PermissionRequest[]; questions: QuestionRequest[]; @@ -295,45 +330,61 @@ interface MessageListProps { hasMoreAbove: boolean; isLoadingOlder: boolean; onLoadOlder: () => void; - hasRenderEarlier?: boolean; - onRenderEarlier?: () => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; scrollRef?: React.RefObject; } export interface MessageListHandle { + scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => boolean; 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 }; + | { + kind: 'ungrouped'; + key: string; + message: ChatMessageEntry; + previousMessage?: ChatMessageEntry; + nextMessage?: ChatMessageEntry; + } + | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean }; + +type TurnUiState = { isExpanded: boolean }; + + interface MessageRowProps { message: ChatMessageEntry; + previousMessage?: ChatMessageEntry; + nextMessage?: ChatMessageEntry; + turnGroupingContext?: TurnGroupingContext; + animateUserOnMount?: boolean; + onUserAnimationConsumed?: (messageId: string) => void; onContentChange: (reason?: ContentChangeReason) => void; animationHandlers: AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } -// Static MessageRow - does NOT subscribe to dynamic context -// Used for messages NOT in the last turn - no re-renders during streaming -const StaticMessageRow = React.memo(({ +const MessageRow = React.memo(({ message, + previousMessage, + nextMessage, + turnGroupingContext, + animateUserOnMount, + onUserAnimationConsumed, onContentChange, animationHandlers, scrollToBottom, }) => { - const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id); - const turnGroupingContext = useTurnGroupingContextStatic(message.info.id); - return ( (({ ); }); -StaticMessageRow.displayName = 'StaticMessageRow'; - -// Dynamic MessageRow - subscribes to dynamic context for streaming state -// Used for messages in the LAST turn only -const DynamicMessageRow = React.memo(({ - message, - onContentChange, - animationHandlers, - scrollToBottom, -}) => { - const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id); - const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id); - - return ( - - ); -}); - -DynamicMessageRow.displayName = 'DynamicMessageRow'; +MessageRow.displayName = 'MessageRow'; interface TurnBlockProps { - turn: Turn; + turn: TurnRecord; isLastTurn: boolean; + sessionIsWorking: boolean; + defaultActivityExpanded: boolean; + turnUiStates: Map; + onToggleTurnGroup: (turnId: string) => void; + chatRenderMode: 'sorted' | 'live'; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader?: boolean; + shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; + onUserAnimationConsumed: (messageId: string) => void; } const TurnBlock: React.FC = ({ turn, isLastTurn, + sessionIsWorking, + defaultActivityExpanded, + turnUiStates, + onToggleTurnGroup, + chatRenderMode, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader = true, + shouldAnimateUserMessage, + onUserAnimationConsumed, }) => { + const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded }; + + const messageOrder = React.useMemo(() => { + const ordered = [turn.userMessage, ...turn.assistantMessages]; + const lookup = new Map(); + ordered.forEach((message, index) => { + lookup.set(message.info.id, index); + }); + return { ordered, lookup }; + }, [turn.assistantMessages, turn.userMessage]); + + const visibleAssistantMessages = React.useMemo(() => { + if (chatRenderMode === 'live') { + return turn.assistantMessages; + } + const completed = turn.assistantMessages.filter(isAssistantMessageCompleted); + if (completed.length === turn.assistantMessages.length) { + return turn.assistantMessages; + } + if (completed.length > 0) { + return completed; + } + const firstAssistant = turn.assistantMessages[0]; + return firstAssistant ? [firstAssistant] : []; + }, [chatRenderMode, turn.assistantMessages]); + + const completedAssistantMessages = React.useMemo(() => { + if (chatRenderMode !== 'sorted') { + return turn.assistantMessages; + } + return turn.assistantMessages.filter(isAssistantMessageCompleted); + }, [chatRenderMode, turn.assistantMessages]); + + const visibleAssistantIds = React.useMemo(() => { + const ids = new Map(); + visibleAssistantMessages.forEach((assistant, index) => { + ids.set(assistant.info.id, index); + }); + return ids; + }, [visibleAssistantMessages]); + + const completedAssistantIdSet = React.useMemo(() => { + return new Set(completedAssistantMessages.map((assistant) => assistant.info.id)); + }, [completedAssistantMessages]); + + const visibleActivityParts = React.useMemo(() => { + if (chatRenderMode !== 'sorted') { + return turn.activityParts; + } + if (completedAssistantMessages.length === turn.assistantMessages.length) { + return turn.activityParts; + } + return turn.activityParts.filter((activity) => completedAssistantIdSet.has(activity.messageId)); + }, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activityParts, turn.assistantMessages.length]); + + const visibleActivitySegments = React.useMemo(() => { + if (chatRenderMode !== 'sorted') { + return turn.activitySegments; + } + if (completedAssistantMessages.length === turn.assistantMessages.length) { + return turn.activitySegments; + } + return turn.activitySegments + .map((segment) => { + const parts = segment.parts.filter((activity) => completedAssistantIdSet.has(activity.messageId)); + if (parts.length === 0) { + return null; + } + const anchorMessageId = completedAssistantIdSet.has(segment.anchorMessageId) + ? segment.anchorMessageId + : parts[0]?.messageId; + if (!anchorMessageId) { + return null; + } + return { + ...segment, + anchorMessageId, + parts, + }; + }) + .filter((segment): segment is NonNullable => segment !== null); + }, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activitySegments, turn.assistantMessages.length]); + + const turnGroupingContextBase = React.useMemo(() => { + const userCreatedAt = (turn.userMessage.info.time as { created?: number } | undefined)?.created; + const rawVariant = (turn.userMessage.info as { variant?: unknown } | undefined)?.variant; + const userMessageVariant = typeof rawVariant === 'string' && rawVariant.trim().length > 0 + ? rawVariant + : undefined; + return { + turnId: turn.turnId, + summaryBody: turn.summaryText, + activityParts: visibleActivityParts, + activityGroupSegments: visibleActivitySegments, + headerMessageId: turn.headerMessageId, + hasTools: turn.hasTools, + hasReasoning: turn.hasReasoning, + diffStats: turn.diffStats, + userMessageCreatedAt: typeof userCreatedAt === 'number' ? userCreatedAt : undefined, + userMessageVariant, + }; + }, [turn.diffStats, turn.hasReasoning, turn.hasTools, turn.headerMessageId, turn.summaryText, turn.turnId, turn.userMessage.info, visibleActivityParts, visibleActivitySegments]); + const renderMessage = React.useCallback( (message: ChatMessageEntry) => { - const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role; - const isInLastTurn = role !== 'user' && isLastTurn; - const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow; + const messageIndex = messageOrder.lookup.get(message.info.id); + const previousMessage = typeof messageIndex === 'number' && messageIndex > 0 + ? messageOrder.ordered[messageIndex - 1] + : undefined; + const nextMessage = typeof messageIndex === 'number' && messageIndex < messageOrder.ordered.length - 1 + ? messageOrder.ordered[messageIndex + 1] + : undefined; + + const assistantIndex = visibleAssistantIds.get(message.info.id) ?? -1; + + const turnGroupingContext = assistantIndex >= 0 + ? { + ...turnGroupingContextBase, + isFirstAssistantInTurn: assistantIndex === 0, + isLastAssistantInTurn: assistantIndex === visibleAssistantMessages.length - 1, + isWorking: isLastTurn && sessionIsWorking, + isGroupExpanded: turnUiState.isExpanded, + toggleGroup: () => onToggleTurnGroup(turn.turnId), + } satisfies TurnGroupingContext + : undefined; return ( - ); }, - [getAnimationHandlers, isLastTurn, onMessageContentChange, scrollToBottom] + [ + getAnimationHandlers, + isLastTurn, + messageOrder.lookup, + messageOrder.ordered, + onMessageContentChange, + scrollToBottom, + sessionIsWorking, + turn.turnId, + turnUiState.isExpanded, + turnGroupingContextBase, + visibleAssistantMessages, + visibleAssistantIds, + shouldAnimateUserMessage, + onUserAnimationConsumed, + onToggleTurnGroup, + ] ); - return ( -
- {stickyUserHeader ? ( -
-
- {renderMessage(turn.userMessage)} -
- - ) : ( - renderMessage(turn.userMessage) - )} + const renderableTurn = React.useMemo(() => { + if (visibleAssistantMessages === turn.assistantMessages) { + return turn; + } + return { + ...turn, + assistantMessages: visibleAssistantMessages, + }; + }, [turn, visibleAssistantMessages]); -
- {turn.assistantMessages.map((message) => renderMessage(message))} -
-
+ return ( + ); }; @@ -433,24 +605,32 @@ TurnBlock.displayName = 'TurnBlock'; interface UngroupedMessageRowProps { message: ChatMessageEntry; - isInLastTurn: boolean; + previousMessage?: ChatMessageEntry; + nextMessage?: ChatMessageEntry; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; + shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; + onUserAnimationConsumed: (messageId: string) => void; } const UngroupedMessageRow: React.FC = React.memo(({ message, - isInLastTurn, + previousMessage, + nextMessage, onMessageContentChange, getAnimationHandlers, scrollToBottom, + shouldAnimateUserMessage, + onUserAnimationConsumed, }) => { - const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow; - return ( - AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader?: boolean; + sessionIsWorking: boolean; + defaultActivityExpanded: boolean; + turnUiStates: Map; + onToggleTurnGroup: (turnId: string) => void; + chatRenderMode: 'sorted' | 'live'; + shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; + onUserAnimationConsumed: (messageId: string) => void; } const MessageListEntry: React.FC = React.memo(({ @@ -474,15 +661,25 @@ const MessageListEntry: React.FC = React.memo(({ getAnimationHandlers, scrollToBottom, stickyUserHeader, + sessionIsWorking, + defaultActivityExpanded, + turnUiStates, + onToggleTurnGroup, + chatRenderMode, + shouldAnimateUserMessage, + onUserAnimationConsumed, }) => { if (entry.kind === 'ungrouped') { return ( ); } @@ -491,6 +688,13 @@ const MessageListEntry: React.FC = React.memo(({ void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader: boolean; -}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader }) => { + sessionIsWorking: boolean; + defaultActivityExpanded: boolean; + turnUiStates: Map; + onToggleTurnGroup: (turnId: string) => void; + chatRenderMode: 'sorted' | 'live'; + shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; + onUserAnimationConsumed: (messageId: string) => void; +}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => { + const renderEntry = React.useCallback((entry: RenderEntry) => { + return ( + + ); + }, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); + return ( - <> - {entries.map((entry) => ( - - ))} - + ); }; const MessageList = React.forwardRef(({ + sessionKey, + turnStart, + disableStaging, messages, permissions, questions, @@ -556,20 +796,51 @@ const MessageList = React.forwardRef(({ hasMoreAbove, isLoadingOlder, onLoadOlder, - hasRenderEarlier, - onRenderEarlier, scrollToBottom, scrollRef, }, ref) => { const { isMobile } = useDeviceInfo(); + const { isWorking: sessionIsWorking } = useCurrentSessionActivity(); + const { working } = useAssistantStatus(); + const currentAgentName = useConfigStore((state) => state.currentAgentName); const stickyUserHeader = useUIStore(state => state.stickyUserHeader); + const chatRenderMode = useUIStore((state) => state.chatRenderMode); + const activityRenderMode = useUIStore((state) => state.activityRenderMode); + const defaultActivityExpanded = activityRenderMode === 'summary'; + const [turnUiStates, setTurnUiStates] = React.useState>(() => new Map()); + const userAnimationRef = React.useRef<{ + sessionKey: string | undefined; + previousOrder: string[]; + animatedIds: Set; + }>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() }); + + const stableOnMessageContentChange = useStableEvent(onMessageContentChange); + const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers); + const stableOnLoadOlder = useStableEvent(onLoadOlder); + const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => { + scrollToBottom?.(options); + }); React.useEffect(() => { if (permissions.length === 0 && questions.length === 0) { return; } - onMessageContentChange('permission'); - }, [permissions, questions, onMessageContentChange]); + stableOnMessageContentChange('permission'); + }, [permissions, questions, stableOnMessageContentChange]); + + React.useEffect(() => { + setTurnUiStates(new Map()); + }, [activityRenderMode]); + + const toggleTurnGroup = React.useCallback((turnId: string) => { + setTurnUiStates((previous) => { + const next = new Map(previous); + const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded }; + next.set(turnId, { isExpanded: !current.isExpanded }); + return next; + }); + }, [defaultActivityExpanded]); + const baseDisplayMessages = React.useMemo(() => { const seenIdsFromTail = new Set(); @@ -650,10 +921,14 @@ const MessageList = React.forwardRef(({ const [fallbackRetryTimestamp, setFallbackRetryTimestamp] = React.useState(0); const fallbackRetrySessionRef = React.useRef(null); - const [scrollContainer, setScrollContainer] = React.useState(null); - - React.useLayoutEffect(() => { - setScrollContainer(scrollRef?.current ?? null); + const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => { + if (scrollRef?.current) { + return scrollRef.current; + } + if (typeof document === 'undefined') { + return null; + } + return document.querySelector('[data-scrollbar="chat"]'); }, [scrollRef]); React.useEffect(() => { @@ -670,157 +945,169 @@ const MessageList = React.forwardRef(({ }, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]); const displayMessages = React.useMemo(() => { - if (!activeRetrySessionId) { - return baseDisplayMessages; - } - - const retryError = { - name: 'SessionRetry', + return applyRetryOverlay(baseDisplayMessages, { + sessionId: activeRetrySessionId, message: activeRetryMessage, - data: { message: activeRetryMessage }, - }; - - let lastUserIndex = -1; - for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) { - if (resolveMessageRole(baseDisplayMessages[index]) === 'user') { - lastUserIndex = index; - break; - } - } - - if (lastUserIndex < 0) { - return baseDisplayMessages; - } - - // Prefer attaching retry error to the assistant message in the current turn (if one exists) - // to avoid rendering a separate header-only placeholder + error block. - let targetAssistantIndex = -1; - for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) { - if (resolveMessageRole(baseDisplayMessages[index]) === 'assistant') { - targetAssistantIndex = index; - break; - } - } - - if (targetAssistantIndex >= 0) { - const existing = baseDisplayMessages[targetAssistantIndex]; - const existingInfo = existing.info as unknown as { error?: unknown }; - if (existingInfo.error) { - return baseDisplayMessages; - } - - return baseDisplayMessages.map((message, index) => { - if (index !== targetAssistantIndex) { - return message; - } - return { - ...message, - info: { - ...(message.info as unknown as Record), - error: retryError, - } as unknown as Message, - }; - }); - } - - const eventTime = typeof activeRetryConfirmedAt === 'number' ? activeRetryConfirmedAt : fallbackRetryTimestamp; - const syntheticId = `synthetic_retry_notice_${activeRetrySessionId}`; - const synthetic: ChatMessageEntry = { - info: { - id: syntheticId, - sessionID: activeRetrySessionId, - role: 'assistant', - time: { created: eventTime, completed: eventTime }, - finish: 'stop', - error: retryError, - } as unknown as Message, - parts: [], - }; - - const next = baseDisplayMessages.slice(); - next.splice(lastUserIndex + 1, 0, synthetic); - return next; + confirmedAt: activeRetryConfirmedAt, + fallbackTimestamp: fallbackRetryTimestamp, + }); }, [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]); - const turns = React.useMemo(() => detectTurns(displayMessages), [displayMessages]); + const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, { + showTextJustificationActivity: chatRenderMode === 'sorted', + }); + const turns = React.useMemo(() => { + if (!streamingTurn) { + return staticTurns; + } + return [...staticTurns, streamingTurn]; + }, [staticTurns, streamingTurn]); const renderEntries = React.useMemo(() => { - const entries: RenderEntry[] = []; - const turnByUserId = new Map(); - const groupedAssistantIds = new Set(); - const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null; - const lastTurnId = lastTurn?.turnId ?? null; - const lastTurnMessageIds = new Set(); - if (lastTurn) { - lastTurnMessageIds.add(lastTurn.userMessage.info.id); - lastTurn.assistantMessages.forEach((assistantMessage: ChatMessageEntry) => { - lastTurnMessageIds.add(assistantMessage.info.id); - }); + const turnEntries = turns.map((turn) => ({ + kind: 'turn' as const, + key: `turn:${turn.turnId}`, + turn, + isLastTurn: turn.turnId === projection.lastTurnId, + })); + + if (projection.ungroupedMessageIds.size === 0) { + return turnEntries; } - turns.forEach((turn: Turn) => { - turnByUserId.set(turn.userMessage.info.id, turn); - turn.assistantMessages.forEach((assistantMessage: ChatMessageEntry) => { - groupedAssistantIds.add(assistantMessage.info.id); - }); + const turnEntryByUserMessageId = new Map(); + turnEntries.forEach((entry) => { + turnEntryByUserMessageId.set(entry.turn.userMessage.info.id, entry); }); - 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, - }); + const orderedEntries: RenderEntry[] = []; + displayMessages.forEach((message, index) => { + const turnEntry = turnEntryByUserMessageId.get(message.info.id); + if (turnEntry) { + orderedEntries.push(turnEntry); return; } - if (groupedAssistantIds.has(message.info.id)) { + if (!projection.ungroupedMessageIds.has(message.info.id)) { return; } - entries.push({ + orderedEntries.push({ kind: 'ungrouped', key: `msg:${message.info.id}`, message, - isInLastTurn: lastTurnMessageIds.has(message.info.id), + previousMessage: index > 0 ? displayMessages[index - 1] : undefined, + nextMessage: index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined, }); }); - return entries; - }, [displayMessages, turns]); + return orderedEntries; + }, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, turns]); - const shouldVirtualize = Boolean(scrollContainer) && renderEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD; + const staging = useStageTurns({ + sessionKey, + turnStart, + totalTurns: renderEntries.length, + disabled: disableStaging, + }); + + const stagedEntries = React.useMemo(() => { + if (staging.stageStartIndex <= 0) { + return renderEntries; + } + return renderEntries.slice(staging.stageStartIndex); + }, [renderEntries, staging.stageStartIndex]); + + const currentUserOrder = React.useMemo(() => { + return messages + .filter((message) => resolveMessageRole(message) === 'user') + .map((message) => message.info.id); + }, [messages]); + + // Detect new user messages SYNCHRONOUSLY during render. + // Must happen during render (not in useEffect) so that ToolRevealOnMount + // receives animate=true on the FIRST render of the new message, + // starting it hidden (opacity 0). An effect-based approach causes + // the message to flash visible before the animation starts. + { + const anim = userAnimationRef.current; + + // Reset on session switch + if (anim.sessionKey !== sessionKey) { + anim.sessionKey = sessionKey; + anim.previousOrder = currentUserOrder; + anim.animatedIds = new Set(); + } + + // Detect appended user messages + const prev = anim.previousOrder; + if (currentUserOrder.length > prev.length) { + const isAppendOnly = prev.every((id, i) => currentUserOrder[i] === id); + if (isAppendOnly && hasPendingUserSendAnimation(sessionKey)) { + for (let i = prev.length; i < currentUserOrder.length; i += 1) { + const id = currentUserOrder[i]; + if (id && !anim.animatedIds.has(id)) { + if (!consumePendingUserSendAnimation(sessionKey)) break; + anim.animatedIds.add(id); + } + } + } + } + anim.previousOrder = currentUserOrder; + } + + const shouldAnimateUserMessage = React.useCallback((message: ChatMessageEntry): boolean => { + if (resolveMessageRole(message) !== 'user') return false; + return userAnimationRef.current.animatedIds.has(message.info.id); + }, []); + + const onUserAnimationConsumed = React.useCallback(() => { + // Animation plays once via ToolRevealOnMount; no cleanup needed. + // The ref-based animatedIds set is reset on session switch. + }, []); + + const shouldVirtualize = Boolean(resolveScrollContainer()) && stagedEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD; const estimateEntrySize = React.useCallback( (index: number): number => { - const entry = renderEntries[index]; + const entry = stagedEntries[index]; if (!entry) { - return 300; + return 220; } if (entry.kind === 'turn') { const assistantCount = entry.turn.assistantMessages.length; - return Math.min(3600, 140 + assistantCount * 260); + return Math.min( + TURN_ESTIMATE_MAX_PX, + TURN_ESTIMATE_BASE_PX + assistantCount * TURN_ESTIMATE_PER_ASSISTANT_PX, + ); } const role = resolveMessageRole(entry.message); - return role === 'user' ? 120 : 280; + return role === 'user' ? 100 : 220; }, - [renderEntries] + [stagedEntries] ); const virtualizer = useMessageListVirtualizer({ - count: renderEntries.length, - getScrollElement: () => scrollContainer, + count: stagedEntries.length, + getScrollElement: resolveScrollContainer, estimateSize: estimateEntrySize, overscan: isMobile ? MESSAGE_VIRTUAL_OVERSCAN_MOBILE : MESSAGE_VIRTUAL_OVERSCAN_DESKTOP, - getItemKey: (index: number) => renderEntries[index]?.key ?? index, + getItemKey: (index: number) => stagedEntries[index]?.key ?? index, enabled: shouldVirtualize, useFlushSync: false, }); const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems() : []; + const lastNonEmptyVirtualRowsRef = React.useRef([]); + if (shouldVirtualize && virtualRows.length > 0) { + lastNonEmptyVirtualRowsRef.current = virtualRows; + } + + const effectiveVirtualRows = shouldVirtualize + ? (virtualRows.length > 0 ? virtualRows : lastNonEmptyVirtualRowsRef.current) + : []; + + const renderVirtualized = shouldVirtualize && effectiveVirtualRows.length > 0; const scrollVirtualizerToIndex = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => { if (!virtualizer) { @@ -833,7 +1120,7 @@ const MessageList = React.forwardRef(({ const messageIndexMap = React.useMemo(() => { const indexMap = new Map(); - renderEntries.forEach((entry, index) => { + stagedEntries.forEach((entry, index) => { if (entry.kind === 'ungrouped') { indexMap.set(entry.message.info.id, index); return; @@ -845,18 +1132,28 @@ const MessageList = React.forwardRef(({ }); return indexMap; - }, [renderEntries]); + }, [stagedEntries]); + + const turnIndexMap = React.useMemo(() => { + const indexMap = new Map(); + stagedEntries.forEach((entry, index) => { + if (entry.kind === 'turn') { + indexMap.set(entry.turn.turnId, index); + } + }); + return indexMap; + }, [stagedEntries]); const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => { - const container = scrollContainer; + const container = resolveScrollContainer(); if (!container) { return null; } return container.querySelector(`[data-message-id="${messageId}"]`); - }, [scrollContainer]); + }, [resolveScrollContainer]); const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { - const container = scrollContainer; + const container = resolveScrollContainer(); if (!container) { return false; } @@ -871,7 +1168,7 @@ const MessageList = React.forwardRef(({ const top = messageRect.top - containerRect.top + container.scrollTop - offset; container.scrollTo({ top, behavior }); return true; - }, [findMessageElement, scrollContainer]); + }, [findMessageElement, resolveScrollContainer]); React.useLayoutEffect(() => { if (!ref) { @@ -879,6 +1176,42 @@ const MessageList = React.forwardRef(({ } const handle: MessageListHandle = { + scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior }) => { + const behavior = options?.behavior ?? 'auto'; + const index = turnIndexMap.get(turnId); + if (index === undefined) { + return false; + } + + if (shouldVirtualize) { + scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior); + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + const container = resolveScrollContainer(); + if (!container) { + return; + } + const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); + if (turnElement) { + turnElement.scrollIntoView({ behavior, block: 'start' }); + } + }); + } + return true; + } + + const container = resolveScrollContainer(); + if (!container) { + return false; + } + const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); + if (!turnElement) { + return false; + } + turnElement.scrollIntoView({ behavior, block: 'start' }); + return true; + }, + scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => { const behavior = options?.behavior ?? 'auto'; const index = messageIndexMap.get(messageId); @@ -888,12 +1221,21 @@ const MessageList = React.forwardRef(({ if (shouldVirtualize) { scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior); + if (scrollMessageElementIntoView(messageId, behavior)) { + return true; + } if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - scrollMessageElementIntoView(messageId, behavior); - }); - }); + let attempts = 0; + const retry = () => { + attempts += 1; + if (scrollMessageElementIntoView(messageId, behavior)) { + return; + } + if (attempts < 3) { + window.requestAnimationFrame(retry); + } + }; + window.requestAnimationFrame(retry); } return true; } @@ -902,13 +1244,13 @@ const MessageList = React.forwardRef(({ }, captureViewportAnchor: () => { - const container = scrollContainer; + const container = resolveScrollContainer(); if (!container) { return null; } const containerRect = container.getBoundingClientRect(); - const nodes = Array.from(container.querySelectorAll('[data-message-id]')); + const nodes: HTMLElement[] = Array.from(container.querySelectorAll('[data-message-id]')); const firstVisible = nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); if (!firstVisible) { return null; @@ -926,7 +1268,7 @@ const MessageList = React.forwardRef(({ }, restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => { - const container = scrollContainer; + const container = resolveScrollContainer(); if (!container) { return false; } @@ -940,21 +1282,36 @@ const MessageList = React.forwardRef(({ scrollVirtualizerToIndex(index, 'auto'); } + const applyAnchor = (): boolean => { + const element = findMessageElement(anchor.messageId); + if (!element) { + return false; + } + const containerRect = container.getBoundingClientRect(); + const targetTop = element.getBoundingClientRect().top - containerRect.top; + const delta = targetTop - anchor.offsetTop; + if (delta !== 0) { + container.scrollTop += delta; + } + return true; + }; + + if (applyAnchor()) { + return true; + } + 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; - } - }); - }); + let attempts = 0; + const retry = () => { + attempts += 1; + if (applyAnchor()) { + return; + } + if (attempts < 3) { + window.requestAnimationFrame(retry); + } + }; + window.requestAnimationFrame(retry); } return true; @@ -973,26 +1330,13 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, scrollContainer, scrollVirtualizerToIndex, shouldVirtualize, ref]); + }, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, scrollVirtualizerToIndex, shouldVirtualize, turnIndexMap, ref]); - const disableFadeIn = shouldVirtualize && virtualizer.isScrolling; + const disableFadeIn = isLoadingOlder || (renderVirtualized && virtualizer.isScrolling); return ( - -
- {hasRenderEarlier && ( -
- -
- )} - - {hasMoreAbove && ( +
+ {(turnStart > 0 || hasMoreAbove) && (
{isLoadingOlder ? ( @@ -1001,7 +1345,7 @@ const MessageList = React.forwardRef(({ ) : (
)} + {staging.isStaging ? ( +
+ + Revealing history… + +
+ ) : null} + - {shouldVirtualize ? ( + {renderVirtualized ? (
- {virtualRows.map((virtualRow: VirtualItem) => { - const entry = renderEntries[virtualRow.index]; + {effectiveVirtualRows.map((virtualRow: VirtualItem) => { + const entry = stagedEntries[virtualRow.index]; if (!entry) { return null; } @@ -1032,23 +1384,39 @@ const MessageList = React.forwardRef(({ >
); })}
) : ( +
+
)} @@ -1063,10 +1431,24 @@ const MessageList = React.forwardRef(({
)} +
+ +
+ {/* Bottom spacer */} - -
+ ); }); diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 60d130c9..bf673941 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -14,6 +14,7 @@ import { useSessionStore } from "@/stores/useSessionStore"; import { useUIStore } from "@/stores/useUIStore"; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; import { isVSCodeRuntime } from "@/lib/desktop"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; const statusConfig: Record = { in_progress: { @@ -42,6 +43,19 @@ const priorityIcon: Record = { low:
)} - {showSummaryBody && ( - -
- - {shouldShowFooter && ( -
-
- {footerButtons} -
-
- {turnDurationText ? ( - - - {turnDurationText} - - ) : null} - {footerTimestamp ? ( - - - {footerTimestamp} - - ) : null} -
-
- )} -
-
- )}
- {!showSummaryBody && shouldShowFooter && ( + {shouldShowFooter && (
{footerButtons} diff --git a/packages/ui/src/components/chat/message/MessageHeader.tsx b/packages/ui/src/components/chat/message/MessageHeader.tsx index 16731214..80ecd822 100644 --- a/packages/ui/src/components/chat/message/MessageHeader.tsx +++ b/packages/ui/src/components/chat/message/MessageHeader.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { RiAiAgentLine, RiBrainAi3Line, RiUser3Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { getAgentColor } from '@/lib/agentColors'; -import { FadeInOnReveal } from './FadeInOnReveal'; import { useProviderLogo } from '@/hooks/useProviderLogo'; interface MessageHeaderProps { @@ -18,84 +17,82 @@ const MessageHeader: React.FC = ({ isUser, providerID, agent const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID); return ( - -
-
+
+
+
+
+ {isUser ? ( +
+ +
+ ) : ( +
+ {hasLogo && logoSrc ? ( + {`${providerID} + ) : ( + + )} +
+ )} +
-
- {isUser ? ( -
- -
- ) : ( -
- {hasLogo && logoSrc ? ( - {`${providerID} - ) : ( - - )} -
+

-
-

+ {isUser ? 'You' : (modelName || 'Assistant')} +

+ {!isUser && agentName && ( +
- {isUser ? 'You' : (modelName || 'Assistant')} -

- {!isUser && agentName && ( -
- - {agentName} -
- )} - {!isUser && variant && ( -
- - {variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant} -
- )} -
+ + {agentName} +
+ )} + {!isUser && variant && ( +
+ + {variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant} +
+ )}
- +
); }; diff --git a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts new file mode 100644 index 00000000..e62f0129 --- /dev/null +++ b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts @@ -0,0 +1,33 @@ +import type { Part } from '@opencode-ai/sdk/v2'; + +const shouldKeepSyntheticUserText = (text: string): boolean => { + const trimmed = text.trim(); + if (trimmed.startsWith('User has requested to enter plan mode')) return true; + if (trimmed.startsWith('The plan at ')) return true; + if (trimmed.startsWith('The following tool was executed by the user')) return true; + return false; +}; + +export const normalizeUserDisplayParts = (parts: Part[]): Part[] => { + return parts + .filter((part) => { + const synthetic = (part as { synthetic?: boolean }).synthetic === true; + if (!synthetic) return true; + if (part.type !== 'text') return false; + const text = (part as { text?: unknown }).text; + return typeof text === 'string' ? shouldKeepSyntheticUserText(text) : false; + }) + .map((part) => { + const rawPart = part as Record; + if (rawPart.type === 'compaction') { + return { type: 'text', text: '/compact' } as Part; + } + if (rawPart.type === 'text') { + const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : ''; + if (text.startsWith('The following tool was executed by the user')) { + return { type: 'text', text: '/shell' } as Part; + } + } + return part; + }); +}; diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index d589007a..db0e9334 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -3,7 +3,8 @@ import type { Part } from '@opencode-ai/sdk/v2'; import { MarkdownRenderer } from '../../MarkdownRenderer'; import type { StreamPhase } from '../types'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; -import { ReasoningTimelineBlock, formatReasoningText } from './ReasoningPart'; +import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; +import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } }; @@ -11,69 +12,91 @@ interface AssistantTextPartProps { part: Part; messageId: string; streamPhase: StreamPhase; - allowAnimation: boolean; + chatRenderMode?: 'sorted' | 'live'; onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void; - renderAsReasoning?: boolean; } const AssistantTextPart: React.FC = ({ part, messageId, streamPhase, - allowAnimation, - onContentChange, - renderAsReasoning = false, + chatRenderMode = 'live', }) => { const partWithText = part as PartWithText; - const rawText = partWithText.text; - const baseTextContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; - const textContent = React.useMemo(() => { - if (renderAsReasoning) { - return formatReasoningText(baseTextContent); - } - return baseTextContent; - }, [baseTextContent, renderAsReasoning]); + const rawText = typeof partWithText.text === 'string' ? partWithText.text : ''; + const contentText = typeof partWithText.content === 'string' ? partWithText.content : ''; + const valueText = typeof partWithText.value === 'string' ? partWithText.value : ''; + const textContent = [rawText, contentText, valueText].reduce((best, candidate) => { + return candidate.length > best.length ? candidate : best; + }, ''); const isStreamingPhase = streamPhase === 'streaming'; const isCooldownPhase = streamPhase === 'cooldown'; - const wasStreamingRef = React.useRef(isStreamingPhase); + const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase); - if (isStreamingPhase || isCooldownPhase) { - wasStreamingRef.current = true; - return null; - } + const throttledTextContent = useStreamingTextThrottle({ + text: textContent, + isStreaming, + identityKey: `${messageId}:${part.id ?? 'text'}`, + }); + + const displayTextContent = resolveAssistantDisplayText({ + textContent, + throttledTextContent, + isStreaming, + }); + + const lastDisplayLengthRef = React.useRef(0); + React.useEffect(() => { + if (!isStreaming || typeof window === 'undefined') { + lastDisplayLengthRef.current = displayTextContent.length; + return; + } + const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1'; + if (!debugEnabled) { + lastDisplayLengthRef.current = displayTextContent.length; + return; + } + if (displayTextContent.length < lastDisplayLengthRef.current) { + console.info('[STREAM-TRACE] render_shrink', { + messageId, + partId: part.id, + rawTextLen: rawText.length, + contentLen: contentText.length, + valueLen: valueText.length, + chosenLen: textContent.length, + throttledLen: throttledTextContent.length, + displayLen: displayTextContent.length, + prevDisplayLen: lastDisplayLengthRef.current, + }); + } + lastDisplayLengthRef.current = displayTextContent.length; + }, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]); const time = partWithText.time; - const isFinalized = time && typeof time.end !== 'undefined'; + const isFinalized = Boolean(time && typeof time.end !== 'undefined'); - if (!isFinalized && (!textContent || textContent.trim().length === 0)) { + const isRenderableTextPart = part.type === 'text' || part.type === 'reasoning'; + if (!isRenderableTextPart) { return null; } - if (!textContent || textContent.trim().length === 0) { + if (!shouldRenderAssistantText({ + displayTextContent, + isFinalized, + })) { return null; } - if (renderAsReasoning) { - return ( - - ); - } - return (
); diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md new file mode 100644 index 00000000..9bda2915 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -0,0 +1,87 @@ +# Chat Message Parts: Rendering Architecture + +This folder contains renderers for chat message parts (text, tools, reasoning, placeholders) and shared tool presentation helpers. + +Use this doc when you ask an agent to change tool/header/description behavior. + +## High-level flow + +- Message parts are rendered from `MessageBody.tsx`. +- There are two tool rendering paths: + - **Static grouped tools** -> `StaticToolRow` in `ProgressiveGroup.tsx` + - **Expandable tools** -> `ToolPart.tsx` +- Shared tool icon mapping is centralized in `toolPresentation.tsx` (`getToolIcon`). + +## Which file controls what + +- `ProgressiveGroup.tsx` + - Renders grouped Activity rows and grouped static tools. + - Contains `StaticToolRow`. + - Contains static tool short description logic (`getToolShortDescription`). + - If you want to change how `read/grep/perplexity/webfetch/...` look in compact/grouped mode, edit here. + +- `ToolPart.tsx` + - Renders expandable tool rows (bash/edit/write/question/task + fallback). + - Controls expandable header title/description/diff stats/timer and expanded output body. + - If you want to change expandable tool layout, edit here. + +- `toolPresentation.tsx` + - Shared icon mapping for tool names (`getToolIcon`). + - Used by both `ProgressiveGroup.tsx` and `ToolPart.tsx`. + +- `toolRenderUtils.ts` + - Core classification helpers: + - `isExpandableTool` + - `isStaticTool` + - `isStandaloneTool` + - `getStaticGroupToolName` + - If a tool should switch between static vs expandable, change it here. + +- `ReasoningPart.tsx` + - Thinking block UI (`ReasoningTimelineBlock`), summary + optional duration. + +- `JustificationBlock.tsx` + - Justification block wrapper over `ReasoningTimelineBlock`. + +## Current important behavior + +- `read` and most search/fetch tools are treated as **static tools** and usually render via `StaticToolRow`. +- `bash/edit/write/question/task` are **expandable tools** and render via `ToolPart`. +- `perplexity` is currently treated as static and grouped into search/web-search style rows (through static grouping + short description extraction). +- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). + +## "I want to change description for Perplexity" (example recipe) + +If task is: "change text shown near Perplexity tool header/description": + +1. Edit `ProgressiveGroup.tsx` -> `getToolShortDescription(activity)`. +2. Update the branch that handles web-search tools (`websearch`, `web-search`, `search_web`, `codesearch`, `perplexity`, etc.). +3. If needed, update group rendering in `StaticToolRow` (search/fetch specific rendering branches). +4. Keep icon changes (if any) in `toolPresentation.tsx`. + +Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the primary path. + +## "I want tool to become expandable" (example) + +1. Update `toolRenderUtils.ts`: + - add/remove tool name in `EXPANDABLE_TOOL_NAMES` +2. Ensure `ToolPart.tsx` supports desired header + expanded output format for that tool. +3. Validate both modes (`sorted` and `live`). + +## Safe editing checklist + +- Do not duplicate icon logic; keep it in `toolPresentation.tsx`. +- For static tool copy changes, prefer `ProgressiveGroup.tsx` first. +- For expanded output changes, edit `ToolPart.tsx`. +- After edits run: + - `bun run type-check` + - `bun run lint` + - `bun run build` + +## Quick map of files in this folder + +- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx` +- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx` +- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx` +- Status/placeholders: `WorkingPlaceholder.tsx`, `GenericStatusSpinner.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx` +- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx` diff --git a/packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx b/packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx new file mode 100644 index 00000000..50178c7f --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +/** + * Starfield Twinkle — a 4×4 grid of tiny dots that flicker + * like stars in a night sky. Each dot has its own random phase + * and duration so the pattern never looks mechanical. + * + * Corners are hidden (same as original) to soften the grid shape. + */ + +const COLS = 4; +const ROWS = 4; +const SPACING = 3.2; // viewBox units between centers +const OFFSET = 2.7; // center the grid in 15×15 +const DOT_R = 0.7; // small dot radius — star-like + +const cornerIndices = new Set([0, 3, 12, 15]); + +const stars = Array.from({ length: COLS * ROWS }, (_, i) => ({ + id: i, + cx: (i % COLS) * SPACING + OFFSET, + cy: Math.floor(i / COLS) * SPACING + OFFSET, + isCorner: cornerIndices.has(i), + // Each star gets its own rhythm — varying duration + delay + duration: 2.4 + Math.random() * 2.4, + delay: Math.random() * 3.5, +})); + +export function GenericStatusSpinner({ className }: { className?: string }) { + return ( + + ); +} diff --git a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx index 9d340514..f7c05b04 100644 --- a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; +import { useUIStore } from '@/stores/useUIStore'; import { ReasoningTimelineBlock } from './ReasoningPart'; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; @@ -29,6 +30,7 @@ const JustificationBlock: React.FC = ({ messageId, onContentChange, }) => { + const chatRenderMode = useUIStore((state) => state.chatRenderMode); const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]); @@ -46,6 +48,7 @@ const JustificationBlock: React.FC = ({ onContentChange={onContentChange} blockId={part.id || `${messageId}-justification`} time={time} + showDuration={chatRenderMode !== 'sorted'} /> ); }; diff --git a/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx b/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx new file mode 100644 index 00000000..122e4c2d --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Text } from '@/components/ui/text'; + +const MAX_SHINE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap + +interface MinDurationShineTextProps { + active: boolean; + minDurationMs?: number; + className?: string; + children: React.ReactNode; + style?: React.CSSProperties; + title?: string; +} + +export const MinDurationShineText: React.FC = ({ + active, + minDurationMs = 300, + className, + children, + style, + title, +}) => { + // Once active, we latch shine on and only turn it off after active becomes + // false AND minDurationMs has elapsed since we first started shining. + // All bookkeeping lives in refs so intermediate re-renders (children + // changing, props updating) can never cause a flicker. + const shineStartRef = React.useRef(active ? Date.now() : null); + const [isShining, setIsShining] = React.useState(active); + const timerRef = React.useRef | null>(null); + + // Latch on: if active becomes true, start shining immediately. + if (active && shineStartRef.current === null) { + shineStartRef.current = Date.now(); + } + if (active && !isShining) { + // Synchronous state set during render is fine for a latch-on — React + // will coalesce it with the current render pass. + // But we can't call setState during render, so we use an effect below. + } + + React.useEffect(() => { + if (active) { + // Cancel any pending off-timer. + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (shineStartRef.current === null) { + shineStartRef.current = Date.now(); + } + + // Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools + const elapsed = Date.now() - shineStartRef.current; + if (elapsed >= MAX_SHINE_DURATION_MS) { + setIsShining(false); + shineStartRef.current = null; + return; + } + + setIsShining(true); + return; + } + + if (!isShining) { + shineStartRef.current = null; + return; + } + + // active went false — schedule turn-off respecting minDurationMs. + const startedAt = shineStartRef.current ?? Date.now(); + const elapsed = Date.now() - startedAt; + + // Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools + if (elapsed >= MAX_SHINE_DURATION_MS) { + setIsShining(false); + shineStartRef.current = null; + return; + } + + const remaining = Math.max(0, minDurationMs - elapsed); + + timerRef.current = setTimeout(() => { + setIsShining(false); + shineStartRef.current = null; + timerRef.current = null; + }, remaining); + + return () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [active, minDurationMs, isShining]); + + if (isShining) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 8a0f49f2..4816e8f0 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -1,16 +1,25 @@ import React from 'react'; -import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react'; +import { RiStackLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; -import type { TurnActivityPart } from '../../hooks/useTurnGrouping'; +import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; +import type { StreamPhase } from '../types'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import type { ToolPopupContent } from '../types'; import ToolPart from './ToolPart'; +import { MinDurationShineText } from './MinDurationShineText'; +import { ToolRevealOnMount } from './ToolRevealOnMount'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { Text } from '@/components/ui/text'; +import { FadeInOnReveal } from '../FadeInOnReveal'; +import { getToolIcon } from './toolPresentation'; +import { getToolMetadata } from '@/lib/toolHelpers'; +import { getStaticGroupToolName, isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUtils'; +import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useUIStore } from '@/stores/useUIStore'; import ReasoningPart from './ReasoningPart'; import JustificationBlock from './JustificationBlock'; -import { FadeInOnReveal } from '../FadeInOnReveal'; - -const MAX_VISIBLE_COLLAPSED = 6; interface DiffStats { additions: number; @@ -21,6 +30,7 @@ interface DiffStats { interface ProgressiveGroupProps { parts: TurnActivityPart[]; isExpanded: boolean; + collapsedPreviewCount?: number; onToggle: () => void; syntaxTheme: Record; isMobile: boolean; @@ -28,42 +38,671 @@ interface ProgressiveGroupProps { onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; onContentChange?: (reason?: ContentChangeReason) => void; + streamPhase: StreamPhase; + showHeader: boolean; + animateRows?: boolean; + animatedToolIds?: Set; diffStats?: DiffStats; } -const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => { - return [...parts].sort((a, b) => { - const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined; - const bTime = typeof b.endedAt === 'number' ? b.endedAt : undefined; +const EDIT_LIKE_TOOL_NAMES = new Set([ + 'edit', + 'multiedit', + 'apply_patch', + 'str_replace', + 'str_replace_based_edit_tool', +]); - if (aTime === undefined && bTime === undefined) return 0; - if (aTime === undefined) return 1; - if (bTime === undefined) return -1; - - return aTime - bTime; - }); +const isEditLikeTool = (toolName: unknown): boolean => { + return typeof toolName === 'string' && EDIT_LIKE_TOOL_NAMES.has(toolName.toLowerCase()); }; -const getToolConnections = ( - parts: TurnActivityPart[] -): Record => { - const connections: Record = {}; - const toolParts = parts.filter((p) => p.kind === 'tool'); +const parseDiffCounts = (diffText: string): { added: number; removed: number } => { + const lines = diffText.split('\n'); + let added = 0; + let removed = 0; - toolParts.forEach((activity, index) => { - const partId = activity.id; - connections[partId] = { - hasPrev: index > 0, - hasNext: index < toolParts.length - 1, - }; - }); + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) added += 1; + if (line.startsWith('-') && !line.startsWith('---')) removed += 1; + } - return connections; + return { added, removed }; +}; + +type FileDiffAggregate = { filePath: string; added: number; removed: number }; + +const aggregateFileDiffs = (parts: TurnActivityPart[]): FileDiffAggregate[] => { + const byPath = new Map(); + + const addToPath = (filePath: string, added: number, removed: number) => { + if (!filePath) return; + const current = byPath.get(filePath) ?? { added: 0, removed: 0 }; + current.added += Math.max(0, added); + current.removed += Math.max(0, removed); + byPath.set(filePath, current); + }; + + for (const activity of parts) { + if (activity.kind !== 'tool') continue; + const toolPart = activity.part as ToolPartType; + if (!isEditLikeTool(toolPart.tool)) continue; + + const state = toolPart.state as { metadata?: Record; input?: Record } | undefined; + const metadata = state?.metadata; + const input = state?.input; + const files = Array.isArray(metadata?.files) ? metadata?.files : []; + + if (files.length > 0) { + for (const file of files) { + if (!file || typeof file !== 'object') continue; + const record = file as { + relativePath?: unknown; + filePath?: unknown; + path?: unknown; + additions?: unknown; + deletions?: unknown; + diff?: unknown; + }; + + const filePath = + (typeof record.relativePath === 'string' && record.relativePath) || + (typeof record.filePath === 'string' && record.filePath) || + (typeof record.path === 'string' && record.path) || + ''; + + const explicitAdditions = typeof record.additions === 'number' ? record.additions : null; + const explicitDeletions = typeof record.deletions === 'number' ? record.deletions : null; + + if (explicitAdditions !== null || explicitDeletions !== null) { + addToPath(filePath, explicitAdditions ?? 0, explicitDeletions ?? 0); + continue; + } + + if (typeof record.diff === 'string' && record.diff.trim().length > 0) { + const counts = parseDiffCounts(record.diff); + addToPath(filePath, counts.added, counts.removed); + } + } + continue; + } + + const fallbackPath = + (typeof input?.filePath === 'string' && input.filePath) || + (typeof input?.file_path === 'string' && input.file_path) || + (typeof input?.path === 'string' && input.path) || + ''; + + if (typeof metadata?.diff === 'string' && metadata.diff.trim().length > 0) { + const counts = parseDiffCounts(metadata.diff); + addToPath(fallbackPath || 'Diff', counts.added, counts.removed); + } + } + + return Array.from(byPath.entries()) + .map(([filePath, counts]) => ({ filePath, added: counts.added, removed: counts.removed })) + .filter((entry) => entry.added > 0 || entry.removed > 0) + .sort((a, b) => { + const aChanges = a.added + a.removed; + const bChanges = b.added + b.removed; + if (aChanges !== bChanges) return bChanges - aChanges; + return a.filePath.localeCompare(b.filePath); + }); +}; + +const toDisplayFileName = (filePath: string): string => { + const normalized = filePath.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + if (segments.length === 0) return normalized; + return segments[segments.length - 1]; +}; + +const isActivityRunning = (activity: TurnActivityPart): boolean => { + if (activity.kind !== 'tool') return false; + const part = activity.part as ToolPartType; + const status = (part.state?.status as string) || undefined; + const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; + if (isFinalized) { + return false; + } + if (status === 'running' || status === 'pending' || status === 'started') { + return true; + } + return typeof activity.endedAt !== 'number'; +}; + +/** + * Parts arrive in correct chronological order: + * messages in sequence, parts within each message in their natural LLM + * production order. No re-sorting needed — time-based sorting breaks this + * because text parts get time.end = message completion time (later than + * tools), pushing text after tools within the same message. + */ +const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => parts; + +/** + * Extract a short filename from a tool part's input (for aggregation display). + */ +const getToolFileName = (activity: TurnActivityPart): string | null => { + const part = activity.part as ToolPartType; + const state = part.state as { input?: Record; metadata?: Record } | undefined; + const input = state?.input; + const metadata = state?.metadata; + + const filePath = + (input?.filePath as string) || + (input?.file_path as string) || + (input?.path as string) || + (metadata?.filePath as string) || + (metadata?.file_path as string) || + (metadata?.path as string); + + if (typeof filePath === 'string' && filePath.trim().length > 0) { + const lastSlash = filePath.lastIndexOf('/'); + return lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath; + } + + return null; +}; + +const getToolFilePath = (activity: TurnActivityPart): string | null => { + const part = activity.part as ToolPartType; + const state = part.state as { input?: Record; metadata?: Record } | undefined; + const input = state?.input; + const metadata = state?.metadata; + + const filePath = + (input?.filePath as string) || + (input?.file_path as string) || + (input?.path as string) || + (metadata?.filePath as string) || + (metadata?.file_path as string) || + (metadata?.path as string); + + return typeof filePath === 'string' && filePath.trim().length > 0 ? filePath : null; +}; + +const toTodoStatusKey = (value: unknown): 'pending' | 'in_progress' | 'completed' | 'cancelled' | null => { + if (typeof value !== 'string') { + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === 'pending') return 'pending'; + if (normalized === 'in_progress' || normalized === 'in progress' || normalized === 'inprogress') return 'in_progress'; + if (normalized === 'completed' || normalized === 'done') return 'completed'; + if (normalized === 'cancelled' || normalized === 'canceled') return 'cancelled'; + return null; +}; + +const formatTodoSummary = (todos: unknown[]): string | null => { + if (todos.length === 0) { + return null; + } + + let pending = 0; + let inProgress = 0; + let completed = 0; + let cancelled = 0; + + for (const todo of todos) { + if (!todo || typeof todo !== 'object') { + continue; + } + const status = toTodoStatusKey((todo as { status?: unknown }).status); + if (!status) { + continue; + } + if (status === 'pending') pending += 1; + if (status === 'in_progress') inProgress += 1; + if (status === 'completed') completed += 1; + if (status === 'cancelled') cancelled += 1; + } + + const total = pending + inProgress + completed + cancelled; + if (total === 0) { + return null; + } + + if (completed === total) { + return `All ${total} tasks done`; + } + + const parts: string[] = []; + if (inProgress > 0) parts.push(`${inProgress} in progress`); + if (pending > 0) parts.push(`${pending} pending`); + if (completed > 0) parts.push(`${completed} done`); + if (cancelled > 0) parts.push(`${cancelled} cancelled`); + + return parts.length > 0 ? parts.join(', ') : null; +}; + +const getTodoSummaryFromActivity = (activity: TurnActivityPart): string | null => { + const part = activity.part as ToolPartType; + const state = part.state as { input?: Record; output?: unknown } | undefined; + const input = state?.input; + const output = state?.output; + + if (Array.isArray(input?.todos)) { + const summary = formatTodoSummary(input.todos); + if (summary) return summary; + } + + if (Array.isArray(output)) { + const summary = formatTodoSummary(output); + if (summary) return summary; + } + + if (output && typeof output === 'object' && Array.isArray((output as { todos?: unknown }).todos)) { + const summary = formatTodoSummary((output as { todos: unknown[] }).todos); + if (summary) return summary; + } + + if (typeof output === 'string' && output.trim().length > 0) { + try { + const parsed = JSON.parse(output) as unknown; + if (Array.isArray(parsed)) { + const summary = formatTodoSummary(parsed); + if (summary) return summary; + } + if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { todos?: unknown }).todos)) { + const summary = formatTodoSummary((parsed as { todos: unknown[] }).todos); + if (summary) return summary; + } + } catch { + // Ignore non-JSON output. + } + } + + return null; +}; + +const getToolReadOffset = (activity: TurnActivityPart): number | undefined => { + const part = activity.part as ToolPartType; + const state = part.state as { input?: Record; metadata?: Record } | undefined; + const input = state?.input; + const metadata = state?.metadata; + + const rawOffset = + (typeof input?.offset === 'number' && Number.isFinite(input.offset) ? input.offset : undefined) + ?? (typeof input?.line === 'number' && Number.isFinite(input.line) ? input.line : undefined) + ?? (typeof metadata?.offset === 'number' && Number.isFinite(metadata.offset) ? metadata.offset : undefined) + ?? (typeof metadata?.line === 'number' && Number.isFinite(metadata.line) ? metadata.line : undefined); + + if (typeof rawOffset !== 'number' || rawOffset <= 0) { + return undefined; + } + + return Math.floor(rawOffset); +}; + +const normalizePathValue = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + return trimmed.replace(/\\/g, '/'); +}; + +const resolveAbsolutePath = (currentDirectory: string, filePath: string): string => { + const normalizedPath = normalizePathValue(filePath); + if (!normalizedPath) { + return ''; + } + if (normalizedPath.startsWith('/')) { + return normalizedPath; + } + const normalizedDirectory = normalizePathValue(currentDirectory); + if (!normalizedDirectory) { + return normalizedPath; + } + return normalizedDirectory.endsWith('/') ? `${normalizedDirectory}${normalizedPath}` : `${normalizedDirectory}/${normalizedPath}`; +}; + +const getContextDirectoryForPath = (currentDirectory: string, absolutePath: string): string => { + const normalizedDirectory = normalizePathValue(currentDirectory); + if (normalizedDirectory) { + return normalizedDirectory; + } + + const normalizedPath = normalizePathValue(absolutePath); + if (!normalizedPath) { + return ''; + } + const parent = normalizedPath.replace(/\/[^/]*$/, ''); + return parent || normalizedPath; +}; + +/** + * Get a short description for a static tool (for aggregation display). + */ +const getToolShortDescription = (activity: TurnActivityPart): string | null => { + const part = activity.part as ToolPartType; + const toolName = part.tool?.toLowerCase() ?? ''; + const state = part.state as { input?: Record; metadata?: Record } | undefined; + const input = state?.input; + const metadata = state?.metadata; + + // For search tools, show pattern + if (toolName === 'grep' || toolName === 'search' || toolName === 'find' || toolName === 'ripgrep') { + const pattern = input?.pattern; + if (typeof pattern === 'string' && pattern.trim().length > 0) { + return pattern.length > 40 ? pattern.slice(0, 40) + '...' : pattern; + } + } + + // For glob, show pattern + if (toolName === 'glob') { + const pattern = input?.pattern; + if (typeof pattern === 'string' && pattern.trim().length > 0) { + return pattern.length > 40 ? pattern.slice(0, 40) + '...' : pattern; + } + } + + // For web search tools, show query + if (toolName === 'websearch' || toolName === 'web-search' || toolName === 'search_web' || toolName === 'codesearch' || toolName === 'perplexity') { + const query = input?.query; + if (typeof query === 'string' && query.trim().length > 0) { + return query.length > 50 ? query.slice(0, 50) + '...' : query; + } + } + + // For skill, show name + if (toolName === 'skill') { + const name = input?.name; + if (typeof name === 'string' && name.trim().length > 0) { + return name; + } + } + + // For fetch-url tools, show URL + if (toolName === 'webfetch' || toolName === 'fetch' || toolName === 'curl' || toolName === 'wget') { + const url = + (typeof input?.url === 'string' && input.url) || + (typeof input?.URL === 'string' && input.URL) || + (typeof metadata?.url === 'string' && metadata.url) || + (typeof metadata?.URL === 'string' && metadata.URL) || + ''; + + if (typeof url === 'string' && url.trim().length > 0) { + return url.trim(); + } + } + + // For todo tools, show status summary without task names + if (toolName === 'todowrite' || toolName === 'todoread') { + return getTodoSummaryFromActivity(activity); + } + + // Fallback: try filename + return getToolFileName(activity); +}; + +type AggregatedRow = + | { type: 'tool-expandable'; activity: TurnActivityPart } + | { type: 'tool-static-group'; toolName: string; activities: TurnActivityPart[] } + | { type: 'reasoning'; activity: TurnActivityPart } + | { type: 'justification'; activity: TurnActivityPart } + | { type: 'tool-fallback'; activity: TurnActivityPart }; + +/** + * Aggregate sorted activity parts into display rows. + * Consecutive static tools of the same type are merged into a single row. + * Reasoning/justification become inline text. + * Expandable tools (edit, bash, write, question) stay as individual rows. + * Unknown tools stay as individual expandable rows (fallback). + */ +const aggregateRows = (parts: TurnActivityPart[]): AggregatedRow[] => { + const rows: AggregatedRow[] = []; + + let i = 0; + while (i < parts.length) { + const activity = parts[i]; + + if (activity.kind === 'reasoning') { + rows.push({ type: 'reasoning', activity }); + i++; + continue; + } + + if (activity.kind === 'justification') { + rows.push({ type: 'justification', activity }); + i++; + continue; + } + + // Tool part + const toolPart = activity.part as ToolPartType; + const toolName = toolPart.tool?.toLowerCase() ?? ''; + + if (isStandaloneTool(toolName)) { + // Standalone tools are rendered separately, skip + i++; + continue; + } + + if (isExpandableTool(toolName)) { + rows.push({ type: 'tool-expandable', activity }); + i++; + continue; + } + + if (isStaticTool(toolName)) { + // Aggregate consecutive static tools of the same name + const groupedToolName = getStaticGroupToolName(toolName); + const group: TurnActivityPart[] = [activity]; + let j = i + 1; + while (j < parts.length) { + const next = parts[j]; + if (next.kind !== 'tool') break; + const nextTool = (next.part as ToolPartType).tool?.toLowerCase() ?? ''; + if (getStaticGroupToolName(nextTool) !== groupedToolName) break; + group.push(next); + j++; + } + rows.push({ type: 'tool-static-group', toolName: groupedToolName, activities: group }); + i = j; + continue; + } + + // Unknown/fallback tool — keep as expandable + rows.push({ type: 'tool-fallback', activity }); + i++; + } + + return rows; +}; + +/** + * Render a static aggregated tool row. + * Shows: [icon] DisplayName file1.tsx file2.tsx ... + */ +export const StaticToolRow: React.FC<{ + toolName: string; + activities: TurnActivityPart[]; + animateTailText: boolean; +}> = ({ toolName, activities, animateTailText }) => { + const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); + const displayName = getToolMetadata(toolName).displayName; + const icon = getToolIcon(toolName); + const isReadGroup = toolName.toLowerCase() === 'read'; + const runtime = React.useContext(RuntimeAPIContext); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]); + + const descriptions = React.useMemo(() => { + const descs: string[] = []; + for (const activity of activities) { + const desc = getToolShortDescription(activity); + if (desc && !descs.includes(desc)) { + descs.push(desc); + } + } + return descs; + }, [activities]); + + const readFileEntries = React.useMemo(() => { + if (!isReadGroup) return [] as Array<{ path: string; name: string; offset?: number }>; + + const entries: Array<{ path: string; name: string; offset?: number }> = []; + for (const activity of activities) { + const filePath = getToolFilePath(activity); + const fileName = getToolFileName(activity); + const offset = getToolReadOffset(activity); + if (!filePath || !fileName) continue; + if (entries.some((entry) => entry.path === filePath)) continue; + entries.push({ path: filePath, name: fileName, offset }); + } + return entries; + }, [activities, isReadGroup]); + + const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => { + const absolutePath = resolveAbsolutePath(currentDirectory, filePath); + if (!absolutePath) { + return; + } + + if (runtime?.editor) { + void runtime.editor.openFile(absolutePath, offset); + return; + } + + const uiStore = useUIStore.getState(); + const contextDirectory = getContextDirectoryForPath(currentDirectory, absolutePath); + if (offset && Number.isFinite(offset)) { + uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1); + return; + } + uiStore.openContextFile(contextDirectory, absolutePath); + }, [currentDirectory, runtime]); + + const isSearchGroup = toolName.toLowerCase() === 'grep'; + const isFetchGroup = toolName.toLowerCase() === 'webfetch' || toolName.toLowerCase() === 'fetch' || toolName.toLowerCase() === 'curl' || toolName.toLowerCase() === 'wget'; + + return ( +
+
+ {icon} +
+ + {displayName} + + {isReadGroup && readFileEntries.length > 0 + ? readFileEntries.map((entry) => ( + + )) + : null} + {isSearchGroup && descriptions.length > 0 + ? descriptions.map((desc, index) => ( + + + "{desc}" + + + )) + : null} + {isFetchGroup && descriptions.length > 0 + ? descriptions.map((url, index) => ( + + {url} + + )) + : null} + {!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? ( + + {descriptions.join(' ')} + + ) : null} +
+ ); +}; + +/** + * Inline reasoning text block — rendered as dimmed italic markdown. + */ +const InlineReasoningBlock: React.FC<{ + activity: TurnActivityPart; + onContentChange?: (reason?: ContentChangeReason) => void; +}> = ({ activity, onContentChange }) => { + return ( + + ); +}; + +/** + * Inline justification text block — rendered as normal assistant text between tools. + */ +const InlineJustificationBlock: React.FC<{ + activity: TurnActivityPart; + onContentChange?: (reason?: ContentChangeReason) => void; +}> = ({ activity, onContentChange }) => { + return ( + + ); }; const ProgressiveGroup: React.FC = ({ parts, isExpanded, + collapsedPreviewCount = 0, onToggle, syntaxTheme, isMobile, @@ -71,249 +710,223 @@ const ProgressiveGroup: React.FC = ({ onToggleTool, onShowPopup, onContentChange, - diffStats, + streamPhase: _streamPhase, + showHeader, + animateRows = true, + animatedToolIds, }) => { - const previousExpandedRef = React.useRef(isExpanded); - // Track if we just expanded from collapsed state - const [justExpandedFromCollapsed, setJustExpandedFromCollapsed] = React.useState(false); + void _streamPhase; + const previewCount = showHeader && !isExpanded + ? Math.max(0, Math.floor(collapsedPreviewCount)) + : 0; + const shouldRenderRows = !showHeader || isExpanded || previewCount > 0; - const [expansionKey, setExpansionKey] = React.useState(0); - - // Track which parts have already been shown in collapsed view (for fade-in animation) - const shownInCollapsedRef = React.useRef>(new Set()); - - React.useEffect(() => { - if (previousExpandedRef.current === isExpanded) return; - const wasCollapsed = previousExpandedRef.current === false; - previousExpandedRef.current = isExpanded; - onContentChange?.('structural'); - - if (isExpanded && wasCollapsed) { - setExpansionKey((k) => k + 1); - setJustExpandedFromCollapsed(true); - // Clear collapsed tracking when expanding (will restart when collapsed again) - shownInCollapsedRef.current.clear(); - // Reset after a short delay (after animations would have started) - const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50); - return () => clearTimeout(timer); - } else { - setJustExpandedFromCollapsed(false); + const sortedParts = React.useMemo(() => { + if (!shouldRenderRows) { + return [] as TurnActivityPart[]; } - }, [isExpanded, onContentChange]); - - const displayParts = React.useMemo(() => { return sortPartsByTime(parts); - }, [parts]); + }, [parts, shouldRenderRows]); - const toolConnections = getToolConnections(displayParts); - - // For collapsed state: show last N items, but ensure at least one in-flight item is visible if exists - const visibleCollapsedParts = React.useMemo(() => { - const defaultVisible = displayParts.slice(-MAX_VISIBLE_COLLAPSED); - - const hasVisibleActive = defaultVisible.some((p) => p.endedAt === undefined); - if (hasVisibleActive) { - return defaultVisible; + const rows = React.useMemo(() => { + if (!shouldRenderRows) { + return [] as AggregatedRow[]; } + return aggregateRows(sortedParts); + }, [shouldRenderRows, sortedParts]); - const activeParts = displayParts.filter((p) => p.endedAt === undefined); - if (activeParts.length === 0) { - return defaultVisible; + const previewHiddenCount = React.useMemo(() => { + if (isExpanded || previewCount === 0) { + return 0; } + return Math.max(0, rows.length - previewCount); + }, [isExpanded, previewCount, rows.length]); - const newestActive = activeParts[activeParts.length - 1]; - const visibleIds = new Set(defaultVisible.map((p) => p.id)); - - if (visibleIds.has(newestActive.id)) { - return defaultVisible; + const visibleRows = React.useMemo(() => { + if (isExpanded || previewCount === 0) { + return rows; } + return rows.slice(-previewCount); + }, [isExpanded, previewCount, rows]); - const replacementIndex = 0; - const result = [...defaultVisible]; - result[replacementIndex] = newestActive; - - return result.sort((a, b) => { - const aIndex = displayParts.findIndex((p) => p.id === a.id); - const bIndex = displayParts.findIndex((p) => p.id === b.id); - return aIndex - bIndex; - }); - }, [displayParts]); + const toolCount = React.useMemo( + () => parts.filter((activity) => activity.kind === 'tool').length, + [parts] + ); - // Set of part IDs that were visible in collapsed state - const visibleInCollapsedIds = React.useMemo(() => { - const ids = new Set(); - visibleCollapsedParts.forEach((p) => { - ids.add(p.id); - }); - return ids; - }, [visibleCollapsedParts]); + const aggregatedFileDiffs = React.useMemo(() => aggregateFileDiffs(parts), [parts]); - // Connections for collapsed view (based on visible parts only) - const collapsedToolConnections = React.useMemo(() => { - return getToolConnections(visibleCollapsedParts); - }, [visibleCollapsedParts]); + const hasToolMetric = toolCount > 0; + const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); - const hiddenCount = Math.max(0, displayParts.length - MAX_VISIBLE_COLLAPSED); - - if (displayParts.length === 0) { + if (shouldRenderRows && rows.length === 0) { return null; } - const partsToRender = isExpanded ? displayParts : visibleCollapsedParts; - const connectionsToUse = isExpanded ? toolConnections : collapsedToolConnections; + const wrapRow = (key: string, content: React.ReactNode) => { + if (!animateRows) { + return {content}; + } + return {content}; + }; - // If there are no hidden items, header is not interactive - const isHeaderInteractive = hiddenCount > 0; + const renderToolRow = (key: string, content: React.ReactNode, animate: boolean) => { + if (!animate) { + return wrapRow(key, content); + } + return wrapRow( + key, + + {content} + + ); + }; + + const renderedRows = shouldRenderRows + ? visibleRows.map((row, index) => { + switch (row.type) { + case 'reasoning': + return wrapRow( + row.activity.id, + <> + + + ); + + case 'justification': + return wrapRow( + row.activity.id, + <> + + + ); + + case 'tool-expandable': + return renderToolRow( + row.activity.id, + <> + onToggleTool(row.activity.id)} + syntaxTheme={syntaxTheme} + isMobile={isMobile} + onContentChange={onContentChange} + onShowPopup={onShowPopup} + animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} + /> + , + Boolean(animatedToolIds?.has(row.activity.id)) + ); + + case 'tool-static-group': + return renderToolRow( + `static-${row.toolName}-${row.activities[0]?.id ?? index}`, + <> + animatedToolIds?.has(activity.id))} + /> + , + row.activities.some((activity) => animatedToolIds?.has(activity.id)) + ); + + case 'tool-fallback': + return renderToolRow( + row.activity.id, + <> + onToggleTool(row.activity.id)} + syntaxTheme={syntaxTheme} + isMobile={isMobile} + onContentChange={onContentChange} + onShowPopup={onShowPopup} + animateTailText={Boolean(animatedToolIds?.has(row.activity.id))} + /> + , + Boolean(animatedToolIds?.has(row.activity.id)) + ); + + default: + return null; + } + }) + : null; + + const shouldShowRowsContainer = isExpanded || visibleRows.length > 0; + + if (!showHeader) { + return ( + +
{renderedRows}
+
+ ); + } return (
-
-
-
- {isHeaderInteractive ? ( - <> -
- -
-
- {isExpanded ? ( - - ) : ( - - )} -
- - ) : ( - - )} -
- Activity -
- - {diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && ( -
- - - +{Math.max(0, diffStats.additions)} - - / - - -{Math.max(0, diffStats.deletions)} - - -
- )} -
- -
-
- {!isExpanded && hiddenCount > 0 && ( -
+ + + + Activity + + {hasToolMetric ? ( + + {toolCount} {toolCount === 1 ? 'tool' : 'tools'} + + ) : null} + {aggregatedFileDiffs.map((entry, index) => ( + - +{hiddenCount} more... -
- )} - - {partsToRender.map((activity) => { - const partId = activity.id; - const connection = connectionsToUse[partId]; - - const animationKey = `${partId}-exp${expansionKey}`; - - // Determine if animation should be skipped: - // 1. When expanding from collapsed: skip for items that were already visible - // 2. When collapsed: skip for items already shown before (track in ref) - const wasVisibleInCollapsed = visibleInCollapsedIds.has(activity.id); - - let skipAnimation = false; - if (justExpandedFromCollapsed && wasVisibleInCollapsed) { - // Expanding: don't animate items that were already visible in collapsed state - skipAnimation = true; - } else if (!isExpanded) { - // Collapsed: animate only items that haven't been shown yet - if (shownInCollapsedRef.current.has(activity.id)) { - skipAnimation = true; - } else { - // Mark as shown for future renders - shownInCollapsedRef.current.add(activity.id); - } - } - - switch (activity.kind) { - case 'tool': - return ( - - onToggleTool(partId)} - syntaxTheme={syntaxTheme} - isMobile={isMobile} - onContentChange={onContentChange} - onShowPopup={onShowPopup} - hasPrevTool={connection?.hasPrev ?? false} - hasNextTool={connection?.hasNext ?? false} - /> - - ); - - case 'reasoning': - return ( - - - - ); - - case 'justification': - return ( - - - - ); - - default: - return null; - } - })} -
+ {showToolFileIcons ? : null} + + {toDisplayFileName(entry.filePath)} + + + +{entry.added} + / + -{entry.removed} + + + ))} + + {shouldShowRowsContainer ? ( +
+
+ ) : null}
); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index c232a4c4..59df8213 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -3,7 +3,6 @@ import type { ComponentType } from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; -import { formatTimestampForDisplay } from '../timeFormat'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useUIStore } from '@/stores/useUIStore'; @@ -89,6 +88,7 @@ type ReasoningTimelineBlockProps = { onContentChange?: (reason?: ContentChangeReason) => void; blockId: string; time?: { start?: number; end?: number }; + showDuration?: boolean; }; export const ReasoningTimelineBlock: React.FC = ({ @@ -97,23 +97,14 @@ export const ReasoningTimelineBlock: React.FC = ({ onContentChange, blockId, time, + showDuration = true, }) => { const [isExpanded, setIsExpanded] = React.useState(false); - const isMobile = useUIStore((state) => state.isMobile); - const showActivityHeaderTimestamps = useUIStore((state) => state.showActivityHeaderTimestamps); const summary = React.useMemo(() => getReasoningSummary(text), [text]); const { label, Icon } = variantConfig[variant]; const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined; const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined; - const endedTimestampText = React.useMemo(() => { - if (typeof timeEnd !== 'number') { - return null; - } - - const formatted = formatTimestampForDisplay(timeEnd); - return formatted.length > 0 ? formatted : null; - }, [timeEnd]); React.useEffect(() => { if (text.trim().length === 0) { @@ -158,38 +149,18 @@ export const ReasoningTimelineBlock: React.FC = ({ {label}
- {(summary || typeof timeStart === 'number' || endedTimestampText) ? ( + {(summary || (showDuration && typeof timeStart === 'number')) ? (
{summary ? {summary} : null} - {typeof timeStart === 'number' ? ( + {showDuration && typeof timeStart === 'number' ? ( - + - {!isMobile && endedTimestampText && showActivityHeaderTimestamps ? ( - - {endedTimestampText} - - ) : null} - - ) : null} - {typeof timeStart !== 'number' && !isMobile && endedTimestampText && showActivityHeaderTimestamps ? ( - - {endedTimestampText} ) : null}
@@ -199,9 +170,7 @@ export const ReasoningTimelineBlock: React.FC = ({ {isExpanded && (
= ({ onContentChange, messageId, }) => { + const chatRenderMode = useUIStore((state) => state.chatRenderMode); const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); @@ -246,6 +216,7 @@ const ReasoningPart: React.FC = ({ onContentChange={onContentChange} blockId={part.id || `${messageId}-reasoning`} time={time} + showDuration={chatRenderMode !== 'sorted'} /> ); }; diff --git a/packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx b/packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx new file mode 100644 index 00000000..9087bf29 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx @@ -0,0 +1,270 @@ +import React from 'react'; + +/** + * 5x5 grid letter patterns (indices 0-24). + * Grid layout: + * 0 1 2 3 4 + * 5 6 7 8 9 + * 10 11 12 13 14 + * 15 16 17 18 19 + * 20 21 22 23 24 + * + * Each letter is represented as an array of "on" cell indices. + */ +const LETTER_PATTERNS: Record = { + // 0 1 2 3 4 + // 5 6 7 8 9 + // 10 11 12 13 14 + // 15 16 17 18 19 + // 20 21 22 23 24 + A: [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24], + B: [0, 1, 2, 3, 5, 9, 10, 11, 12, 13, 15, 19, 20, 21, 22, 23], + C: [1, 2, 3, 5, 10, 15, 21, 22, 23], + D: [0, 1, 2, 3, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23], + E: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20, 21, 22, 23], + F: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20], + G: [1, 2, 3, 5, 10, 12, 13, 15, 18, 19, 21, 22, 23], + H: [0, 4, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24], + I: [1, 2, 3, 7, 12, 17, 21, 22, 23], + J: [1, 2, 3, 8, 13, 15, 18, 21, 22], + K: [0, 3, 5, 7, 10, 11, 15, 17, 20, 23], + L: [0, 5, 10, 15, 20, 21, 22, 23], + M: [0, 4, 5, 6, 8, 9, 10, 12, 14, 15, 19, 20, 24], + N: [0, 4, 5, 6, 9, 10, 12, 14, 15, 18, 19, 20, 24], + O: [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23], + P: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 20], + Q: [1, 2, 3, 5, 9, 10, 14, 15, 18, 19, 21, 22, 24], + R: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 17, 20, 23], + S: [1, 2, 3, 5, 11, 12, 13, 19, 21, 22, 23], + T: [0, 1, 2, 3, 4, 7, 12, 17, 22], + U: [0, 4, 5, 9, 10, 14, 15, 19, 21, 22, 23], + V: [0, 4, 5, 9, 10, 14, 16, 18, 22], + W: [0, 4, 5, 9, 10, 12, 14, 15, 16, 18, 19, 21, 23], + X: [0, 4, 6, 8, 12, 16, 18, 20, 24], + Y: [0, 4, 6, 8, 12, 17, 22], + Z: [0, 1, 2, 3, 4, 8, 12, 16, 20, 21, 22, 23, 24], + '0': [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23], + '1': [2, 6, 7, 12, 17, 20, 21, 22, 23, 24], + '2': [1, 2, 3, 9, 11, 12, 13, 16, 20, 21, 22, 23, 24], + '3': [0, 1, 2, 3, 9, 11, 12, 13, 19, 20, 21, 22, 23], + '4': [0, 4, 5, 9, 10, 11, 12, 13, 14, 19, 24], + '5': [0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 19, 20, 21, 22, 23], + '6': [1, 2, 3, 5, 10, 11, 12, 13, 15, 19, 21, 22, 23], + '7': [0, 1, 2, 3, 4, 9, 13, 17, 22], + '8': [1, 2, 3, 5, 9, 11, 12, 13, 15, 19, 21, 22, 23], + '9': [1, 2, 3, 5, 9, 11, 12, 13, 19, 21, 22, 23], + ' ': [], +}; + +// Build Set versions for O(1) lookups +const LETTER_SETS: Record> = {}; +for (const [key, indices] of Object.entries(LETTER_PATTERNS)) { + LETTER_SETS[key] = new Set(indices); +} + +/** Duration each letter is displayed (ms) */ +const LETTER_DURATION_MS = 800; +/** Crossfade transition duration (ms) */ +const TRANSITION_MS = 500; +/** Pause between full cycles (ms) */ +const CYCLE_PAUSE_MS = 1000; + +/** Spacing between dot centers in SVG units */ +const DOT_SPACING = 4; +/** Dot radius */ +const DOT_RADIUS = 1.2; + +/** + * Octagonal grid layout (7 rows): + * + * • • • row 0: 3 dots (cols 2-4) + * • • • • • row 1: 5 dots (cols 1-5) → letter row 0 + * • • • • • • • row 2: 7 dots (cols 0-6) → letter row 1 + * • • • • • • • row 3: 7 dots (cols 0-6) → letter row 2 + * • • • • • • • row 4: 7 dots (cols 0-6) → letter row 3 + * • • • • • row 5: 5 dots (cols 1-5) → letter row 4 + * • • • row 6: 3 dots (cols 2-4) + * + * Letter indices (0-24) map to the inner 5x5 zone: + * rows 1-5, cols 1-5 + */ +const OCTAGON_ROWS: { row: number; cols: number[] }[] = [ + { row: 0, cols: [2, 3, 4] }, + { row: 1, cols: [1, 2, 3, 4, 5] }, + { row: 2, cols: [0, 1, 2, 3, 4, 5, 6] }, + { row: 3, cols: [0, 1, 2, 3, 4, 5, 6] }, + { row: 4, cols: [0, 1, 2, 3, 4, 5, 6] }, + { row: 5, cols: [1, 2, 3, 4, 5] }, + { row: 6, cols: [2, 3, 4] }, +]; + +interface OctCell { + id: number; + cx: number; + cy: number; + /** Index into the 5x5 letter grid (0-24), or -1 for border-only dots */ + letterIndex: number; + // Stable random timing + shimmerDuration: number; + shimmerDelay: number; + idleDuration: number; + idleDelay: number; +} + +const CELLS: OctCell[] = []; +let cellId = 0; +for (const { row, cols } of OCTAGON_ROWS) { + for (const col of cols) { + const cx = col * DOT_SPACING; + const cy = row * DOT_SPACING; + + // Letter zone: rows 1-5 (octagon), cols 1-5 (octagon) + // maps to 5x5 letter index + let letterIndex = -1; + const letterRow = row - 1; + const letterCol = col - 1; + if (letterRow >= 0 && letterRow < 5 && letterCol >= 0 && letterCol < 5) { + letterIndex = letterRow * 5 + letterCol; + } + + CELLS.push({ + id: cellId++, + cx, + cy, + letterIndex, + shimmerDuration: 3 + Math.random() * 3, + shimmerDelay: Math.random() * 3, + idleDuration: 1 + Math.random(), + idleDelay: Math.random() * 1.5, + }); + } +} + +const VIEW_SIZE = 6 * DOT_SPACING + DOT_RADIUS * 2; +const VIEW_OFFSET = -DOT_RADIUS; + +interface SessionActiveSpinnerProps { + className?: string; + /** Text to spell out letter by letter. Falls back to idle pulse when empty/undefined. */ + text?: string; +} + +/** + * Idle mode: random pulsing octagonal dot grid. + * Text mode: cycles through characters of `text`, morphing between letter shapes. + */ +export function SessionActiveSpinner({ className, text }: SessionActiveSpinnerProps) { + const normalizedText = text?.toUpperCase().replace(/[^A-Z0-9 ]/g, '') || ''; + const hasText = normalizedText.length > 0; + + const [charIndex, setCharIndex] = React.useState(0); + const [phase, setPhase] = React.useState<'hold' | 'morph'>('hold'); + + // Intro fade: foreground starts invisible and fades in + const [introReady, setIntroReady] = React.useState(false); + React.useEffect(() => { + const id = requestAnimationFrame(() => setIntroReady(true)); + return () => cancelAnimationFrame(id); + }, []); + + // Reset on text change + React.useEffect(() => { + setCharIndex(0); + setPhase('hold'); + }, [normalizedText]); + + // Letter cycling timer + React.useEffect(() => { + if (!hasText) return; + + const total = normalizedText.length; + + if (phase === 'hold') { + const isLastChar = charIndex === total - 1; + const delay = LETTER_DURATION_MS + (isLastChar ? CYCLE_PAUSE_MS : 0); + const timer = setTimeout(() => setPhase('morph'), delay); + return () => clearTimeout(timer); + } + + const timer = setTimeout(() => { + setCharIndex((prev) => (prev + 1) % total); + setPhase('hold'); + }, TRANSITION_MS); + return () => clearTimeout(timer); + }, [hasText, charIndex, normalizedText, phase]); + + // Compute current and next letter sets for morphing + const total = normalizedText.length; + const currentSet = hasText + ? (LETTER_SETS[normalizedText[charIndex]] ?? LETTER_SETS[' ']) + : null; + const nextIndex = hasText ? (charIndex + 1) % total : 0; + const nextSet = hasText + ? (LETTER_SETS[normalizedText[nextIndex]] ?? LETTER_SETS[' ']) + : null; + + return ( + + ); +} diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 8d49c781..36469fa9 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1,10 +1,9 @@ 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 { RiArrowDownSLine, RiArrowRightSLine, RiExternalLinkLine } from '@remixicon/react'; import { File as PierreFile, PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; -import { formatTimestampForDisplay } from '../timeFormat'; 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'; @@ -15,25 +14,24 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; +import { Text } from '@/components/ui/text'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import type { ToolPopupContent } from '../types'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; +import type { MessageRecord } from '@/lib/messageCompletion'; import { - renderListOutput, - renderGrepOutput, - renderGlobOutput, - renderTodoOutput, - renderWebSearchOutput, formatEditOutput, detectLanguageFromOutput, formatInputForDisplay, - parseReadToolOutput, } from '../toolRenderers'; import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle'; -import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock'; +import { MinDurationShineText } from './MinDurationShineText'; +import { ToolRevealOnMount } from './ToolRevealOnMount'; +import { getToolIcon } from './toolPresentation'; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; @@ -45,80 +43,110 @@ interface ToolPartProps { isMobile: boolean; onContentChange?: (reason?: ContentChangeReason) => void; onShowPopup?: (content: ToolPopupContent) => void; - hasPrevTool?: boolean; - hasNextTool?: boolean; + animateTailText?: boolean; } -// eslint-disable-next-line react-refresh/only-export-components -export const getToolIcon = (toolName: string) => { - const iconClass = 'h-3.5 w-3.5 flex-shrink-0'; - const tool = toolName.toLowerCase(); +const getMultiFileDescription = ( + metadata: Record | undefined, + animate = true, + showFileIcons = true, +): React.ReactNode => { + const files = Array.isArray(metadata?.files) ? metadata?.files : []; + if (files.length <= 1) return null; - if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') { - return ; + const parseCount = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.max(0, Math.trunc(value)); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) { + return Math.max(0, parsed); + } + } + return null; + }; + + const combineCounts = (base: number | null, incoming: number | null): number | null => { + if (base === null) return incoming; + if (incoming === null) return base; + return base + incoming; + }; + + const entriesByPath = new Map(); + + for (const file of files) { + const fileObj = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown }; + const filePath = fileObj.relativePath || fileObj.filePath || ''; + if (!filePath) continue; + const fileName = filePath.split('/').pop() || filePath; + const added = parseCount(fileObj.additions); + const removed = parseCount(fileObj.deletions); + + const existing = entriesByPath.get(filePath); + if (existing) { + existing.added = combineCounts(existing.added, added); + existing.removed = combineCounts(existing.removed, removed); + continue; + } + + entriesByPath.set(filePath, { path: filePath, name: fileName, added, removed }); } - if (tool === 'write' || tool === 'create' || tool === 'file_write') { - return ; - } - if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') { - return ; - } - if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') { - return ; - } - if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') { - return ; - } - if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') { - return ; - } - if (tool === 'glob') { - return ; - } - if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') { - return ; - } - if ( - tool === 'web-search' || - tool === 'websearch' || - tool === 'search_web' || - tool === 'codesearch' || - tool === 'google' || - tool === 'bing' || - tool === 'duckduckgo' || - tool === 'perplexity' - ) { - return ; - } - if (tool === 'todowrite' || tool === 'todoread') { - return ; - } - if (tool === 'structuredoutput' || tool === 'structured_output') { - return ; - } - if (tool === 'skill') { - return ; - } - if (tool === 'task') { - return ; - } - if (tool === 'question') { - return ; - } - if (tool === 'plan_enter') { - return ; - } - if (tool === 'plan_exit') { - return ; - } - if (tool.startsWith('git')) { - return ; - } - return ; + + const entries = Array.from(entriesByPath.values()); + + return ( + <> + {entries.map((entry) => { + const hasPerFileDiff = entry.added !== null || entry.removed !== null; + return ( + + {showFileIcons ? : null} + + {entry.name} + + {hasPerFileDiff ? ( + + +{entry.added ?? 0} + / + -{entry.removed ?? 0} + + ) : null} + + ); + })} + + ); }; +const normalizeToolName = (toolName: string | undefined | null): string => { + if (typeof toolName !== 'string') { + return ''; + } + + const trimmed = toolName.trim().toLowerCase(); + if (!trimmed) { + return ''; + } + + if (trimmed.includes('.')) { + const dotParts = trimmed.split('.').filter(Boolean); + const last = dotParts[dotParts.length - 1]; + if (last) return last; + } + + return trimmed; +}; + +const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap + const formatDuration = (start: number, end?: number, now: number = Date.now()) => { - const duration = end ? end - start : now - start; + const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS); const seconds = duration / 1000; const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds; @@ -157,6 +185,12 @@ const parseDiffStats = (metadata?: Record): { added: number; re return { added, removed }; }; +const parseWriteLineCount = (input?: Record): number | null => { + if (!input?.content || typeof input.content !== 'string') return null; + const lines = input.content.split('\n'); + return lines.length; +}; + const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => { if (!diffText || typeof diffText !== 'string') { return undefined; @@ -333,55 +367,6 @@ const parseQuestionOutput = (output: string): Array<{ question: string; answer: return pairs.length > 0 ? pairs : null; }; -const formatStructuredOutputDescription = (input: Record | undefined, output: unknown): string => { - if (typeof output === 'string' && output.trim().length > 0) { - const maxLength = 100; - const text = output.trim(); - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; - } - - if (!input || typeof input !== 'object') { - return 'Result'; - } - - const rawValue = Object.prototype.hasOwnProperty.call(input, 'result') ? input.result : input; - - const toPreview = (value: unknown): string => { - if (typeof value === 'string') { - return value; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - if (Array.isArray(value)) { - const joined = value - .map((item) => (typeof item === 'string' ? item : JSON.stringify(item))) - .join(', '); - return joined; - } - if (value && typeof value === 'object') { - const record = value as Record; - if (typeof record.subject === 'string' && record.subject.trim().length > 0) { - return record.subject; - } - if (typeof record.title === 'string' && record.title.trim().length > 0) { - return record.title; - } - return JSON.stringify(value); - } - return ''; - }; - - const preview = toPreview(rawValue).trim(); - if (!preview) { - return 'Result'; - } - - const maxLength = 100; - const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview; - return truncated; -}; - const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; @@ -405,7 +390,7 @@ const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, curre } } - if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool) && input) { + if (['write', 'create', 'file_write'].includes(part.tool) && input) { const filePath = input?.filePath || input?.file_path || input?.path; if (typeof filePath === 'string') { return getRelativePath(filePath, currentDirectory); @@ -419,11 +404,6 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDi const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; - const tool = part.tool.toLowerCase(); - - if (tool === 'structuredoutput' || tool === 'structured_output') { - return formatStructuredOutputDescription(input, stateWithData.output); - } const filePathLabel = getToolDescriptionPath(part, state, currentDirectory); if (filePathLabel) { @@ -435,7 +415,7 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDi if (files.length > 1) { return `${files.length} files`; } - return 'Patch'; + return ''; } // Question tool: show "Asked N question(s)" @@ -453,18 +433,6 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDi return input.description.substring(0, 80); } - if (part.tool === 'skill' && input?.name && typeof input.name === 'string') { - return input.name; - } - - if (part.tool === 'plan_enter') { - return 'Switching to planning'; - } - - if (part.tool === 'plan_exit') { - return 'Switching to building'; - } - const desc = input?.description || metadata?.description || ('title' in state && state.title) || ''; return typeof desc === 'string' ? desc : ''; }; @@ -484,44 +452,119 @@ const ToolScrollableSection: React.FC = ({ outerClassName, disableHorizontal = false, }) => ( - -
- {children} -
-
+
+ +
+ {children} +
+
+
); +const getToolOutputLanguage = ( + output: string, + part: ToolPartType, + metadata: Record | undefined, + input: Record | undefined, +): string => { + if (part.tool === 'bash') { + return 'bash'; + } + + return detectLanguageFromOutput(formatEditOutput(output, part.tool, metadata), part.tool, input); +}; + +const getToolOutputText = ( + output: string, + part: ToolPartType, + metadata: Record | undefined, +): string => { + if (part.tool === 'bash') { + return output; + } + + return formatEditOutput(output, part.tool, metadata); +}; + +const ToolScrollableTextOutput: React.FC<{ + output: string; + part: ToolPartType; + metadata: Record | undefined; + input: Record | undefined; + syntaxTheme: { [key: string]: React.CSSProperties }; +}> = ({ output, part, metadata, input, syntaxTheme }) => { + const renderedOutput = getToolOutputText(output, part, metadata); + const outputLanguage = getToolOutputLanguage(output, part, metadata, input); + + return ( +
+ + {renderedOutput} + +
+ ); +}; + +ToolScrollableTextOutput.displayName = 'ToolScrollableTextOutput'; + type TaskToolSummaryEntry = { id?: string; tool?: string; state?: { status?: string; title?: string; + input?: Record; }; }; -type SessionMessageWithParts = { - info?: { - role?: string; - }; - parts?: Array<{ - id?: string; - type?: string; - tool?: string; - state?: { - status?: string; - title?: string; - }; - }>; -}; +type SessionMessageWithParts = MessageRecord; const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = []; +const normalizeSessionIdCandidate = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const readTaskSessionIdFromRecord = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + return ( + normalizeSessionIdCandidate(record.sessionID) + ?? normalizeSessionIdCandidate(record.sessionId) + ); +}; + const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => { if (typeof output !== 'string' || output.trim().length === 0) { return undefined; @@ -530,9 +573,10 @@ const readTaskSessionIdFromOutput = (output: string | undefined): string | undef if (parsedMetadata.sessionId) { return parsedMetadata.sessionId; } - const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/); - const candidate = match?.[1]; - return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined; + const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i); + const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i); + const candidate = taskMatch?.[1] ?? sessionMatch?.[1]; + return normalizeSessionIdCandidate(candidate); }; const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => { @@ -547,16 +591,20 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]) if (part?.type !== 'tool') { continue; } - const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : ''; + const toolName = normalizeToolName(part.tool); if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') { continue; } + const partState = part.state as { status?: string; title?: string; input?: unknown } | undefined; entries.push({ id: part.id, tool: part.tool, state: { - status: part.state?.status, - title: part.state?.title, + status: partState?.status, + title: partState?.title, + input: partState?.input && typeof partState.input === 'object' + ? (partState.input as Record) + : undefined, }, }); } @@ -570,10 +618,21 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { if (typeof title === 'string' && title.trim().length > 0) { return title; } - if (typeof entry.tool === 'string' && entry.tool.trim().length > 0) { - return entry.tool; + + const input = entry.state?.input; + if (input && typeof input === 'object') { + const pathCandidate = input.filePath ?? input.file_path ?? input.path; + if (typeof pathCandidate === 'string' && pathCandidate.trim().length > 0) { + return pathCandidate.trim(); + } + + const urlCandidate = input.url; + if (typeof urlCandidate === 'string' && urlCandidate.trim().length > 0) { + return urlCandidate.trim(); + } } - return 'tool'; + + return ''; }; const FILE_PATH_LABEL_TOOLS = new Set([ @@ -631,7 +690,7 @@ const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => tool?: unknown; title?: unknown; status?: unknown; - state?: { status?: unknown; title?: unknown }; + state?: { status?: unknown; title?: unknown; input?: unknown }; }; const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined; @@ -645,6 +704,9 @@ const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => state: { status, title, + input: record.state?.input && typeof record.state.input === 'object' + ? (record.state.input as Record) + : undefined, }, }); } @@ -702,18 +764,16 @@ const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; isMobile: boolean; - hasPrevTool: boolean; - hasNextTool: boolean; output?: string; sessionId?: string; onShowPopup?: (content: ToolPopupContent) => void; input?: Record; -}> = ({ entries, isExpanded, isMobile, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => { + animateTailText?: boolean; + isActive?: boolean; +}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => { const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const displayEntries = React.useMemo(() => { - const nonPending = entries.filter((entry) => entry.state?.status !== 'pending'); - return nonPending.length > 0 ? nonPending : entries; - }, [entries]); + const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); + const displayEntries = entries; const trimmedOutput = typeof output === 'string' ? stripTaskMetadataFromOutput(output) @@ -733,7 +793,13 @@ const TaskToolSummary: React.FC<{ : 'subagent'; if (displayEntries.length === 0 && !hasOutput && !sessionId) { - return null; + return ( +
+
+ {isActive ? 'Waiting for subagent activity...' : 'No subagent session id on task metadata.'} +
+
+ ); } const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6); @@ -744,8 +810,7 @@ const TaskToolSummary: React.FC<{ className={cn( 'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]', 'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]', - hasPrevTool ? 'before:top-[-0.45rem]' : 'before:top-[-0.25rem]', - hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0' + 'before:top-[-0.25rem] before:bottom-0' )} > {displayEntries.length > 0 ? ( @@ -756,26 +821,53 @@ const TaskToolSummary: React.FC<{ ) : null} {visibleEntries.map((entry, idx) => { - const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool'; + const normalizedToolName = normalizeToolName(entry.tool); + const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool'; const label = getTaskSummaryLabel(entry); + const hasLabel = label.trim().length > 0; const status = entry.state?.status; const displayName = getToolMetadata(toolName).displayName; return ( -
- {getToolIcon(toolName)} - {displayName} - {status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( - renderPathLikeGitChanges(label) - ) : ( - {label} - )} -
+ +
+ {getToolIcon(toolName)} + + {displayName} + + {hasLabel ? ( + status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( + renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons) + ) : ( + status === 'error' ? ( + + {label} + + ) : ( + + {label} + + ) + ) + ) : null} +
+
); })}
@@ -886,6 +978,58 @@ const renderPathLikeGitChanges = (path: string, grow = true) => { ); }; +const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true) => { + const lastSlash = path.lastIndexOf('/'); + + if (lastSlash === -1) { + return ( + + {showFileIcons ? : null} + + {path} + + + ); + } + + const dir = path.slice(0, lastSlash); + const name = path.slice(lastSlash + 1); + + return ( + + {showFileIcons ? : null} + + + {dir} + + + + / + + + {name} + + + + + ); +}; + const getDiffPatchEntries = ( metadata: Record | undefined, fallbackDiff: string, @@ -988,7 +1132,7 @@ const WriteInputPreview: React.FC = React.memo(({ return (
-
+
{renderPathLikeGitChanges(displayPath)} ({headerLineLabel})
@@ -1012,96 +1156,6 @@ const WriteInputPreview: React.FC = React.memo(({ WriteInputPreview.displayName = 'WriteInputPreview'; -// ── PERF-007: Read tool output with virtualised highlighting ───────── -interface ReadToolVirtualizedProps { - outputString: string; - input?: Record; - syntaxTheme: { [key: string]: React.CSSProperties }; - toolName: string; - currentDirectory: string; - pierreTheme: { light: string; dark: string }; - pierreThemeType: 'light' | 'dark'; - renderScrollableBlock: ( - content: React.ReactNode, - options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string } - ) => React.ReactNode; -} - -const ReadToolVirtualized: React.FC = React.memo(({ - outputString, - input, - syntaxTheme, - toolName, - currentDirectory, - pierreTheme, - pierreThemeType, - renderScrollableBlock, -}) => { - const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]); - - const language = React.useMemo(() => { - const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n'); - return detectLanguageFromOutput(contentForLanguage, toolName, input as Record); - }, [parsedReadOutput, toolName, input]); - - const rawFilePath = - typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : 'read-output'; - const displayPath = getRelativePath(rawFilePath, currentDirectory); - - 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'); - const lineCount = Math.max(parsedReadOutput.lines.length, 1); - const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; - return renderScrollableBlock( -
-
- {renderPathLikeGitChanges(displayPath)} - ({headerLineLabel}) -
- -
, - { className: 'p-1' } - ) as React.ReactElement; - } - - return renderScrollableBlock( - , - { className: 'p-1' } - ) as React.ReactElement; -}); - -ReadToolVirtualized.displayName = 'ReadToolVirtualized'; - interface ImagePreviewProps { content: string; filePath: string; @@ -1127,10 +1181,10 @@ const ImagePreview: React.FC = React.memo(({ content, filePat return (
-
+
{renderPathLikeGitChanges(displayPath)}
-
+
{displayPath} void; - hasPrevTool: boolean; - hasNextTool: boolean; } const ToolExpandedContent: React.FC = React.memo(({ part, state, syntaxTheme, - isMobile, currentDirectory, onShowPopup, - hasPrevTool, - hasNextTool, }) => { const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); @@ -1274,67 +1322,6 @@ const ToolExpandedContent: React.FC = React.memo(({ return
Awaiting response...
; } - if (part.tool === 'todowrite' || part.tool === 'todoread') { - if (state.status === 'completed' && hasStringOutput) { - const todoContent = renderTodoOutput(outputString, { unstyled: true }); - return renderScrollableBlock( - todoContent ?? ( -
Unable to parse todo list
- ) - ); - } - - if (state.status === 'error' && 'error' in state) { - return ( -
-
Error:
-
- {state.error} -
-
- ); - } - - return
Processing todo list...
; - } - - if (part.tool === 'list' && hasStringOutput) { - const listOutput = renderListOutput(outputString, { unstyled: true }); - return renderScrollableBlock( - listOutput ?? ( -
-                        {outputString}
-                    
- ) - ); - } - - if (part.tool === 'grep' && hasStringOutput) { - const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true }); - return renderScrollableBlock( - grepOutput ?? ( -
-                        {outputString}
-                    
- ) - ); - } - - if (part.tool === 'glob' && hasStringOutput) { - const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true }); - return renderScrollableBlock( - globOutput ?? ( -
-                        {outputString}
-                    
- ) - ); - } - if (part.tool === 'task' && hasStringOutput) { return renderScrollableBlock(
@@ -1343,40 +1330,13 @@ const ToolExpandedContent: React.FC = React.memo(({ ); } - if ((part.tool === 'web-search' || part.tool === 'websearch' || part.tool === 'search_web') && hasStringOutput) { - const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true }); - return renderScrollableBlock( - webSearchContent ?? ( -
-                        {outputString}
-                    
- ) - ); - } - - if (part.tool === 'codesearch' && hasStringOutput) { - return renderScrollableBlock( -
- -
- ); - } - - if (part.tool === 'skill' && hasStringOutput) { - return renderScrollableBlock( -
- -
- ); - } - if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffEntries.length > 0) { return renderScrollableBlock(
{diffEntries.map((entry) => (
{diffEntries.length > 1 ? ( -
+
{renderPathLikeGitChanges(entry.title)}
) : null} @@ -1394,40 +1354,18 @@ const ToolExpandedContent: React.FC = React.memo(({ } if (hasStringOutput && outputString.trim()) { - if (part.tool === 'read') { - return ; - } - - return renderScrollableBlock( - - {formatEditOutput(outputString, part.tool, metadata)} - , - { className: 'p-1' } + />, + { + className: 'p-1', + maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined, + } ); } @@ -1440,19 +1378,10 @@ const ToolExpandedContent: React.FC = React.memo(({ return (
-
- {(part.tool === 'todowrite' || part.tool === 'todoread' || part.tool === 'question') ? ( + {part.tool === 'question' ? ( renderResultContent() ) : ( <> @@ -1481,28 +1410,34 @@ const ToolExpandedContent: React.FC = React.memo(({ ) : hasInputText ? (
{renderScrollableBlock( -
- {inputTextContent} -
, - { maxHeightClass: 'max-h-60', className: 'tool-input-surface' } + part.tool === 'bash' ? ( +
+                                        {inputTextContent}
+                                    
+ ) : ( +
+ {inputTextContent} +
+ ), + { + maxHeightClass: 'max-h-60', + className: part.tool === 'bash' ? 'tool-input-surface p-0' : 'tool-input-surface', + } )}
) : null} {part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
-
-
- Result: -
- {(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? ( + {(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? ( +
- ) : null} -
+
+ ) : null} {renderResultContent()}
)} @@ -1535,19 +1470,36 @@ const ToolPart: React.FC = ({ isMobile, onContentChange, onShowPopup, - hasPrevTool = false, - hasNextTool = false, + animateTailText = true, }) => { const state = part.state; + const showToolFileIcons = useUIStore((s) => s.showToolFileIcons); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); - const showActivityHeaderTimestamps = useUIStore((store) => store.showActivityHeaderTimestamps); - const isTaskTool = part.tool.toLowerCase() === 'task'; + const normalizedPartTool = normalizeToolName(part.tool); + const isTaskTool = normalizedPartTool === 'task'; - const status = state.status as string | undefined; - const isFinalized = status === 'completed' || status === 'error'; - const isActive = status === 'running' || status === 'pending' || status === 'started'; - const isError = state.status === 'error'; + const status = state?.status as string | undefined; + const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; + const isError = status === 'error' || status === 'failed'; + + const [activeLatched, setActiveLatched] = React.useState(!isFinalized); + const previousPartIdRef = React.useRef(part.id); + + React.useEffect(() => { + if (previousPartIdRef.current === part.id) { + return; + } + previousPartIdRef.current = part.id; + // Reset latch only when tool identity changes. + setActiveLatched(!isFinalized); + }, [isFinalized, part.id]); + + React.useEffect(() => { + if (!isFinalized) { + setActiveLatched(true); + } + }, [isFinalized]); @@ -1564,15 +1516,30 @@ const ToolPart: React.FC = ({ const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; + const partMetadata = (part as unknown as { metadata?: unknown }).metadata; const input = stateWithData.input; const time = stateWithData.time; const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>({}); + const [localStartAt, setLocalStartAt] = React.useState(undefined); + const [localFinalizedAt, setLocalFinalizedAt] = React.useState(undefined); React.useEffect(() => { setPinnedTime({}); + setLocalStartAt(undefined); + setLocalFinalizedAt(undefined); }, [part.id]); + React.useEffect(() => { + if (isFinalized) { + return; + } + if (typeof time?.start === 'number') { + return; + } + setLocalStartAt((prev) => prev ?? Date.now()); + }, [isFinalized, time?.start]); + React.useEffect(() => { setPinnedTime((prev) => { const next = { ...prev }; @@ -1583,7 +1550,7 @@ const ToolPart: React.FC = ({ changed = true; } - if (typeof time?.end === 'number' && prev.end !== time.end) { + if (typeof time?.end === 'number' && (typeof prev.end !== 'number' || time.end > prev.end)) { next.end = time.end; changed = true; } @@ -1592,17 +1559,20 @@ const ToolPart: React.FC = ({ }); }, [time?.end, time?.start]); - const effectiveTimeStart = pinnedTime.start ?? time?.start; - const effectiveTimeEnd = pinnedTime.end ?? time?.end; - - const endedTimestampText = React.useMemo(() => { - if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) { - return null; + const effectiveTimeStart = React.useMemo(() => { + // Once we captured a local start (during pending, before server sends time.start), + // always prefer it so the timer never jumps when server start arrives later. + if (typeof localStartAt === 'number') { + return localStartAt; } - - const formatted = formatTimestampForDisplay(effectiveTimeEnd); - return formatted.length > 0 ? formatted : null; - }, [effectiveTimeEnd]); + const candidates = [pinnedTime.start, time?.start].filter( + (value): value is number => typeof value === 'number' + ); + if (candidates.length === 0) { + return undefined; + } + return Math.min(...candidates); + }, [localStartAt, pinnedTime.start, time?.start]); const taskOutputString = React.useMemo(() => { return typeof stateWithData.output === 'string' ? stateWithData.output : undefined; @@ -1616,15 +1586,22 @@ const ToolPart: React.FC = ({ if (!isTaskTool) { return undefined; } - const candidate = metadata as { sessionId?: string } | undefined; - if (typeof candidate?.sessionId === 'string' && candidate.sessionId.trim().length > 0) { - return candidate.sessionId; + + const metadataSessionId = readTaskSessionIdFromRecord(metadata); + if (metadataSessionId) { + return metadataSessionId; } + + const partLevelSessionId = readTaskSessionIdFromRecord(partMetadata); + if (partLevelSessionId) { + return partLevelSessionId; + } + if (parsedTaskMetadata.sessionId) { return parsedTaskMetadata.sessionId; } return readTaskSessionIdFromOutput(taskOutputString); - }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, taskOutputString]); + }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]); const childSessionMessages = useSessionStore( React.useCallback((store) => { @@ -1661,6 +1638,59 @@ const ToolPart: React.FC = ({ return buildTaskSummaryEntriesFromSession(childSessionMessages); }, [childSessionMessages, isTaskTool, taskSessionId]); + const childSessionHasInFlightTools = React.useMemo(() => { + if (!isTaskTool || !taskSessionId || !Array.isArray(childSessionMessages) || childSessionMessages.length === 0) { + return false; + } + + for (const message of childSessionMessages) { + if (message?.info?.role !== 'assistant') { + continue; + } + const parts = Array.isArray(message.parts) ? message.parts : []; + for (const childPart of parts) { + if (childPart?.type !== 'tool') { + continue; + } + const childStatus = + typeof childPart === 'object' && childPart !== null && 'state' in childPart + ? (childPart.state as { status?: string } | undefined)?.status + : undefined; + if (childStatus === 'running' || childStatus === 'pending' || childStatus === 'started') { + return true; + } + } + } + + return false; + }, [childSessionMessages, isTaskTool, taskSessionId]); + + React.useEffect(() => { + if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') { + setLocalFinalizedAt(undefined); + return; + } + + if (typeof effectiveTimeStart !== 'number') { + return; + } + + if (!isFinalized) { + return; + } + + setLocalFinalizedAt((prev) => prev ?? Date.now()); + }, [ + effectiveTimeStart, + isFinalized, + pinnedTime.end, + time?.end, + ]); + + const effectiveTimeEnd = isFinalized ? (pinnedTime.end ?? time?.end ?? localFinalizedAt) : undefined; + const isActive = !isFinalized && activeLatched; + const shouldTreatAsFinalized = isFinalized; + const taskSummaryEntries = React.useMemo(() => { if (childSessionTaskSummaryEntries.length > 0) { return childSessionTaskSummaryEntries; @@ -1668,41 +1698,47 @@ const ToolPart: React.FC = ({ return metadataTaskSummaryEntries; }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]); - const fetchedTaskSessionsRef = React.useRef>(new Set()); React.useEffect(() => { if (!isTaskTool || !taskSessionId) { return; } - if (childSessionTaskSummaryEntries.length > 0) { - return; - } - if (fetchedTaskSessionsRef.current.has(taskSessionId)) { + + const shouldPoll = isActive || childSessionHasInFlightTools || childSessionTaskSummaryEntries.length === 0; + const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll; + if (!shouldFetchSnapshot) { return; } - fetchedTaskSessionsRef.current.add(taskSessionId); let cancelled = false; + let pollTimer: number | undefined; - void opencodeClient - .getSessionMessages(taskSessionId, 500) - .then((messages) => { - if (cancelled || !Array.isArray(messages)) { - return; - } - if (messages.length === 0) { - fetchedTaskSessionsRef.current.delete(taskSessionId); + const fetchSessionMessages = async () => { + try { + const messages = await opencodeClient.getSessionMessages(taskSessionId, 500); + if (cancelled || !Array.isArray(messages) || messages.length === 0) { return; } useSessionStore.getState().syncMessages(taskSessionId, messages); - }) - .catch(() => { - fetchedTaskSessionsRef.current.delete(taskSessionId); - }); + } catch { + // Ignore transient subagent fetch errors. + } + }; + + void fetchSessionMessages(); + + if (shouldPoll && typeof window !== 'undefined') { + pollTimer = window.setInterval(() => { + void fetchSessionMessages(); + }, 1200); + } return () => { cancelled = true; + if (typeof pollTimer === 'number') { + window.clearInterval(pollTimer); + } }; - }, [childSessionTaskSummaryEntries.length, isTaskTool, taskSessionId]); + }, [childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, isActive, isTaskTool, taskSessionId]); const taskSummaryLenRef = React.useRef(taskSummaryEntries.length); @@ -1717,18 +1753,25 @@ const ToolPart: React.FC = ({ onContentChange?.('structural'); }, [isTaskTool, onContentChange, taskSummaryEntries.length]); - const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null; - const descriptionPath = getToolDescriptionPath(part, state, currentDirectory); - const description = getToolDescription(part, state, currentDirectory); - const displayName = getToolMetadata(part.tool).displayName; + const diffStats = (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') ? parseDiffStats(metadata) : null; + const writeLineCount = normalizedPartTool === 'write' ? parseWriteLineCount(input) : null; + const isMultiFileApplyPatch = normalizedPartTool === 'apply_patch' && Array.isArray(metadata?.files) && (metadata?.files as []).length > 1; + const normalizedPart = normalizedPartTool !== part.tool ? ({ ...part, tool: normalizedPartTool } as ToolPartType) : part; + const descriptionPath = getToolDescriptionPath(normalizedPart, state, currentDirectory); + const description = getToolDescription(normalizedPart, state, currentDirectory); + const displayName = getToolMetadata(normalizedPartTool || part.tool).displayName; - // Get justification text (tool title/description) when setting is enabled - const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity); + // Tool title/description — shown inline as context const justificationText = React.useMemo(() => { - if (!showTextJustificationActivity) return null; - if (part.tool === 'apply_patch') return null; - if (part.tool.toLowerCase() === 'structuredoutput' || part.tool.toLowerCase() === 'structured_output') return null; - // Get title or description from state - this is the "yapping" text like "Shows system information" + if (normalizedPartTool === 'apply_patch') { + return null; + } + if ( + descriptionPath + && (normalizedPartTool === 'apply_patch' || normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'write') + ) { + return null; + } const title = (stateWithData as { title?: string }).title; if (typeof title === 'string' && title.trim().length > 0) { return title; @@ -1738,7 +1781,7 @@ const ToolPart: React.FC = ({ return inputDesc; } return null; - }, [showTextJustificationActivity, part.tool, stateWithData, input]); + }, [descriptionPath, normalizedPartTool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); @@ -1765,7 +1808,7 @@ const ToolPart: React.FC = ({ if (typeof filePath === 'string') { toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath); } - } else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) { + } else if (['write', 'create', 'file_write'].includes(part.tool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; } @@ -1794,23 +1837,24 @@ const ToolPart: React.FC = ({ handleMainClick(event); }; - if (!isFinalized && !isActive && !isTaskTool) { + if (!shouldTreatAsFinalized && !isActive && !isTaskTool) { return null; } return ( -
+
{}
-
+
{}
= ({ )} style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-icon)' }} > - {getToolIcon(part.tool)} + {getToolIcon(normalizedPartTool || part.tool)}
{}
= ({ {isExpanded ? : }
- - {displayName} - -
- -
-
- {justificationText && ( - - {justificationText} - - )} - {!justificationText && description && ( - descriptionPath && description === descriptionPath ? ( - renderPathLikeGitChanges(descriptionPath, false) - ) : ( - - {description} - - ) - )} - {diffStats && ( - - +{diffStats.added} - {' '} - -{diffStats.removed} - - )} -
- {typeof effectiveTimeStart === 'number' ? ( - - + - - - {!isMobile && endedTimestampText && showActivityHeaderTimestamps ? ( - - {endedTimestampText} + {displayName} + + {typeof effectiveTimeStart === 'number' ? ( + + ) : null} - - ) : null} - {typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText && showActivityHeaderTimestamps ? ( - - {endedTimestampText} - - ) : null} + {getMultiFileDescription(metadata, animateTailText, showToolFileIcons)} + + ) : ( + <> +
+ + {displayName} + +
+ {typeof effectiveTimeStart === 'number' ? ( + + + + ) : null} + + )}
+ + {!isMultiFileApplyPatch && ( +
+
+ {justificationText && ( + + {justificationText} + + )} + {!justificationText && description && ( + descriptionPath && description === descriptionPath ? ( + renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons) + ) : ( + + {description} + + ) + )} + {diffStats && ( + + +{diffStats.added} + / + -{diffStats.removed} + + )} + {writeLineCount && ( + + +{writeLineCount} + + )} +
+
+ )}
{} - {isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized || taskSessionId) ? ( + {isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || taskSessionId) ? ( ) : null} {!isTaskTool && isExpanded ? ( - +
+
) : null}
); diff --git a/packages/ui/src/components/chat/message/parts/ToolRevealOnMount.tsx b/packages/ui/src/components/chat/message/parts/ToolRevealOnMount.tsx new file mode 100644 index 00000000..7dfc5e71 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ToolRevealOnMount.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +const WIPE_MASK = + 'linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)'; + +interface ToolRevealOnMountProps { + children: React.ReactNode; + animate: boolean; + wipe?: boolean; + delayMs?: number; + className?: string; +} + +export const ToolRevealOnMount: React.FC = ({ + children, + animate, + wipe = true, + delayMs = 0, + className, +}) => { + const rootRef = React.useRef(null); + + const clearRevealStyles = React.useCallback((target: HTMLElement | null) => { + if (!target) { + return; + } + target.style.opacity = ''; + target.style.filter = ''; + target.style.transform = ''; + target.style.maskImage = ''; + target.style.webkitMaskImage = ''; + target.style.maskSize = ''; + target.style.webkitMaskSize = ''; + target.style.maskRepeat = ''; + target.style.webkitMaskRepeat = ''; + target.style.maskPosition = ''; + target.style.webkitMaskPosition = ''; + }, []); + + React.useLayoutEffect(() => { + const el = rootRef.current; + + if (!animate) { + clearRevealStyles(el); + return; + } + + if (!el || typeof window === 'undefined') { + return; + } + + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + clearRevealStyles(el); + return; + } + + const maskSupported = + wipe && + typeof CSS !== 'undefined' && + (CSS.supports('mask-image', 'linear-gradient(to right, black, transparent)') || + CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)')); + + el.style.opacity = '0'; + el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)'; + el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)'; + + if (maskSupported) { + el.style.maskImage = WIPE_MASK; + el.style.webkitMaskImage = WIPE_MASK; + el.style.maskSize = '240% 100%'; + el.style.webkitMaskSize = '240% 100%'; + el.style.maskRepeat = 'no-repeat'; + el.style.webkitMaskRepeat = 'no-repeat'; + el.style.maskPosition = '100% 0%'; + el.style.webkitMaskPosition = '100% 0%'; + } + + let animation: Animation | null = null; + const frame = window.requestAnimationFrame(() => { + const node = rootRef.current; + if (!node) { + return; + } + + const keyframes: Keyframe[] = maskSupported + ? [ + { opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' }, + { opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' }, + ] + : [ + { + opacity: 0, + filter: wipe ? 'blur(3px)' : 'blur(2px)', + transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)', + }, + { opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' }, + ]; + + animation = node.animate(keyframes, { + duration: 500, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + delay: delayMs, + fill: 'forwards', + }); + + animation.finished + .catch(() => undefined) + .finally(() => { + const target = rootRef.current; + clearRevealStyles(target); + }); + }); + + return () => { + window.cancelAnimationFrame(frame); + animation?.cancel(); + clearRevealStyles(el); + }; + }, [animate, clearRevealStyles, delayMs, wipe]); + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 5b603cee..f6fc444e 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { Text } from '@/components/ui/text'; +// import { SessionActiveSpinner } from './SessionActiveSpinner'; +import { GenericStatusSpinner } from './GenericStatusSpinner'; interface WorkingPlaceholderProps { isWorking: boolean; @@ -7,6 +9,7 @@ interface WorkingPlaceholderProps { isGenericStatus?: boolean; isWaitingForPermission?: boolean; retryInfo?: { attempt?: number; next?: number } | null; + agentName?: string; } const STATUS_DISPLAY_TIME_MS = 1200; @@ -191,12 +194,13 @@ export function WorkingPlaceholder({ return (
- + + {retryText} @@ -215,14 +219,15 @@ export function WorkingPlaceholder({ return (
- + + {displayText} diff --git a/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts b/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts new file mode 100644 index 00000000..f5f50adc --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts @@ -0,0 +1,17 @@ +export const resolveAssistantDisplayText = (input: { + textContent: string; + throttledTextContent: string; + isStreaming: boolean; +}): string => { + return input.isStreaming ? input.throttledTextContent : input.textContent; +}; + +export const shouldRenderAssistantText = (input: { + displayTextContent: string; + isFinalized: boolean; +}): boolean => { + if (!input.isFinalized && input.displayTextContent.trim().length === 0) { + return false; + } + return input.displayTextContent.trim().length > 0; +}; diff --git a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx new file mode 100644 index 00000000..cf125699 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { + RiAiAgentLine, + RiBookLine, + RiFileEditLine, + RiFileList2Line, + RiFileSearchLine, + RiFileTextLine, + RiFolder6Line, + RiGitBranchLine, + RiGlobalLine, + RiListCheck2, + RiListCheck3, + RiMenuSearchLine, + RiPencilLine, + RiSurveyLine, + RiTaskLine, + RiTerminalBoxLine, + RiToolsLine, +} from '@remixicon/react'; + +export const getToolIcon = (toolName: string) => { + const iconClass = 'h-3.5 w-3.5 flex-shrink-0'; + const tool = toolName.toLowerCase(); + + if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') { + return ; + } + if (tool === 'write' || tool === 'create' || tool === 'file_write') { + return ; + } + if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') { + return ; + } + if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') { + return ; + } + if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') { + return ; + } + if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') { + return ; + } + if (tool === 'glob') { + return ; + } + if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') { + return ; + } + if ( + tool === 'web-search' || + tool === 'websearch' || + tool === 'search_web' || + tool === 'codesearch' || + tool === 'google' || + tool === 'bing' || + tool === 'duckduckgo' || + tool === 'perplexity' + ) { + return ; + } + if (tool === 'todowrite' || tool === 'todoread') { + return ; + } + if (tool === 'structuredoutput' || tool === 'structured_output') { + return ; + } + if (tool === 'skill') { + return ; + } + if (tool === 'task') { + return ; + } + if (tool === 'question') { + return ; + } + if (tool === 'plan_enter') { + return ; + } + if (tool === 'plan_exit') { + return ; + } + if (tool.startsWith('git')) { + return ; + } + return ; +}; diff --git a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts new file mode 100644 index 00000000..46b840b4 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts @@ -0,0 +1,44 @@ +const EXPANDABLE_TOOL_NAMES = new Set([ + 'edit', 'multiedit', 'apply_patch', 'str_replace', 'str_replace_based_edit_tool', + 'bash', 'shell', 'cmd', 'terminal', + 'write', 'create', 'file_write', + 'question', 'task', +]); + +const STANDALONE_TOOL_NAMES = new Set(['task']); + +const SEARCH_TOOL_NAMES = new Set(['grep', 'search', 'find', 'ripgrep', 'glob']); + +const normalizeToolName = (toolName: unknown): string => { + if (typeof toolName !== 'string') return ''; + const trimmed = toolName.trim().toLowerCase(); + if (!trimmed) return ''; + + const withoutIndex = trimmed.replace(/:\d+$/, ''); + if (withoutIndex.includes('.')) { + const parts = withoutIndex.split('.').filter(Boolean); + return parts[parts.length - 1] ?? withoutIndex; + } + return withoutIndex; +}; + +export const isExpandableTool = (toolName: unknown): boolean => { + return EXPANDABLE_TOOL_NAMES.has(normalizeToolName(toolName)); +}; + +export const isStandaloneTool = (toolName: unknown): boolean => { + return STANDALONE_TOOL_NAMES.has(normalizeToolName(toolName)); +}; + +export const isStaticTool = (toolName: unknown): boolean => { + if (typeof toolName !== 'string') return false; + return !isExpandableTool(toolName) && !isStandaloneTool(toolName); +}; + +export const getStaticGroupToolName = (toolName: string): string => { + const normalized = normalizeToolName(toolName); + if (SEARCH_TOOL_NAMES.has(normalized)) { + return 'grep'; + } + return normalized; +}; diff --git a/packages/ui/src/components/comments/InlineCommentCard.tsx b/packages/ui/src/components/comments/InlineCommentCard.tsx index 2ca822d4..8936ec43 100644 --- a/packages/ui/src/components/comments/InlineCommentCard.tsx +++ b/packages/ui/src/components/comments/InlineCommentCard.tsx @@ -30,10 +30,11 @@ export function InlineCommentCard({ const themeContext = useOptionalThemeSystem(); const currentTheme = themeContext?.currentTheme; const [isOpen, setIsOpen] = useState(false); + const draftText = typeof draft.text === 'string' ? draft.text : ''; // Check if content is long enough to warrant collapsing (rough estimate) // In a real app we might measure line height, but length check is a good proxy for now - const isLongContent = draft.text.length > 150 || draft.text.split('\n').length > 3; + const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3; return (
- {draft.text} + {draftText}
{isLongContent && ( diff --git a/packages/ui/src/components/comments/PierreDiffCommentUtils.ts b/packages/ui/src/components/comments/PierreDiffCommentUtils.ts index df1927dc..873d1d68 100644 --- a/packages/ui/src/components/comments/PierreDiffCommentUtils.ts +++ b/packages/ui/src/components/comments/PierreDiffCommentUtils.ts @@ -29,9 +29,14 @@ export const buildPierreLineAnnotations = ( const annotations: DiffLineAnnotation[] = []; for (const draft of drafts) { + if (!Number.isFinite(draft.endLine)) { + continue; + } + + const lineNumber = Math.max(1, Math.floor(draft.endLine)); const side: AnnotationSide = draft.side === 'original' ? 'deletions' : 'additions'; annotations.push({ - lineNumber: draft.endLine, + lineNumber, side, metadata: { type: draft.id === editingDraftId ? 'edit' : 'saved', diff --git a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx b/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx deleted file mode 100644 index fb355c4f..00000000 --- a/packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; -import { RiInformationLine, RiRestartLine } from '@remixicon/react'; -import { NumberInput } from '@/components/ui/number-input'; -import { ButtonSmall } from '@/components/ui/button-small'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { useUIStore } from '@/stores/useUIStore'; -import { updateDesktopSettings } from '@/lib/persistence'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { DEFAULT_MESSAGE_LIMIT } from '@/stores/types/sessionTypes'; - -const MIN_LIMIT = 10; -const MAX_LIMIT = 500; - -export const MemoryLimitsSettings: React.FC = () => { - const messageLimit = useUIStore((state) => state.messageLimit); - const setMessageLimit = useUIStore((state) => state.setMessageLimit); - - const [isLoading, setIsLoading] = React.useState(true); - - // Load settings from server on mount - React.useEffect(() => { - const loadSettings = async () => { - try { - let data: { messageLimit?: number } | null = null; - - // 1. Runtime settings API (VSCode) - if (!data) { - const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; - if (runtimeSettings) { - try { - const result = await runtimeSettings.load(); - const settings = result?.settings as Record | undefined; - if (settings) { - data = { - messageLimit: typeof settings.messageLimit === 'number' ? settings.messageLimit : undefined, - }; - } - } catch { - // fall through - } - } - } - - // 2. Fetch API (Web/server) - if (!data) { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - data = await response.json(); - } - } - - if (data && typeof data.messageLimit === 'number') { - setMessageLimit(data.messageLimit); - } - } catch (error) { - console.warn('Failed to load memory limits settings:', error); - } finally { - setIsLoading(false); - } - }; - loadSettings(); - }, [setMessageLimit]); - - const handleChange = React.useCallback((value: number) => { - setMessageLimit(value); - void updateDesktopSettings({ messageLimit: value }).catch((error: unknown) => { - console.warn('Failed to save messageLimit:', error); - }); - }, [setMessageLimit]); - - if (isLoading) { - return null; - } - - const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT; - - return ( -
-
-
-

Message Memory

- - - - - - Limit how many messages are loaded per session in memory.
- Older messages are available via "Load more". Background sessions are trimmed automatically. -
-
-
-
- -
-
-
- Message Limit -
-
- - handleChange(DEFAULT_MESSAGE_LIMIT)} - disabled={isDefault} - className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground" - aria-label="Reset message limit" - title="Reset" - > - - -
-
-
-
- ); -}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index dcd84da0..bfb9d17f 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; -import { MemoryLimitsSettings } from './MemoryLimitsSettings'; import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { NotificationSettings } from './NotificationSettings'; @@ -115,12 +114,12 @@ const VisualSectionContent: React.FC = () => { ]} />; }; -// Chat section: Default Tool Output, User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Justification activity, Activity header timestamps, Queue mode, Persist draft +// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft const ChatSectionContent: React.FC = () => { - return ; + return ; }; -// Sessions section: Default model & agent, Session retention, Memory limits +// Sessions section: Default model & agent, Session retention const SessionsSectionContent: React.FC = () => { const isVSCode = isVSCodeRuntime(); return ( @@ -134,9 +133,6 @@ const SessionsSectionContent: React.FC = () => {
-
- -
); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 2572c759..a1ea0660 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -19,7 +19,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; +import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { useDeviceInfo } from '@/lib/device'; import { usePwaDetection } from '@/hooks/usePwaDetection'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -49,13 +49,6 @@ const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [ }, ]; -const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed' | 'changes'; label: string; description: string }> = [ - { value: 'collapsed', label: 'Collapsed', description: 'Activity and tool calls stay collapsed by default.' }, - { value: 'activity', label: 'Summary', description: 'Activity opens by default; tool calls stay collapsed.' }, - { value: 'detailed', label: 'Detailed', description: 'Activity opens; key tools auto-expand for richer detail.' }, - { value: 'changes', label: 'Changes', description: 'Activity opens; only edit/write/patch tools auto-expand.' }, -]; - const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [ { id: 'dynamic', @@ -120,11 +113,37 @@ const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [ }, ]; +const CHAT_RENDER_MODE_OPTIONS: Option<'sorted' | 'live'>[] = [ + { + id: 'sorted', + label: 'Sorted', + description: 'Render completed assistant messages without live streaming.', + }, + { + id: 'live', + label: 'Live', + description: 'Stream assistant text and tools as they arrive.', + }, +]; + +const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [ + { + id: 'collapsed', + label: 'Collapsed', + description: 'Keep Activity collapsed by default.', + }, + { + id: 'summary', + label: 'Summary', + description: 'Expand Activity by default.', + }, +]; + const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'activityHeaderTimestamps' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -137,18 +156,17 @@ export const OpenChamberVisualSettings: React.FC const directoryShowHidden = useDirectoryShowHidden(); const showReasoningTraces = useUIStore(state => state.showReasoningTraces); const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces); - const showTextJustificationActivity = useUIStore(state => state.showTextJustificationActivity); - const setShowTextJustificationActivity = useUIStore(state => state.setShowTextJustificationActivity); - const showActivityHeaderTimestamps = useUIStore(state => state.showActivityHeaderTimestamps); - const setShowActivityHeaderTimestamps = useUIStore(state => state.setShowActivityHeaderTimestamps); - const toolCallExpansion = useUIStore(state => state.toolCallExpansion); - const setToolCallExpansion = useUIStore(state => state.setToolCallExpansion); + const mermaidRenderingMode = useUIStore(state => state.mermaidRenderingMode); const setMermaidRenderingMode = useUIStore(state => state.setMermaidRenderingMode); const userMessageRenderingMode = useUIStore(state => state.userMessageRenderingMode); const setUserMessageRenderingMode = useUIStore(state => state.setUserMessageRenderingMode); const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader); + const chatRenderMode = useUIStore(state => state.chatRenderMode); + const setChatRenderMode = useUIStore(state => state.setChatRenderMode); + const activityRenderMode = useUIStore(state => state.activityRenderMode); + const setActivityRenderMode = useUIStore(state => state.setActivityRenderMode); const fontSize = useUIStore(state => state.fontSize); const setFontSize = useUIStore(state => state.setFontSize); const terminalFontSize = useUIStore(state => state.terminalFontSize); @@ -171,6 +189,12 @@ export const OpenChamberVisualSettings: React.FC const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled); + const showToolFileIcons = useUIStore(state => state.showToolFileIcons); + const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons); + const showExpandedBashTools = useUIStore(state => state.showExpandedBashTools); + const setShowExpandedBashTools = useUIStore(state => state.setShowExpandedBashTools); + const showExpandedEditTools = useUIStore(state => state.showExpandedEditTools); + const setShowExpandedEditTools = useUIStore(state => state.setShowExpandedEditTools); const isNavRailExpanded = useUIStore(state => state.isNavRailExpanded); const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded); const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar); @@ -188,6 +212,18 @@ export const OpenChamberVisualSettings: React.FC } = useThemeSystem(); const [themesReloading, setThemesReloading] = React.useState(false); + const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0); + + React.useEffect(() => { + const intervalId = setInterval(() => { + setChatRenderPreviewTick((prev) => (prev + 1) % 24); + }, 420); + + return () => { + clearInterval(intervalId); + }; + }, []); + const handleUserMessageRenderingModeChange = React.useCallback((mode: 'markdown' | 'plain') => { setUserMessageRenderingMode(mode); void updateDesktopSettings({ userMessageRenderingMode: mode }); @@ -203,6 +239,36 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ inputSpellcheckEnabled: enabled }); }, [setInputSpellcheckEnabled]); + const handleChatRenderModeChange = React.useCallback((mode: 'sorted' | 'live') => { + setChatRenderMode(mode); + void updateDesktopSettings({ chatRenderMode: mode }); + }, [setChatRenderMode]); + + const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => { + setActivityRenderMode(mode); + void updateDesktopSettings({ activityRenderMode: mode }); + }, [setActivityRenderMode]); + + const handleMermaidRenderingModeChange = React.useCallback((mode: 'svg' | 'ascii') => { + setMermaidRenderingMode(mode); + void updateDesktopSettings({ mermaidRenderingMode: mode }); + }, [setMermaidRenderingMode]); + + const handleShowToolFileIconsChange = React.useCallback((enabled: boolean) => { + setShowToolFileIcons(enabled); + void updateDesktopSettings({ showToolFileIcons: enabled }); + }, [setShowToolFileIcons]); + + const handleShowExpandedBashToolsChange = React.useCallback((enabled: boolean) => { + setShowExpandedBashTools(enabled); + void updateDesktopSettings({ showExpandedBashTools: enabled }); + }, [setShowExpandedBashTools]); + + const handleShowExpandedEditToolsChange = React.useCallback((enabled: boolean) => { + setShowExpandedEditTools(enabled); + void updateDesktopSettings({ showExpandedEditTools: enabled }); + }, [setShowExpandedEditTools]); + const lightThemes = React.useMemo( () => availableThemes .filter((theme) => theme.metadata.variant === 'light') @@ -241,22 +307,22 @@ export const OpenChamberVisualSettings: React.FC const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode; const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile); - const hasBehaviorSettings = shouldShow('toolOutput') - || shouldShow('mermaidRendering') + const hasBehaviorSettings = shouldShow('mermaidRendering') || shouldShow('userMessageRendering') + || shouldShow('chatRenderMode') + || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || shouldShow('stickyUserHeader') || shouldShow('diffLayout') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('reasoning') || shouldShow('queueMode') - || shouldShow('textJustificationActivity') - || shouldShow('activityHeaderTimestamps') || shouldShow('persistDraft') + || shouldShow('showToolFileIcons') + || shouldShow('expandedTools') || (!isMobile && shouldShow('inputSpellcheck')); - const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion); - const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab; + const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode; const [pwaInstallName, setPwaInstallName] = React.useState(''); const applyPwaInstallName = React.useCallback(async (value: string) => { @@ -426,12 +492,12 @@ export const OpenChamberVisualSettings: React.FC
{showPwaInstallNameSetting && ( -
-
+
+
Install App Name Used by PWA installation process.
-
+
{ @@ -715,44 +781,182 @@ export const OpenChamberVisualSettings: React.FC {hasBehaviorSettings && (
- {shouldShow('toolOutput') && ( -
-

Default Tool Output

-
- {TOOL_EXPANSION_OPTIONS.map((option) => { - return ( - setToolCallExpansion(option.value)} - > - {option.label} - - ); - })} -
- {selectedToolExpansionOption && ( -

- {selectedToolExpansionOption.description} -

- )} -
- )} - {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || (shouldShow('diffLayout') && !isVSCode)) && ( + + {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
+ {shouldShow('chatRenderMode') && ( +
+

Chat Render Mode

+
+ {CHAT_RENDER_MODE_OPTIONS.map((option) => { + const selected = chatRenderMode === option.id; + const previewPhase = chatRenderPreviewTick % 12; + return ( + + ); + })} +
+
+ )} + + {shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && ( +
+

Activity Default

+
+ {ACTIVITY_RENDER_MODE_OPTIONS.map((option) => { + const selected = activityRenderMode === option.id; + return ( +
handleActivityRenderModeChange(option.id)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleActivityRenderModeChange(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + handleActivityRenderModeChange(option.id)} + ariaLabel={`Activity default mode: ${option.label}`} + /> + + {option.label} + +
+ ); + })} +
+
+ )} + + {shouldShow('expandedTools') && ( +
+
Show tools opened by default:
+ +
handleShowExpandedBashToolsChange(!showExpandedBashTools)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleShowExpandedBashToolsChange(!showExpandedBashTools); + } + }} + > + + Bash +
+ +
handleShowExpandedEditToolsChange(!showExpandedEditTools)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleShowExpandedEditToolsChange(!showExpandedEditTools); + } + }} + > + + Edit tools +
+
+ )} + {shouldShow('userMessageRendering') && (

User Message Rendering

-
+
{USER_MESSAGE_RENDERING_OPTIONS.map((option) => { const selected = normalizeUserMessageRenderingMode(userMessageRenderingMode) === option.id; return ( @@ -768,7 +972,7 @@ export const OpenChamberVisualSettings: React.FC handleUserMessageRenderingModeChange(option.id); } }} - className="flex w-full items-center gap-2 py-0.5 text-left" + className="flex w-full items-center gap-2 py-0 text-left" > {shouldShow('mermaidRendering') && (

Mermaid Rendering

-
+
{MERMAID_RENDERING_OPTIONS.map((option) => { const selected = mermaidRenderingMode === option.id; return ( @@ -797,18 +1001,18 @@ export const OpenChamberVisualSettings: React.FC role="button" tabIndex={0} aria-pressed={selected} - onClick={() => setMermaidRenderingMode(option.id)} + onClick={() => handleMermaidRenderingModeChange(option.id)} onKeyDown={(event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); - setMermaidRenderingMode(option.id); + handleMermaidRenderingModeChange(option.id); } }} - className="flex w-full items-center gap-2 py-0.5 text-left" + className="flex w-full items-center gap-2 py-0 text-left" > setMermaidRenderingMode(option.id)} + onChange={() => handleMermaidRenderingModeChange(option.id)} ariaLabel={`Mermaid rendering: ${option.label}`} /> @@ -824,7 +1028,7 @@ export const OpenChamberVisualSettings: React.FC {shouldShow('diffLayout') && !isVSCode && (

Diff Layout

-
+
{DIFF_LAYOUT_OPTIONS.map((option) => { const selected = diffLayoutPreference === option.id; return ( @@ -840,7 +1044,7 @@ export const OpenChamberVisualSettings: React.FC setDiffLayoutPreference(option.id); } }} - className="flex w-full items-center gap-2 py-0.5 text-left" + className="flex w-full items-center gap-2 py-0 text-left" > {shouldShow('diffLayout') && !isVSCode && (

Diff View Mode

-
+
{DIFF_VIEW_MODE_OPTIONS.map((option) => { const selected = diffViewMode === option.id; return ( @@ -876,7 +1080,7 @@ export const OpenChamberVisualSettings: React.FC setDiffViewMode(option.id); } }} - className="flex w-full items-center gap-2 py-0.5 text-left" + className="flex w-full items-center gap-2 py-0 text-left" >
)} - {(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && ( + {(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {shouldShow('reasoning') && ( +
setShowReasoningTraces(!showReasoningTraces)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setShowReasoningTraces(!showReasoningTraces); + } + }} + > + + Show Reasoning Traces +
+ )} + {shouldShow('stickyUserHeader') && (
)} + {shouldShow('showToolFileIcons') && ( +
handleShowToolFileIconsChange(!showToolFileIcons)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleShowToolFileIconsChange(!showToolFileIcons); + } + }} + > + + Show Tool File Icons +
+ )} + {shouldShow('mobileStatusBar') && isMobile && (
{shouldShow('dotfiles') && !isVSCodeRuntime() && (
{shouldShow('queueMode') && (
{shouldShow('persistDraft') && (
)} - {shouldShow('reasoning') && ( -
setShowReasoningTraces(!showReasoningTraces)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setShowReasoningTraces(!showReasoningTraces); - } - }} - > - - Show Reasoning Traces -
- )} - - {shouldShow('textJustificationActivity') && ( -
setShowTextJustificationActivity(!showTextJustificationActivity)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setShowTextJustificationActivity(!showTextJustificationActivity); - } - }} - > - - Show Justification Activity -
- )} - - {shouldShow('activityHeaderTimestamps') && ( -
setShowActivityHeaderTimestamps(!showActivityHeaderTimestamps)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setShowActivityHeaderTimestamps(!showActivityHeaderTimestamps); - } - }} - > - - Show Activity Header Timestamps -
- )}
)} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 1f77c654..47ca64e9 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -319,6 +319,7 @@ export const SessionSidebar: React.FC = ({ worktreeMetadata, pinnedSessionIds, gitDirectories, + isVSCode, }); const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({ diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 83f5f95e..0e582873 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -34,6 +34,7 @@ import { RiUnpinLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; @@ -140,6 +141,7 @@ export function SessionNodeItem(props: Props): React.ReactNode { const displayMode = useSessionDisplayStore((state) => state.displayMode); const isMinimalMode = displayMode === 'minimal'; + const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const session = node.session; const sessionDirectory = @@ -463,22 +465,24 @@ export function SessionNodeItem(props: Props): React.ReactNode { ); })() : null} - { - if (!sessionDirectory) return; - openContextPanelTab(sessionDirectory, { - mode: 'chat', - dedupeKey: `session:${session.id}`, - label: sessionTitle, - }); - }} - className="[&>svg]:mr-1" - > - - Open in Side Panel - beta - + {!isVSCode ? ( + { + if (!sessionDirectory) return; + openContextPanelTab(sessionDirectory, { + mode: 'chat', + dedupeKey: `session:${session.id}`, + label: sessionTitle, + }); + }} + className="[&>svg]:mr-1" + > + + Open in Side Panel + beta + + ) : null} handleDeleteSession(session, { archivedBucket })}> diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index f4b6baf2..de5dfebc 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -85,16 +85,29 @@ export function SidebarProjectsList(props: Props): React.ReactNode { if (!activeSection) { return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState; } - const group = + const primaryGroup = activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0) ?? activeSection.groups.find((candidate) => candidate.sessions.length > 0) ?? activeSection.groups.find((candidate) => candidate.isMain) ?? activeSection.groups[0]; - if (!group) { + if (!primaryGroup) { return
No sessions yet.
; } - const groupKey = `${activeSection.project.id}:${group.id}`; - return props.renderGroupSessions(group, groupKey, activeSection.project.id, props.showOnlyMainWorkspace); + const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket); + const groupsToRender = [ + primaryGroup, + ...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []), + ]; + + return groupsToRender.map((group) => { + const groupKey = `${activeSection.project.id}:${group.id}`; + const hideGroupLabel = group.id === primaryGroup.id; + return ( + + {props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel)} + + ); + }); })()}
) : ( diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts index 418680e0..05b924be 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts @@ -54,6 +54,33 @@ export const useProjectSessionLists = (args: Args) => { const getArchivedSessionsForProject = React.useCallback( (project: { normalizedPath: string }) => { + if (isVSCode) { + const archived = archivedSessions.filter((session) => { + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + + if (sessionDirectory) { + return sessionDirectory === project.normalizedPath; + } + + return projectWorktree === project.normalizedPath; + }); + + const unassignedLive = sessions.filter((session) => { + if (session.time?.archived) { + return false; + } + const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (sessionDirectory) { + return false; + } + const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + return projectWorktree === project.normalizedPath; + }); + + return dedupeSessionsById([...archived, ...unassignedLive]); + } + const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); const validDirectories = new Set([ project.normalizedPath, diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts index 6744924f..0ab2438b 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -16,6 +16,7 @@ type Args = { worktreeMetadata: Map; pinnedSessionIds: Set; gitDirectories: Map; + isVSCode: boolean; }; export const useSessionGrouping = (args: Args) => { @@ -219,13 +220,13 @@ export const useSessionGrouping = (args: Args) => { isArchivedBucket: true, worktree: null, directory: null, - folderScopeKey: normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null, + folderScopeKey: !args.isVSCode && normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null, sessions: groupedNodes.get(archivedKey) ?? [], }); return groups; }, - [args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories], + [args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories, args.isVSCode], ); return { diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts index 9fa099b1..57d7ee34 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts @@ -34,8 +34,8 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessa } const hasMessages = state.messages.has(nextSessionId); - const memory = state.sessionMemoryState.get(nextSessionId); - const isHydrated = hasMessages && memory?.historyComplete !== undefined; + const historyMeta = state.sessionHistoryMeta.get(nextSessionId); + const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean'; if (isHydrated) { continue; } @@ -57,8 +57,8 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessa const state = useSessionStore.getState(); const hasMessages = state.messages.has(sessionId); - const memory = state.sessionMemoryState.get(sessionId); - const isHydrated = hasMessages && memory?.historyComplete !== undefined; + const historyMeta = state.sessionHistoryMeta.get(sessionId); + const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean'; if (isHydrated) { return; } diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index da9ce239..5b222077 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -5,7 +5,7 @@ import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionT import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; -import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react'; +import { RiCloseLine, RiDatabase2Line, RiPulseLine } from '@remixicon/react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface MemoryDebugPanelProps { @@ -18,8 +18,6 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = messages, sessionMemoryState, currentSessionId, - trimToViewportWindow, - evictLeastRecentlyUsed } = useSessionStore(); const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); @@ -90,10 +88,6 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = Viewport Window: {getBackgroundTrimLimit()} messages
-
- Background Stream Limit: - {MEMORY_LIMITS.BACKGROUND_STREAMING_BUFFER} messages -
Zombie Timeout: {MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes @@ -139,45 +133,7 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) =
- {}
- - - - - - Trim current session to only 10 most recent messages - - - - - - - - Remove least recently used sessions from memory cache - -