Skip to content

Commit 3021919

Browse files
committed
Window message rendering so long chats stay smooth
Mounting every message in a 500+ turn conversation is the actual source of scroll jank (each one runs a Markdown parse) — only the most recent 60 messages render by default now, with a "Show N earlier messages" control to widen the window on request. Jumping to a pinned message older than the window widens it first so the target node exists before scrolling to it. Absolute message indices are preserved throughout (copy/edit/regenerate/fork/pin all still address the full message array), so nothing about message identity changes, only what's mounted.
1 parent 30b4e54 commit 3021919

2 files changed

Lines changed: 56 additions & 19 deletions

File tree

‎frontend/src/lib/translations.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface Dictionary {
1111
model: string;
1212
sendMessage: string;
1313
startConversationWith: (model: string) => string;
14+
showEarlierMessages: (count: number) => string;
1415
attach: string;
1516
attachFiles: string;
1617
attachProjectFolder: string;
@@ -319,6 +320,7 @@ export const en: Dictionary = {
319320
model: "Model",
320321
sendMessage: "Send a message...",
321322
startConversationWith: (model) => `Start a conversation with ${model}.`,
323+
showEarlierMessages: (count) => `Show ${count} earlier message${count === 1 ? "" : "s"}`,
322324
attach: "Attach",
323325
attachFiles: "Attach files",
324326
attachProjectFolder: "Attach project folder",
@@ -637,6 +639,7 @@ export const tr: Dictionary = {
637639
model: "Model",
638640
sendMessage: "Bir mesaj gönderin...",
639641
startConversationWith: (model) => `${model} ile sohbete başlayın.`,
642+
showEarlierMessages: (count) => `${count} önceki mesajı göster`,
640643
attach: "Ekle",
641644
attachFiles: "Dosya ekle",
642645
attachProjectFolder: "Proje klasörü ekle",

‎frontend/src/pages/Chat.tsx‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ const CUSTOM_SENTINEL = "__custom__";
107107
// every file's full content into the prompt (which would blow out context on
108108
// smaller models and waste tokens on larger ones).
109109
const RAG_THRESHOLD_CHARS = 20_000;
110+
// Mounting every message in a very long chat is what actually causes scroll
111+
// jank (each one runs a Markdown parse) — so past this many messages, only
112+
// the most recent window renders by default, with older ones revealed a
113+
// window at a time on request rather than all at once.
114+
const RENDER_WINDOW_SIZE = 60;
110115
// Read-only tools are safe to let the model call repeatedly without a fresh
111116
// click each time — write_file and run_command always require explicit
112117
// per-call approval since they have real, potentially irreversible effects.
@@ -439,6 +444,7 @@ export default function Chat() {
439444
const [planSteps, setPlanSteps] = useState<PlanStep[]>([]);
440445
const [contextSummary, setContextSummary] = useState<string | null>(null);
441446
const [contextSummaryThroughIndex, setContextSummaryThroughIndex] = useState(0);
447+
const [renderLimit, setRenderLimit] = useState(RENDER_WINDOW_SIZE);
442448
const [projectScripts, setProjectScripts] = useState<ProjectScripts>({});
443449
const [quickActionRunning, setQuickActionRunning] = useState(false);
444450
const [undoMessage, setUndoMessage] = useState<string | null>(null);
@@ -522,6 +528,7 @@ export default function Chat() {
522528
setPlanSteps(session.planSteps ?? []);
523529
setContextSummary(session.contextSummary ?? null);
524530
setContextSummaryThroughIndex(session.contextSummaryThroughIndex ?? 0);
531+
setRenderLimit(RENDER_WINDOW_SIZE);
525532
setAutoApprovedTools(new Set());
526533
setWriteDiffPreviews({});
527534
setUndoMessage(null);
@@ -1280,6 +1287,20 @@ export default function Chat() {
12801287
}
12811288

12821289
function scrollToMessage(index: number) {
1290+
// The target may be outside the current render window (an older
1291+
// message than what's mounted) — widen it enough to include the
1292+
// target before the DOM node can be found at all.
1293+
if (index < visibleStartIndex) {
1294+
setRenderLimit(messages.length - index + RENDER_WINDOW_SIZE);
1295+
requestAnimationFrame(() => {
1296+
requestAnimationFrame(() => {
1297+
viewportRef.current
1298+
?.querySelector(`[data-message-index="${index}"]`)
1299+
?.scrollIntoView({ behavior: "smooth", block: "center" });
1300+
});
1301+
});
1302+
return;
1303+
}
12831304
const el = viewportRef.current?.querySelector(`[data-message-index="${index}"]`);
12841305
el?.scrollIntoView({ behavior: "smooth", block: "center" });
12851306
}
@@ -1366,6 +1387,8 @@ export default function Chat() {
13661387
};
13671388
}, [attachments]);
13681389
const lastAssistantIndex = useMemo(() => messages.findLastIndex((message) => message.role === "assistant"), [messages]);
1390+
const visibleStartIndex = messages.length > renderLimit ? messages.length - renderLimit : 0;
1391+
const hiddenMessageCount = visibleStartIndex;
13691392
const sessionCost = useMemo(
13701393
() =>
13711394
parsedModel && parsedModel.provider !== "ollama" && parsedModel.provider !== "llamacpp"
@@ -1938,25 +1961,36 @@ export default function Chat() {
19381961
</div>
19391962
</div>
19401963
)}
1941-
{messages.map((m, i) => (
1942-
<MessageBubble
1943-
key={i}
1944-
message={m}
1945-
index={i}
1946-
isLastAssistant={m.role === "assistant" && i === lastAssistantIndex}
1947-
isStreaming={isStreaming}
1948-
copied={copiedIndex === i}
1949-
speaking={speakingIndex === i}
1950-
provider={parsedModel?.provider}
1951-
modelId={parsedModel?.modelId}
1952-
onCopy={handleCopyMessage}
1953-
onEdit={handleEditUserMessage}
1954-
onRegenerate={handleRegenerate}
1955-
onToggleSpeak={toggleSpeak}
1956-
onTogglePin={togglePinMessage}
1957-
onFork={forkFromMessage}
1958-
/>
1959-
))}
1964+
{hiddenMessageCount > 0 && (
1965+
<button
1966+
onClick={() => setRenderLimit((n) => n + RENDER_WINDOW_SIZE)}
1967+
className="mx-auto rounded-full border border-border bg-muted/50 px-4 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted"
1968+
>
1969+
{t.showEarlierMessages(hiddenMessageCount)}
1970+
</button>
1971+
)}
1972+
{messages.slice(visibleStartIndex).map((m, localIndex) => {
1973+
const i = visibleStartIndex + localIndex;
1974+
return (
1975+
<MessageBubble
1976+
key={i}
1977+
message={m}
1978+
index={i}
1979+
isLastAssistant={m.role === "assistant" && i === lastAssistantIndex}
1980+
isStreaming={isStreaming}
1981+
copied={copiedIndex === i}
1982+
speaking={speakingIndex === i}
1983+
provider={parsedModel?.provider}
1984+
modelId={parsedModel?.modelId}
1985+
onCopy={handleCopyMessage}
1986+
onEdit={handleEditUserMessage}
1987+
onRegenerate={handleRegenerate}
1988+
onToggleSpeak={toggleSpeak}
1989+
onTogglePin={togglePinMessage}
1990+
onFork={forkFromMessage}
1991+
/>
1992+
);
1993+
})}
19601994
{pendingToolCalls.map((call) => {
19611995
if (call.name === "request_checkpoint") {
19621996
const summary = String(call.arguments.summary ?? "");

0 commit comments

Comments
 (0)