Skip to content
Merged
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
43 changes: 33 additions & 10 deletions backend/__tests__/chat_tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,41 @@ describe('GET /v1/tools', () => {
throw new Error(`Expected status 200 but received ${res.status}. Body: ${errorBody}`);
}
const body = await res.json();

// Verify response structure
assert.ok(Array.isArray(body.tools), 'tools array present');
assert.ok(Array.isArray(body.available_tools), 'available_tools array present');
// Should list all registered tools
assert.ok(body.available_tools.includes('get_time'));
assert.ok(body.available_tools.includes('web_search'));
assert.ok(body.available_tools.includes('web_search_exa'));
// Tool specs should include function definitions
const names = body.tools.map(t => t?.function?.name).filter(Boolean);
assert.ok(names.includes('get_time'));
assert.ok(names.includes('web_search'));
assert.ok(names.includes('web_search_exa'));

// Verify that at least some tools are registered
assert.ok(body.available_tools.length > 0, 'at least one tool should be available');
assert.ok(body.tools.length > 0, 'at least one tool spec should be present');

// Verify both arrays have the same length
assert.equal(
body.tools.length,
body.available_tools.length,
'tools and available_tools should have matching counts'
);

// Verify tool specs have proper structure (OpenAI format)
for (const tool of body.tools) {
assert.ok(tool.type === 'function', 'tool should have type "function"');
assert.ok(tool.function, 'tool should have function property');
assert.ok(typeof tool.function.name === 'string', 'tool function should have name');
assert.ok(typeof tool.function.description === 'string', 'tool function should have description');
assert.ok(tool.function.parameters, 'tool function should have parameters');
}

// Verify consistency: all tool names in specs should be in available_tools
const specNames = body.tools.map((t) => t?.function?.name).filter(Boolean);
for (const name of specNames) {
assert.ok(body.available_tools.includes(name), `tool spec name "${name}" should be in available_tools`);
}

// Verify all available_tools have corresponding specs
for (const toolName of body.available_tools) {
assert.ok(specNames.includes(toolName), `available tool "${toolName}" should have a corresponding spec`);
}
});
});
});

60 changes: 50 additions & 10 deletions frontend/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Sun, Moon, Settings, RefreshCw, Loader2 } from 'lucide-react';
import { Sun, Moon, Settings, RefreshCw, Loader2, Menu, PanelLeft, PanelRight } from 'lucide-react';

Check warning on line 2 in frontend/components/ChatHeader.tsx

View workflow job for this annotation

GitHub Actions / frontend

'Menu' is defined but never used
import { useTheme } from '../contexts/ThemeContext';
import ModelSelector from './ui/ModelSelector';
import { type Group as TabGroup } from './ui/TabbedSelect';
Expand All @@ -20,6 +20,10 @@
fallbackOptions?: { value: string; label: string }[];
modelToProvider?: Record<string, string> | Map<string, string>;
onFocusMessageInput?: () => void;
onToggleLeftSidebar?: () => void;
onToggleRightSidebar?: () => void;
showLeftSidebarButton?: boolean;
showRightSidebarButton?: boolean;
}

export function ChatHeader({
Expand All @@ -35,6 +39,10 @@
fallbackOptions,
modelToProvider,
onFocusMessageInput,
onToggleLeftSidebar,
onToggleRightSidebar,
showLeftSidebarButton = false,
showRightSidebarButton = false,
}: ChatHeaderProps) {
const { theme, setTheme, resolvedTheme } = useTheme();

Expand Down Expand Up @@ -97,23 +105,36 @@
};

