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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import RAHChat from '@/components/agents/RAHChat';
|
|
||||||
import PaneHeader from './PaneHeader';
|
import PaneHeader from './PaneHeader';
|
||||||
import { WorkflowsPaneProps, PaneType, AgentDelegation } from './types';
|
import { WorkflowsPaneProps, AgentDelegation } from './types';
|
||||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
|
||||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
|
||||||
|
|
||||||
type ViewMode = 'list' | 'detail' | 'summary';
|
|
||||||
|
|
||||||
export default function WorkflowsPane({
|
export default function WorkflowsPane({
|
||||||
slot,
|
slot,
|
||||||
@@ -21,57 +16,6 @@ export default function WorkflowsPane({
|
|||||||
activeTabId = null,
|
activeTabId = null,
|
||||||
activeDimension,
|
activeDimension,
|
||||||
}: WorkflowsPaneProps) {
|
}: 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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -83,82 +27,14 @@ export default function WorkflowsPane({
|
|||||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
{viewMode === 'list' && (
|
<WorkflowsListView />
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflows list view
|
// Workflows list view - simplified for rah-light (delegation system removed)
|
||||||
function WorkflowsListView({
|
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -184,394 +60,33 @@ function WorkflowsListView({
|
|||||||
}}>
|
}}>
|
||||||
Workflows
|
Workflows
|
||||||
</span>
|
</span>
|
||||||
{activeDelegations.length > 0 && (
|
|
||||||
<span style={{
|
|
||||||
color: '#22c55e',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{activeDelegations.length} running
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* Content */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
padding: '12px'
|
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={{
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
justifyContent: 'center',
|
||||||
marginBottom: '8px'
|
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={{
|
<div style={{
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontSize: '12px',
|
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>
|
||||||
)}
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user