diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index a1585ad6a..d5d102b7c 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -2685,6 +2685,7 @@ body {
}
.chat-messages {
+ position: relative;
flex: 1;
overflow-y: auto;
padding: 24px;
@@ -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
diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx
index eed0a00b4..d626cac80 100644
--- a/src/renderer/src/screens/Chat/Chat.tsx
+++ b/src/renderer/src/screens/Chat/Chat.tsx
@@ -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";
@@ -174,7 +174,8 @@ function Chat({
};
}, []);
- const { containerRef, bottomRef } = useChatScroll(messages);
+ const { containerRef, bottomRef, isScrolledUp, scrollToBottomNow } =
+ useChatScroll(messages);
const modelConfig = useModelConfig(profile);
const {
fastMode,
@@ -562,6 +563,17 @@ function Chat({
/>
)}
+ {isScrolledUp && (
+
+ )}
{contextFolder && worktreeVisible && (
diff --git a/src/renderer/src/screens/Chat/hooks/useChatScroll.ts b/src/renderer/src/screens/Chat/hooks/useChatScroll.ts
index e20b53971..1bde00377 100644
--- a/src/renderer/src/screens/Chat/hooks/useChatScroll.ts
+++ b/src/renderer/src/screens/Chat/hooks/useChatScroll.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatMessage } from "../types";
/**
@@ -6,30 +6,41 @@ import type { ChatMessage } from "../types";
*
* - 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;
bottomRef: React.RefObject;
+ isScrolledUp: boolean;
+ scrollToBottomNow: () => void;
} {
const containerRef = useRef(null);
const bottomRef = useRef(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" });
+ }, []);
+
// 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);
@@ -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 };
}
diff --git a/src/shared/i18n/locales/en/chat.ts b/src/shared/i18n/locales/en/chat.ts
index 1a28eae43..a6e8b04b2 100644
--- a/src/shared/i18n/locales/en/chat.ts
+++ b/src/shared/i18n/locales/en/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/es/chat.ts b/src/shared/i18n/locales/es/chat.ts
index 1cdf05413..58bc2e9a1 100644
--- a/src/shared/i18n/locales/es/chat.ts
+++ b/src/shared/i18n/locales/es/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/id/chat.ts b/src/shared/i18n/locales/id/chat.ts
index bf4a24061..d410d5e09 100644
--- a/src/shared/i18n/locales/id/chat.ts
+++ b/src/shared/i18n/locales/id/chat.ts
@@ -82,6 +82,7 @@ export default {
version: "Tampilkan versi Hermes",
},
queuedCancel: "Batalkan pesan antrian",
+ scrollToBottom: "Gulir ke bawah",
worktree: {
loading: "Memuat",
empty: "Folder kosong",
diff --git a/src/shared/i18n/locales/ja/chat.ts b/src/shared/i18n/locales/ja/chat.ts
index 83461d9f9..9d9a0faf3 100644
--- a/src/shared/i18n/locales/ja/chat.ts
+++ b/src/shared/i18n/locales/ja/chat.ts
@@ -81,6 +81,7 @@ export default {
version: "Hermes バージョンを表示",
},
queuedCancel: "キューから削除",
+ scrollToBottom: "一番下までスクロール",
worktree: {
loading: "読み込み中",
empty: "フォルダは空です",
diff --git a/src/shared/i18n/locales/pl/chat.ts b/src/shared/i18n/locales/pl/chat.ts
index fa5b153c1..d949e9c5f 100644
--- a/src/shared/i18n/locales/pl/chat.ts
+++ b/src/shared/i18n/locales/pl/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/pt-BR/chat.ts b/src/shared/i18n/locales/pt-BR/chat.ts
index 1050f2767..4d7a20332 100644
--- a/src/shared/i18n/locales/pt-BR/chat.ts
+++ b/src/shared/i18n/locales/pt-BR/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/pt-PT/chat.ts b/src/shared/i18n/locales/pt-PT/chat.ts
index 819dbc9bf..372ae5f2b 100644
--- a/src/shared/i18n/locales/pt-PT/chat.ts
+++ b/src/shared/i18n/locales/pt-PT/chat.ts
@@ -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",
diff --git a/src/shared/i18n/locales/tr/chat.ts b/src/shared/i18n/locales/tr/chat.ts
index 40c29f531..ba4d2c813 100644
--- a/src/shared/i18n/locales/tr/chat.ts
+++ b/src/shared/i18n/locales/tr/chat.ts
@@ -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ş",
diff --git a/src/shared/i18n/locales/zh-CN/chat.ts b/src/shared/i18n/locales/zh-CN/chat.ts
index 49d107459..8f6c30601 100644
--- a/src/shared/i18n/locales/zh-CN/chat.ts
+++ b/src/shared/i18n/locales/zh-CN/chat.ts
@@ -78,6 +78,7 @@ export default {
version: "查看 Hermes 版本",
},
queuedCancel: "从队列中移除",
+ scrollToBottom: "滚动到底部",
worktree: {
loading: "加载中",
empty: "文件夹为空",
diff --git a/src/shared/i18n/locales/zh-TW/chat.ts b/src/shared/i18n/locales/zh-TW/chat.ts
index 337c15a67..47a4eb483 100644
--- a/src/shared/i18n/locales/zh-TW/chat.ts
+++ b/src/shared/i18n/locales/zh-TW/chat.ts
@@ -67,6 +67,7 @@ export default {
},
queued: "{{count}} 則訊息已排隊 — 代理完成後將自動傳送",
queuedCancel: "從佇列中移除",
+ scrollToBottom: "捲動到底部",
worktree: {
loading: "載入中",
empty: "資料夾是空的",