Skip to content

Commit a0c670e

Browse files
committed
fix/perf: elide chat history when too long
1 parent 5d5ce0a commit a0c670e

File tree

8 files changed

+157
-5
lines changed

8 files changed

+157
-5
lines changed

src/components/AIView.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { getModelName } from "@/utils/ai/models";
1717
import { GitStatusIndicator } from "./GitStatusIndicator";
1818
import type { GitStatus } from "@/types/workspace";
1919
import { TooltipWrapper, Tooltip } from "./Tooltip";
20+
import type { DisplayedMessage } from "@/types/message";
2021

2122
const ViewContainer = styled.div`
2223
flex: 1;
@@ -223,7 +224,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
223224

224225
// When editing, find the cutoff point
225226
const editCutoffHistoryId = editingMessage
226-
? messages.find((msg) => msg.historyId === editingMessage.id)?.historyId
227+
? messages.find(
228+
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" }> =>
229+
msg.type !== "history-hidden" && msg.historyId === editingMessage.id
230+
)?.historyId
227231
: undefined;
228232

229233
const handleMessageSent = useCallback(() => {
@@ -361,7 +365,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
361365
<>
362366
{messages.map((msg) => {
363367
const isAtCutoff =
364-
editCutoffHistoryId !== undefined && msg.historyId === editCutoffHistoryId;
368+
editCutoffHistoryId !== undefined &&
369+
msg.type !== "history-hidden" &&
370+
msg.historyId === editCutoffHistoryId;
365371

366372
return (
367373
<React.Fragment key={msg.id}>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import type { DisplayedMessage } from "@/types/message";
4+
5+
const HiddenIndicator = styled.div`
6+
margin: 20px 0;
7+
padding: 12px 15px;
8+
background: rgba(255, 255, 255, 0.03);
9+
border-left: 3px solid #569cd6;
10+
border-radius: 3px;
11+
color: #888888;
12+
font-size: 12px;
13+
font-weight: 400;
14+
text-align: center;
15+
font-family: var(--font-primary);
16+
`;
17+
18+
interface HistoryHiddenMessageProps {
19+
message: DisplayedMessage & { type: "history-hidden" };
20+
className?: string;
21+
}
22+
23+
export const HistoryHiddenMessage: React.FC<HistoryHiddenMessageProps> = ({
24+
message,
25+
className,
26+
}) => {
27+
return (
28+
<HiddenIndicator className={className}>
29+
{message.hiddenCount} older message{message.hiddenCount !== 1 ? "s" : ""} hidden for
30+
performance
31+
</HiddenIndicator>
32+
);
33+
};

src/components/Messages/MessageRenderer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AssistantMessage } from "./AssistantMessage";
55
import { ToolMessage } from "./ToolMessage";
66
import { ReasoningMessage } from "./ReasoningMessage";
77
import { StreamErrorMessage } from "./StreamErrorMessage";
8+
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
89

910
interface MessageRendererProps {
1011
message: DisplayedMessage;
@@ -29,6 +30,8 @@ export const MessageRenderer: React.FC<MessageRendererProps> = ({
2930
return <ReasoningMessage message={message} className={className} />;
3031
case "stream-error":
3132
return <StreamErrorMessage message={message} className={className} />;
33+
case "history-hidden":
34+
return <HistoryHiddenMessage message={message} className={className} />;
3235
default:
3336
console.error("don't know how to render message", message);
3437
return null;

src/components/Messages/MessageWindow.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { ReactNode } from "react";
2-
import React, { useState } from "react";
2+
import React, { useState, useMemo } from "react";
33
import styled from "@emotion/styled";
44
import type { CmuxMessage, DisplayedMessage } from "@/types/message";
55
import { HeaderButton } from "../tools/shared/ToolPrimitives";
6+
import { formatTimestamp } from "@/utils/ui/dateTime";
67

78
const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string }>`
89
margin-bottom: 15px;
@@ -25,11 +26,23 @@ const MessageHeader = styled.div`
2526
font-weight: 500;
2627
`;
2728

29+
const LeftSection = styled.div`
30+
display: flex;
31+
align-items: center;
32+
gap: 12px;
33+
`;
34+
2835
const MessageTypeLabel = styled.div`
2936
text-transform: uppercase;
3037
letter-spacing: 0.5px;
3138
`;
3239

