fix(chat): make conversation layout responsive
Signed-off-by: Andrew Barnes <bortstheboat@gmail.com>
This commit is contained in:
parent
6a4f02dd46
commit
3290e71978
|
|
@ -28,7 +28,7 @@ export default function ChatInterface({
|
|||
chatSuggestions = [],
|
||||
chatSuggestionsEnabled = false,
|
||||
chatSuggestionsLoading = false,
|
||||
rewriteModelAvailable = false
|
||||
rewriteModelAvailable = false,
|
||||
}: ChatInterfaceProps) {
|
||||
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
||||
const { addNotification } = useNotifications()
|
||||
|
|
@ -95,33 +95,39 @@ export default function ChatInterface({
|
|||
<p className="text-text-muted text-sm">
|
||||
Interact with your installed language models directly in the Command Center.
|
||||
</p>
|
||||
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
|
||||
<div className="mt-8">
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{chatSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setInput(suggestion)
|
||||
// Focus the textarea after setting input
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, 0)
|
||||
}}
|
||||
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
{chatSuggestionsEnabled &&
|
||||
chatSuggestions &&
|
||||
chatSuggestions.length > 0 &&
|
||||
!chatSuggestionsLoading && (
|
||||
<div className="mt-8">
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{chatSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setInput(suggestion)
|
||||
// Focus the textarea after setting input
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, 0)
|
||||
}}
|
||||
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{/* Display bouncing dots while loading suggestions */}
|
||||
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
|
||||
{chatSuggestionsEnabled && chatSuggestionsLoading && (
|
||||
<BouncingDots text="Thinking" containerClassName="mt-8" />
|
||||
)}
|
||||
{!chatSuggestionsEnabled && (
|
||||
<div className="mt-8 text-sm text-text-muted">
|
||||
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
|
||||
Need some inspiration? Enable chat suggestions in settings to get started with
|
||||
example prompts.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -144,7 +150,7 @@ export default function ChatInterface({
|
|||
{isLoading && (
|
||||
<div className="flex gap-4 justify-start">
|
||||
<ChatAssistantAvatar />
|
||||
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
|
||||
<div className="max-w-[85%] sm:max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
|
||||
<BouncingDots text="Thinking" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -203,8 +209,8 @@ export default function ChatInterface({
|
|||
title={`Download ${DEFAULT_QUERY_REWRITE_MODEL}?`}
|
||||
confirmText="Download"
|
||||
cancelText="Cancel"
|
||||
confirmIcon='IconDownload'
|
||||
confirmVariant='primary'
|
||||
confirmIcon="IconDownload"
|
||||
confirmVariant="primary"
|
||||
confirmLoading={isDownloading}
|
||||
onConfirm={handleDownloadModel}
|
||||
onCancel={() => setDownloadDialogOpen(false)}
|
||||
|
|
@ -212,8 +218,10 @@ export default function ChatInterface({
|
|||
>
|
||||
<p className="text-text-primary">
|
||||
This will dispatch a background download job for{' '}
|
||||
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
|
||||
will be used to rewrite queries for improved RAG retrieval performance. Note that download is only supported when using Ollama. If using an OpenAI API interface, please download the model with that software.
|
||||
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may
|
||||
take some time to complete. The model will be used to rewrite queries for improved RAG
|
||||
retrieval performance. Note that download is only supported when using Ollama. If using
|
||||
an OpenAI API interface, please download the model with that software.
|
||||
</p>
|
||||
</StyledModal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ interface ChatSidebarProps {
|
|||
onNewChat: () => void
|
||||
onClearHistory: () => void
|
||||
isInModal?: boolean
|
||||
isMobileOpen?: boolean
|
||||
onMobileClose?: () => void
|
||||
}
|
||||
|
||||
export default function ChatSidebar({
|
||||
|
|
@ -22,6 +24,8 @@ export default function ChatSidebar({
|
|||
onNewChat,
|
||||
onClearHistory,
|
||||
isInModal = false,
|
||||
isMobileOpen = false,
|
||||
onMobileClose,
|
||||
}: ChatSidebarProps) {
|
||||
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
||||
const [isKnowledgeBaseModalOpen, setIsKnowledgeBaseModalOpen] = useState(
|
||||
|
|
@ -39,9 +43,25 @@ export default function ChatSidebar({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
|
||||
<aside
|
||||
id="chat-sidebar"
|
||||
className={classNames(
|
||||
'w-64 bg-surface-secondary border-r border-border-subtle flex-col h-full shrink-0',
|
||||
'fixed inset-y-0 left-0 z-50 md:static md:z-auto md:flex',
|
||||
isMobileOpen ? 'flex' : 'hidden'
|
||||
)}
|
||||
aria-label="Chat conversations"
|
||||
>
|
||||
<div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
|
||||
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
|
||||
<StyledButton
|
||||
onClick={() => {
|
||||
onNewChat()
|
||||
onMobileClose?.()
|
||||
}}
|
||||
icon="IconPlus"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
>
|
||||
New Chat
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
|
@ -54,7 +74,10 @@ export default function ChatSidebar({
|
|||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSessionSelect(session.id)}
|
||||
onClick={() => {
|
||||
onSessionSelect(session.id)
|
||||
onMobileClose?.()
|
||||
}}
|
||||
className={classNames(
|
||||
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
|
||||
activeSessionId === session.id
|
||||
|
|
@ -142,6 +165,6 @@ export default function ChatSidebar({
|
|||
{isKnowledgeBaseModalOpen && (
|
||||
<KnowledgeBaseModal aiAssistantName={aiAssistantName} onClose={handleCloseKnowledgeBase} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { formatBytes } from '~/lib/util'
|
|||
import { useModals } from '~/context/ModalContext'
|
||||
import { ChatMessage } from '../../../types/chat'
|
||||
import classNames from '~/lib/classNames'
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
import { IconMenu2, IconX } from '@tabler/icons-react'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
|
||||
import { useSystemSetting } from '~/hooks/useSystemSetting'
|
||||
|
||||
|
|
@ -36,8 +36,18 @@ export default function Chat({
|
|||
const [pendingModelSwitch, setPendingModelSwitch] = useState<string | null>(null)
|
||||
const pageLoadNormalizedRef = useRef(false)
|
||||
const [isStreamingResponse, setIsStreamingResponse] = useState(false)
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileSidebarOpen) return
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsMobileSidebarOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [isMobileSidebarOpen])
|
||||
|
||||
// Fetch all sessions
|
||||
const { data: sessions = [] } = useQuery({
|
||||
queryKey: ['chatSessions'],
|
||||
|
|
@ -84,7 +94,7 @@ export default function Chat({
|
|||
})
|
||||
|
||||
const rewriteModelAvailable = useMemo(() => {
|
||||
return installedModels.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
return installedModels.some((model) => model.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
}, [installedModels])
|
||||
|
||||
const deleteAllSessionsMutation = useMutation({
|
||||
|
|
@ -323,7 +333,12 @@ export default function Chat({
|
|||
|
||||
try {
|
||||
await api.streamChatMessage(
|
||||
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined },
|
||||
{
|
||||
model: selectedModel || 'llama3.2',
|
||||
messages: chatMessages,
|
||||
stream: true,
|
||||
sessionId: sessionId ? Number(sessionId) : undefined,
|
||||
},
|
||||
(chunkContent, chunkThinking, done) => {
|
||||
if (chunkThinking.length > 0 && thinkingStartTime === null) {
|
||||
thinkingStartTime = Date.now()
|
||||
|
|
@ -348,20 +363,23 @@ export default function Chat({
|
|||
if (isThinkingPhase && chunkContent.length > 0) {
|
||||
isThinkingPhase = false
|
||||
if (thinkingStartTime !== null) {
|
||||
thinkingDuration = Math.max(1, Math.round((Date.now() - thinkingStartTime) / 1000))
|
||||
thinkingDuration = Math.max(
|
||||
1,
|
||||
Math.round((Date.now() - thinkingStartTime) / 1000)
|
||||
)
|
||||
}
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? {
|
||||
...m,
|
||||
content: m.content + chunkContent,
|
||||
thinking: (m.thinking ?? '') + chunkThinking,
|
||||
isStreaming: !done,
|
||||
isThinking: isThinkingPhase,
|
||||
thinkingDuration: thinkingDuration ?? undefined,
|
||||
}
|
||||
...m,
|
||||
content: m.content + chunkContent,
|
||||
thinking: (m.thinking ?? '') + chunkThinking,
|
||||
isStreaming: !done,
|
||||
isThinking: isThinkingPhase,
|
||||
thinkingDuration: thinkingDuration ?? undefined,
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
|
|
@ -376,9 +394,7 @@ export default function Chat({
|
|||
setMessages((prev) => {
|
||||
const hasAssistantMsg = prev.some((m) => m.id === assistantMsgId)
|
||||
if (hasAssistantMsg) {
|
||||
return prev.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
|
||||
)
|
||||
return prev.map((m) => (m.id === assistantMsgId ? { ...m, isStreaming: false } : m))
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
|
|
@ -399,9 +415,7 @@ export default function Chat({
|
|||
if (fullContent && sessionId) {
|
||||
// Ensure the streaming cursor is removed
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
|
||||
)
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, isStreaming: false } : m))
|
||||
)
|
||||
|
||||
// Refresh sessions to pick up backend-persisted messages and title
|
||||
|
|
@ -422,103 +436,128 @@ export default function Chat({
|
|||
|
||||
return (
|
||||
<>
|
||||
{pendingModelSwitch && (
|
||||
<StyledModal
|
||||
title={`Switch to ${pendingModelSwitch}?`}
|
||||
onConfirm={handleConfirmModelSwitch}
|
||||
onCancel={handleCancelModelSwitch}
|
||||
open={true}
|
||||
confirmText="Switch & New Chat"
|
||||
cancelText="Cancel"
|
||||
confirmVariant="primary"
|
||||
>
|
||||
<p className="text-text-primary">
|
||||
Switching to <strong>{pendingModelSwitch}</strong> will start a new chat. Your current
|
||||
conversation stays available in the sidebar.
|
||||
</p>
|
||||
</StyledModal>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
|
||||
isInModal ? 'h-full rounded-lg' : 'h-screen'
|
||||
{pendingModelSwitch && (
|
||||
<StyledModal
|
||||
title={`Switch to ${pendingModelSwitch}?`}
|
||||
onConfirm={handleConfirmModelSwitch}
|
||||
onCancel={handleCancelModelSwitch}
|
||||
open={true}
|
||||
confirmText="Switch & New Chat"
|
||||
cancelText="Cancel"
|
||||
confirmVariant="primary"
|
||||
>
|
||||
<p className="text-text-primary">
|
||||
Switching to <strong>{pendingModelSwitch}</strong> will start a new chat. Your current
|
||||
conversation stays available in the sidebar.
|
||||
</p>
|
||||
</StyledModal>
|
||||
)}
|
||||
>
|
||||
<ChatSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewChat={handleNewChat}
|
||||
onClearHistory={handleClearHistory}
|
||||
isInModal={isInModal}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<KbPolicyPromptBanner />
|
||||
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{activeSession?.title || 'New Chat'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
{remoteOllamaUrlSetting?.value && (
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xs rounded px-2 py-1 font-medium',
|
||||
remoteStatus?.connected === false
|
||||
? 'text-red-700 bg-red-50 border border-red-200'
|
||||
: 'text-green-700 bg-green-50 border border-green-200'
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
|
||||
isInModal ? 'h-full rounded-lg' : 'h-screen'
|
||||
)}
|
||||
>
|
||||
<ChatSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewChat={handleNewChat}
|
||||
onClearHistory={handleClearHistory}
|
||||
isInModal={isInModal}
|
||||
isMobileOpen={isMobileSidebarOpen}
|
||||
onMobileClose={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
{isMobileSidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation sidebar"
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<KbPolicyPromptBanner />
|
||||
<div className="px-3 sm:px-6 py-3 border-b border-border-subtle bg-surface-secondary flex flex-wrap items-center justify-between gap-2 min-h-[75px] flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-1.5 hover:bg-surface-primary focus:outline-none focus:ring-2 focus:ring-desert-green md:hidden"
|
||||
aria-label="Open conversation sidebar"
|
||||
aria-controls="chat-sidebar"
|
||||
aria-expanded={isMobileSidebarOpen}
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
{remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="model-select" className="text-sm text-text-secondary">
|
||||
Model:
|
||||
</label>
|
||||
{isLoadingModels ? (
|
||||
<div className="text-sm text-text-muted">Loading models...</div>
|
||||
) : installedModels.length === 0 ? (
|
||||
<div className="text-sm text-red-600">No models installed</div>
|
||||
) : (
|
||||
<select
|
||||
id="model-select"
|
||||
value={pendingModelSwitch ?? selectedModel}
|
||||
onChange={(e) => handleUserSelectedModel(e.target.value)}
|
||||
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||
<IconMenu2 className="h-6 w-6 text-text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-text-primary truncate">
|
||||
{activeSession?.title || 'New Chat'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
{remoteOllamaUrlSetting?.value && (
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xs rounded px-2 py-1 font-medium',
|
||||
remoteStatus?.connected === false
|
||||
? 'text-red-700 bg-red-50 border border-red-200'
|
||||
: 'text-green-700 bg-green-50 border border-green-200'
|
||||
)}
|
||||
>
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.name}{model.size > 0 ? ` (${formatBytes(model.size)})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label htmlFor="model-select" className="text-sm text-text-secondary">
|
||||
Model:
|
||||
</label>
|
||||
{isLoadingModels ? (
|
||||
<div className="text-sm text-text-muted">Loading models...</div>
|
||||
) : installedModels.length === 0 ? (
|
||||
<div className="text-sm text-red-600">No models installed</div>
|
||||
) : (
|
||||
<select
|
||||
id="model-select"
|
||||
value={pendingModelSwitch ?? selectedModel}
|
||||
onChange={(e) => handleUserSelectedModel(e.target.value)}
|
||||
className="min-w-0 max-w-44 sm:max-w-none px-2 sm:px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||
>
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
{model.size > 0 ? ` (${formatBytes(model.size)})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{isInModal && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close chat"
|
||||
onClick={() => {
|
||||
if (onClose) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
className="rounded-lg hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<IconX className="h-6 w-6 text-text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isInModal && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onClose) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
className="rounded-lg hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<IconX className="h-6 w-6 text-text-muted" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatInterface
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
isLoading={isStreamingResponse || chatMutation.isPending}
|
||||
chatSuggestions={chatSuggestions}
|
||||
chatSuggestionsEnabled={suggestionsEnabled}
|
||||
chatSuggestionsLoading={chatSuggestionsLoading}
|
||||
rewriteModelAvailable={rewriteModelAvailable}
|
||||
/>
|
||||
</div>
|
||||
<ChatInterface
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
isLoading={isStreamingResponse || chatMutation.isPending}
|
||||
chatSuggestions={chatSuggestions}
|
||||
chatSuggestionsEnabled={suggestionsEnabled}
|
||||
chatSuggestionsLoading={chatSuggestionsLoading}
|
||||
rewriteModelAvailable={rewriteModelAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue