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
31 changes: 31 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -2685,6 +2685,7 @@ body {
}

.chat-messages {
position: relative;
flex: 1;
overflow-y: auto;
padding: 24px;
Expand Down Expand Up @@ -4204,6 +4205,36 @@ body {
animation: fadeIn 0.2s ease;
}

/* Floating "scroll to bottom" button — appears when the user scrolls up
away from the latest messages. */
.chat-scroll-bottom {
position: sticky;
bottom: 12px;
align-self: center;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--bg-elevated);
border: 1px solid var(--border-bright);
border-radius: 50%;
color: var(--text-secondary);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
transition: all var(--transition);
z-index: 10;
}

.chat-scroll-bottom:hover {
color: var(--text-primary);
background: var(--bg-hover);
border-color: var(--border-focus, var(--accent));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

/* ── Chat history sub-rows (reasoning / tool_call / tool_result) ───────
*
* Surfaced for resumed sessions: see src/main/sessions.ts
Expand Down
16 changes: 14 additions & 2 deletions src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Zap } from "lucide-react";
import { Zap, ArrowDown } from "lucide-react";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState";
import { MessageList } from "./MessageList";
Expand Down Expand Up @@ -174,7 +174,8 @@ function Chat({
};
}, []);

const { containerRef, bottomRef } = useChatScroll(messages);
const { containerRef, bottomRef, isScrolledUp, scrollToBottomNow } =
useChatScroll(messages);
const modelConfig = useModelConfig(profile);
const {
fastMode,
Expand Down Expand Up @@ -562,6 +563,17 @@ function Chat({
/>
)}
<div ref={bottomRef} />
{isScrolledUp && (
<button
type="button"
className="chat-scroll-bottom"
onClick={scrollToBottomNow}
title={t("chat.scrollToBottom")}
aria-label={t("chat.scrollToBottom")}
>
<ArrowDown size={16} />
</button>
)}
</div>

{contextFolder && worktreeVisible && (
Expand Down
20 changes: 16 additions & 4 deletions src/renderer/src/screens/Chat/hooks/useChatScroll.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatMessage } from "../types";

/**
* Auto-scroll behavior for the chat messages container.
*
* - Tracks whether the user has manually scrolled up; pauses auto-scroll in that case.
* - Re-engages auto-scroll when a new user message is sent.
* - Exposes the container ref and a bottom sentinel ref to be placed in JSX.
* - Exposes the container ref, bottom sentinel ref, and scroll state.
*/
export function useChatScroll(messages: ChatMessage[]): {
containerRef: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>;
isScrolledUp: boolean;
scrollToBottomNow: () => void;
} {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const userScrolledUpRef = useRef(false);
const prevMessageCountRef = useRef(messages.length);
const [isScrolledUp, setIsScrolledUp] = useState(false);

const scrollToBottom = useCallback((force?: boolean) => {
if (!force && userScrolledUpRef.current) return;
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);

const scrollToBottomNow = useCallback(() => {
userScrolledUpRef.current = false;
setIsScrolledUp(false);
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
Comment on lines +28 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Smooth scroll fires intermediate events that re-engage scrolled-up state

scrollToBottomNow sets userScrolledUpRef.current = false and setIsScrolledUp(false), then immediately kicks off a behavior: "smooth" scroll. The smooth animation generates a continuous stream of scroll events as the container travels from its current position to the bottom. Each intermediate event calls handleScroll, which evaluates atBottom = scrollHeight - scrollTop - clientHeight < 60 — a check that returns false for most of the animation. This causes setIsScrolledUp(true) and userScrolledUpRef.current = true to fire repeatedly mid-animation: the button flashes back into view and auto-scroll is paused again the instant the user clicks it.

The simplest fix is to use instant scrolling in scrollToBottomNow so no intermediate events are generated. Direct assignment to scrollTop is cleaner than scrollIntoView here because it operates on the container you already have a ref to. Alternatively, keep scrollIntoView but guard handleScroll with an isProgrammaticScrollRef flag that is set before the call and cleared in a short setTimeout.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


// Track manual scroll position
useEffect(() => {
const container = containerRef.current;
if (!container) return;
function handleScroll(): void {
const el = container!;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 60;
userScrolledUpRef.current = !atBottom;
setIsScrolledUp(!atBottom);
}
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
Expand All @@ -44,11 +55,12 @@ export function useChatScroll(messages: ChatMessage[]): {
messages[messages.length - 1]?.role === "user";
if (userJustSent) {
userScrolledUpRef.current = false;
setIsScrolledUp(false);
scrollToBottom(true);
} else {
scrollToBottom();
}
}, [messages, scrollToBottom]);

return { containerRef, bottomRef };
return { containerRef, bottomRef, isScrolledUp, scrollToBottomNow };
}
1 change: 1 addition & 0 deletions src/shared/i18n/locales/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default {
queuedCount: "{{count}} queued",
queuedAttachment: "{{count}} attachment(s)",
queuedCancel: "Remove from queue",
scrollToBottom: "Scroll to bottom",
worktree: {
loading: "Loading",
empty: "Folder is empty",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export default {
},
queued: "{{count}} mensaje(s) en cola — se enviará cuando el agente termine",
queuedCancel: "Quitar de la cola",
scrollToBottom: "Ir al final",
worktree: {
loading: "Cargando",
empty: "La carpeta está vacía",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/id/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default {
version: "Tampilkan versi Hermes",
},
queuedCancel: "Batalkan pesan antrian",
scrollToBottom: "Gulir ke bawah",
worktree: {
loading: "Memuat",
empty: "Folder kosong",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/ja/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default {
version: "Hermes バージョンを表示",
},
queuedCancel: "キューから削除",
scrollToBottom: "一番下までスクロール",
worktree: {
loading: "読み込み中",
empty: "フォルダは空です",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pl/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default {
queued:
"{{count}} wiadomość/wiadomości w kolejce — zostaną wysłane po zakończeniu pracy agenta",
queuedCancel: "Usuń z kolejki",
scrollToBottom: "Przewiń na dół",
worktree: {
loading: "Ładowanie",
empty: "Folder jest pusty",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default {
version: "Mostrar a versão do Hermes",
},
queuedCancel: "Remover da fila",
scrollToBottom: "Rolar para o final",
worktree: {
loading: "Carregando",
empty: "A pasta está vazia",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pt-PT/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default {
queued:
"{{count}} mensagem(ns) em fila — serão enviadas quando o agente terminar",
queuedCancel: "Remover da fila",
scrollToBottom: "Descer até ao fim",
worktree: {
loading: "A carregar",
empty: "A pasta está vazia",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/tr/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default {
},
queued: "{{count}} mesaj sırada — ajan işini bitirince gönderilecek",
queuedCancel: "Sıradan kaldır",
scrollToBottom: "En alta kaydır",
worktree: {
loading: "Yükleniyor",
empty: "Klasör boş",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/zh-CN/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default {
version: "查看 Hermes 版本",
},
queuedCancel: "从队列中移除",
scrollToBottom: "滚动到底部",
worktree: {
loading: "加载中",
empty: "文件夹为空",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/zh-TW/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default {
},
queued: "{{count}} 則訊息已排隊 — 代理完成後將自動傳送",
queuedCancel: "從佇列中移除",
scrollToBottom: "捲動到底部",
worktree: {
loading: "載入中",
empty: "資料夾是空的",
Expand Down
Loading