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
This commit is contained in:
Mohammed Efaz
2026-01-24 22:57:00 +06:00
committed by GitHub
parent 42fee90a27
commit 8bea05478a
4 changed files with 218 additions and 47 deletions
@@ -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<ReturnType<typeof createTauriAdapter>>;
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 (
<AssistantRuntimeProvider runtime={runtime}>
<ThreadWrapper onResetIndex={onResetIndex} />
<ThreadWrapper
onResetIndex={onResetIndex}
isLoadingHistory={isLoadingHistory}
hasActiveConversation={hasActiveConversation}
/>
</AssistantRuntimeProvider>
);
};
// 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 <Thread sources={sources} onClear={handleClear} onResetIndex={onResetIndex} />;
return (
<Thread
sources={sources}
onClear={handleClear}
onResetIndex={onResetIndex}
isLoadingHistory={isLoadingHistory}
hasActiveConversation={hasActiveConversation}
/>
);
};
const AIAssistant = ({ bookKey }: AIAssistantProps) => {
@@ -153,8 +153,6 @@ const ChatHistoryView: React.FC<ChatHistoryViewProps> = ({ bookKey }) => {
}
}}
>
<LuMessageSquare className='text-base-content/40 mt-0.5 size-4 flex-shrink-0' />
<div className='min-w-0 flex-1'>
{editingId === conversation.id ? (
<div
@@ -1,6 +1,6 @@
'use client';
import type { FC } from 'react';
import { useEffect, useRef, type FC } from 'react';
import {
ActionBarPrimitive,
AssistantIf,
@@ -9,11 +9,14 @@ import {
MessagePrimitive,
ThreadPrimitive,
useAssistantState,
useThreadViewport,
useThread,
} from '@assistant-ui/react';
import {
ArrowUpIcon,
BookOpenIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
@@ -37,35 +40,173 @@ interface ThreadProps {
sources?: ScoredChunk[];
onClear?: () => void;
onResetIndex?: () => void;
isLoadingHistory?: boolean;
hasActiveConversation?: boolean;
}
export const Thread: FC<ThreadProps> = ({ sources = [], onClear, onResetIndex }) => {
const LoadingOverlay: FC<{ isVisible: boolean }> = ({ isVisible }) => {
return (
<ThreadPrimitive.Root className='bg-base-100 flex h-full w-full flex-col items-stretch px-3'>
<ThreadPrimitive.Empty>
<div className='animate-in fade-in flex h-full flex-col items-center justify-center duration-300'>
<div className='bg-base-content/10 mb-4 rounded-full p-3'>
<BookOpenIcon className='text-base-content size-6' />
<div
className={cn(
'absolute inset-0 z-20 flex items-center justify-center',
'bg-base-100/60 backdrop-blur-sm',
'transition-all duration-300 ease-out',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<div className='bg-base-content/10 size-8 animate-pulse rounded-full' />
</div>
);
};
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 (
<ThreadPrimitive.ScrollToBottom
className={cn(
'absolute bottom-4 left-1/2 z-10',
'flex items-center justify-center rounded-full p-2',
'bg-base-300 text-base-content',
'hover:bg-base-200',
'border-base-content/10 border',
'shadow-sm',
'active:scale-[0.97]',
'transition-[opacity,filter,transform] duration-150 ease-out',
isAtBottom
? 'pointer-events-none -translate-x-1/2 scale-90 opacity-0 blur-sm'
: '-translate-x-1/2 scale-100 opacity-100 blur-0',
)}
style={{
animation: isAtBottom ? 'none' : 'subtleBounce 2.5s ease-in-out infinite',
}}
aria-hidden={isAtBottom}
aria-label='Scroll to bottom'
>
<ChevronDownIcon className='size-4' />
<style>{`
@keyframes subtleBounce {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(1px); }
}
`}</style>
</ThreadPrimitive.ScrollToBottom>
);
};
export const Thread: FC<ThreadProps> = ({
sources = [],
onClear,
onResetIndex,
isLoadingHistory = false,
hasActiveConversation = false,
}) => {
const viewportRef = useRef<HTMLDivElement>(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 (
<ThreadPrimitive.Root className='bg-base-100 relative flex h-full w-full flex-col items-stretch px-3'>
<LoadingOverlay isVisible={showLoading} />
{!hasActiveConversation && (
<ThreadPrimitive.Empty>
<div className='animate-in fade-in flex h-full flex-col items-center justify-center duration-300'>
<div className='bg-base-content/10 mb-4 rounded-full p-3'>
<BookOpenIcon className='text-base-content size-6' />
</div>
<h3 className='text-base-content mb-1 text-sm font-medium'>Ask about this book</h3>
<p className='text-base-content/60 mb-4 text-xs'>
Get answers based on the book content
</p>
<Composer onClear={onClear} onResetIndex={onResetIndex} />
</div>
<h3 className='text-base-content mb-1 text-sm font-medium'>Ask about this book</h3>
<p className='text-base-content/60 mb-4 text-xs'>Get answers based on the book content</p>
<Composer onClear={onClear} onResetIndex={onResetIndex} />
</div>
</ThreadPrimitive.Empty>
</ThreadPrimitive.Empty>
)}
<AssistantIf condition={(s) => s.thread.isEmpty === false}>
<ThreadPrimitive.Viewport className='flex grow flex-col overflow-y-auto scroll-smooth pt-2'>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage: () => <AssistantMessage sources={sources} />,
}}
/>
<p className='text-base-content/40 mx-auto w-full p-1 text-center text-[10px]'>
AI can make mistakes. Verify with the book.
</p>
</ThreadPrimitive.Viewport>
<div
className={cn(
'relative min-h-0 flex-1 transition-opacity duration-300',
showLoading ? 'opacity-0' : 'opacity-100',
)}
>
<ThreadPrimitive.Viewport
ref={viewportRef}
autoScroll={false}
className='absolute inset-0 flex flex-col overflow-y-auto scroll-smooth pt-2'
>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage: () => <AssistantMessage sources={sources} />,
}}
/>
<p className='text-base-content/40 mx-auto w-full p-1 text-center text-[10px]'>
AI can make mistakes. Verify with the book.
</p>
<div
className={cn('flex-shrink transition-all duration-300', getSpacerHeight())}
aria-hidden='true'
/>
</ThreadPrimitive.Viewport>
<ScrollToBottomButton />
</div>
<Composer onClear={onClear} onResetIndex={onResetIndex} />
</AssistantIf>
</ThreadPrimitive.Root>
@@ -213,7 +354,10 @@ const AssistantMessage: FC<AssistantMessageProps> = ({ sources = [] }) => {
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root className='group/message animate-in fade-in slide-in-from-bottom-1 relative mx-auto mb-1 flex w-full flex-col pb-0.5 duration-200'>
<MessagePrimitive.Root
className='group/message animate-in fade-in slide-in-from-bottom-1 relative mx-auto mb-1 flex w-full flex-col pb-0.5 duration-200'
data-message-role='user'
>
<div className='flex flex-col items-end'>
<div className='border-base-content/10 bg-base-200 text-base-content relative max-w-[90%] rounded-2xl rounded-br-md border px-3 py-2'>
<div className='prose prose-xs text-base-content [&_*]:!text-base-content select-text text-sm'>
@@ -505,18 +505,20 @@ const AIPanel: React.FC = () => {
>
{_('Test Connection')}
</button>
{connectionStatus === 'success' && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle />
{_('Connected')}
</span>
)}
{connectionStatus === 'error' && (
<span className='text-error flex items-center gap-1 text-sm'>
<PiWarningCircle />
{errorMessage || _('Failed')}
</span>
)}
<div>
{connectionStatus === 'success' && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle className='size-4 shrink-0' />
{_('Connected')}
</span>
)}
{connectionStatus === 'error' && (
<span className='text-error flex items-center gap-1 text-sm'>
<PiWarningCircle className='size-4 shrink-0' />
{errorMessage || _('Failed')}
</span>
)}
</div>
</div>
</div>
</div>