Files
ra-h-os/src/components/agents/RAHChat.tsx
T
“BeeRad”andClaude Opus 4.5 6b7bb5a5f9 feat(rah-light): remove agent delegation system
- Delete src/services/agents/delegation.ts (AgentDelegationService)
- Delete app/api/rah/delegations/ directory (all routes)
- Delete src/components/agents/DelegationIndicator.tsx
- Update workflowExecutor.ts to work without delegation streaming
- Update executeWorkflow.ts tool to execute workflows directly
- Update quickAdd.ts to execute directly without delegation tracking
- Add stub AgentDelegation types to UI components for compatibility
- Add stub voice hooks to RAHChat.tsx (will be removed in story 2)

Files deleted:
- src/services/agents/delegation.ts
- app/api/rah/delegations/route.ts
- app/api/rah/delegations/stream/route.ts
- app/api/rah/delegations/[sessionId]/route.ts
- app/api/rah/delegations/[sessionId]/summary/route.ts
- src/components/agents/DelegationIndicator.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:11:15 +11:00

752 lines
25 KiB
TypeScript

"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';
import { apiKeyService } from '@/services/storage/apiKeys';
// 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;
}
// Stub voice hooks (voice system will be removed in story 2)
function useVoiceSession() {
return {
isActive: false,
amplitude: 0,
startSession: () => {},
stopSession: () => {},
resetTranscript: () => {},
setStatus: (_status: string) => {},
setAmplitude: (_amp: number) => {},
setInterimTranscript: (_text: string) => {},
appendFinalTranscript: (_text: string) => {},
};
}
function useAssistantTTS(_options: { onSpeechStart?: () => void; onSpeechComplete?: () => void; onError?: (e: Error) => void }) {
return {
speak: (_text: string, _options?: { flush?: boolean; metadata?: Record<string, string> }) => {},
stop: () => {},
status: 'idle' as const,
};
}
function useVoiceInterruption(_options: { amplitude: number; isVoiceActive: boolean; ttsStatus: string; onInterruption: () => void }) {}
function useRealtimeVoiceClient(_handlers: { onStatusChange?: (status: string) => void; onInterimTranscript?: (text: string) => void; onFinalTranscript?: (text: string) => void; onAmplitude?: (amp: number) => void; onError?: (e: Error) => void }, _options: { getAuthToken: () => string | null }) {
return {
connect: () => {},
disconnect: () => {},
start: () => {},
stop: () => {},
};
}
const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const createVoiceRequestId = () =>
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `voice_${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 voice = useVoiceSession();
const {
isActive: isVoiceActive,
amplitude: voiceAmplitude,
startSession: startVoice,
stopSession: stopVoice,
resetTranscript: resetVoiceTranscript,
setStatus: setVoiceStatus,
setAmplitude: setVoiceAmplitude,
setInterimTranscript,
appendFinalTranscript,
} = voice;
const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]);
const assistantSpeechMapRef = useRef<Map<string, string>>(new Map());
const [voiceError, setVoiceError] = useState<string | null>(null);
const voiceErrorHandledRef = useRef(false);
const voiceStartTimestampRef = useRef<number | null>(null);
const handleVoiceError = useCallback((error: Error) => {
console.error('[RAHChat] Voice error:', error);
setVoiceError(error.message);
if (isVoiceActive) {
stopVoice();
}
resetVoiceTranscript();
setVoiceAmplitude(0);
setVoiceStatus('idle');
pendingVoiceQueueRef.current = [];
assistantSpeechMapRef.current.clear();
}, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]);
const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({
onSpeechStart: () => {
if (isVoiceActive) {
setVoiceStatus('speaking');
}
},
onSpeechComplete: () => {
setVoiceStatus(isVoiceActive ? 'listening' : 'idle');
},
onError: handleVoiceError,
});
const handleVoiceInterruption = useCallback(() => {
stopAssistantTTS();
setVoiceStatus('listening');
}, [setVoiceStatus, stopAssistantTTS]);
const sse = useSSEChat('/api/rah/chat', setMessages, {
getAuthToken: () => null,
beforeRequest: checkQuotaBeforeRequest,
onRequestError: handleQuotaApiError,
onStreamComplete: async () => {
const handler = streamCompleteHandlerRef.current;
if (handler) {
await handler();
}
},
});
useVoiceInterruption({
amplitude: voiceAmplitude,
isVoiceActive,
ttsStatus,
onInterruption: handleVoiceInterruption,
});
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 handleVoiceFinalTranscript = useCallback(
(raw: string) => {
const normalized = raw.trim();
console.info('[RAHVoice] Final transcript received:', normalized || '(empty)');
setInterimTranscript('');
if (!normalized) {
console.info('[RAHVoice] Ignoring empty transcript');
return;
}
appendFinalTranscript(normalized);
if (sse.isLoading) {
pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() });
console.info('[RAHVoice] SSE busy, queueing transcript', {
queuedCount: pendingVoiceQueueRef.current.length,
});
setVoiceStatus('thinking');
return;
}
setVoiceStatus('thinking');
console.info('[RAHVoice] Dispatching transcript to /api/rah/chat');
void sendMessage(normalized);
},
[appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading]
);
const voiceRealtime = useRealtimeVoiceClient(
{
onStatusChange: (status) => {
if (!isVoiceActive && status !== 'idle') return;
if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) {
setVoiceStatus('thinking');
return;
}
setVoiceStatus(status);
},
onInterimTranscript: setInterimTranscript,
onFinalTranscript: handleVoiceFinalTranscript,
onAmplitude: setVoiceAmplitude,
onError: handleVoiceError,
},
{
getAuthToken: () => null,
}
);
const handleStreamComplete = useCallback(async () => {
if (pendingVoiceQueueRef.current.length > 0) {
console.info('[RAHVoice] SSE stream complete, draining queued transcripts', {
queued: pendingVoiceQueueRef.current.length,
});
}
while (pendingVoiceQueueRef.current.length > 0) {
const nextQueued = pendingVoiceQueueRef.current.shift();
if (!nextQueued) {
break;
}
const queueLatency = Date.now() - nextQueued.queuedAt;
setVoiceStatus('thinking');
console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', {
queuedMs: queueLatency,
});
await sendMessage(nextQueued.text);
}
await refetchUsage();
}, [sendMessage, setVoiceStatus, refetchUsage]);
useEffect(() => {
streamCompleteHandlerRef.current = handleStreamComplete;
}, [handleStreamComplete]);
useEffect(() => {
setMessagesRef.current = setMessages;
}, [setMessages]);
useEffect(() => {
if (!voiceError) {
voiceErrorHandledRef.current = false;
return;
}
if (voiceErrorHandledRef.current) return;
voiceErrorHandledRef.current = true;
voiceRealtime.stop();
stopAssistantTTS();
}, [voiceError, voiceRealtime, stopAssistantTTS]);
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]);
useEffect(() => {
if (!isVoiceActive) {
assistantSpeechMapRef.current.clear();
stopAssistantTTS();
return;
}
if (sse.isLoading) return;
const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT);
if (!assistantMessages.length) return;
const latest = assistantMessages[assistantMessages.length - 1];
const spokenContent = assistantSpeechMapRef.current.get(latest.id);
if (!latest.content.trim() || spokenContent === latest.content) return;
assistantSpeechMapRef.current.set(latest.id, latest.content);
const voiceRequestId = createVoiceRequestId();
speakAssistantResponse(latest.content, {
flush: true,
metadata: {
sessionId,
helper: helperKey,
requestId: voiceRequestId,
messageId: latest.id,
},
});
}, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]);
const handleNewChat = () => {
if (delegationMode) return;
sse.abort();
setMessages((_prev) => []);
setSessionId(createSessionId());
if (isVoiceActive) {
stopVoice();
resetVoiceTranscript();
}
};
// 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]);
useEffect(() => {
if (delegationMode && isVoiceActive) {
voiceRealtime.stop();
stopVoice();
resetVoiceTranscript();
stopAssistantTTS();
}
}, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]);
const handleVoiceToggle = useCallback(async () => {
if (isVoiceActive) {
voiceRealtime.stop();
stopVoice();
resetVoiceTranscript();
setVoiceAmplitude(0);
setVoiceStatus('idle');
assistantSpeechMapRef.current.clear();
pendingVoiceQueueRef.current = [];
stopAssistantTTS();
voiceStartTimestampRef.current = null;
return;
}
setVoiceError(null);
try {
voiceStartTimestampRef.current = performance.now();
console.info('[RAHVoice] Voice session starting');
await voiceRealtime.start();
startVoice();
setVoiceStatus('listening');
} catch (error) {
voiceStartTimestampRef.current = null;
handleVoiceError(error instanceof Error ? error : new Error(String(error)));
}
}, [
handleVoiceError,
isVoiceActive,
resetVoiceTranscript,
setVoiceAmplitude,
setVoiceStatus,
setVoiceError,
startVoice,
stopAssistantTTS,
stopVoice,
voiceRealtime,
]);
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}
/>
))}
</>
)}
{/* Voice transcript preview removed for streamlined UI */}
<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={isVoiceActive ? 'voice mode active — end session to type…' : `ask ${helperDisplayName}...`}
helperId={activeTabId ?? undefined}
disabledExternally={isVoiceActive}
disabledMessage="voice mode active"
onVoiceToggle={handleVoiceToggle}
isVoiceActive={isVoiceActive}
voiceAmplitude={voiceAmplitude}
voiceError={voiceError || 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>
);
}