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: "資料夾是空的",