Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
useWorkspaceAggregator,
useWorkspaceUsage,
useWorkspaceStatsSnapshot,
workspaceStore,
} from "@/browser/stores/WorkspaceStore";
import { WorkspaceHeader } from "./WorkspaceHeader";
import { getModelName } from "@/common/utils/ai/models";
Expand Down Expand Up @@ -247,6 +248,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
// Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise)
const [vimEnabled] = usePersistedState<boolean>(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,
Expand All @@ -257,7 +266,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
jumpToBottom,
handleScroll,
markUserInteraction,
} = useAutoScroll();
} = useAutoScroll({ onScrollNearTop });

// ChatInput API for focus management
const chatInputAPI = useRef<ChatInputAPI | null>(null);
Expand Down Expand Up @@ -402,6 +411,29 @@ const AIViewInner: React.FC<AIViewProps> = ({
[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);
Expand Down Expand Up @@ -658,6 +690,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
foregroundBashToolCallIds={foregroundToolCallIds}
onSendBashToBackground={handleSendBashToBackground}
bashOutputGroup={bashOutputGroup}
onLoadMoreHistory={handleLoadMoreHistory}
/>
</div>
{/* Show collapsed indicator after the first item in a bash_output group */}
Expand Down
15 changes: 10 additions & 5 deletions src/browser/components/Messages/HistoryHiddenMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HistoryHiddenMessageProps> = ({
message,
className,
onLoadMore,
}) => {
return (
<div
<button
type="button"
onClick={onLoadMore}
className={cn(
"my-5 rounded-sm border-l-[3px] border-accent bg-[var(--color-message-hidden-bg)] px-[15px] py-3",
"my-5 w-full rounded-sm border-l-[3px] border-accent bg-[var(--color-message-hidden-bg)] px-[15px] py-3",
"font-sans text-center text-xs font-normal text-muted",
"cursor-pointer hover:bg-[var(--color-message-hidden-bg-hover)] transition-colors",
className
)}
>
{message.hiddenCount} older message{message.hiddenCount !== 1 ? "s" : ""} hidden for
performance
</div>
{message.hiddenCount} older message{message.hiddenCount !== 1 ? "s" : ""} hidden — click to
load more
</button>
);
};
11 changes: 10 additions & 1 deletion src/browser/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,6 +46,7 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
foregroundBashToolCallIds,
onSendBashToBackground,
bashOutputGroup,
onLoadMoreHistory,
}) => {
// Route based on message type
switch (message.type) {
Expand Down Expand Up @@ -83,7 +86,13 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
case "stream-error":
return <StreamErrorMessage message={message} className={className} />;
case "history-hidden":
return <HistoryHiddenMessage message={message} className={className} />;
return (
<HistoryHiddenMessage
message={message}
className={className}
onLoadMore={onLoadMoreHistory}
/>
);
case "workspace-init":
return <InitMessage message={message} className={className} />;
case "plan-display":
Expand Down
71 changes: 42 additions & 29 deletions src/browser/hooks/useAutoScroll.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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<HTMLDivElement>(null);
const lastScrollTopRef = useRef<number>(0);
Expand Down Expand Up @@ -80,38 +85,46 @@ export function useAutoScroll() {
}
}, []);

const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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();
Expand Down
17 changes: 17 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1590,6 +1606,7 @@ export const workspaceStore = {
getStoreInstance().getFileModifyingToolMs(workspaceId),
clearFileModifyingToolMs: (workspaceId: string) =>
getStoreInstance().clearFileModifyingToolMs(workspaceId),
expandDisplayLimit: (workspaceId: string) => getStoreInstance().expandDisplayLimit(workspaceId),
};

/**
Expand Down
4 changes: 4 additions & 0 deletions src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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%);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading