summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <blackhao0426@gmail.com>2026-02-14 05:07:13 +0000
committerYurenHao0426 <blackhao0426@gmail.com>2026-02-14 05:07:13 +0000
commit147451f94030cd47a24821015f12cde967bb2305 (patch)
treeb9becba4aefbc927ec1df1c58f9bd408f5b2e9f0
parentdb1249889090ba6283dd92783776bf500f36d9e4 (diff)
Create quick chat nodes immediately on send, fix Enter key routing
- Move node creation decision before API call so new nodes appear instantly with loading state instead of waiting for full response - Use useFlowStore.getState().nodes for fresh state in the decision - Fix Enter key in prompt textarea to call handleRunCouncil/handleRunDebate for council/debate nodes instead of always calling handleRun Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--frontend/src/components/Sidebar.tsx457
1 files changed, 183 insertions, 274 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 08f0988..bf0363d 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1334,7 +1334,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const userInput = quickChatInput;
const attachedFilesCopy = [...quickChatAttachedFiles];
const msgId = `qc_${Date.now()}_u`;
-
+
const userMessage: Message = {
id: msgId,
role: 'user',
@@ -1365,11 +1365,152 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const councilModelsAtSend = isCouncilSend ? [...quickChatCouncilModels] : [];
const chairmanAtSend = isCouncilSend ? (quickChatChairmanModel || quickChatCouncilModels[0]) : null;
+ // ====== Determine target node BEFORE API call ======
+ // Create new node immediately so it appears on the blueprint with loading state
+ const fromNodeId = quickChatLastNodeId || quickChatTrace.sourceNodeId;
+ const freshNodes = useFlowStore.getState().nodes;
+ const fromNode = freshNodes.find(n => n.id === fromNodeId);
+ const fromNodeHasResponse = fromNode?.data.response && fromNode.data.response.trim() !== '';
+
+ let targetNodeId: string; // The node that will receive the response
+ let createdNewNode = false;
+
+ if (!fromNodeHasResponse && fromNode) {
+ // Overwrite the source node (it's empty) — set loading immediately
+ targetNodeId = fromNodeId;
+ const nodeUpdate: any = {
+ userPrompt: userInput,
+ model: isCouncilSend ? chairmanAtSend!.model : modelAtSend,
+ temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend),
+ reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend,
+ enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend,
+ attachedFileIds: attachedFilesCopy,
+ status: 'loading',
+ response: '',
+ querySentAt: Date.now(),
+ };
+ if (isCouncilSend) {
+ nodeUpdate.councilMode = true;
+ nodeUpdate.councilModels = councilModelsAtSend;
+ nodeUpdate.chairmanModel = chairmanAtSend;
+ }
+ updateNodeData(targetNodeId, nodeUpdate);
+ setQuickChatLastNodeId(fromNodeId);
+ } else {
+ // Create new node immediately with loading state
+ createdNewNode = true;
+ targetNodeId = `node_${Date.now()}`;
+ const sourceNode = fromNode || selectedNode;
+ const newPos = {
+ x: sourceNode.position.x + 300,
+ y: sourceNode.position.y
+ };
+
+ const newNodeData: any = {
+ label: isCouncilSend ? 'Council Chat' : 'Quick Chat',
+ model: isCouncilSend ? chairmanAtSend!.model : modelAtSend,
+ temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend),
+ systemPrompt: '',
+ userPrompt: userInput,
+ mergeStrategy: 'smart' as const,
+ reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend,
+ enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend,
+ traces: [],
+ outgoingTraces: [],
+ forkedTraces: [],
+ mergedTraces: [],
+ activeTraceIds: [],
+ attachedFileIds: attachedFilesCopy,
+ response: '',
+ status: 'loading' as const,
+ inputs: 1,
+ querySentAt: Date.now(),
+ };
+ if (isCouncilSend) {
+ newNodeData.councilMode = true;
+ newNodeData.councilModels = councilModelsAtSend;
+ newNodeData.chairmanModel = chairmanAtSend;
+ }
+
+ addNode({
+ id: targetNodeId,
+ type: 'llmNode',
+ position: newPos,
+ data: newNodeData,
+ });
+
+ // Connect edge immediately
+ setTimeout(() => {
+ const store = useFlowStore.getState();
+ const currentEdges = store.edges;
+ const sourceNodeData = store.nodes.find(n => n.id === fromNodeId);
+
+ let sourceHandle = 'new-trace';
+ const currentTraceId = quickChatTrace?.id || '';
+ const isNewTrace = currentTraceId.startsWith('new-trace-');
+ const isMergedTrace = currentTraceId.startsWith('merged-');
+
+ if (isMergedTrace) {
+ const evolvedMergedId = `${currentTraceId}_${fromNodeId}`;
+ let mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id === evolvedMergedId);
+ if (!mergedOutgoing) mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.startsWith(currentTraceId) && t.id.endsWith(`_${fromNodeId}`));
+ if (!mergedOutgoing) mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.startsWith(currentTraceId) || t.id === currentTraceId);
+ if (!mergedOutgoing) mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.startsWith('merged-'));
+ sourceHandle = mergedOutgoing ? `trace-${mergedOutgoing.id}` : `trace-${currentTraceId}`;
+ } else if (isNewTrace) {
+ const originalStartNodeId = currentTraceId.replace('new-trace-', '');
+ const isOriginalNode = fromNodeId === originalStartNodeId;
+ if (isOriginalNode) {
+ const hasOutgoingEdges = currentEdges.some(e => e.source === fromNodeId);
+ if (hasOutgoingEdges) {
+ sourceHandle = 'new-trace';
+ } else {
+ const selfTrace = sourceNodeData?.data.outgoingTraces?.find(t => t.id === `trace-${fromNodeId}`);
+ if (selfTrace) sourceHandle = `trace-${selfTrace.id}`;
+ }
+ } else {
+ const matchingTrace = sourceNodeData?.data.outgoingTraces?.find(t => t.id.includes(originalStartNodeId));
+ if (matchingTrace) {
+ sourceHandle = `trace-${matchingTrace.id}`;
+ } else {
+ const incoming = sourceNodeData?.data.traces?.find(t => t.id.includes(originalStartNodeId));
+ if (incoming) sourceHandle = `trace-${incoming.id}`;
+ else {
+ const anyMatch = sourceNodeData?.data.outgoingTraces?.find(t => t.id === `trace-${fromNodeId}`);
+ if (anyMatch) sourceHandle = `trace-${anyMatch.id}`;
+ }
+ }
+ }
+ } else {
+ const baseTraceId = currentTraceId.replace(/^trace-/, '');
+ const matchingOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.replace(/^trace-/, '') === baseTraceId);
+ if (matchingOutgoing) {
+ sourceHandle = `trace-${matchingOutgoing.id}`;
+ } else {
+ const matchingIncoming = sourceNodeData?.data.traces?.find(t => t.id.replace(/^trace-/, '') === baseTraceId);
+ if (matchingIncoming) sourceHandle = `trace-${matchingIncoming.id}`;
+ }
+ }
+
+ store.onConnect({
+ source: fromNodeId,
+ sourceHandle,
+ target: targetNodeId,
+ targetHandle: 'input-0'
+ });
+ setQuickChatNeedsDuplicate(false);
+ }, 50);
+
+ // Update quick chat trace to point to new node
+ const currentId = quickChatTrace?.id || '';
+ setQuickChatTrace(prev => prev ? { ...prev, sourceNodeId: targetNodeId } : null);
+ setQuickChatLastNodeId(targetNodeId);
+ }
+
+ // ====== Now make API call and stream into targetNodeId ======
try {
- // Build scopes for file search (Quick Chat uses a temp scope)
const projectPath = currentBlueprintPath || 'untitled';
const scopes = [`${projectPath}/quick_chat_temp`];
-
let fullResponse = '';
if (isCouncilSend && chairmanAtSend) {
@@ -1458,7 +1599,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
break;
case 'stage3_chunk':
stage3Full += evt.data.chunk;
- // Stream chairman response into chat
setQuickChatMessages(prev => {
const newMsgs = [...prev];
const lastMsg = newMsgs[newMsgs.length - 1];
@@ -1481,7 +1621,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
};
setQuickChatCouncilData(finalData);
setQuickChatCouncilStage('');
- // Ensure final message is set
setQuickChatMessages(prev => {
const newMsgs = [...prev];
const lastMsg = newMsgs[newMsgs.length - 1];
@@ -1502,7 +1641,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
fullResponse = stage3Full;
} else {
// ========== SINGLE MODEL MODE ==========
- // Determine provider
const isClaude = modelAtSend.includes('claude');
const isOpenAI = modelAtSend.includes('gpt') || modelAtSend === 'o3';
const provider = isClaude ? 'claude' : isOpenAI ? 'openai' : 'google';
@@ -1555,289 +1693,55 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
}
}
- // Determine whether to overwrite current node or create new one
- // Use quickChatLastNodeId as the "current" node in the chat flow to ensure continuity
- // If not set, fallback to quickChatTrace.sourceNodeId (initial state)
- const fromNodeId = quickChatLastNodeId || quickChatTrace.sourceNodeId;
- const fromNode = nodes.find(n => n.id === fromNodeId);
- const fromNodeHasResponse = fromNode?.data.response && fromNode.data.response.trim() !== '';
-
- if (!fromNodeHasResponse && fromNode) {
- // Overwrite the source node (it's empty)
- const nodeUpdate: any = {
- userPrompt: userInput,
- response: fullResponse,
- model: isCouncilSend ? chairmanAtSend!.model : modelAtSend,
- temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend),
- reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend,
- enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend,
- attachedFileIds: attachedFilesCopy,
- status: 'success',
- querySentAt: Date.now(),
- responseReceivedAt: Date.now(),
- };
- if (isCouncilSend) {
- nodeUpdate.councilMode = true;
- nodeUpdate.councilModels = councilModelsAtSend;
- nodeUpdate.chairmanModel = chairmanAtSend;
- nodeUpdate.councilData = quickChatCouncilData;
- }
- updateNodeData(fromNodeId, nodeUpdate);
-
- // Update trace to reflect current node now has content
- setQuickChatTrace(prev => prev ? {
- ...prev,
- messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]
- } : null);
-
- // Update last node ID
- setQuickChatLastNodeId(fromNodeId);
-
- // Generate title
- generateTitle(fromNodeId, userInput, fullResponse);
- } else {
- // Create new node (source node has response, continue the chain)
- const newNodeId = `node_${Date.now()}`;
- const sourceNode = fromNode || selectedNode;
- const newPos = {
- x: sourceNode.position.x + 300,
- y: sourceNode.position.y
- };
-
- const newNodeData: any = {
- label: isCouncilSend ? 'Council Chat' : 'Quick Chat',
- model: isCouncilSend ? chairmanAtSend!.model : modelAtSend,
- temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend),
- systemPrompt: '',
- userPrompt: userInput,
- mergeStrategy: 'smart' as const,
- reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend,
- enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend,
- traces: [],
- outgoingTraces: [],
- forkedTraces: [],
- mergedTraces: [],
- activeTraceIds: [],
- attachedFileIds: attachedFilesCopy,
- response: fullResponse,
- status: 'success' as const,
- inputs: 1,
- querySentAt: Date.now(),
- responseReceivedAt: Date.now(),
- };
- if (isCouncilSend) {
- newNodeData.councilMode = true;
- newNodeData.councilModels = councilModelsAtSend;
- newNodeData.chairmanModel = chairmanAtSend;
- newNodeData.councilData = quickChatCouncilData;
- }
- const newNode = {
- id: newNodeId,
- type: 'llmNode',
- position: newPos,
- data: {
- ...newNodeData,
- }
- };
+ // ====== Finalize: update target node with response ======
+ const finalUpdate: any = {
+ response: fullResponse,
+ status: 'success',
+ responseReceivedAt: Date.now(),
+ };
+ if (isCouncilSend) {
+ finalUpdate.councilData = quickChatCouncilData;
+ }
+ updateNodeData(targetNodeId, finalUpdate);
+
+ // Update trace messages to include final response
+ setQuickChatTrace(prev => prev ? {
+ ...prev,
+ messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]
+ } : null);
- addNode(newNode);
+ // Generate title
+ generateTitle(targetNodeId, userInput, fullResponse);
- // Connect to the source node
+ // For new nodes, update trace ID after onConnect has run
+ if (createdNewNode) {
setTimeout(() => {
const store = useFlowStore.getState();
- const currentEdges = store.edges;
- const sourceNodeData = store.nodes.find(n => n.id === fromNodeId);
-
- // Find the right trace handle to use
- let sourceHandle = 'new-trace';
-
- // Get the base trace ID (e.g., 'trace-A' from 'trace-A_B_C' or 'new-trace-A' or 'merged-xxx')
- const currentTraceId = quickChatTrace?.id || '';
- const isNewTrace = currentTraceId.startsWith('new-trace-');
- const isMergedTrace = currentTraceId.startsWith('merged-');
-
- if (isMergedTrace) {
- // For merged trace: find the merged trace handle on the source node
- // The trace ID may have evolved (e.g., 'merged-xxx' -> 'merged-xxx_nodeA' -> 'merged-xxx_nodeA_nodeB')
- // We need to find the version that ends with the current source node ID
-
- // First try: exact match with evolved ID (merged-xxx_sourceNodeId)
- const evolvedMergedId = `${currentTraceId}_${fromNodeId}`;
- let mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
- t => t.id === evolvedMergedId
- );
-
- // Second try: find trace that starts with merged ID and ends with this node
- if (!mergedOutgoing) {
- mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
- t => t.id.startsWith(currentTraceId) && t.id.endsWith(`_${fromNodeId}`)
- );
- }
-
- // Third try: find any trace that contains the merged ID
- if (!mergedOutgoing) {
- mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
- t => t.id.startsWith(currentTraceId) || t.id === currentTraceId
- );
- }
-
- // Fourth try: find any merged trace
- if (!mergedOutgoing) {
- mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
- t => t.id.startsWith('merged-')
- );
- }
-
- if (mergedOutgoing) {
- sourceHandle = `trace-${mergedOutgoing.id}`;
- } else {
- // Last resort: use the merged trace ID directly
- sourceHandle = `trace-${currentTraceId}`;
- }
- } else if (isNewTrace) {
- // For "Start New Trace": create a fresh independent trace from the original node
- // First, check if this is the original starting node or a continuation node
- const originalStartNodeId = currentTraceId.replace('new-trace-', '');
- const isOriginalNode = fromNodeId === originalStartNodeId;
-
- if (isOriginalNode) {
- // This is the first round - starting from original node
- const hasOutgoingEdges = currentEdges.some(e => e.source === fromNodeId);
- if (hasOutgoingEdges) {
- // Original node already has downstream - create a new fork
- sourceHandle = 'new-trace';
- } else {
- // No downstream yet - use self trace
- const selfTrace = sourceNodeData?.data.outgoingTraces?.find(
- t => t.id === `trace-${fromNodeId}`
- );
- if (selfTrace) {
- sourceHandle = `trace-${selfTrace.id}`;
- }
- }
- } else {
- // This is a continuation - find the trace ID (should be preserved now)
- // Look for a trace that was created from the original node's self trace
- const matchingTrace = sourceNodeData?.data.outgoingTraces?.find(t => {
- return t.id.includes(originalStartNodeId);
- });
-
- if (matchingTrace) {
- sourceHandle = `trace-${matchingTrace.id}`;
- } else {
- // Fallback 1: Check INCOMING traces (Connect to Continue Handle)
- const incoming = sourceNodeData?.data.traces?.find(t =>
- t.id.includes(originalStartNodeId)
- );
-
- if (incoming) {
- // ID is preserved, so handle ID is just trace-{id}
- sourceHandle = `trace-${incoming.id}`;
- } else {
- // Fallback 2: find any trace that ends with fromNodeId (unlikely if ID preserved)
- const anyMatch = sourceNodeData?.data.outgoingTraces?.find(
- t => t.id === `trace-${fromNodeId}`
- );
- if (anyMatch) {
- sourceHandle = `trace-${anyMatch.id}`;
- }
- }
- }
- }
- } else {
- // For existing trace: ID is preserved
- const baseTraceId = currentTraceId.replace(/^trace-/, '');
-
- // 1. Try OUTGOING traces first (if already connected downstream)
- const matchingOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => {
- const traceBase = t.id.replace(/^trace-/, '');
- return traceBase === baseTraceId; // Exact match now
- });
-
- if (matchingOutgoing) {
- sourceHandle = `trace-${matchingOutgoing.id}`;
- } else {
- // 2. Try INCOMING traces (Connect to Continue Handle)
- const matchingIncoming = sourceNodeData?.data.traces?.find(t => {
- const tId = t.id.replace(/^trace-/, '');
- return tId === baseTraceId; // Exact match now
- });
-
- if (matchingIncoming) {
- // ID is preserved
- sourceHandle = `trace-${matchingIncoming.id}`;
- }
- }
- }
-
- // If this is the first message and we need to duplicate (has downstream),
- // onConnect will automatically handle the trace duplication
- // because the sourceHandle already has an outgoing edge
-
- store.onConnect({
- source: fromNodeId,
- sourceHandle,
- target: newNodeId,
- targetHandle: 'input-0'
- });
-
- // After first duplication, subsequent messages continue on the new trace
- // Reset the duplicate flag since we're now on the new branch
- setQuickChatNeedsDuplicate(false);
-
- // Update trace for continued chat - use newNodeId as the new source
- // Find the actual trace ID on the new node to ensure continuity
- const newNode = store.nodes.find(n => n.id === newNodeId);
+ const newNode = store.nodes.find(n => n.id === targetNodeId);
const currentId = quickChatTrace?.id || '';
const isMerged = currentId.startsWith('merged-');
const isCurrentNewTrace = currentId.startsWith('new-trace-');
-
+
let nextTraceId = currentId;
-
if (newNode && newNode.data.outgoingTraces) {
- // Find the trace that continues the current conversation
- // Now trace IDs don't evolve, so it should be simpler
-
if (isMerged) {
- // Merged traces might still need evolution or logic check
- // For now assuming linear extension keeps same ID if we changed flowStore
- // But merged trace logic in flowStore might still append ID?
- // Let's check if evolved version exists
- const evolved = newNode.data.outgoingTraces.find(t =>
- t.id === `${currentId}_${newNodeId}`
- );
- if (evolved) nextTraceId = evolved.id;
- else nextTraceId = currentId; // Try keeping same ID
+ const evolved = newNode.data.outgoingTraces.find(t => t.id === `${currentId}_${targetNodeId}`);
+ nextTraceId = evolved ? evolved.id : currentId;
} else if (isCurrentNewTrace) {
- // For new trace, check if we have an outgoing trace with the start node ID
- const startNodeId = currentId.replace('new-trace-', '');
- const match = newNode.data.outgoingTraces.find(t =>
- t.id.includes(startNodeId)
- );
- if (match) nextTraceId = match.id;
- } else {
- // Regular trace: ID should be preserved
- nextTraceId = currentId;
+ const startNodeId = currentId.replace('new-trace-', '');
+ const match = newNode.data.outgoingTraces.find(t => t.id.includes(startNodeId));
+ if (match) nextTraceId = match.id;
}
}
-
- setQuickChatTrace(prev => prev ? {
- ...prev,
- id: nextTraceId,
- sourceNodeId: newNodeId,
- messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]
- } : null);
-
- // Update last node ID to the new node
- setQuickChatLastNodeId(newNodeId);
-
- // Generate title
- generateTitle(newNodeId, userInput, fullResponse);
- }, 100);
+
+ setQuickChatTrace(prev => prev ? { ...prev, id: nextTraceId } : null);
+ }, 200);
}
} catch (error) {
console.error('Quick chat error:', error);
+ // Mark target node as error
+ updateNodeData(targetNodeId, { status: 'error' });
setQuickChatMessages(prev => [...prev, {
id: `qc_err_${Date.now()}`,
role: 'assistant',
@@ -1847,7 +1751,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
} finally {
setQuickChatLoading(false);
setQuickChatCouncilStage('');
- // Refocus the input after sending
setTimeout(() => {
quickChatInputRef.current?.focus();
}, 50);
@@ -2728,7 +2631,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (selectedNode.data.status !== 'loading' && activeTracesCheck.complete) {
- handleRun();
+ if (selectedNode.data.councilMode && (selectedNode.data.councilModels || []).length >= 2) {
+ handleRunCouncil();
+ } else if (selectedNode.data.debateMode && (selectedNode.data.debateModels || []).length >= 2) {
+ handleRunDebate();
+ } else {
+ handleRun();
+ }
}
}
// Shift+Enter allows normal newline