From 8bea05478a619e82cd189d6eca6c3ec7627c8efc Mon Sep 17 00:00:00 2001 From: Mohammed Efaz <44260523+WhiteHades@users.noreply.github.com> Date: Sat, 24 Jan 2026 22:57:00 +0600 Subject: [PATCH] feat(ai): AI reading assistant phase 2.1 (minor UI/UX updates) (#3059) * refactor(ai): remove chat icons from conversation list items * feat(ai): add scroll-to-bottom button with animations and dynamic spacer * fix(ai): align connection status icon with text in settings * feat(ai): scroll to bottom on chat panel open and improve scroll behavior * feat(ai): add loading overlay with blur transition for chat history * feat(ai): pass loading state through component chain for history loading --- .../reader/components/sidebar/AIAssistant.tsx | 43 +++- .../components/sidebar/ChatHistoryView.tsx | 2 - .../src/components/assistant-ui/thread.tsx | 194 +++++++++++++++--- .../src/components/settings/AIPanel.tsx | 26 +-- 4 files changed, 218 insertions(+), 47 deletions(-) diff --git a/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx b/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx index 101ea929..4df402eb 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/AIAssistant.tsx @@ -84,7 +84,12 @@ const AIAssistantChat = ({ currentPage: number; onResetIndex: () => void; }) => { - const { activeConversationId, messages: storedMessages, addMessage } = useAIChatStore(); + const { + activeConversationId, + messages: storedMessages, + addMessage, + isLoadingHistory, + } = useAIChatStore(); // use a ref to keep up-to-date options without triggering re-renders of the runtime const optionsRef = useRef({ @@ -153,41 +158,55 @@ const AIAssistantChat = ({ adapter={adapter} historyAdapter={historyAdapter} onResetIndex={onResetIndex} + isLoadingHistory={isLoadingHistory} + hasActiveConversation={!!activeConversationId} /> ); }; -// separate component to ensure useLocalRuntime is always called with a valid adapter const AIAssistantWithRuntime = ({ adapter, historyAdapter, onResetIndex, + isLoadingHistory, + hasActiveConversation, }: { adapter: NonNullable>; historyAdapter?: ThreadHistoryAdapter; onResetIndex: () => void; + isLoadingHistory: boolean; + hasActiveConversation: boolean; }) => { const runtime = useLocalRuntime(adapter, { adapters: historyAdapter ? { history: historyAdapter } : undefined, }); - // ensure runtime is available before providing it if (!runtime) return null; return ( - + ); }; -// inner component that uses useAssistantRuntime (must be inside provider) -const ThreadWrapper = ({ onResetIndex }: { onResetIndex: () => void }) => { +const ThreadWrapper = ({ + onResetIndex, + isLoadingHistory, + hasActiveConversation, +}: { + onResetIndex: () => void; + isLoadingHistory: boolean; + hasActiveConversation: boolean; +}) => { const [sources, setSources] = useState(getLastSources()); const assistantRuntime = useAssistantRuntime(); const { setActiveConversation } = useAIChatStore(); - // poll for sources updates (adapter stores them) useEffect(() => { const interval = setInterval(() => { setSources(getLastSources()); @@ -202,7 +221,15 @@ const ThreadWrapper = ({ onResetIndex }: { onResetIndex: () => void }) => { assistantRuntime.switchToNewThread(); }, [assistantRuntime, setActiveConversation]); - return ; + return ( + + ); }; const AIAssistant = ({ bookKey }: AIAssistantProps) => { diff --git a/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx b/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx index 20edd45d..d202fb23 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/ChatHistoryView.tsx @@ -153,8 +153,6 @@ const ChatHistoryView: React.FC = ({ bookKey }) => { } }} > - -
{editingId === conversation.id ? (
void; onResetIndex?: () => void; + isLoadingHistory?: boolean; + hasActiveConversation?: boolean; } -export const Thread: FC = ({ sources = [], onClear, onResetIndex }) => { +const LoadingOverlay: FC<{ isVisible: boolean }> = ({ isVisible }) => { return ( - - -
-
- +
+
+
+ ); +}; + +const ScrollToBottomButton: FC = () => { + const isAtBottom = useThreadViewport((v) => v.isAtBottom); + const lastMessageRole = useThread((t) => t.messages.at(-1)?.role); + const isRunning = useThread((t) => t.isRunning); + + // Don't show button if last message is user with no AI response yet + if (lastMessageRole === 'user' && !isRunning) return null; + + return ( + + + + + ); +}; + +export const Thread: FC = ({ + sources = [], + onClear, + onResetIndex, + isLoadingHistory = false, + hasActiveConversation = false, +}) => { + const viewportRef = useRef(null); + const isInitialMount = useRef(true); + const messageCount = useThread((t) => t.messages.length); + const lastMessageRole = useThread((t) => t.messages.at(-1)?.role); + const isRunning = useThread((t) => t.isRunning); + + const showLoading = isLoadingHistory && hasActiveConversation; + + useEffect(() => { + if (isInitialMount.current && messageCount > 0 && viewportRef.current) { + isInitialMount.current = false; + requestAnimationFrame(() => { + const viewport = viewportRef.current; + if (viewport) { + viewport.scrollTop = viewport.scrollHeight; + } + }); + } + }, [messageCount]); + + useEffect(() => { + if (lastMessageRole === 'user' && viewportRef.current && !isInitialMount.current) { + requestAnimationFrame(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const messages = viewport.querySelectorAll('[data-message-role="user"]'); + const lastUserMessage = messages[messages.length - 1]; + if (lastUserMessage) { + lastUserMessage.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }); + } + }, [messageCount, lastMessageRole]); + + const getSpacerHeight = () => { + if (lastMessageRole === 'user' && !isRunning) { + return 'min-h-8'; + } + if (isRunning) { + return 'min-h-[50vh]'; + } + if (lastMessageRole === 'assistant') { + return 'min-h-4'; + } + return 'min-h-4'; + }; + + return ( + + + + {!hasActiveConversation && ( + +
+
+ +
+

Ask about this book

+

+ Get answers based on the book content +

+
-

Ask about this book

-

Get answers based on the book content

- -
- + + )} s.thread.isEmpty === false}> - - , - }} - /> -

- AI can make mistakes. Verify with the book. -

-
+
+ + , + }} + /> +

+ AI can make mistakes. Verify with the book. +

+ + @@ -213,7 +354,10 @@ const AssistantMessage: FC = ({ sources = [] }) => { const UserMessage: FC = () => { return ( - +
diff --git a/apps/readest-app/src/components/settings/AIPanel.tsx b/apps/readest-app/src/components/settings/AIPanel.tsx index 9ddbfd7f..e78cf384 100644 --- a/apps/readest-app/src/components/settings/AIPanel.tsx +++ b/apps/readest-app/src/components/settings/AIPanel.tsx @@ -505,18 +505,20 @@ const AIPanel: React.FC = () => { > {_('Test Connection')} - {connectionStatus === 'success' && ( - - - {_('Connected')} - - )} - {connectionStatus === 'error' && ( - - - {errorMessage || _('Failed')} - - )} +
+ {connectionStatus === 'success' && ( + + + {_('Connected')} + + )} + {connectionStatus === 'error' && ( + + + {errorMessage || _('Failed')} + + )} +