40+
const TimestampText = styled.span`
41+
font-size: 10px;
42+
color: var(--color-text-secondary);
43+
font-weight: 400;
44+
`;
45+
3346
const ButtonGroup = styled.div`
3447
display: flex;
3548
gap: 6px;
@@ -81,10 +94,23 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
8194
}) => {
8295
const [showJson, setShowJson] = useState(false);
8396

97+
// Get timestamp from message if available
98+
const timestamp =
99+
"timestamp" in message && typeof message.timestamp === "number" ? message.timestamp : null;
100+
101+
// Memoize formatted timestamp to avoid recalculating on every render
102+
const formattedTimestamp = useMemo(
103+
() => (timestamp ? formatTimestamp(timestamp) : null),
104+
[timestamp]
105+
);
106+
84107
return (
85108
<MessageBlock borderColor={borderColor} backgroundColor={backgroundColor} className={className}>
86109
<MessageHeader>
87-
<MessageTypeLabel>{label}</MessageTypeLabel>
110+
<LeftSection>
111+
<MessageTypeLabel>{label}</MessageTypeLabel>
112+
{formattedTimestamp && <TimestampText>{formattedTimestamp}</TimestampText>}
113+
</LeftSection>
88114
<ButtonGroup>
89115
{rightLabel}
90116
{buttons.map((button, index) => (

src/types/message.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ export type DisplayedMessage =
112112
historySequence: number; // Global ordering across all messages
113113
timestamp?: number;
114114
model?: string;
115+
}
116+
| {
117+
type: "history-hidden";
118+
id: string; // Display ID for UI/React keys
119+
hiddenCount: number; // Number of messages hidden
120+
historySequence: number; // Global ordering across all messages
115121
};
116122

117123
// Helper to create a simple text message

src/utils/messages/StreamingMessageAggregator.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import type {
1919
} from "@/types/toolParts";
2020
import { isDynamicToolPart } from "@/types/toolParts";
2121

22+
// Maximum number of messages to display in the DOM for performance
23+
// Full history is still maintained internally for token counting and stats
24+
const MAX_DISPLAYED_MESSAGES = 128;
25+
2226
interface StreamingContext {
2327
startTime: number;
2428
isComplete: boolean;
@@ -554,6 +558,23 @@ export class StreamingMessageAggregator {
554558
}
555559
}
556560

561+
// Limit to last N messages for DOM performance
562+
// Full history is still maintained internally for token counting
563+
if (displayedMessages.length > MAX_DISPLAYED_MESSAGES) {
564+
const hiddenCount = displayedMessages.length - MAX_DISPLAYED_MESSAGES;
565+
const slicedMessages = displayedMessages.slice(-MAX_DISPLAYED_MESSAGES);
566+
567+
// Add history-hidden indicator as the first message
568+
const historyHiddenMessage: DisplayedMessage = {
569+
type: "history-hidden",
570+
id: "history-hidden",
571+
hiddenCount,
572+
historySequence: -1, // Place it before all messages
573+
};
574+
575+
return [historyHiddenMessage, ...slicedMessages];
576+
}
577+
557578
return displayedMessages;
558579
}
559580
}

src/utils/messages/messageUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export function extractTextContent(message: CmuxMessage): string {
1818
* - For multi-part messages, only show on the last part
1919
*/
2020
export function shouldShowInterruptedBarrier(msg: DisplayedMessage): boolean {
21-
if (msg.type === "user" || msg.type === "stream-error") return false;
21+
if (msg.type === "user" || msg.type === "stream-error" || msg.type === "history-hidden")
22+
return false;
2223

2324
// Only show on the last part of multi-part messages
2425
if (!msg.isLastPartOfMessage) return false;

src/utils/ui/dateTime.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Formats a Unix timestamp (milliseconds) into a "kitchen" format:
3+
* - "8:13 PM" if the timestamp is from today
4+
* - "Oct 23, 8:13 PM" if the timestamp is from a different day
5+
*
6+
* @param timestamp Unix timestamp in milliseconds
7+
* @returns Formatted time string
8+
*/
9+
export function formatTimestamp(timestamp: number): string {
10+
const date = new Date(timestamp);
11+
const now = new Date();
12+
13+
// Check if the timestamp is from today
14+
const isToday =
15+
date.getDate() === now.getDate() &&
16+
date.getMonth() === now.getMonth() &&
17+
date.getFullYear() === now.getFullYear();
18+
19+
if (isToday) {
20+
// Format: "8:13 PM"
21+
return date.toLocaleTimeString("en-US", {
22+
hour: "numeric",
23+
minute: "2-digit",
24+
hour12: true,
25+
});
26+
} else {
27+
// Format: "Oct 23, 8:13 PM"
28+
return date.toLocaleTimeString("en-US", {
29+
month: "short",
30+
day: "numeric",
31+
hour: "numeric",
32+
minute: "2-digit",
33+
hour12: true,
34+
});
35+
}
36+
}
37+
38+
/**
39+
* Formats a Unix timestamp (milliseconds) into a full date/time string with high precision.
40+
* Used for tooltips and detailed views.
41+
*
42+
* @param timestamp Unix timestamp in milliseconds
43+
* @returns Formatted full timestamp string (e.g., "October 23, 2025, 8:13:42 PM")
44+
*/
45+
export function formatFullTimestamp(timestamp: number): string {
46+
const date = new Date(timestamp);
47+
return date.toLocaleString("en-US", {
48+
year: "numeric",
49+
month: "long",
50+
day: "numeric",
51+
hour: "numeric",
52+
minute: "2-digit",
53+
second: "2-digit",
54+
hour12: true,
55+
});
56+
}

0 commit comments

Comments
 (0)