From fab32ca9921c0e844723f6fde22d551adfa12c7a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 12 Mar 2026 23:45:45 +0200 Subject: [PATCH] refactor(chat): complete turn-based pipeline and stabilize streaming, scroll, and tool UX (#629) * refactor: add canonical turn projection pipeline for chat Centralizes turn/activity/summary derivation, migrates render consumers to shared projection sources, and adds projector regression coverage for retries, malformed parent fallbacks, and activity segmentation. * refactor: stream assistant text live in turn-first render path * refactor: complete phase-3 turn timeline history/navigation * refactor: complete phase-4 turn pipeline cutover Remove legacy grouping adapters and lock key edge cases with executable regression tests so turn-centric rendering stays stable across retry/history/navigation flows. * fix: align chat history loading path with opencode parity * fix: stabilize chat streaming and reduce render churn * feat: add blurIn streaming text animation and smooth lerp-based auto-scroll Streaming markdown now uses Streamdown blurIn animation (150ms, ease-out) for per-word reveal Auto-scroll during streaming uses continuous exponential smoothing (lerp) instead of restarting spring animations One-shot scroll-to-bottom (button click) uses motion spring for natural deceleration * fix: respect user scroll-up during streaming and re-pin on scroll back to bottom * fix: stabilize activity stream and tool progress rendering Activity now stays chronologically consistent and avoids duplicate reasoning/justification. Tool rows show running state and duration earlier with smoother pending behavior. Removed legacy trim/timestamp paths to prevent message drops and simplify Activity UI. * fix: reverted regression of revert functionality * fix: stabilize chat activity layout and tool progress behavior Stopped user messages from re-animating when new user messages appear. Made Activity and aggregated tool rows wrap inline consistently without misaligned headers/icons. Fixed tool part matching so duration and shine no longer reset across pending/in-progress status updates. * fix: restore activity mode behavior and stabilize tool rendering Re-enabled Collapsed/Summary/Detailed/Changes defaults for Activity and tool expansion. Fixed tool row wrapping/alignment, removed user fade re-animations, and disabled FadeInOnReveal animations. Improved tool status handling to reduce timer/shine resets and made reasoning bold render as normal text. * refactor: simplify chat tool and message rendering Replace activity-group rendering with flat inline part rendering Remove obsolete tool output and justification activity settings Keep user message animation and improve edit/apply-patch file path headers * style: narrow chat and composer columns equally * fix: Tool status checks and abort flow regression * fix: improve multi-file apply patch header rendering * fix: make timeline message jump scroll to selected chat entry Conversation Timeline clicks now reliably scroll to the selected message in chat Timeline dialog waits for navigation result before closing to avoid silent failures Chat message scrolling now resolves the active chat scroller consistently * chore: remove branch-added chat tests and clean lint leftovers Removes newly added chat and message test files from this branch Fixes lint blockers in ProgressiveGroup, ToolPart, and messageStore Keeps type-check, lint, and build passing after cleanup * fix: keep composer status row closest to the input Status/todo row now renders below queued messages and attachments. Linked GitHub issue/PR chips stay above status so input context order is consistent. Improves chat composer readability while assistant is working. * fix: disable assistant header animations and polish chat status behavior Assistant message header now renders without transition effects Removed a lint issue from the working placeholder props Chat status and message flow updates improve consistency during active turns * feat: increase bottom spacer in mobile chat for better scrolling * feat: add sorted chat mode with progressive activity rendering Adds global chat render mode settings for sorted and live behavior Renders non-stop assistant output in Activity as messages complete Disables streaming text animation in sorted mode and restores classic reasoning/justification blocks * fix: stabilize chat timeline navigation and older history loading Prevents auto-loading older history from scroll and keeps loading behind the button Fixes timeline jump-to-message behavior that snapped back to the bottom Reduces chat list flicker and empty-state glitches on large session pagination * fix: stabilize sorted activity text and hide reasoning timers Prevented sorted justification from appearing before message completion by requiring explicit non-stop finish. Removed duration values for sorted Thinking and Justification blocks. Kept smooth sorted auto-follow behavior tuned to reduce bottom-jump jitter. * refactor: simplify chat tool rendering paths and add parts docs Centralized tool icon mapping into a shared presentation helper used by grouped and expandable rows. Removed static-tool-specific branches from ToolPart to keep it focused on expandable tools. Added a new DOCUMENTATION.md explaining where to change tool descriptions and rendering behavior. * feat: enhance event stream handling for message parts - Refactor event key building functions to improve clarity and reduce redundancy. - Introduce `applyPartDelta` method in message store for handling delta updates to message parts. - Update `useEventStream` to utilize the new `applyPartDelta` method for processing incoming deltas. - Enhance text extraction logic to accommodate new part types and improve merging behavior. - Implement logic to skip delta-driven updates for ongoing text/reasoning parts to prevent overwriting. - Adjust server-side text merging logic to handle trimmed text comparisons and improve consistency. * feat: enhance OpencodeService with delta handling and stale delta management - Introduced globalSseStaleDeltas to track stale deltas. - Added methods to generate keys for delta and updated part events. - Updated global SSE loop to skip processing stale deltas. - Refactored global SSE event handling to improve performance and reliability. refactor: remove unused streaming part handling in messageStore - Eliminated streaming part queue and associated logic. - Simplified message merging logic by removing redundant checks. - Streamlined message handling to improve clarity and maintainability. chore: clean up unused streaming text normalization functions - Removed deprecated functions related to streaming text identity and merging. - Simplified normalizeStreamingTextPayload to focus on current functionality. * refactor: simplify streaming event passthrough Use SDK-native global event streaming in the UI client Remove dead server-side SSE normalization helpers Keep streaming payloads closer to upstream behavior * revert: remove message memory settings UI Drop the restored message memory settings screen Keep session settings focused on current defaults Preserve chat history limits as internal behavior * feat: add compact collapsed Activity preview in sorted mode Collapsed Activity now shows a live preview of the latest 7 entries Older activity rows roll up behind a +N more indicator The +N more indicator is clickable to expand the full Activity * fix: adjusted activity changed files text prominence * fix: make sorted mode snap to bottom when not streaming * perf: show chat scrollbar only on user-driven scrolling Chat scrollbar now stays hidden during automatic follow-to-bottom Scrollbar visibility is gated by real user scroll intent Reduced scroll overhead by removing timer-based intent tracking * refactor: align web and vscode session activity streaming Route VS Code SSE through SDK-based proxy flow Restore controlled web session activity tracking and cooldowns Keep chat activity behavior consistent across runtimes * fix: adjusted muted foreground themes color * fix: stabilize session activity and attention updates Keep streamed chat state in sync as sessions complete Restore reliable attention indicators for inactive sessions Tighten server and UI event handling for chat updates * feat: add chat render previews and file icon visibility setting Added animated chat render mode previews in Chat settings. Added a new toggle to show or hide file icons across tool rows. Persisted chat render, activity, and mermaid modes to shared settings file. * fix: update default GitHub OAuth client ID Use the new OpenChamber org GitHub OAuth app ID by default. Keep web server and VS Code auth defaults in sync. * fix: sync chat settings across VS Code chat views Broadcast settings changes to both sidebar and editor chat webviews. Apply synced settings in each webview via shared settings refresh. Ensures chat render mode updates appear immediately in editor tabs. * fix: stop writing session folders file in VS Code workspaces Disable disk persistence for session folders in VS Code webview runtime Keep session folder state in VS Code/webview storage instead of project files Preserve existing behavior for non-VS Code runtimes * style: polish expanded tool panels and bash output readability Removed extra borders/labels in expanded tool cards for a cleaner UI Added softer scroll shadows and full-height decorative activity line Switched bash command/output to code-style typography with tighter spacing * feat: add default-open tool controls for chat activity Added Chat settings toggles to open Bash and Edit tools by default Default-open behavior now applies across both sorted and live activity rendering Users can still manually collapse default-open tools, plus bash output height was reduced * fix: align chat loading skeleton with message layout Match skeleton width and horizontal alignment to chat message column Add one-line tool-style skeleton rows with subtle circular placeholders Increase top spacing so loading state no longer touches chat header * fix: prevent blank screen when adding inline diff comments Sanitize persisted inline comment drafts to ignore invalid data Harden diff annotations against malformed line numbers Fail safely when annotation updates error instead of crashing the view * fix: streamline VS Code archived sessions sidebar Show archived sessions in VS Code only for the active workspace directory. Render archived sessions as a flat list in VS Code without project-root subfolders. Hide the "Open in Side Panel" session action in VS Code. --- .../ui/src/components/chat/ChatContainer.tsx | 452 ++---- packages/ui/src/components/chat/ChatInput.tsx | 29 +- .../ui/src/components/chat/ChatMessage.tsx | 476 ++++--- .../src/components/chat/MarkdownRenderer.tsx | 27 +- .../ui/src/components/chat/MessageList.tsx | 936 +++++++++---- packages/ui/src/components/chat/StatusRow.tsx | 92 +- .../ui/src/components/chat/TimelineDialog.tsx | 42 +- .../chat/components/ScrollToBottomButton.tsx | 33 + .../chat/components/TurnActivity.tsx | 37 + .../chat/components/TurnAssistantBlock.tsx | 18 + .../components/chat/components/TurnItem.tsx | 39 + .../components/chat/components/TurnList.tsx | 16 + .../chat/contexts/TurnGroupingContext.tsx | 656 --------- .../chat/hooks/useChatTimelineController.ts | 420 ++++++ .../chat/hooks/useChatTurnNavigation.ts | 189 +++ .../chat/hooks/useStreamingTextThrottle.ts | 105 ++ .../components/chat/hooks/useTurnGrouping.ts | 517 ------- .../components/chat/hooks/useTurnLookup.ts | 26 + .../components/chat/hooks/useTurnRecords.ts | 55 + .../components/chat/lib/blockingRequests.ts | 47 + .../chat/lib/scroll/scrollIntent.ts | 78 ++ .../components/chat/lib/scroll/scrollSpy.ts | 315 +++++ .../chat/lib/turns/applyRetryOverlay.ts | 91 ++ .../components/chat/lib/turns/constants.ts | 11 + .../chat/lib/turns/historySignals.ts | 67 + .../chat/lib/turns/projectTurnActivity.ts | 243 ++++ .../chat/lib/turns/projectTurnIndexes.ts | 64 + .../chat/lib/turns/projectTurnRecords.ts | 327 +++++ .../chat/lib/turns/projectTurnSummary.ts | 104 ++ .../chat/lib/turns/stabilizeTurnProjection.ts | 56 + .../components/chat/lib/turns/stageTurns.ts | 159 +++ .../ui/src/components/chat/lib/turns/types.ts | 122 ++ .../components/chat/lib/turns/windowTurns.ts | 129 ++ .../chat/message/FadeInOnReveal.tsx | 29 +- .../components/chat/message/MessageBody.tsx | 644 ++++----- .../components/chat/message/MessageHeader.tsx | 139 +- .../chat/message/normalizeUserDisplayParts.ts | 33 + .../chat/message/parts/AssistantTextPart.tsx | 99 +- .../chat/message/parts/DOCUMENTATION.md | 87 ++ .../message/parts/GenericStatusSpinner.tsx | 56 + .../chat/message/parts/JustificationBlock.tsx | 3 + .../message/parts/MinDurationShineText.tsx | 109 ++ .../chat/message/parts/ProgressiveGroup.tsx | 1107 +++++++++++---- .../chat/message/parts/ReasoningPart.tsx | 45 +- .../message/parts/SessionActiveSpinner.tsx | 270 ++++ .../chat/message/parts/ToolPart.tsx | 1227 +++++++++-------- .../chat/message/parts/ToolRevealOnMount.tsx | 126 ++ .../chat/message/parts/WorkingPlaceholder.tsx | 13 +- .../message/parts/assistantTextVisibility.ts | 17 + .../chat/message/parts/toolPresentation.tsx | 87 ++ .../chat/message/parts/toolRenderUtils.ts | 44 + .../components/comments/InlineCommentCard.tsx | 5 +- .../comments/PierreDiffCommentUtils.ts | 7 +- .../openchamber/MemoryLimitsSettings.tsx | 128 -- .../sections/openchamber/OpenChamberPage.tsx | 10 +- .../openchamber/OpenChamberVisualSettings.tsx | 464 +++++-- .../src/components/session/SessionSidebar.tsx | 1 + .../session/sidebar/SessionNodeItem.tsx | 36 +- .../session/sidebar/SidebarProjectsList.tsx | 21 +- .../sidebar/hooks/useProjectSessionLists.ts | 27 + .../sidebar/hooks/useSessionGrouping.ts | 5 +- .../sidebar/hooks/useSessionPrefetch.ts | 8 +- .../ui/src/components/ui/MemoryDebugPanel.tsx | 46 +- .../ui/src/components/ui/OverlayScrollbar.tsx | 75 +- packages/ui/src/components/ui/text.tsx | 39 +- .../src/components/views/PierreDiffViewer.tsx | 12 +- packages/ui/src/hooks/useChatScrollManager.ts | 240 +++- packages/ui/src/hooks/useEventStream.ts | 449 +++--- packages/ui/src/hooks/useKeyboardShortcuts.ts | 2 +- packages/ui/src/hooks/useScrollEngine.ts | 163 ++- packages/ui/src/index.css | 44 +- packages/ui/src/lib/api/types.ts | 8 +- packages/ui/src/lib/appearanceAutoSave.ts | 12 - packages/ui/src/lib/desktop.ts | 8 +- packages/ui/src/lib/fileTypeIcons.ts | 41 +- packages/ui/src/lib/opencode/client.ts | 205 +-- packages/ui/src/lib/persistence.ts | 84 +- .../ui/src/lib/theme/themes/aura-dark.json | 2 +- .../ui/src/lib/theme/themes/aura-light.json | 2 +- .../ui/src/lib/theme/themes/ayu-dark.json | 2 +- .../ui/src/lib/theme/themes/ayu-light.json | 2 +- .../src/lib/theme/themes/carbonfox-dark.json | 2 +- .../src/lib/theme/themes/carbonfox-light.json | 2 +- .../src/lib/theme/themes/catppuccin-dark.json | 2 +- .../lib/theme/themes/catppuccin-light.json | 2 +- .../ui/src/lib/theme/themes/dracula-dark.json | 2 +- .../src/lib/theme/themes/dracula-light.json | 2 +- .../ui/src/lib/theme/themes/flexoki-dark.json | 2 +- .../src/lib/theme/themes/flexoki-light.json | 2 +- .../ui/src/lib/theme/themes/gruvbox-dark.json | 2 +- .../src/lib/theme/themes/kanagawa-dark.json | 2 +- .../src/lib/theme/themes/kanagawa-light.json | 2 +- .../ui/src/lib/theme/themes/mono-dark.json | 2 +- .../ui/src/lib/theme/themes/mono-light.json | 4 +- .../src/lib/theme/themes/mono-plus-dark.json | 2 +- .../src/lib/theme/themes/mono-plus-light.json | 4 +- .../ui/src/lib/theme/themes/monokai-dark.json | 2 +- .../src/lib/theme/themes/monokai-light.json | 2 +- .../src/lib/theme/themes/nightowl-dark.json | 2 +- .../src/lib/theme/themes/nightowl-light.json | 2 +- .../ui/src/lib/theme/themes/nord-dark.json | 4 +- .../ui/src/lib/theme/themes/nord-light.json | 4 +- .../src/lib/theme/themes/onedarkpro-dark.json | 4 +- .../lib/theme/themes/onedarkpro-light.json | 4 +- .../src/lib/theme/themes/solarized-dark.json | 4 +- .../src/lib/theme/themes/solarized-light.json | 4 +- .../src/lib/theme/themes/tokyonight-dark.json | 4 +- .../lib/theme/themes/tokyonight-light.json | 4 +- .../ui/src/lib/theme/themes/vesper-dark.json | 4 +- .../ui/src/lib/theme/themes/vesper-light.json | 4 +- .../lib/theme/themes/vitesse-dark-dark.json | 4 +- .../lib/theme/themes/vitesse-light-light.json | 4 +- packages/ui/src/lib/toolStatus.ts | 83 ++ packages/ui/src/lib/userSendAnimation.ts | 34 + packages/ui/src/stores/messageStore.ts | 1055 ++++---------- packages/ui/src/stores/types/sessionTypes.ts | 32 +- .../src/stores/useInlineCommentDraftStore.ts | 75 + .../ui/src/stores/useSessionFoldersStore.ts | 22 + packages/ui/src/stores/useSessionStore.ts | 73 +- packages/ui/src/stores/useUIStore.ts | 56 +- .../ui/src/stores/utils/messageProjectors.ts | 46 + packages/ui/src/stores/utils/messageUtils.ts | 214 ++- packages/ui/src/styles/typography.css | 4 +- .../vscode/src/AgentManagerPanelProvider.ts | 388 +----- packages/vscode/src/ChatViewProvider.ts | 402 +----- .../vscode/src/SessionEditorPanelProvider.ts | 374 +---- packages/vscode/src/bridge.ts | 3 +- packages/vscode/src/extension.ts | 7 + packages/vscode/src/githubAuth.ts | 2 +- packages/vscode/src/sessionActivityWatcher.ts | 104 +- packages/vscode/src/sseProxy.ts | 140 ++ packages/vscode/webview/main.tsx | 7 + packages/web/server/index.js | 39 +- packages/web/server/lib/github/auth.js | 2 +- 134 files changed, 9366 insertions(+), 6147 deletions(-) create mode 100644 packages/ui/src/components/chat/components/ScrollToBottomButton.tsx create mode 100644 packages/ui/src/components/chat/components/TurnActivity.tsx create mode 100644 packages/ui/src/components/chat/components/TurnAssistantBlock.tsx create mode 100644 packages/ui/src/components/chat/components/TurnItem.tsx create mode 100644 packages/ui/src/components/chat/components/TurnList.tsx delete mode 100644 packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx create mode 100644 packages/ui/src/components/chat/hooks/useChatTimelineController.ts create mode 100644 packages/ui/src/components/chat/hooks/useChatTurnNavigation.ts create mode 100644 packages/ui/src/components/chat/hooks/useStreamingTextThrottle.ts delete mode 100644 packages/ui/src/components/chat/hooks/useTurnGrouping.ts create mode 100644 packages/ui/src/components/chat/hooks/useTurnLookup.ts create mode 100644 packages/ui/src/components/chat/hooks/useTurnRecords.ts create mode 100644 packages/ui/src/components/chat/lib/blockingRequests.ts create mode 100644 packages/ui/src/components/chat/lib/scroll/scrollIntent.ts create mode 100644 packages/ui/src/components/chat/lib/scroll/scrollSpy.ts create mode 100644 packages/ui/src/components/chat/lib/turns/applyRetryOverlay.ts create mode 100644 packages/ui/src/components/chat/lib/turns/constants.ts create mode 100644 packages/ui/src/components/chat/lib/turns/historySignals.ts create mode 100644 packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts create mode 100644 packages/ui/src/components/chat/lib/turns/projectTurnIndexes.ts create mode 100644 packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts create mode 100644 packages/ui/src/components/chat/lib/turns/projectTurnSummary.ts create mode 100644 packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts create mode 100644 packages/ui/src/components/chat/lib/turns/stageTurns.ts create mode 100644 packages/ui/src/components/chat/lib/turns/types.ts create mode 100644 packages/ui/src/components/chat/lib/turns/windowTurns.ts create mode 100644 packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts create mode 100644 packages/ui/src/components/chat/message/parts/DOCUMENTATION.md create mode 100644 packages/ui/src/components/chat/message/parts/GenericStatusSpinner.tsx create mode 100644 packages/ui/src/components/chat/message/parts/MinDurationShineText.tsx create mode 100644 packages/ui/src/components/chat/message/parts/SessionActiveSpinner.tsx create mode 100644 packages/ui/src/components/chat/message/parts/ToolRevealOnMount.tsx create mode 100644 packages/ui/src/components/chat/message/parts/assistantTextVisibility.ts create mode 100644 packages/ui/src/components/chat/message/parts/toolPresentation.tsx create mode 100644 packages/ui/src/components/chat/message/parts/toolRenderUtils.ts delete mode 100644 packages/ui/src/components/sections/openchamber/MemoryLimitsSettings.tsx create mode 100644 packages/ui/src/lib/toolStatus.ts create mode 100644 packages/ui/src/lib/userSendAnimation.ts create mode 100644 packages/ui/src/stores/utils/messageProjectors.ts create mode 100644 packages/vscode/src/sseProxy.ts 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 - -