feat(rah-light): remove RAHChat and related components
- Deleted src/components/agents/RAHChat.tsx - Deleted src/components/agents/TerminalInput.tsx - Deleted src/components/agents/TerminalMessage.tsx - Deleted src/components/agents/hooks/useSSEChat.ts - Deleted src/components/helpers/ReasoningTrace.tsx - Deleted src/components/helpers/ToolDisplay.tsx - Simplified WorkflowsPane to show MCP integration message (no chat) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
a398819e26
commit
2661be7c80
@@ -1,501 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import AsciiBanner from './AsciiBanner';
|
||||
import TerminalMessage from './TerminalMessage';
|
||||
import TerminalInput from './TerminalInput';
|
||||
import { Zap, Flame } from 'lucide-react';
|
||||
import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat';
|
||||
import { useQuotaHandler } from '@/hooks/useQuotaHandler';
|
||||
|
||||
// Stub type for delegation (delegation system removed in rah-light)
|
||||
type AgentDelegation = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
task: string;
|
||||
status: 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||
summary?: string | null;
|
||||
agentType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// Stub DelegationIndicator component (delegation system removed)
|
||||
function DelegationIndicator({ delegations }: { delegations: AgentDelegation[] }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
interface HighlightedPassage {
|
||||
selectedText: string;
|
||||
nodeId: number;
|
||||
nodeTitle: string;
|
||||
}
|
||||
|
||||
interface RAHChatProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onClearDimension?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations?: AgentDelegation[];
|
||||
messages?: ChatMessage[];
|
||||
setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
mode?: 'easy' | 'hard';
|
||||
delegationMode?: boolean;
|
||||
delegationSessionId?: string;
|
||||
onQuickAdd?: () => void;
|
||||
highlightedPassage?: HighlightedPassage | null;
|
||||
onClearPassage?: () => void;
|
||||
}
|
||||
|
||||
export default function RAHChat({
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onClearDimension: _onClearDimension,
|
||||
onNodeClick,
|
||||
delegations = [],
|
||||
messages: externalMessages,
|
||||
setMessages: externalSetMessages,
|
||||
mode = 'easy',
|
||||
delegationMode = false,
|
||||
delegationSessionId,
|
||||
onQuickAdd: _onQuickAdd,
|
||||
highlightedPassage: _highlightedPassage,
|
||||
onClearPassage: _onClearPassage,
|
||||
}: RAHChatProps) {
|
||||
// Use external state if provided (lifted state), otherwise use local state
|
||||
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
|
||||
const messages = externalMessages !== undefined ? externalMessages : internalMessages;
|
||||
const setMessages = externalSetMessages || internalSetMessages;
|
||||
|
||||
const [sessionId, setSessionId] = useState(() => createSessionId());
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatMode = mode === 'hard' ? 'hard' : 'easy';
|
||||
const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy';
|
||||
const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)';
|
||||
const {
|
||||
quotaError,
|
||||
handleAPIError: handleQuotaApiError,
|
||||
checkQuotaBeforeRequest,
|
||||
refetchUsage,
|
||||
isQuotaExceeded,
|
||||
} = useQuotaHandler();
|
||||
const streamCompleteHandlerRef = useRef<() => Promise<void> | void>(async () => {
|
||||
await refetchUsage();
|
||||
});
|
||||
const setMessagesRef = useRef(setMessages);
|
||||
|
||||
const sse = useSSEChat('/api/rah/chat', setMessages, {
|
||||
getAuthToken: () => null,
|
||||
beforeRequest: checkQuotaBeforeRequest,
|
||||
onRequestError: handleQuotaApiError,
|
||||
onStreamComplete: async () => {
|
||||
const handler = streamCompleteHandlerRef.current;
|
||||
if (handler) {
|
||||
await handler();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (delegationMode) return; // Delegation chats are read-only
|
||||
if (isQuotaExceeded) {
|
||||
checkQuotaBeforeRequest();
|
||||
return;
|
||||
}
|
||||
await sse.send({
|
||||
text,
|
||||
history: messages,
|
||||
openTabs: openTabsData,
|
||||
activeTabId,
|
||||
sessionId,
|
||||
mode: chatMode
|
||||
});
|
||||
}, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]);
|
||||
|
||||
const handleStreamComplete = useCallback(async () => {
|
||||
await refetchUsage();
|
||||
}, [refetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
streamCompleteHandlerRef.current = handleStreamComplete;
|
||||
}, [handleStreamComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessagesRef.current = setMessages;
|
||||
}, [setMessages]);
|
||||
|
||||
const focusSummary = useMemo(() => {
|
||||
if (!openTabsData.length) return null;
|
||||
const titles = openTabsData.map((node) => node?.title || 'Untitled');
|
||||
const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0];
|
||||
const truncate = (value: string, limit = 64) => {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
};
|
||||
return {
|
||||
id: activeNode?.id ?? null,
|
||||
title: truncate(activeNode?.title || 'Untitled'),
|
||||
total: titles.length,
|
||||
};
|
||||
}, [openTabsData, activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleNewChat = () => {
|
||||
if (delegationMode) return;
|
||||
sse.abort();
|
||||
setMessages((_prev) => []);
|
||||
setSessionId(createSessionId());
|
||||
};
|
||||
|
||||
// Subscribe to delegation stream if in delegation mode
|
||||
useEffect(() => {
|
||||
if (!delegationMode || !delegationSessionId) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
setMessagesRef.current((prev) => {
|
||||
const lastMsg = prev[prev.length - 1];
|
||||
if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: data.delta,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start' || data.type === 'tool-call') {
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId || crypto.randomUUID()}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: (data.status as ChatMessage['status']) || 'running',
|
||||
toolArgs: data.input ?? data.args ?? data.parameters
|
||||
};
|
||||
setMessagesRef.current((prev) => [...prev, toolMessage]);
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available' || data.type === 'tool-result') {
|
||||
setMessagesRef.current((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? {
|
||||
...m,
|
||||
content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`,
|
||||
status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'),
|
||||
toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined),
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'assistant-message') {
|
||||
setMessagesRef.current((prev) => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RAHChat] Failed to parse delegation stream event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.error('[RAHChat] Delegation stream connection error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [delegationMode, delegationSessionId]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{focusSummary && (
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
{/* Focused node info */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, flex: 1 }}>
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.18em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Focused Node ({focusSummary.total})
|
||||
</span>
|
||||
<span style={{ color: '#d0d0d0', fontSize: '10px' }}>#{focusSummary.id}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#f3f3f3',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
title={focusSummary.title}
|
||||
>
|
||||
{focusSummary.title}
|
||||
</span>
|
||||
</div>
|
||||
<DelegationIndicator delegations={delegations} />
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px', background: '#0a0a0a' }}>
|
||||
{messages.length === 0 ? (
|
||||
<AsciiBanner helperName="ra-h" displayName={helperDisplayName} />
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<TerminalMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
timestamp={message.timestamp}
|
||||
toolName={message.toolName}
|
||||
status={message.status}
|
||||
toolArgs={message.toolArgs}
|
||||
toolResult={message.toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{!delegationMode && (
|
||||
<div style={{
|
||||
padding: '0 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{(quotaError || isQuotaExceeded) && (
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.12)',
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '11px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
{quotaError?.message ?? 'Rate limit reached. Please wait a moment and try again.'}
|
||||
</div>
|
||||
)}
|
||||
<TerminalInput
|
||||
onSubmit={sendMessage}
|
||||
isProcessing={sse.isLoading || isQuotaExceeded}
|
||||
placeholder={`ask ${helperDisplayName}...`}
|
||||
helperId={activeTabId ?? undefined}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<ModelSelector chatMode={chatMode} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
transition: 'color 0.2s',
|
||||
fontFamily: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#d0d0d0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0
|
||||
}}>+</span>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
chatMode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
function ModelSelector({ chatMode }: ModelSelectorProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)';
|
||||
const Icon = chatMode === 'easy' ? Zap : Flame;
|
||||
const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316';
|
||||
|
||||
const options = [
|
||||
{ id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' },
|
||||
{ id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' },
|
||||
{ id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: '6px',
|
||||
background: '#0f0f0f',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||||
}}
|
||||
>
|
||||
<Icon size={12} strokeWidth={2.4} color={activeColor} />
|
||||
{currentModel}
|
||||
<span style={{
|
||||
marginLeft: '4px',
|
||||
transform: dropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s'
|
||||
}}>▲</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', /* Open upward */
|
||||
left: '0',
|
||||
marginBottom: '4px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '6px',
|
||||
minWidth: '150px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
}}>
|
||||
{options.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
if (!option.disabled && !isActive) {
|
||||
window.dispatchEvent(new CustomEvent('rah:mode-toggle', { detail: { mode: option.id as 'easy' | 'hard' } }));
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
background: isActive ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
color: option.disabled ? '#666' : (isActive ? '#22c55e' : '#e5e5e5'),
|
||||
fontSize: '12px',
|
||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!option.disabled && !isActive) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{OptionIcon && <OptionIcon size={12} strokeWidth={2} color={option.color} />}
|
||||
{!OptionIcon && <div style={{ width: '12px' }} />} {/* Spacer for alignment */}
|
||||
{option.label}
|
||||
{isActive && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, type DragEvent } from 'react';
|
||||
|
||||
interface TerminalInputProps {
|
||||
onSubmit: (text: string) => void;
|
||||
isProcessing: boolean;
|
||||
placeholder?: string;
|
||||
helperId?: number;
|
||||
disabledExternally?: boolean;
|
||||
disabledMessage?: string;
|
||||
}
|
||||
|
||||
export default function TerminalInput({
|
||||
onSubmit,
|
||||
isProcessing,
|
||||
placeholder,
|
||||
helperId,
|
||||
disabledExternally = false,
|
||||
disabledMessage,
|
||||
}: TerminalInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [rows, setRows] = useState(1);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!helperId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/helpers/${helperId}/prompts`);
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load prompts:', e);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [helperId]);
|
||||
|
||||
// Auto-resize textarea - only resize when needed to avoid jumps
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const lineHeight = 24; // line-height * font-size approximately
|
||||
const minHeight = 32;
|
||||
const maxHeight = 120;
|
||||
|
||||
// Check if content overflows current height (need to grow)
|
||||
const needsGrow = textarea.scrollHeight > textarea.clientHeight;
|
||||
|
||||
// Check if we cleared content significantly (need to shrink)
|
||||
const lineCount = (input.match(/\n/g) || []).length + 1;
|
||||
const estimatedHeight = Math.max(lineCount * lineHeight, minHeight);
|
||||
const needsShrink = !input.trim() || (textarea.clientHeight > estimatedHeight + lineHeight);
|
||||
|
||||
if (needsGrow || needsShrink) {
|
||||
// Only recalculate when necessary
|
||||
textarea.style.height = 'auto';
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
|
||||
const newRows = Math.min(Math.max(1, Math.floor(newHeight / lineHeight)), 5);
|
||||
setRows(newRows);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (input.trim() && !isProcessing && !disabledExternally) {
|
||||
// Numeric slash expansion: only when input is exactly /N
|
||||
const m = input.trim().match(/^\/(\d{1,2})$/);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
const idx = n - 1;
|
||||
if (!isNaN(idx) && idx >= 0 && idx < prompts.length) {
|
||||
const content = String(prompts[idx]?.content || '').trim();
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
setInput('');
|
||||
setRows(1);
|
||||
setShowSlashMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSubmit(input.trim());
|
||||
setInput('');
|
||||
setRows(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Slash menu navigation
|
||||
if (showSlashMenu) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; }
|
||||
if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const p = prompts[activeIndex];
|
||||
if (p) { setInput(p.content); setShowSlashMenu(false); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { setShowSlashMenu(false); }
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Toggle slash menu when typing starting '/'
|
||||
const trimmed = input.trimStart();
|
||||
if (trimmed.startsWith('/')) {
|
||||
setShowSlashMenu(true);
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const buttonIsDisabled = !trimmedInput || isProcessing || disabledExternally;
|
||||
|
||||
// Handle node drag over chat input
|
||||
const handleDragOver = (e: DragEvent<HTMLTextAreaElement>) => {
|
||||
// Check if it's a node being dragged (either custom MIME or text/plain fallback)
|
||||
if (e.dataTransfer.types.includes('application/x-rah-node') ||
|
||||
e.dataTransfer.types.includes('application/node-info') ||
|
||||
e.dataTransfer.types.includes('text/plain')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLTextAreaElement>) => {
|
||||
// Only reset if actually leaving the textarea (not entering a child)
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
// Try application/x-rah-node first (structured data with id + title)
|
||||
let nodeData = e.dataTransfer.getData('application/x-rah-node');
|
||||
if (nodeData) {
|
||||
try {
|
||||
const { id, title } = JSON.parse(nodeData);
|
||||
const token = `[NODE:${id}:"${title}"]`;
|
||||
insertAtCursor(token);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Failed to parse x-rah-node data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try application/node-info (from NodesPanel)
|
||||
nodeData = e.dataTransfer.getData('application/node-info');
|
||||
if (nodeData) {
|
||||
try {
|
||||
const { id, title } = JSON.parse(nodeData);
|
||||
const token = `[NODE:${id}:"${title || 'Untitled'}"]`;
|
||||
insertAtCursor(token);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Failed to parse node-info data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use text/plain (might already be formatted as [NODE:id:"title"])
|
||||
const plainText = e.dataTransfer.getData('text/plain');
|
||||
if (plainText && plainText.startsWith('[NODE:')) {
|
||||
insertAtCursor(plainText);
|
||||
}
|
||||
};
|
||||
|
||||
const insertAtCursor = (text: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
setInput(prev => prev + text + ' ');
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart || 0;
|
||||
const end = textarea.selectionEnd || 0;
|
||||
const before = input.slice(0, start);
|
||||
const after = input.slice(end);
|
||||
|
||||
// Add space before if there's text and it doesn't end with space
|
||||
const needsSpaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n');
|
||||
// Add space after
|
||||
const newText = (needsSpaceBefore ? ' ' : '') + text + ' ';
|
||||
|
||||
setInput(before + newText + after);
|
||||
|
||||
// Set cursor position after the inserted text
|
||||
setTimeout(() => {
|
||||
const newPos = start + newText.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { opacity: 0.5; color: #3a3a3a; }
|
||||
50% { opacity: 0.7; color: #22c55e; }
|
||||
}
|
||||
textarea::placeholder {
|
||||
animation: subtle-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
padding: '8px 16px 12px',
|
||||
background: 'transparent',
|
||||
borderTop: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Terminal Prompt Symbol */}
|
||||
<span style={{
|
||||
color: '#4a4a4a',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
paddingTop: '6px',
|
||||
userSelect: 'none',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{'>'}
|
||||
</span>
|
||||
|
||||
{/* Input Wrapper */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{/* Input Row with Textarea and Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={isProcessing || disabledExternally}
|
||||
placeholder={placeholder || `ask ra-h...`}
|
||||
rows={rows}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: isDragOver ? 'rgba(34, 197, 94, 0.08)' : 'transparent',
|
||||
border: isDragOver ? '1px dashed #22c55e' : 'none',
|
||||
borderRadius: isDragOver ? '4px' : '0',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '16px',
|
||||
fontFamily: 'inherit',
|
||||
padding: '8px 4px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
lineHeight: '1.5',
|
||||
transition: 'all 200ms ease',
|
||||
minHeight: '32px',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
caretColor: '#22c55e',
|
||||
...((isProcessing || disabledExternally) && {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
caretColor: 'transparent'
|
||||
})
|
||||
}}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
|
||||
{/* Slash menu */}
|
||||
{showSlashMenu && prompts.length > 0 && (
|
||||
<div style={{ position: 'absolute', bottom: '48px', left: '40px', background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '6px', minWidth: '260px', maxHeight: '200px', overflowY: 'auto', zIndex: 1000 }}>
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onMouseDown={(e) => { e.preventDefault(); setInput(p.content); setShowSlashMenu(false); }}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
style={{ display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 8px', cursor: 'pointer', background: i === activeIndex ? '#1a1a1a' : 'transparent', color: '#cfcfcf', fontSize: '12px' }}
|
||||
>
|
||||
<span style={{ color: '#5c9aff', fontSize: '10px', width: '20px' }}>/ {i + 1}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={buttonIsDisabled}
|
||||
aria-label={isProcessing ? 'Processing' : 'Send message'}
|
||||
title={
|
||||
disabledExternally
|
||||
? (disabledMessage || 'Disabled')
|
||||
: isProcessing
|
||||
? 'Processing…'
|
||||
: 'Send (Enter)'
|
||||
}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#22c55e',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: '50%',
|
||||
color: '#0a0a0a',
|
||||
cursor: buttonIsDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
opacity: buttonIsDisabled ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34,197,94, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!buttonIsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span style={{ fontSize: '12px' }}>•••</span>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4l-6.5 6.5 1.42 1.42L11 8.84V20h2V8.84l4.08 3.08 1.42-1.42L12 4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtle Keyboard Hints */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
fontSize: '10px',
|
||||
color: '#353535',
|
||||
userSelect: 'none',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<span>⏎ send</span>
|
||||
<span>⇧⏎ newline</span>
|
||||
{(isProcessing || disabledExternally) && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
color: disabledExternally ? '#a855f7' : '#ffcc66',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em'
|
||||
}}>
|
||||
{disabledExternally ? (disabledMessage || 'disabled') : 'processing...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import ToolDisplay from '@/components/helpers/ToolDisplay';
|
||||
import MarkdownRenderer from '@/components/helpers/MarkdownRenderer';
|
||||
import ReasoningTrace from '@/components/helpers/ReasoningTrace';
|
||||
import { extractToolContext, extractSources } from '@/utils/toolFormatting';
|
||||
|
||||
interface TerminalMessageProps {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'thinking';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'sending' | 'delivered' | 'error' | 'processing' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
// no local state needed
|
||||
|
||||
export default function TerminalMessage({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
toolName,
|
||||
status = 'delivered',
|
||||
toolArgs,
|
||||
toolResult,
|
||||
onNodeClick
|
||||
}: TerminalMessageProps) {
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
const colors = {
|
||||
user: '#5c9aff',
|
||||
assistant: '#52d97a',
|
||||
tool: '#ffcc66',
|
||||
thinking: '#b794f6',
|
||||
system: '#69d2e7'
|
||||
};
|
||||
return colors[role] || colors.assistant;
|
||||
}, [role]);
|
||||
|
||||
const isAnimated = role === 'thinking' || status === 'processing';
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}).toLowerCase();
|
||||
};
|
||||
|
||||
// Handle tool messages specially
|
||||
if (role === 'tool') {
|
||||
// THINK tool: display structured trace when present in toolResult
|
||||
if (toolName === 'think') {
|
||||
const trace = toolResult?.data?.trace || null;
|
||||
return <ReasoningTrace trace={trace} collapsible />;
|
||||
}
|
||||
|
||||
// Generic tool display with context and sources
|
||||
const context =
|
||||
extractToolContext(toolName, toolArgs) ||
|
||||
(toolName === 'webSearch' && toolResult?.data?.query ? `Searching for: ${String(toolResult.data.query)}` : undefined);
|
||||
const sources = extractSources(toolName, toolResult);
|
||||
const normalizedStatus =
|
||||
status === 'processing' ? 'running' : status === 'delivered' ? 'complete' : ((status as any) || 'complete');
|
||||
return (
|
||||
<ToolDisplay
|
||||
name={toolName || 'tool'}
|
||||
status={normalizedStatus}
|
||||
context={context}
|
||||
sources={sources}
|
||||
result={toolResult}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '8px 0',
|
||||
fontFamily: 'inherit'
|
||||
}}>
|
||||
{/* Status Dot */}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
marginTop: '7px',
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
transition: 'all 150ms ease',
|
||||
...(isAnimated && {
|
||||
animation: 'pulse 1.5s infinite'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Message Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{content ? (
|
||||
<MarkdownRenderer content={content} streaming={status === 'processing'} onNodeClick={onNodeClick} />
|
||||
) : (
|
||||
role === 'thinking' ? <span>Thinking...</span> : null
|
||||
)}
|
||||
{/* References block for assistant messages: extract [Title](URL) */}
|
||||
{role === 'assistant' && typeof content === 'string' && (() => {
|
||||
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
||||
const refs: Array<{ title: string; url: string }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(content)) !== null) {
|
||||
refs.push({ title: m[1], url: m[2] });
|
||||
if (refs.length >= 12) break;
|
||||
}
|
||||
if (refs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ color: '#8a8a8a', fontSize: 11, marginBottom: 4 }}>References</div>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{refs.map((r, i) => (
|
||||
<div key={i} style={{ color: '#bdbdbd', fontSize: 12 }}>
|
||||
{i + 1}. <span style={{ color: '#e5e5e5' }}>{r.title}</span> — <a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>{r.url}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '4px',
|
||||
color: '#4a4a4a',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
{formatTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
TOOL = 'tool',
|
||||
SYSTEM = 'system',
|
||||
THINKING = 'thinking',
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT | MessageRole.TOOL | MessageRole.SYSTEM | MessageRole.THINKING;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
status?: 'processing' | 'delivered' | 'error' | 'starting' | 'running' | 'complete';
|
||||
toolArgs?: any;
|
||||
toolResult?: any;
|
||||
}
|
||||
|
||||
interface SendParams {
|
||||
text: string;
|
||||
history: ChatMessage[];
|
||||
openTabs: any[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
currentView?: 'nodes' | 'memory';
|
||||
sessionId: string;
|
||||
mode: 'easy' | 'hard';
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
interface UseSSEChatOptions {
|
||||
getAuthToken?: () => string | null | undefined;
|
||||
beforeRequest?: () => boolean;
|
||||
onRequestError?: (error: unknown, response?: Response) => boolean | void;
|
||||
onStreamComplete?: () => void | Promise<void>;
|
||||
onUsageUpdate?: (usage: UsageData) => void;
|
||||
}
|
||||
|
||||
export function useSSEChat(
|
||||
endpoint: string,
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
|
||||
options: UseSSEChatOptions = {}
|
||||
) {
|
||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, onUsageUpdate } = options;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const abort = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
|
||||
const send = async ({ text, history, openTabs, activeTabId, activeDimension, currentView, sessionId, mode }: SendParams) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (beforeRequest && !beforeRequest()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.USER,
|
||||
content: trimmed,
|
||||
timestamp: new Date()
|
||||
};
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
|
||||
let handledError = false;
|
||||
let currentAssistantMessage = assistantMessage;
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getAuthToken?.();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
messages: history
|
||||
.concat(userMessage)
|
||||
.filter((m) => [MessageRole.USER, MessageRole.ASSISTANT, MessageRole.SYSTEM].includes(m.role))
|
||||
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
|
||||
openTabs,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
currentView,
|
||||
sessionId,
|
||||
mode
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP error! status: ${response.status}`);
|
||||
handledError = Boolean(onRequestError?.(error, response));
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
if (handledError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
let fullContent = '';
|
||||
let toolCallsActive: Record<string, string> = {};
|
||||
let hasToolCalls = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
|
||||
if (data.type === 'text-delta' && data.delta) {
|
||||
fullContent += data.delta;
|
||||
setMessages((prev) => prev.map((m) => (m.id === currentAssistantMessage.id ? { ...m, content: fullContent } : m)));
|
||||
}
|
||||
|
||||
if (data.type === 'tool-input-start') {
|
||||
hasToolCalls = true;
|
||||
toolCallsActive[data.toolCallId] = data.toolName;
|
||||
const toolMessage: ChatMessage = {
|
||||
id: `tool-${data.toolCallId}`,
|
||||
role: MessageRole.TOOL,
|
||||
content: data.toolName,
|
||||
timestamp: new Date(),
|
||||
toolName: data.toolName,
|
||||
status: 'running',
|
||||
toolArgs: (data.args ?? data.input ?? data.parameters ?? null)
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== toolMessage.id);
|
||||
const index = filtered.findIndex((m) => m.id === currentAssistantMessage.id);
|
||||
return [...filtered.slice(0, index), toolMessage, ...filtered.slice(index)];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'tool-output-available') {
|
||||
delete toolCallsActive[data.toolCallId];
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `tool-${data.toolCallId}`
|
||||
? { ...m, content: `${m.toolName} ✓`, status: 'complete', toolResult: (data.result ?? data.output ?? null) }
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
||||
if (Object.keys(toolCallsActive).length === 0 && hasToolCalls) {
|
||||
const newAssistantMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
currentAssistantMessage = newAssistantMessage;
|
||||
fullContent = '';
|
||||
setMessages((prev) => [...prev, newAssistantMessage]);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture usage data from finish event (if AI SDK sends it)
|
||||
if (data.type === 'finish' && data.usage) {
|
||||
onUsageUpdate?.({
|
||||
inputTokens: data.usage.promptTokens ?? data.usage.inputTokens ?? 0,
|
||||
outputTokens: data.usage.completionTokens ?? data.usage.outputTokens ?? 0
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onStreamComplete) {
|
||||
await onStreamComplete();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
if (!handledError) {
|
||||
handledError = Boolean(onRequestError?.(err));
|
||||
}
|
||||
if (!handledError) {
|
||||
setError(err?.message || 'Failed to send message');
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.filter((m) => !(m.id === currentAssistantMessage.id && m.content === ''))
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { isLoading, error, send, abort };
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ReasoningTraceProps {
|
||||
trace?: {
|
||||
purpose?: string;
|
||||
thoughts?: string;
|
||||
next_action?: string;
|
||||
step?: number;
|
||||
done?: boolean;
|
||||
timestamp?: string;
|
||||
} | null;
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export default function ReasoningTrace({ trace, collapsible = true }: ReasoningTraceProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!trace) return null;
|
||||
const short = (s?: string, n = 180) => (s || '').slice(0, n) + ((s || '').length > n ? '…' : '');
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 0', padding: '8px 10px',
|
||||
background: '#0e0e12', border: '1px solid #2a2a2a',
|
||||
borderLeft: '2px solid #6b6b7f', borderRadius: 6,
|
||||
fontSize: 12, color: '#cfcfcf', lineHeight: 1.5
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ color: '#8b8b9f', fontWeight: 600 }}>Reasoning trace</span>
|
||||
{trace.timestamp ? (
|
||||
<span style={{ color: '#4a4a4a', fontSize: 11 }}>{new Date(trace.timestamp).toLocaleTimeString()}</span>
|
||||
) : null}
|
||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{trace.done ? 'final' : 'in-progress'}</span>
|
||||
</div>
|
||||
{/* Collapsed: show only the purpose as the title */}
|
||||
{trace.purpose ? (
|
||||
<div style={{ color: '#9adbc2' }}>Goal: {expanded ? trace.purpose : short(trace.purpose, 80)}</div>
|
||||
) : null}
|
||||
{/* Expanded: show details */}
|
||||
{expanded && trace.thoughts ? <div style={{ marginTop: 6 }}>{trace.thoughts}</div> : null}
|
||||
{expanded && trace.next_action ? (
|
||||
<div style={{ marginTop: 6, color: '#a5b4fc' }}>Next: {trace.next_action}</div>
|
||||
) : null}
|
||||
{collapsible ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide reasoning' : 'Show full reasoning'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import SourceChip from './SourceChip';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ToolDisplayProps {
|
||||
name: string;
|
||||
status: 'starting' | 'running' | 'complete' | 'error';
|
||||
context?: string;
|
||||
sources?: Array<{ url?: string; domain?: string }> | string[];
|
||||
result?: any;
|
||||
error?: string;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export default function ToolDisplay({ name, status, context, sources, result, error, onNodeClick }: ToolDisplayProps) {
|
||||
const isRunning = status === 'starting' || status === 'running';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const accentColor = '#22c55e';
|
||||
const bgTint = '#0b0b0b';
|
||||
const displayName = name;
|
||||
|
||||
// Normalize sources to array of objects
|
||||
const normalizedSources: Array<{ url?: string; domain?: string }> = Array.isArray(sources)
|
||||
? sources.map((s: any) => (typeof s === 'string' ? { domain: s } : s))
|
||||
: [];
|
||||
|
||||
// Web search simple preview renderer
|
||||
const renderWebSearchPreview = () => {
|
||||
const items = result?.data?.results || result?.results || [];
|
||||
if (!Array.isArray(items) || items.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a', color: '#8a8a8a',
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 4, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Hide results' : `Show results (${Math.min(items.length, 3)})`}
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 6 }}>
|
||||
{items.slice(0, 3).map((r: any, i: number) => (
|
||||
<div key={i} style={{
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
fontSize: 12,
|
||||
color: '#cfcfcf'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{r.title || 'Result'}</div>
|
||||
{r.snippet ? <div style={{ color: '#9a9a9a', marginTop: 2 }}>{r.snippet}</div> : null}
|
||||
{r.url ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<a href={r.url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff', fontSize: 11 }}>
|
||||
{r.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
margin: '8px 0',
|
||||
padding: '10px 12px',
|
||||
background: bgTint,
|
||||
border: '1px solid #2a2a2a',
|
||||
borderLeft: `2px solid ${accentColor}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: '#cfcfcf'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
{isRunning ? (
|
||||
<span style={{
|
||||
display: 'inline-block', width: 12, height: 12,
|
||||
border: '2px solid #2a2a2a', borderTopColor: accentColor,
|
||||
borderRadius: '50%', animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
) : (
|
||||
<span style={{ color: accentColor, fontSize: 12 }}>{status === 'error' ? '✗' : '✓'}</span>
|
||||
)}
|
||||
<span style={{ color: accentColor, fontWeight: 600 }}>{displayName}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#4a4a4a', fontSize: 11 }}>{status}</span>
|
||||
</div>
|
||||
{context ? <div style={{ color: '#9adbc2' }}>{context}</div> : null}
|
||||
{normalizedSources.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{normalizedSources.slice(0, 6).map((s, i) => (
|
||||
<SourceChip key={i} url={s.url} domain={s.domain} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Specific previews */}
|
||||
{name === 'webSearch' ? renderWebSearchPreview() : null}
|
||||
{name === 'websiteExtract' ? (
|
||||
<WebsiteExtractSummary result={result} onNodeClick={onNodeClick} />
|
||||
) : null}
|
||||
{error ? (
|
||||
<div style={{ color: '#ff6b6b', marginTop: 6 }}>Error: {error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteExtractSummary({ result, onNodeClick }: { result?: any; onNodeClick?: (id: number) => void }) {
|
||||
const data = result?.data || {};
|
||||
const title = data.title || 'Website added';
|
||||
const url = data.url || '';
|
||||
const nodeId = data.nodeId as number | undefined;
|
||||
return (
|
||||
<div style={{ marginTop: 8, border: '1px solid #2a2a2a', background: '#0f0f0f', borderRadius: 6, padding: 8 }}>
|
||||
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{title}</div>
|
||||
{url ? (
|
||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#5c9aff' }}>
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{typeof nodeId === 'number' && onNodeClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNodeClick(nodeId)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#cfcfcf',
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Open node #{nodeId}
|
||||
</button>
|
||||
) : null}
|
||||
{typeof data.contentLength === 'number' ? (
|
||||
<span style={{ fontSize: 11, color: '#7a7a7a' }}>
|
||||
content ~{Math.round((data.contentLength as number) / 1000)}k chars
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import RAHChat from '@/components/agents/RAHChat';
|
||||
import { useState } from 'react';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { WorkflowsPaneProps, PaneType, AgentDelegation } from './types';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'summary';
|
||||
import { WorkflowsPaneProps, AgentDelegation } from './types';
|
||||
|
||||
export default function WorkflowsPane({
|
||||
slot,
|
||||
@@ -21,57 +16,6 @@ export default function WorkflowsPane({
|
||||
activeTabId = null,
|
||||
activeDimension,
|
||||
}: WorkflowsPaneProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedDelegationId, setSelectedDelegationId] = useState<string | null>(null);
|
||||
const [delegationMessages, setDelegationMessages] = useState<Record<string, ChatMessage[]>>({});
|
||||
|
||||
const selectedDelegation = delegations.find(d => d.sessionId === selectedDelegationId);
|
||||
|
||||
const getDelegationMessages = useCallback((sessionId: string) => {
|
||||
return delegationMessages[sessionId] || [];
|
||||
}, [delegationMessages]);
|
||||
|
||||
const setDelegationMessagesFor = useCallback((sessionId: string) => {
|
||||
return (updater: (prev: ChatMessage[]) => ChatMessage[]) => {
|
||||
setDelegationMessages(prev => ({
|
||||
...prev,
|
||||
[sessionId]: updater(prev[sessionId] || [])
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectDelegation = (sessionId: string) => {
|
||||
const delegation = delegations.find(d => d.sessionId === sessionId);
|
||||
setSelectedDelegationId(sessionId);
|
||||
|
||||
// Show summary view for completed/failed with no messages
|
||||
if (delegation &&
|
||||
(delegation.status === 'completed' || delegation.status === 'failed') &&
|
||||
getDelegationMessages(sessionId).length === 0) {
|
||||
setViewMode('summary');
|
||||
} else {
|
||||
setViewMode('detail');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDelegation = async (sessionId: string) => {
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${sessionId}:`, error);
|
||||
}
|
||||
// The parent will handle removing from delegations list via SSE
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedDelegationId(null);
|
||||
setViewMode('list');
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
@@ -83,82 +27,14 @@ export default function WorkflowsPane({
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{viewMode === 'list' && (
|
||||
<WorkflowsListView
|
||||
delegations={delegations}
|
||||
onSelectDelegation={handleSelectDelegation}
|
||||
onDeleteDelegation={handleDeleteDelegation}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'summary' && selectedDelegation && (
|
||||
<DelegationSummaryView
|
||||
delegation={selectedDelegation}
|
||||
onBack={handleBack}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedDelegation && (
|
||||
<DelegationDetailView
|
||||
delegation={selectedDelegation}
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={delegations}
|
||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
<WorkflowsListView />
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Workflows list view
|
||||
function WorkflowsListView({
|
||||
delegations,
|
||||
onSelectDelegation,
|
||||
onDeleteDelegation,
|
||||
onNodeClick
|
||||
}: {
|
||||
delegations: AgentDelegation[];
|
||||
onSelectDelegation: (sessionId: string) => void;
|
||||
onDeleteDelegation: (sessionId: string) => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}) {
|
||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
||||
|
||||
const getStatusInfo = (delegation: AgentDelegation) => {
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
let color = '#6b6b6b';
|
||||
let label = 'Queued';
|
||||
|
||||
if (delegation.status === 'in_progress') {
|
||||
color = isWiseRAH ? '#8b5cf6' : '#22c55e';
|
||||
label = 'Running';
|
||||
} else if (delegation.status === 'completed') {
|
||||
color = '#22c55e';
|
||||
label = 'Done';
|
||||
} else if (delegation.status === 'failed') {
|
||||
color = '#ff6b6b';
|
||||
label = 'Failed';
|
||||
}
|
||||
|
||||
return { color, label };
|
||||
};
|
||||
|
||||
// Workflows list view - simplified for rah-light (delegation system removed)
|
||||
function WorkflowsListView() {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
@@ -184,394 +60,33 @@ function WorkflowsListView({
|
||||
}}>
|
||||
Workflows
|
||||
</span>
|
||||
{activeDelegations.length > 0 && (
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{activeDelegations.length} running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '12px'
|
||||
}}>
|
||||
{delegations.length === 0 ? (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
No workflows yet. Use Quick Capture or ask RA-H to run a workflow.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{activeDelegations.map((delegation) => {
|
||||
const { color, label } = getStatusInfo(delegation);
|
||||
return (
|
||||
<WorkflowCard
|
||||
key={delegation.sessionId}
|
||||
delegation={delegation}
|
||||
statusColor={color}
|
||||
statusLabel={label}
|
||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeDelegations.length > 0 && completedDelegations.length > 0 && (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: '#1f1f1f',
|
||||
margin: '8px 0'
|
||||
}} />
|
||||
)}
|
||||
|
||||
{completedDelegations.map((delegation) => {
|
||||
const { color, label } = getStatusInfo(delegation);
|
||||
return (
|
||||
<WorkflowCard
|
||||
key={delegation.sessionId}
|
||||
delegation={delegation}
|
||||
statusColor={color}
|
||||
statusLabel={label}
|
||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual workflow card
|
||||
function WorkflowCard({
|
||||
delegation,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onNodeClick
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
statusColor: string;
|
||||
statusLabel: string;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}) {
|
||||
const isActive = delegation.status === 'in_progress' || delegation.status === 'queued';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: '#151515',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px'
|
||||
justifyContent: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
animation: isActive ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
<span style={{
|
||||
color: statusColor,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#555',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#444',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#ff6b6b'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#444'}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
|
||||
{delegation.summary && delegation.status === 'completed' && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginTop: '8px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{parseAndRenderContent(delegation.summary, onNodeClick)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary view for completed delegations
|
||||
function DelegationSummaryView({
|
||||
delegation,
|
||||
onBack,
|
||||
onNodeClick
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
onBack?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}) {
|
||||
const isSuccess = delegation.status === 'completed';
|
||||
const statusColor = isSuccess ? '#22c55e' : '#ff6b6b';
|
||||
const statusLabel = isSuccess ? 'Completed' : 'Failed';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
padding: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor
|
||||
}} />
|
||||
<span style={{
|
||||
color: statusColor,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
{new Date(delegation.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Task
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{delegation.summary && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Result
|
||||
</div>
|
||||
<div style={{
|
||||
color: isSuccess ? '#a8a8a8' : '#fca5a5',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{parseAndRenderContent(delegation.summary || '', onNodeClick)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!delegation.summary && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic'
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
maxWidth: '300px'
|
||||
}}>
|
||||
No details available
|
||||
<p style={{ marginBottom: '16px' }}>
|
||||
Workflows are available via MCP server integration.
|
||||
</p>
|
||||
<p style={{ color: '#555', fontSize: '11px' }}>
|
||||
Connect your coding agent (Claude Code, Cursor, etc.) via MCP to run workflows like Integrate, Extract, and more.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Detail view with chat
|
||||
function DelegationDetailView({
|
||||
delegation,
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onNodeClick,
|
||||
delegations,
|
||||
messages,
|
||||
setMessages,
|
||||
onBack
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
openTabsData: any[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations: AgentDelegation[];
|
||||
messages: ChatMessage[];
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: 'transparent'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#a8a8a8'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#666'}
|
||||
>
|
||||
← Workflows
|
||||
</button>
|
||||
<span style={{ color: '#555', fontSize: '11px' }}>|</span>
|
||||
<span style={{
|
||||
color: delegation.status === 'in_progress' ? '#22c55e' : '#666',
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{delegation.status === 'in_progress' ? 'Running' : delegation.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={delegations}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={delegation.sessionId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user