fix(chat): make conversation layout responsive

Signed-off-by: Andrew Barnes <bortstheboat@gmail.com>
This commit is contained in:
Andrew Barnes 2026-07-12 19:23:00 -04:00
parent 6a4f02dd46
commit 3290e71978
No known key found for this signature in database
GPG Key ID: A2B96F4BB60D03A1
3 changed files with 209 additions and 139 deletions

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
</>
)
}