diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 966b586080..de46324e4c 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -48,6 +48,7 @@ import { useWorkspaceAggregator, useWorkspaceUsage, useWorkspaceStatsSnapshot, + workspaceStore, } from "@/browser/stores/WorkspaceStore"; import { WorkspaceHeader } from "./WorkspaceHeader"; import { getModelName } from "@/common/utils/ai/models"; @@ -247,6 +248,14 @@ const AIViewInner: React.FC = ({ // Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise) const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); + // Load more history when user scrolls to top + // Use ref + useCallback pattern to avoid recreating useAutoScroll options on every render + // eslint-disable-next-line @typescript-eslint/no-empty-function + const handleLoadMoreHistoryRef = useRef<() => void>(() => {}); + const onScrollNearTop = useCallback(() => { + handleLoadMoreHistoryRef.current(); + }, []); + // Use auto-scroll hook for scroll management const { contentRef, @@ -257,7 +266,7 @@ const AIViewInner: React.FC = ({ jumpToBottom, handleScroll, markUserInteraction, - } = useAutoScroll(); + } = useAutoScroll({ onScrollNearTop }); // ChatInput API for focus management const chatInputAPI = useRef(null); @@ -402,6 +411,29 @@ const AIViewInner: React.FC = ({ [api] ); + // Load more history when user scrolls to top or clicks the hidden history button + const handleLoadMoreHistory = useCallback(() => { + // Capture scroll position before loading more messages + const scrollContainer = chatAreaRef.current; + const scrollHeightBefore = scrollContainer?.scrollHeight ?? 0; + const scrollTopBefore = scrollContainer?.scrollTop ?? 0; + + const loaded = workspaceStore.expandDisplayLimit(workspaceId); + + // Restore scroll position after new messages are rendered + if (loaded && scrollContainer) { + // Use requestAnimationFrame to wait for DOM update + requestAnimationFrame(() => { + const scrollHeightAfter = scrollContainer.scrollHeight; + const heightDiff = scrollHeightAfter - scrollHeightBefore; + scrollContainer.scrollTop = scrollTopBefore + heightDiff; + }); + } + }, [workspaceId]); + + // Keep ref in sync for scroll-triggered loading + handleLoadMoreHistoryRef.current = handleLoadMoreHistory; + const openTerminal = useOpenTerminal(); const handleOpenTerminal = useCallback(() => { openTerminal(workspaceId, runtimeConfig); @@ -658,6 +690,7 @@ const AIViewInner: React.FC = ({ foregroundBashToolCallIds={foregroundToolCallIds} onSendBashToBackground={handleSendBashToBackground} bashOutputGroup={bashOutputGroup} + onLoadMoreHistory={handleLoadMoreHistory} /> {/* Show collapsed indicator after the first item in a bash_output group */} diff --git a/src/browser/components/Messages/HistoryHiddenMessage.tsx b/src/browser/components/Messages/HistoryHiddenMessage.tsx index 2113ce294a..ba211200f7 100644 --- a/src/browser/components/Messages/HistoryHiddenMessage.tsx +++ b/src/browser/components/Messages/HistoryHiddenMessage.tsx @@ -5,22 +5,27 @@ import type { DisplayedMessage } from "@/common/types/message"; interface HistoryHiddenMessageProps { message: DisplayedMessage & { type: "history-hidden" }; className?: string; + onLoadMore?: () => void; } export const HistoryHiddenMessage: React.FC = ({ message, className, + onLoadMore, }) => { return ( -
- {message.hiddenCount} older message{message.hiddenCount !== 1 ? "s" : ""} hidden for - performance -
+ {message.hiddenCount} older message{message.hiddenCount !== 1 ? "s" : ""} hidden — click to + load more + ); }; diff --git a/src/browser/components/Messages/MessageRenderer.tsx b/src/browser/components/Messages/MessageRenderer.tsx index 7387873c8a..66d16751db 100644 --- a/src/browser/components/Messages/MessageRenderer.tsx +++ b/src/browser/components/Messages/MessageRenderer.tsx @@ -29,6 +29,8 @@ interface MessageRendererProps { onSendBashToBackground?: (toolCallId: string) => void; /** Optional bash_output grouping info (computed at render-time) */ bashOutputGroup?: BashOutputGroupInfo; + /** Callback to load more hidden history messages */ + onLoadMoreHistory?: () => void; } // Memoized to prevent unnecessary re-renders when parent (AIView) updates @@ -44,6 +46,7 @@ export const MessageRenderer = React.memo( foregroundBashToolCallIds, onSendBashToBackground, bashOutputGroup, + onLoadMoreHistory, }) => { // Route based on message type switch (message.type) { @@ -83,7 +86,13 @@ export const MessageRenderer = React.memo( case "stream-error": return ; case "history-hidden": - return ; + return ( + + ); case "workspace-init": return ; case "plan-display": diff --git a/src/browser/hooks/useAutoScroll.ts b/src/browser/hooks/useAutoScroll.ts index c1e2c3e836..7c8b0fedc2 100644 --- a/src/browser/hooks/useAutoScroll.ts +++ b/src/browser/hooks/useAutoScroll.ts @@ -1,5 +1,10 @@ import { useRef, useState, useCallback } from "react"; +interface UseAutoScrollOptions { + /** Callback when user scrolls near the top of the container */ + onScrollNearTop?: () => void; +} + /** * Hook to manage auto-scrolling behavior for a scrollable container. * @@ -17,7 +22,7 @@ import { useRef, useState, useCallback } from "react"; * Auto-scroll is disabled when: * - User scrolls up */ -export function useAutoScroll() { +export function useAutoScroll(options: UseAutoScrollOptions = {}) { const [autoScroll, setAutoScroll] = useState(true); const contentRef = useRef(null); const lastScrollTopRef = useRef(0); @@ -80,38 +85,46 @@ export function useAutoScroll() { } }, []); - const handleScroll = useCallback((e: React.UIEvent) => { - const element = e.currentTarget; - const currentScrollTop = element.scrollTop; - const threshold = 100; - const isAtBottom = element.scrollHeight - currentScrollTop - element.clientHeight < threshold; + const handleScroll = useCallback( + (e: React.UIEvent) => { + const element = e.currentTarget; + const currentScrollTop = element.scrollTop; + const threshold = 100; + const isAtBottom = element.scrollHeight - currentScrollTop - element.clientHeight < threshold; - // Only process user-initiated scrolls (within 100ms of interaction) - const isUserScroll = Date.now() - lastUserInteractionRef.current < 100; + // Only process user-initiated scrolls (within 100ms of interaction) + const isUserScroll = Date.now() - lastUserInteractionRef.current < 100; - if (!isUserScroll) { - lastScrollTopRef.current = currentScrollTop; - return; // Ignore programmatic scrolls - } + if (!isUserScroll) { + lastScrollTopRef.current = currentScrollTop; + return; // Ignore programmatic scrolls + } - // Detect scroll direction - const isScrollingUp = currentScrollTop < lastScrollTopRef.current; - const isScrollingDown = currentScrollTop > lastScrollTopRef.current; - - if (isScrollingUp) { - // Always disable auto-scroll when scrolling up - setAutoScroll(false); - autoScrollRef.current = false; - } else if (isScrollingDown && isAtBottom) { - // Only enable auto-scroll if scrolling down AND reached the bottom - setAutoScroll(true); - autoScrollRef.current = true; - } - // If scrolling down but not at bottom, auto-scroll remains disabled + // Detect scroll direction + const isScrollingUp = currentScrollTop < lastScrollTopRef.current; + const isScrollingDown = currentScrollTop > lastScrollTopRef.current; - // Update last scroll position - lastScrollTopRef.current = currentScrollTop; - }, []); + if (isScrollingUp) { + // Always disable auto-scroll when scrolling up + setAutoScroll(false); + autoScrollRef.current = false; + + // Notify when scrolled near the top (for loading more history) + if (currentScrollTop < threshold && options.onScrollNearTop) { + options.onScrollNearTop(); + } + } else if (isScrollingDown && isAtBottom) { + // Only enable auto-scroll if scrolling down AND reached the bottom + setAutoScroll(true); + autoScrollRef.current = true; + } + // If scrolling down but not at bottom, auto-scroll remains disabled + + // Update last scroll position + lastScrollTopRef.current = currentScrollTop; + }, + [options] + ); const markUserInteraction = useCallback(() => { lastUserInteractionRef.current = Date.now(); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index f472790e5a..f4bbc35bdb 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -916,6 +916,22 @@ export class WorkspaceStore { return this.aggregators.get(workspaceId); } + /** + * Expand the display limit to show more historical messages. + * Returns true if more messages were loaded, false if all are already visible. + */ + expandDisplayLimit(workspaceId: string): boolean { + const aggregator = this.aggregators.get(workspaceId); + if (!aggregator) return false; + + const result = aggregator.expandDisplayLimit(); + if (result !== null) { + this.states.bump(workspaceId); + return true; + } + return false; + } + getWorkspaceStatsSnapshot(workspaceId: string): WorkspaceStatsSnapshot | null { return this.statsStore.get(workspaceId, () => { return this.workspaceStats.get(workspaceId) ?? null; @@ -1590,6 +1606,7 @@ export const workspaceStore = { getStoreInstance().getFileModifyingToolMs(workspaceId), clearFileModifyingToolMs: (workspaceId: string) => getStoreInstance().clearFileModifyingToolMs(workspaceId), + expandDisplayLimit: (workspaceId: string) => getStoreInstance().expandDisplayLimit(workspaceId), }; /** diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index fb482a5a0f..8bbaa18a9d 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -107,6 +107,7 @@ --color-message-debug-border: rgba(255, 255, 255, 0.1); --color-message-debug-text: rgba(255, 255, 255, 0.8); --color-message-hidden-bg: rgba(255, 255, 255, 0.03); + --color-message-hidden-bg-hover: rgba(255, 255, 255, 0.07); --color-attachment-border: rgba(255, 255, 255, 0.1); --color-line-number-bg: rgba(0, 0, 0, 0.2); --color-line-number-text: rgba(255, 255, 255, 0.4); @@ -361,6 +362,7 @@ --color-message-debug-border: hsl(210 26% 82%); --color-message-debug-text: hsl(210 28% 32%); --color-message-hidden-bg: hsl(210 36% 94%); + --color-message-hidden-bg-hover: hsl(210 36% 88%); --color-attachment-border: hsl(210 24% 82%); --color-line-number-bg: hsl(210 34% 93%); --color-line-number-text: hsl(210 14% 46%); @@ -601,6 +603,7 @@ --color-message-debug-border: #93a1a1; --color-message-debug-text: #586e75; --color-message-hidden-bg: #f5efdc; + --color-message-hidden-bg-hover: #ede5cc; --color-attachment-border: #93a1a1; --color-line-number-bg: #eee8d5; --color-line-number-text: #839496; @@ -816,6 +819,7 @@ --color-message-debug-border: rgba(88, 110, 117, 0.4); --color-message-debug-text: #93a1a1; --color-message-hidden-bg: rgba(7, 54, 66, 0.3); + --color-message-hidden-bg-hover: rgba(7, 54, 66, 0.45); --color-attachment-border: rgba(88, 110, 117, 0.4); --color-line-number-bg: rgba(0, 43, 54, 0.5); --color-line-number-text: #586e75; diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index 3e23226216..1e8307770e 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -925,4 +925,112 @@ describe("StreamingMessageAggregator", () => { } }); }); + + describe("expandDisplayLimit", () => { + test("should return null when no messages are hidden", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + // Add a few messages (less than default 128 limit) + aggregator.handleMessage({ + type: "message", + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: Date.now(), historySequence: 1 }, + }); + + aggregator.handleMessage({ + type: "message", + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hi!" }], + metadata: { timestamp: Date.now(), historySequence: 2 }, + }); + + // No messages hidden, should return null + expect(aggregator.expandDisplayLimit()).toBeNull(); + }); + + test("should expand limit and reveal hidden messages", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + // Add more messages than the default 128 limit + // Each assistant message can have multiple displayed parts, so be careful with count + for (let i = 0; i < 150; i++) { + aggregator.handleMessage({ + type: "message", + id: `user-${i}`, + role: "user", + parts: [{ type: "text", text: `Message ${i}` }], + metadata: { timestamp: Date.now(), historySequence: i * 2 }, + }); + aggregator.handleMessage({ + type: "message", + id: `assistant-${i}`, + role: "assistant", + parts: [{ type: "text", text: `Response ${i}` }], + metadata: { timestamp: Date.now(), historySequence: i * 2 + 1 }, + }); + } + + const messagesBeforeExpand = aggregator.getDisplayedMessages(); + // Should have history-hidden indicator as first message + expect(messagesBeforeExpand[0].type).toBe("history-hidden"); + const hiddenCountBefore = + messagesBeforeExpand[0].type === "history-hidden" ? messagesBeforeExpand[0].hiddenCount : 0; + expect(hiddenCountBefore).toBeGreaterThan(0); + + // Expand the limit + const newLimit = aggregator.expandDisplayLimit(); + expect(newLimit).not.toBeNull(); + expect(newLimit).toBeGreaterThan(128); + + const messagesAfterExpand = aggregator.getDisplayedMessages(); + // Should still have hidden messages but fewer + if (messagesAfterExpand[0].type === "history-hidden") { + expect(messagesAfterExpand[0].hiddenCount).toBeLessThan(hiddenCountBefore); + } + }); + + test("hasHiddenMessages returns true when messages are truncated", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + // Add more messages than the default limit + for (let i = 0; i < 150; i++) { + aggregator.handleMessage({ + type: "message", + id: `user-${i}`, + role: "user", + parts: [{ type: "text", text: `Message ${i}` }], + metadata: { timestamp: Date.now(), historySequence: i * 2 }, + }); + aggregator.handleMessage({ + type: "message", + id: `assistant-${i}`, + role: "assistant", + parts: [{ type: "text", text: `Response ${i}` }], + metadata: { timestamp: Date.now(), historySequence: i * 2 + 1 }, + }); + } + + expect(aggregator.hasHiddenMessages()).toBe(true); + }); + + test("hasHiddenMessages returns false when all messages are visible", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + // Add just a few messages + for (let i = 0; i < 3; i++) { + aggregator.handleMessage({ + type: "message", + id: `user-${i}`, + role: "user", + parts: [{ type: "text", text: `Message ${i}` }], + metadata: { timestamp: Date.now(), historySequence: i * 2 }, + }); + } + + expect(aggregator.hasHiddenMessages()).toBe(false); + }); + }); }); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 3b4ea9cb79..4f95d13b2d 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -44,7 +44,12 @@ const AgentStatusSchema = z.object({ }); type AgentStatus = z.infer; -const MAX_DISPLAYED_MESSAGES = 128; + +// Default number of messages to display in the DOM for performance +// Full history is still maintained internally for token counting and stats +const DEFAULT_DISPLAY_LIMIT = 128; +// How many additional messages to show when loading more +const DISPLAY_LIMIT_INCREMENT = 64; interface StreamingContext { /** Backend timestamp when stream started (Date.now()) */ @@ -194,6 +199,9 @@ export class StreamingMessageAggregator { // Workspace ID for localStorage persistence private readonly workspaceId: string | undefined; + // Dynamic display limit for DOM performance (can be increased via expandDisplayLimit) + private displayLimit = DEFAULT_DISPLAY_LIMIT; + // Workspace init hook state (ephemeral, not persisted to history) private initState: { status: "running" | "success" | "error"; @@ -1647,9 +1655,9 @@ export class StreamingMessageAggregator { // Limit to last N messages for DOM performance // Full history is still maintained internally for token counting - if (displayedMessages.length > MAX_DISPLAYED_MESSAGES) { - const hiddenCount = displayedMessages.length - MAX_DISPLAYED_MESSAGES; - const slicedMessages = displayedMessages.slice(-MAX_DISPLAYED_MESSAGES); + if (displayedMessages.length > this.displayLimit) { + const hiddenCount = displayedMessages.length - this.displayLimit; + const slicedMessages = displayedMessages.slice(-this.displayLimit); // Add history-hidden indicator as the first message const historyHiddenMessage: DisplayedMessage = { @@ -1668,6 +1676,35 @@ export class StreamingMessageAggregator { return this.cachedDisplayedMessages; } + /** + * Expand the display limit to show more historical messages. + * Returns the new display limit, or null if all messages are already visible. + */ + expandDisplayLimit(): number | null { + const allMessages = this.getAllMessages(); + const totalDisplayed = this.getDisplayedMessages().length; + // Check if we're already showing all messages (no history-hidden indicator) + const hasHidden = + totalDisplayed > 0 && this.getDisplayedMessages()[0].type === "history-hidden"; + if (!hasHidden) { + return null; // Nothing more to load + } + // Increase limit by increment + this.displayLimit += DISPLAY_LIMIT_INCREMENT; + // Invalidate cache so next getDisplayedMessages() uses new limit + this.cachedDisplayedMessages = null; + // Return new limit (capped at total message count for clarity) + return Math.min(this.displayLimit, allMessages.length); + } + + /** + * Check if there are hidden messages that can be loaded. + */ + hasHiddenMessages(): boolean { + const displayed = this.getDisplayedMessages(); + return displayed.length > 0 && displayed[0].type === "history-hidden"; + } + /** * Track a delta for token counting and TPS calculation */