Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions frontend/src/components/Chat/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { StreamingDots } from './StreamingDots';
import { useAppStore } from '../../lib/store';
import { Sparkles, PanelRightOpen, PanelRightClose, Database, MessageSquare, X } from 'lucide-react';
import { listConnectors } from '../../lib/connectors-api';
import { useI18n, type TranslationKey } from '../../lib/i18n';

function getGreeting(): string {
function getGreetingKey(): TranslationKey {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
if (hour < 12) return 'chat.greetingMorning';
if (hour < 18) return 'chat.greetingAfternoon';
return 'chat.greetingEvening';
}

export function ChatArea() {
Expand All @@ -20,6 +21,7 @@ export function ChatArea() {
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
const navigate = useNavigate();
const { t } = useI18n();
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);

Expand Down Expand Up @@ -57,7 +59,7 @@ export function ChatArea() {
onClick={toggleSystemPanel}
className="p-1.5 rounded-md transition-colors cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title={`${systemPanelOpen ? 'Hide' : 'Show'} system panel (${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+I)`}
title={`${systemPanelOpen ? t('chat.hideSystemPanel') : t('chat.showSystemPanel')} (${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+I)`}
>
<PanelIcon size={16} />
</button>
Expand All @@ -74,14 +76,14 @@ export function ChatArea() {
>
<Database size={16} style={{ color: 'var(--color-accent)', flexShrink: 0 }} />
<span style={{ color: 'var(--color-text-secondary)', flex: 1 }}>
Connect your data sources (Gmail, iMessage, Slack, etc.) to get personalized answers.
{t('chat.dataSourcesBanner')}
</span>
<button
onClick={() => navigate('/data-sources')}
className="px-3 py-1 rounded text-xs font-medium cursor-pointer"
style={{ background: 'var(--color-accent)', color: 'var(--color-on-accent)', border: 'none' }}
>
Connect
{t('common.connect')}
</button>
<button
onClick={() => setBannerDismissed(true)}
Expand All @@ -106,10 +108,10 @@ export function ChatArea() {
<Sparkles size={24} />
</div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
{getGreeting()}
{t(getGreetingKey())}
</h2>
<p className="text-sm text-center max-w-sm mb-6" style={{ color: 'var(--color-text-secondary)' }}>
Ask anything. Your AI runs locally — private, fast, and always available.
{t('chat.askAnything')}
</p>

{/* Quick action hints */}
Expand All @@ -126,7 +128,7 @@ export function ChatArea() {
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border)')}
>
<Database size={14} style={{ color: 'var(--color-accent)' }} />
Connect Data Sources
{t('chat.connectDataSources')}
</button>
<button
onClick={() => { navigate('/data-sources'); setTimeout(() => window.dispatchEvent(new CustomEvent('switch-tab', { detail: 'messaging' })), 100); }}
Expand All @@ -140,7 +142,7 @@ export function ChatArea() {
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border)')}
>
<MessageSquare size={14} style={{ color: 'var(--color-accent)' }} />
Set Up Messaging Channels
{t('chat.setUpMessaging')}
</button>
</div>
</div>
Expand Down
42 changes: 20 additions & 22 deletions frontend/src/components/Chat/InputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { fetchSavings, getBase } from '../../lib/api';
import { listConnectors, getSyncStatus } from '../../lib/connectors-api';
import { MicButton } from './MicButton';
import { useSpeech } from '../../hooks/useSpeech';
import { useI18n } from '../../lib/i18n';
import type {
ChatMessage,
MessageTelemetry,
Expand Down Expand Up @@ -79,6 +80,7 @@ export function InputArea() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const { t } = useI18n();

const activeId = useAppStore((s) => s.activeId);
const selectedModel = useAppStore((s) => s.selectedModel);
Expand Down Expand Up @@ -157,7 +159,7 @@ export function InputArea() {
const content = input.trim();
if (!content || streamState.isStreaming) return;
if (!selectedModel) {
toast.error('Pick a model first (⌘K)');
toast.error(t('chat.modelRequiredToast'));
return;
}

Expand Down Expand Up @@ -215,7 +217,7 @@ export function InputArea() {

setStreamState({
isStreaming: true,
phase: deepResearch ? 'Researching...' : 'Generating...',
phase: deepResearch ? t('chat.researching') : t('chat.generating'),
elapsedMs: 0,
activeToolCalls: [],
content: '',
Expand All @@ -241,7 +243,7 @@ export function InputArea() {
status: 'pending',
};
researchTraces.push(trace);
setStreamState({ phase: `Searching: ${trace.query}` });
setStreamState({ phase: t('chat.searching', { query: trace.query }) });
updateLastAssistant(
convId,
accumulatedContent,
Expand Down Expand Up @@ -313,10 +315,10 @@ export function InputArea() {
// missing, KnowledgeStore locked, etc.). Without surfacing the
// message, the user sees only the generic "No response was
// generated" fallback and has no way to self-diagnose.
const msg = ev.message || 'Research failed (no detail provided)';
const msg = ev.message || `${t('chat.researchFailed')} (no detail provided)`;
accumulatedContent = accumulatedContent
? `${accumulatedContent}\n\n**Research stopped:** ${msg}`
: `**Research failed:** ${msg}`;
? `${accumulatedContent}\n\n**${t('chat.researchStopped')}:** ${msg}`
: `**${t('chat.researchFailed')}:** ${msg}`;
setStreamState({ content: accumulatedContent, phase: '' });
useAppStore.getState().addLogEntry({
timestamp: Date.now(),
Expand Down Expand Up @@ -358,9 +360,9 @@ export function InputArea() {
const eventName = sseEvent.event;

if (eventName === 'agent_turn_start') {
setStreamState({ phase: 'Agent thinking...' });
setStreamState({ phase: t('chat.agentThinking') });
} else if (eventName === 'inference_start') {
setStreamState({ phase: 'Generating...' });
setStreamState({ phase: t('chat.generating') });
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'chat',
message: `Generating with ${selectedModel}...`,
Expand Down Expand Up @@ -397,7 +399,7 @@ export function InputArea() {
tc.result = data.result;
}
setStreamState({
phase: 'Generating...',
phase: t('chat.generating'),
activeToolCalls: [...toolCalls],
});
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
Expand Down Expand Up @@ -446,7 +448,7 @@ export function InputArea() {
useAppStore.getState().setLiveEnergy(null);
} finally {
if (!accumulatedContent) {
accumulatedContent = 'No response was generated. Please try again.';
accumulatedContent = t('chat.errorNoResponse');
}
const totalMs = Date.now() - startTime;
const _CLOUD_PREFIXES = ['gpt-', 'o1-', 'o3-', 'o4-', 'claude-', 'gemini-', 'openrouter/', 'MiniMax-', 'chatgpt-'];
Expand Down Expand Up @@ -521,6 +523,7 @@ export function InputArea() {
deepResearch,
temperature,
maxTokens,
t,
]);

const handleKeyDown = (e: React.KeyboardEvent) => {
Expand All @@ -545,22 +548,18 @@ export function InputArea() {
border: `1px solid ${deepResearch ? 'var(--color-accent)' : 'var(--color-border)'}`,
color: deepResearch ? 'var(--color-accent)' : 'var(--color-text-tertiary)',
}}
title={deepResearch ? 'Deep Research: on' : 'Deep Research: off'}
title={deepResearch ? t('chat.deepResearchOn') : t('chat.deepResearchOff')}
>
<Search size={12} />
Deep Research
{t('sidebar.deepResearch')}
</button>
</div>
{deepResearch && corpusSync.syncing && corpusSync.itemsSynced > 0 && (
<div
className="text-[11px] leading-snug"
style={{ color: 'var(--color-text-tertiary)' }}
>
Searching over{' '}
<span key={corpusSync.itemsSynced} className="sync-bump" style={{ color: 'var(--color-text-secondary)' }}>
{corpusSync.itemsSynced.toLocaleString()}
</span>{' '}
items — sync in progress, results will improve as more data is indexed.
{t('chat.searchingCorpus', { count: corpusSync.itemsSynced.toLocaleString() })}
</div>
)}
</div>
Expand All @@ -577,7 +576,7 @@ export function InputArea() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={selectedModel ? 'Message OpenJarvis...' : 'Pick a model first (⌘K)...'}
placeholder={selectedModel ? t('chat.messagePlaceholder') : t('chat.pickModelPlaceholder')}
rows={1}
className="flex-1 bg-transparent outline-none resize-none text-sm leading-relaxed"
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
Expand All @@ -588,7 +587,7 @@ export function InputArea() {
onClick={stopStreaming}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
style={{ background: 'var(--color-error)', color: 'var(--color-on-accent)' }}
title="Stop generating"
title={t('chat.stopGenerating')}
>
<Square size={16} />
</button>
Expand All @@ -603,7 +602,7 @@ export function InputArea() {
<button
onClick={sendMessage}
disabled={!input.trim() || modelLoading || !selectedModel}
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
title={selectedModel ? t('chat.sendMessage') : t('chat.modelRequiredToast')}
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
style={{
background: input.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
Expand All @@ -617,8 +616,7 @@ export function InputArea() {
</div>
<div className="flex items-center justify-center mt-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
<span>
<kbd className="font-mono">Enter</kbd> to send &middot;{' '}
<kbd className="font-mono">Shift+Enter</kbd> for new line
{t('chat.inputHelp')}
</span>
</div>
</div>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/Chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ResearchTimeline } from './ResearchTimeline';
import { rehypeCitations } from '../../lib/rehype-citations';
import { XRayFooter } from './XRayFooter';
import type { ChatMessage } from '../../types';
import { useI18n } from '../../lib/i18n';

function stripThinkTags(text: string): string {
let cleaned = text.replace(/<think>[\s\S]*?<\/think>\s*/gi, '');
Expand Down Expand Up @@ -39,6 +40,7 @@ function getTextContent(node: any): string {

function CodeBlockPre({ children, ...props }: any) {
const [copied, setCopied] = useState(false);
const { t } = useI18n();
const codeElement = Array.isArray(children) ? children[0] : children;
const className = codeElement?.props?.className || '';
const match = /language-([\w-]+)/.exec(className);
Expand Down Expand Up @@ -69,7 +71,7 @@ function CodeBlockPre({ children, ...props }: any) {
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
{copied ? t('common.copied') : t('common.copy')}
</button>
</div>
<pre {...props} style={{ margin: 0, borderRadius: 0 }}>
Expand All @@ -81,6 +83,7 @@ function CodeBlockPre({ children, ...props }: any) {

function CopyMessageButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const { t } = useI18n();

const handleCopy = () => {
navigator.clipboard.writeText(content);
Expand All @@ -93,7 +96,7 @@ function CopyMessageButton({ content }: { content: string }) {
onClick={handleCopy}
className="p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
style={{ color: 'var(--color-text-tertiary)' }}
title="Copy message"
title={t('message.copyMessage')}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
Expand Down
Loading