return (
<header className="sticky top-0 z-40 bg-white dark:bg-neutral-950">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<header className="sticky top-0 z-40 bg-white dark:bg-neutral-950 border-b border-slate-200/50 dark:border-neutral-800/50">
<div className="px-2 sm:px-4 md:px-6 py-2 sm:py-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-1 sm:gap-2 flex-1 min-w-0">
{/* Left Sidebar Toggle - Mobile Only */}
{showLeftSidebarButton && onToggleLeftSidebar && (
<button
onClick={onToggleLeftSidebar}
className="md:hidden w-8 h-8 rounded-md border border-slate-200/80 dark:border-neutral-700/80 bg-white dark:bg-neutral-900 hover:bg-slate-50 dark:hover:bg-neutral-800 flex items-center justify-center text-slate-600 dark:text-slate-300 transition-colors flex-shrink-0"
title="Toggle conversation history"
aria-label="Toggle conversation history"
type="button"
>
<PanelLeft className="w-4 h-4" />
</button>
)}

<ModelSelector
value={model}
onChange={onModelChange}
groups={effectiveGroups}
fallbackOptions={effectiveFallback}
className="text-lg"
className="text-sm sm:text-base md:text-lg"
ariaLabel="Model"
onAfterChange={onFocusMessageInput}
/>
{onRefreshModels && (
<button
onClick={onRefreshModels}
disabled={isLoadingModels}
className="w-8 h-8 rounded-md border border-slate-200/80 dark:border-neutral-700/80 bg-white dark:bg-neutral-900 hover:bg-slate-50 dark:hover:bg-neutral-800 flex items-center justify-center text-slate-600 dark:text-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="hidden sm:flex w-8 h-8 rounded-md border border-slate-200/80 dark:border-neutral-700/80 bg-white dark:bg-neutral-900 hover:bg-slate-50 dark:hover:bg-neutral-800 items-center justify-center text-slate-600 dark:text-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
title="Refresh model list"
aria-label="Refresh model list"
type="button"
Expand All @@ -127,25 +148,44 @@
)}
</div>

<div className="flex items-center gap-2">
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
onClick={onOpenSettings}
className="w-8 h-8 rounded-md border border-slate-200/80 dark:border-neutral-700/80 bg-white dark:bg-neutral-900 hover:bg-slate-50 dark:hover:bg-neutral-800 flex items-center justify-center text-slate-700 dark:text-slate-200 transition-colors"
title="Open settings"
aria-label="Open settings"
type="button"
>
<Settings className="w-4 h-4" />
<Settings className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
<button
onClick={toggleTheme}
className="w-8 h-8 rounded-md border border-slate-200/80 dark:border-neutral-700/80 bg-white dark:bg-neutral-900 hover:bg-slate-50 dark:hover:bg-neutral-800 flex items-center justify-center text-slate-700 dark:text-slate-200 transition-colors"
title={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} theme`}
type="button"
>
{resolvedTheme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{resolvedTheme === 'dark' ? (
<Sun className="w-3 h-3 sm:w-4 sm:h-4" />
) : (
<Moon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
</button>
<AuthButton onShowLogin={onShowLogin} onShowRegister={onShowRegister} />
<div className="hidden sm:block">
<AuthButton onShowLogin={onShowLogin} onShowRegister={onShowRegister} />
</div>

{/* Right Sidebar Toggle - Mobile Only */}
{showRightSidebarButton && onToggleRightSidebar && (
<button
onClick={onToggleRightSidebar}
className="md:hidden w-8 h-8 rounded-md border border-slate-200/80 dark:border-neutral-700/80 bg-white dark:bg-neutral-900 hover:bg-slate-50 dark:hover:bg-neutral-800 flex items-center justify-center text-slate-600 dark:text-slate-300 transition-colors"
title="Toggle system prompts"
aria-label="Toggle system prompts"
type="button"
>
<PanelRight className="w-4 h-4" />
</button>
)}
</div>
</div>
</header>
Expand Down
12 changes: 9 additions & 3 deletions frontend/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ export function ChatSidebar({
}: ChatSidebarProps) {
return (
<aside
className={`${collapsed ? 'w-16' : 'w-72'} z-40 p-4 flex flex-col bg-slate-50 dark:bg-neutral-900 transition-[width] duration-300 ease-in-out relative`}
className={`
${collapsed ? 'w-16' : 'w-72 md:w-72'}
h-full z-40 p-4 flex flex-col bg-slate-50 dark:bg-neutral-900
md:transition-[width] md:duration-300 md:ease-in-out
relative
${!collapsed ? 'w-72 sm:w-80' : ''}
`}
>
{/* Collapse/Expand Button */}
{/* Collapse/Expand Button - Desktop only */}
<button
className="absolute -right-3 top-6 w-6 h-6 rounded-full bg-white dark:bg-neutral-950 border border-slate-200/70 dark:border-neutral-800/70 transition-colors duration-200 flex items-center justify-center text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-neutral-900 hover:text-slate-700 dark:hover:text-slate-200 cursor-pointer"
className="hidden md:flex absolute -right-3 top-6 w-6 h-6 rounded-full bg-white dark:bg-neutral-950 border border-slate-200/70 dark:border-neutral-800/70 transition-colors duration-200 items-center justify-center text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-neutral-900 hover:text-slate-700 dark:hover:text-slate-200 cursor-pointer"
onClick={onToggleCollapse}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
Expand Down
142 changes: 110 additions & 32 deletions frontend/components/ChatV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,47 @@
const [scrollButtons, setScrollButtons] = useState({ showTop: false, showBottom: false });
const isLoadingConversationRef = useRef(false);
const messageInputRef = useRef<MessageInputRef>(null);
const [isMobile, setIsMobile] = useState(false);

Check warning on line 41 in frontend/components/ChatV2.tsx

View workflow job for this annotation

GitHub Actions / frontend

'isMobile' is assigned a value but never used
const hasCheckedMobileRef = useRef(false);

// Detect mobile screen size and auto-collapse sidebars on mount
useEffect(() => {
if (typeof window === 'undefined') return;
if (hasCheckedMobileRef.current) return;

const mobile = window.innerWidth < 768; // md breakpoint
setIsMobile(mobile);

// Auto-collapse both sidebars on initial mount if mobile
if (mobile) {
if (!chat.sidebarCollapsed) {
chat.toggleSidebar();
}
if (!chat.rightSidebarCollapsed) {
chat.toggleRightSidebar();
}
}

hasCheckedMobileRef.current = true;
}, [

Check warning on line 63 in frontend/components/ChatV2.tsx

View workflow job for this annotation

GitHub Actions / frontend

React Hook useEffect has a missing dependency: 'chat'. Either include it or remove the dependency array
chat.sidebarCollapsed,
chat.rightSidebarCollapsed,
chat.toggleSidebar,
chat.toggleRightSidebar,
]);

// Track window resize for responsive behavior
useEffect(() => {
if (typeof window === 'undefined') return;

const handleResize = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
};

window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

// Simple event handlers
const handleCopy = useCallback(async (text: string) => {
Expand Down Expand Up @@ -400,27 +441,49 @@
chat.setInput(text);
messageInputRef.current?.focus();
},
[chat.setInput]

Check warning on line 444 in frontend/components/ChatV2.tsx

View workflow job for this annotation

GitHub Actions / frontend

React Hook useCallback has a missing dependency: 'chat'. Either include it or remove the dependency array
);

return (
<div className="flex h-dvh max-h-dvh bg-white dark:bg-neutral-950">
{chat.historyEnabled && (
<ChatSidebar
conversations={chat.conversations}
nextCursor={chat.nextCursor}
loadingConversations={chat.loadingConversations}
conversationId={chat.conversationId}
collapsed={chat.sidebarCollapsed}
onSelectConversation={handleSelectConversation}
onDeleteConversation={chat.deleteConversation}
onLoadMore={chat.loadMoreConversations}
onRefresh={chat.refreshConversations}
onNewChat={handleNewChat}
onToggleCollapse={chat.toggleSidebar}
<div className="flex h-dvh max-h-dvh bg-white dark:bg-neutral-950 relative">
{/* Mobile Backdrop */}
{(!chat.sidebarCollapsed || !chat.rightSidebarCollapsed) && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40 transition-opacity"
onClick={() => {
if (!chat.sidebarCollapsed) chat.toggleSidebar();
if (!chat.rightSidebarCollapsed) chat.toggleRightSidebar();
}}
aria-hidden="true"
/>
)}
<div className="flex flex-col flex-1">

{/* Left Sidebar - Overlay on mobile, static on desktop */}
{chat.historyEnabled && (
<div
className={`
${chat.sidebarCollapsed ? '-translate-x-full md:translate-x-0' : 'translate-x-0'}
fixed md:relative inset-y-0 left-0 z-50 md:z-auto
transition-transform duration-300 ease-in-out
`}
>
<ChatSidebar
conversations={chat.conversations}
nextCursor={chat.nextCursor}
loadingConversations={chat.loadingConversations}
conversationId={chat.conversationId}
collapsed={chat.sidebarCollapsed}
onSelectConversation={handleSelectConversation}
onDeleteConversation={chat.deleteConversation}
onLoadMore={chat.loadMoreConversations}
onRefresh={chat.refreshConversations}
onNewChat={handleNewChat}
onToggleCollapse={chat.toggleSidebar}
/>
</div>
)}

<div className="flex flex-col flex-1 min-w-0">
<ChatHeader
isStreaming={chat.status === 'streaming'}
onNewChat={handleNewChat}
Expand All @@ -436,6 +499,10 @@
fallbackOptions={chat.modelOptions}
modelToProvider={chat.modelToProvider}
onFocusMessageInput={handleFocusMessageInput}
onToggleLeftSidebar={chat.toggleSidebar}
onToggleRightSidebar={chat.toggleRightSidebar}
showLeftSidebarButton={chat.historyEnabled}
showRightSidebarButton={true}
/>
<div className="flex flex-1 min-h-0">
<div className="flex flex-col flex-1 relative">
Expand All @@ -458,15 +525,15 @@
/>

{/* Scroll Buttons - centered but visually minimal */}
<div className="absolute bottom-40 left-1/2 transform -translate-x-1/2 flex flex-col gap-2 z-40 pointer-events-none">
<div className="absolute bottom-32 sm:bottom-40 left-1/2 transform -translate-x-1/2 flex flex-col gap-2 z-40 pointer-events-none">
{scrollButtons.showTop && (
<button
onClick={scrollToTop}
className="pointer-events-auto p-1.5 rounded-md bg-white dark:bg-neutral-900 text-slate-600 dark:text-slate-200 border border-slate-200/70 dark:border-neutral-700/70 hover:bg-slate-50 dark:hover:bg-neutral-800 transition-colors"
aria-label="Scroll to top"
title="Scroll to top"
>
<ArrowUp className="w-4 h-4" />
<ArrowUp className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
)}
{scrollButtons.showBottom && (
Expand All @@ -476,13 +543,13 @@
aria-label="Scroll to bottom"
title="Scroll to bottom"
>
<ArrowDown className="w-4 h-4" />
<ArrowDown className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
)}
</div>

{/* Removed soft fade to keep a cleaner boundaryless look */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-full max-w-3xl px-6 z-30">
<div className="absolute bottom-2 sm:bottom-4 left-1/2 transform -translate-x-1/2 w-full max-w-3xl px-2 sm:px-4 md:px-6 z-30">
<MessageInput
ref={messageInputRef}
input={chat.input}
Expand Down Expand Up @@ -517,28 +584,39 @@
initialMode={authMode}
/>
</div>
{/* Right Sidebar Resize Handle - Desktop only */}
{!chat.rightSidebarCollapsed && (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize right sidebar"
className={`flex-shrink-0 self-stretch w-1 cursor-col-resize select-none transition-colors duration-150 ${isResizingRightSidebar ? 'bg-slate-400/60 dark:bg-neutral-600/60' : 'bg-transparent hover:bg-slate-400/40 dark:hover:bg-neutral-600/40'}`}
className={`hidden md:block flex-shrink-0 self-stretch w-1 cursor-col-resize select-none transition-colors duration-150 ${isResizingRightSidebar ? 'bg-slate-400/60 dark:bg-neutral-600/60' : 'bg-transparent hover:bg-slate-400/40 dark:hover:bg-neutral-600/40'}`}
onPointerDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
/>
)}
<RightSidebar
userId={chat.user?.id}
conversationId={chat.conversationId || undefined}
collapsed={chat.rightSidebarCollapsed}
onToggleCollapse={chat.toggleRightSidebar}
onEffectivePromptChange={chat.setInlineSystemPromptOverride}
onActivePromptIdChange={chat.setActiveSystemPromptId}
conversationActivePromptId={chat.activeSystemPromptId}
conversationSystemPrompt={chat.systemPrompt}
width={rightSidebarWidth}
isResizing={isResizingRightSidebar}
/>

{/* Right Sidebar - Overlay on mobile, static on desktop */}
<div
className={`
${chat.rightSidebarCollapsed ? 'translate-x-full md:translate-x-0' : 'translate-x-0'}
fixed md:relative inset-y-0 right-0 z-50 md:z-auto
transition-transform duration-300 ease-in-out
`}
>
<RightSidebar
userId={chat.user?.id}
conversationId={chat.conversationId || undefined}
collapsed={chat.rightSidebarCollapsed}
onToggleCollapse={chat.toggleRightSidebar}
onEffectivePromptChange={chat.setInlineSystemPromptOverride}
onActivePromptIdChange={chat.setActiveSystemPromptId}
conversationActivePromptId={chat.activeSystemPromptId}
conversationSystemPrompt={chat.systemPrompt}
width={rightSidebarWidth}
isResizing={isResizingRightSidebar}
/>
</div>
</div>
</div>
</div>
Expand Down
Loading
Loading