feat(chat): native React chat page with session selector and streaming
Replace the iframe-based Control UI embed with a native React implementation that communicates directly with the Gateway via gateway:rpc IPC calls and chat event streaming. New components: - ChatToolbar: session selector dropdown, refresh button, thinking toggle - ChatMessage: message bubbles with markdown (react-markdown + GFM), collapsible thinking blocks, tool use cards, image attachments - ChatInput: textarea with Enter to send, Shift+Enter for new line - message-utils: extractText/extractThinking/extractImages/extractToolUse ported from OpenClaw's message-extract.ts Rewritten chat store with: - Session management (sessions.list, switchSession) - Proper chat.history loading with raw message preservation - chat.send with idempotencyKey and run tracking - Streaming via handleChatEvent (delta/final/error/aborted) - Thinking toggle (show/hide reasoning blocks)
This commit is contained in:
81
src/pages/Chat/ChatToolbar.tsx
Normal file
81
src/pages/Chat/ChatToolbar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Chat Toolbar
|
||||
* Session selector, refresh, and thinking toggle controls.
|
||||
*/
|
||||
import { RefreshCw, Brain, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ChatToolbar() {
|
||||
const sessions = useChatStore((s) => s.sessions);
|
||||
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
|
||||
const switchSession = useChatStore((s) => s.switchSession);
|
||||
const refresh = useChatStore((s) => s.refresh);
|
||||
const loading = useChatStore((s) => s.loading);
|
||||
const showThinking = useChatStore((s) => s.showThinking);
|
||||
const toggleThinking = useChatStore((s) => s.toggleThinking);
|
||||
|
||||
const handleSessionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
switchSession(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Session Selector */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentSessionKey}
|
||||
onChange={handleSessionChange}
|
||||
className={cn(
|
||||
'appearance-none rounded-md border border-border bg-background px-3 py-1.5 pr-8',
|
||||
'text-sm text-foreground cursor-pointer',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
)}
|
||||
>
|
||||
{/* Always show current session */}
|
||||
<option value={currentSessionKey}>
|
||||
{sessions.find((s) => s.key === currentSessionKey)?.displayName
|
||||
|| sessions.find((s) => s.key === currentSessionKey)?.label
|
||||
|| currentSessionKey}
|
||||
</option>
|
||||
{/* Other sessions */}
|
||||
{sessions
|
||||
.filter((s) => s.key !== currentSessionKey)
|
||||
.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.displayName || s.label || s.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Refresh */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => refresh()}
|
||||
disabled={loading}
|
||||
title="Refresh chat"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
|
||||
{/* Thinking Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
showThinking && 'bg-primary/10 text-primary',
|
||||
)}
|
||||
onClick={toggleThinking}
|
||||
title={showThinking ? 'Hide thinking' : 'Show thinking'}
|
||||
>
|
||||
<Brain className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user