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:
“BeeRad”
2026-01-29 15:30:05 +11:00
co-authored by Claude Opus 4.5
parent a398819e26
commit 2661be7c80
7 changed files with 19 additions and 1995 deletions
-501
View File
@@ -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>
);
}
-399
View File
@@ -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>
</>
);
}
-148
View File
@@ -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>
);
}
-226
View File
@@ -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 };
}
-60
View File
@@ -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>
);
}
-157
View File
@@ -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>
);
}
+19 -504
View File
@@ -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>
); );