mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.
Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page
Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
8.7 KiB
TypeScript
223 lines
8.7 KiB
TypeScript
import { IconSend, IconWand } from '@tabler/icons-react'
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import classNames from '~/lib/classNames'
|
|
import { ChatMessage } from '../../../types/chat'
|
|
import ChatMessageBubble from './ChatMessageBubble'
|
|
import ChatAssistantAvatar from './ChatAssistantAvatar'
|
|
import BouncingDots from '../BouncingDots'
|
|
import StyledModal from '../StyledModal'
|
|
import api from '~/lib/api'
|
|
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
|
|
import { useNotifications } from '~/context/NotificationContext'
|
|
import { usePage } from '@inertiajs/react'
|
|
|
|
interface ChatInterfaceProps {
|
|
messages: ChatMessage[]
|
|
onSendMessage: (message: string) => void
|
|
isLoading?: boolean
|
|
chatSuggestions?: string[]
|
|
chatSuggestionsEnabled?: boolean
|
|
chatSuggestionsLoading?: boolean
|
|
rewriteModelAvailable?: boolean
|
|
}
|
|
|
|
export default function ChatInterface({
|
|
messages,
|
|
onSendMessage,
|
|
isLoading = false,
|
|
chatSuggestions = [],
|
|
chatSuggestionsEnabled = false,
|
|
chatSuggestionsLoading = false,
|
|
rewriteModelAvailable = false
|
|
}: ChatInterfaceProps) {
|
|
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
|
const { addNotification } = useNotifications()
|
|
const [input, setInput] = useState('')
|
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
|
|
const [isDownloading, setIsDownloading] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const handleDownloadModel = async () => {
|
|
setIsDownloading(true)
|
|
try {
|
|
await api.downloadModel(DEFAULT_QUERY_REWRITE_MODEL)
|
|
addNotification({ type: 'success', message: 'Model download queued' })
|
|
} catch (error) {
|
|
addNotification({ type: 'error', message: 'Failed to queue model download' })
|
|
} finally {
|
|
setIsDownloading(false)
|
|
setDownloadDialogOpen(false)
|
|
}
|
|
}
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
|
|
useEffect(() => {
|
|
scrollToBottom()
|
|
}, [messages])
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (input.trim() && !isLoading) {
|
|
onSendMessage(input.trim())
|
|
setInput('')
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto'
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSubmit(e)
|
|
}
|
|
}
|
|
|
|
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setInput(e.target.value)
|
|
// Auto-resize textarea
|
|
e.target.style.height = 'auto'
|
|
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
|
{messages.length === 0 ? (
|
|
<div className="h-full flex items-center justify-center">
|
|
<div className="text-center max-w-md">
|
|
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
|
|
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
|
|
<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>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Display bouncing dots while loading suggestions */}
|
|
{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.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{messages.map((message) => (
|
|
<div
|
|
key={message.id}
|
|
className={classNames(
|
|
'flex gap-4',
|
|
message.role === 'user' ? 'justify-end' : 'justify-start'
|
|
)}
|
|
>
|
|
{message.role === 'assistant' && <ChatAssistantAvatar />}
|
|
<ChatMessageBubble message={message} />
|
|
</div>
|
|
))}
|
|
{/* Loading/thinking indicator */}
|
|
{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">
|
|
<BouncingDots text="Thinking" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
|
|
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
|
|
<div className="flex-1 relative">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={input}
|
|
onChange={handleInput}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
|
|
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
|
|
rows={1}
|
|
disabled={isLoading}
|
|
style={{ maxHeight: '200px' }}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={!input.trim() || isLoading}
|
|
className={classNames(
|
|
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
|
|
!input.trim() || isLoading
|
|
? 'bg-border-default text-text-muted cursor-not-allowed'
|
|
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
|
|
)}
|
|
>
|
|
{isLoading ? (
|
|
<div className="h-6 w-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<IconSend className="h-6 w-6" />
|
|
)}
|
|
</button>
|
|
</form>
|
|
{!rewriteModelAvailable && (
|
|
<div className="text-sm text-text-muted mt-2">
|
|
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
|
|
<button
|
|
onClick={() => setDownloadDialogOpen(true)}
|
|
className="text-desert-green underline hover:text-desert-green/80 cursor-pointer"
|
|
>
|
|
downloading it
|
|
</button>{' '}
|
|
for improved retrieval-augmented generation (RAG) performance.
|
|
</div>
|
|
)}
|
|
<StyledModal
|
|
open={downloadDialogOpen}
|
|
title={`Download ${DEFAULT_QUERY_REWRITE_MODEL}?`}
|
|
confirmText="Download"
|
|
cancelText="Cancel"
|
|
confirmIcon='IconDownload'
|
|
confirmVariant='primary'
|
|
confirmLoading={isDownloading}
|
|
onConfirm={handleDownloadModel}
|
|
onCancel={() => setDownloadDialogOpen(false)}
|
|
onClose={() => setDownloadDialogOpen(false)}
|
|
>
|
|
<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.
|
|
</p>
|
|
</StyledModal>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|