feat: add collapse/expand all tool calls toggle button
Adds a floating button in the chat area that lets users collapse or expand all tool call details at once. Useful for long conversations with many tool calls where scrolling through expanded results is tedious. - New ToolCollapseContext provides global collapse/expand state - ToolCall components react to global state changes via version tracking - Toggle button appears only when conversation has tool calls - Supports EN/FR i18n
This commit is contained in:
@@ -6,6 +6,7 @@ import { Sidebar } from './components/Sidebar';
|
|||||||
import { LoginScreen } from './components/LoginScreen';
|
import { LoginScreen } from './components/LoginScreen';
|
||||||
import { ConnectionBanner } from './components/ConnectionBanner';
|
import { ConnectionBanner } from './components/ConnectionBanner';
|
||||||
import { KeyboardShortcuts } from './components/KeyboardShortcuts';
|
import { KeyboardShortcuts } from './components/KeyboardShortcuts';
|
||||||
|
import { ToolCollapseProvider } from './contexts/ToolCollapseContext';
|
||||||
|
|
||||||
const Chat = lazy(() => import('./components/Chat').then(m => ({ default: m.Chat })));
|
const Chat = lazy(() => import('./components/Chat').then(m => ({ default: m.Chat })));
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ToolCollapseProvider>
|
||||||
<div className="h-dvh flex overflow-x-hidden bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]" role="application" aria-label="PinchChat">
|
<div className="h-dvh flex overflow-x-hidden bg-[#1e1e24] text-zinc-300 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]" role="application" aria-label="PinchChat">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -103,5 +105,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
</ToolCollapseProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { ChatMessageComponent } from './ChatMessage';
|
|||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||||
import { Bot, ArrowDown, Loader2 } from 'lucide-react';
|
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { getLocale, type TranslationKey } from '../lib/i18n';
|
import { getLocale, type TranslationKey } from '../lib/i18n';
|
||||||
|
import { useToolCollapse } from '../contexts/ToolCollapseContext';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@@ -126,6 +127,9 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
|
|
||||||
const showTyping = isGenerating && !hasStreamedText(messages);
|
const showTyping = isGenerating && !hasStreamedText(messages);
|
||||||
|
|
||||||
|
const { globalState, collapseAll, expandAll } = useToolCollapse();
|
||||||
|
const hasToolCalls = useMemo(() => messages.some(m => m.blocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')), [messages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative" role="log" aria-label={t('chat.messages')} aria-live="polite">
|
||||||
@@ -164,9 +168,19 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Scroll to bottom FAB */}
|
{/* Floating action buttons */}
|
||||||
{showScrollBtn && (
|
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
|
||||||
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-10">
|
{hasToolCalls && (
|
||||||
|
<button
|
||||||
|
onClick={globalState === 'expand-all' ? collapseAll : expandAll}
|
||||||
|
aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
|
||||||
|
title={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border border-white/10 bg-zinc-800/90 backdrop-blur-lg px-3 py-2 text-xs text-zinc-300 shadow-lg hover:bg-zinc-700/90 transition-all hover:shadow-violet-500/10"
|
||||||
|
>
|
||||||
|
{globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showScrollBtn && (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollToBottom('smooth')}
|
onClick={() => scrollToBottom('smooth')}
|
||||||
aria-label={t('chat.scrollToBottom')}
|
aria-label={t('chat.scrollToBottom')}
|
||||||
@@ -175,8 +189,8 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
<ArrowDown size={14} className="text-cyan-300" />
|
<ArrowDown size={14} className="text-cyan-300" />
|
||||||
<span className="hidden sm:inline">{t('chat.scrollToBottom')}</span>
|
<span className="hidden sm:inline">{t('chat.scrollToBottom')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} />
|
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
import { ChevronRight, ChevronDown, Check, Copy } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Check, Copy } from 'lucide-react';
|
||||||
import hljs from 'highlight.js/lib/common';
|
import hljs from 'highlight.js/lib/common';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { ImageBlock } from './ImageBlock';
|
import { ImageBlock } from './ImageBlock';
|
||||||
|
import { useToolCollapse } from '../contexts/ToolCollapseContext';
|
||||||
|
|
||||||
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
|
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
|
||||||
|
|
||||||
@@ -217,8 +218,19 @@ function extractImageFromResult(result: string): { src: string; remaining: strin
|
|||||||
export function ToolCall({ name, input, result }: { name: string; input?: Record<string, unknown>; result?: string }) {
|
export function ToolCall({ name, input, result }: { name: string; input?: Record<string, unknown>; result?: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { globalState, version } = useToolCollapse();
|
||||||
|
const lastVersion = useRef(version);
|
||||||
const c = getColor(name);
|
const c = getColor(name);
|
||||||
|
|
||||||
|
// Respond to global collapse/expand commands
|
||||||
|
useEffect(() => {
|
||||||
|
if (version !== lastVersion.current) {
|
||||||
|
lastVersion.current = version;
|
||||||
|
if (globalState === 'collapse-all') setOpen(false);
|
||||||
|
else if (globalState === 'expand-all') setOpen(true);
|
||||||
|
}
|
||||||
|
}, [globalState, version]);
|
||||||
|
|
||||||
const inputStr = input ? (typeof input === 'string' ? input : JSON.stringify(input, null, 2)) : '';
|
const inputStr = input ? (typeof input === 'string' ? input : JSON.stringify(input, null, 2)) : '';
|
||||||
const hint = getContextHint(name, input);
|
const hint = getContextHint(name, input);
|
||||||
|
|
||||||
|
|||||||
44
src/contexts/ToolCollapseContext.tsx
Normal file
44
src/contexts/ToolCollapseContext.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type ToolCollapseState = 'none' | 'collapse-all' | 'expand-all';
|
||||||
|
|
||||||
|
interface ToolCollapseContextValue {
|
||||||
|
/** Global override: 'none' means each tool manages its own state */
|
||||||
|
globalState: ToolCollapseState;
|
||||||
|
/** Monotonically increasing version — tool calls reset local state when this changes */
|
||||||
|
version: number;
|
||||||
|
collapseAll: () => void;
|
||||||
|
expandAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolCollapseContext = createContext<ToolCollapseContextValue>({
|
||||||
|
globalState: 'none',
|
||||||
|
version: 0,
|
||||||
|
collapseAll: () => {},
|
||||||
|
expandAll: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ToolCollapseProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [globalState, setGlobalState] = useState<ToolCollapseState>('none');
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
const collapseAll = useCallback(() => {
|
||||||
|
setGlobalState('collapse-all');
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expandAll = useCallback(() => {
|
||||||
|
setGlobalState('expand-all');
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolCollapseContext.Provider value={{ globalState, version, collapseAll, expandAll }}>
|
||||||
|
{children}
|
||||||
|
</ToolCollapseContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToolCollapse() {
|
||||||
|
return useContext(ToolCollapseContext);
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ const en = {
|
|||||||
'chat.send': 'Send',
|
'chat.send': 'Send',
|
||||||
'chat.stop': 'Stop',
|
'chat.stop': 'Stop',
|
||||||
'chat.scrollToBottom': 'New messages',
|
'chat.scrollToBottom': 'New messages',
|
||||||
|
'chat.collapseTools': 'Collapse all tools',
|
||||||
|
'chat.expandTools': 'Expand all tools',
|
||||||
'chat.messages': 'Chat messages',
|
'chat.messages': 'Chat messages',
|
||||||
'chat.thinking': 'Thinking…',
|
'chat.thinking': 'Thinking…',
|
||||||
|
|
||||||
@@ -131,6 +133,8 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'chat.send': 'Envoyer',
|
'chat.send': 'Envoyer',
|
||||||
'chat.stop': 'Arrêter',
|
'chat.stop': 'Arrêter',
|
||||||
'chat.scrollToBottom': 'Nouveaux messages',
|
'chat.scrollToBottom': 'Nouveaux messages',
|
||||||
|
'chat.collapseTools': 'Replier tous les outils',
|
||||||
|
'chat.expandTools': 'Déplier tous les outils',
|
||||||
'chat.messages': 'Messages du chat',
|
'chat.messages': 'Messages du chat',
|
||||||
'chat.thinking': 'Réflexion…',
|
'chat.thinking': 'Réflexion…',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user