From 77be59bc0a6353e98846b9c9bfa2d566efea8b1f Mon Sep 17 00:00:00 2001
From: YurenHao0426 <blackhao0426@gmail.com>
Date: Fri, 13 Feb 2026 21:43:34 +0000
Subject: Add LLM Council mode for multi-model consensus

3-stage council orchestration: parallel model queries (Stage 1),
anonymous peer ranking (Stage 2), and streamed chairman synthesis
(Stage 3). Includes scope-aware file resolution for Google/Claude
providers so upstream file attachments are visible to all providers.

- Backend: council.py orchestrator, /api/run_council_stream endpoint,
  query_model_full() non-streaming wrapper, resolve_provider() helper,
  resolve_scoped_file_ids() for Google/Claude scope parity with OpenAI
- Frontend: council toggle UI, model checkbox selector, chairman picker,
  SSE event parsing, tabbed Stage 1/2/3 response display
- Canvas: amber council node indicator with Users icon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 frontend/src/components/Sidebar.tsx | 555 +++++++++++++++++++++++++++++++-----
 1 file changed, 487 insertions(+), 68 deletions(-)

(limited to 'frontend/src/components/Sidebar.tsx')

diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index ac48c6f..4d7bb51 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
 import { useReactFlow } from 'reactflow';
 import useFlowStore from '../store/flowStore';
 import { useAuthStore } from '../store/authStore';
-import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore';
+import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, CouncilData } from '../store/flowStore';
 import type { Edge } from 'reactflow';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
@@ -17,7 +17,7 @@ const preprocessLaTeX = (content: string): string => {
     .replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math}$`);
 };
 
-import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck } from 'lucide-react';
+import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers, Eye, EyeOff, Copy, ClipboardCheck, Users } from 'lucide-react';
 
 interface SidebarProps {
   isOpen: boolean;
@@ -93,6 +93,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   const [showMergePreview, setShowMergePreview] = useState(false);
   const [isSummarizingMerge, setIsSummarizingMerge] = useState(false);
 
+  // Council mode states
+  const [councilStage, setCouncilStage] = useState<string>('');
+  const [councilStreamBuffer, setCouncilStreamBuffer] = useState('');
+  const [councilTab, setCouncilTab] = useState<'final' | 'responses' | 'rankings'>('final');
+  const [councilResponseTab, setCouncilResponseTab] = useState(0);
+  const [councilRankingTab, setCouncilRankingTab] = useState<'aggregate' | number>('aggregate');
+
   const selectedNode = nodes.find((n) => n.id === selectedNodeId);
 
   // Reset stream buffer and modal states when node changes
@@ -414,6 +421,178 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     }
   };
 
+  // Council mode: 3-stage LLM council execution
+  const handleRunCouncil = async () => {
+    if (!selectedNode) return;
+    const councilModels = selectedNode.data.councilModels || [];
+    const chairmanModel = selectedNode.data.chairmanModel || councilModels[0];
+    if (councilModels.length < 2) return;
+
+    const tracesCheck = checkActiveTracesComplete();
+    if (!tracesCheck.complete) return;
+
+    const runningNodeId = selectedNode.id;
+    const runningPrompt = selectedNode.data.userPrompt;
+    const querySentAt = Date.now();
+
+    updateNodeData(runningNodeId, {
+      status: 'loading',
+      response: '',
+      querySentAt,
+      councilData: { stage1: null, stage2: null, stage3: null },
+    });
+    setStreamBuffer('');
+    setCouncilStreamBuffer('');
+    setCouncilStage('Starting council...');
+    setStreamingNodeId(runningNodeId);
+
+    const context = getActiveContext(runningNodeId);
+    const projectPath = currentBlueprintPath || 'untitled';
+    const traceNodeIds = new Set<string>();
+    traceNodeIds.add(runningNodeId);
+    const visited = new Set<string>();
+    const queue = [runningNodeId];
+    while (queue.length > 0) {
+      const currentNodeId = queue.shift()!;
+      if (visited.has(currentNodeId)) continue;
+      visited.add(currentNodeId);
+      const incomingEdges = edges.filter(e => e.target === currentNodeId);
+      for (const edge of incomingEdges) {
+        if (!visited.has(edge.source)) {
+          traceNodeIds.add(edge.source);
+          queue.push(edge.source);
+        }
+      }
+    }
+    const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`);
+
+    const attachedFiles = selectedNode.data.attachedFileIds || [];
+    const effectivePrompt = runningPrompt?.trim()
+      ? runningPrompt
+      : attachedFiles.length > 0
+        ? 'Please analyze the attached files.'
+        : '';
+
+    try {
+      const response = await fetch(`/api/run_council_stream?user=${encodeURIComponent(user?.username || 'test')}`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
+        body: JSON.stringify({
+          node_id: runningNodeId,
+          incoming_contexts: [{ messages: context }],
+          user_prompt: effectivePrompt,
+          council_models: councilModels.map((m: string) => ({ model_name: m })),
+          chairman_model: chairmanModel,
+          system_prompt: selectedNode.data.systemPrompt || null,
+          temperature: selectedNode.data.temperature,
+          reasoning_effort: selectedNode.data.reasoningEffort || 'medium',
+          merge_strategy: selectedNode.data.mergeStrategy || 'smart',
+          attached_file_ids: attachedFiles,
+          scopes,
+        }),
+      });
+
+      if (!response.body) return;
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let sseBuffer = '';
+      let stage1Results: Array<{ model: string; response: string }> = [];
+      let stage2Data: any = null;
+      let stage3Full = '';
+      let stage3Model = '';
+
+      while (true) {
+        const { value, done } = await reader.read();
+        if (done) break;
+        sseBuffer += decoder.decode(value, { stream: true });
+
+        // Parse SSE events (data: {...}\n\n)
+        const parts = sseBuffer.split('\n\n');
+        sseBuffer = parts.pop() || '';
+
+        for (const part of parts) {
+          const line = part.trim();
+          if (!line.startsWith('data: ')) continue;
+          let evt: any;
+          try {
+            evt = JSON.parse(line.slice(6));
+          } catch { continue; }
+
+          switch (evt.type) {
+            case 'stage1_start':
+              setCouncilStage('Stage 1: Collecting responses...');
+              break;
+            case 'stage1_model_complete':
+              stage1Results = [...stage1Results, evt.data];
+              setCouncilStage(`Stage 1: ${stage1Results.length}/${councilModels.length} models done`);
+              updateNodeData(runningNodeId, {
+                councilData: { stage1: [...stage1Results], stage2: null, stage3: null },
+              });
+              break;
+            case 'stage1_complete':
+              stage1Results = evt.data;
+              updateNodeData(runningNodeId, {
+                councilData: { stage1: stage1Results, stage2: null, stage3: null },
+              });
+              break;
+            case 'stage2_start':
+              setCouncilStage('Stage 2: Peer ranking...');
+              break;
+            case 'stage2_complete':
+              stage2Data = evt.data;
+              updateNodeData(runningNodeId, {
+                councilData: { stage1: stage1Results, stage2: stage2Data, stage3: null },
+              });
+              break;
+            case 'stage3_start':
+              setCouncilStage('Stage 3: Chairman synthesizing...');
+              setCouncilStreamBuffer('');
+              break;
+            case 'stage3_chunk':
+              stage3Full += evt.data.chunk;
+              setCouncilStreamBuffer(stage3Full);
+              setStreamBuffer(stage3Full);
+              break;
+            case 'stage3_complete':
+              stage3Model = evt.data.model;
+              stage3Full = evt.data.response;
+              break;
+            case 'complete': {
+              const responseReceivedAt = Date.now();
+              const councilData: CouncilData = {
+                stage1: stage1Results,
+                stage2: stage2Data,
+                stage3: { model: stage3Model, response: stage3Full },
+              };
+              const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt };
+              const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: stage3Full };
+              updateNodeData(runningNodeId, {
+                status: 'success',
+                response: stage3Full,
+                responseReceivedAt,
+                councilData,
+                messages: [...context, newUserMsg, newAssistantMsg] as any,
+              });
+              setCouncilStage('');
+              generateTitle(runningNodeId, runningPrompt, stage3Full);
+              break;
+            }
+            case 'error':
+              updateNodeData(runningNodeId, { status: 'error' });
+              setCouncilStage('');
+              break;
+          }
+        }
+      }
+    } catch (error) {
+      console.error(error);
+      updateNodeData(runningNodeId, { status: 'error' });
+      setCouncilStage('');
+    } finally {
+      setStreamingNodeId(prev => prev === runningNodeId ? null : prev);
+    }
+  };
+
   const handleChange = (field: keyof NodeData, value: any) => {
     updateNodeData(selectedNode.id, { [field]: value });
   };
@@ -1451,55 +1630,136 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         {activeTab === 'interact' && (
           <div className="space-y-4">
             <div>
-              <label className="block text-sm font-medium text-gray-700 mb-1">Model</label>
-              <select 
-                value={selectedNode.data.model} 
-                onChange={(e) => {
-                  const newModel = e.target.value;
-                  // Auto-set temperature to 1 for reasoning models
-                  const reasoningModels = [
-                    'gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 
-                    'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 
-                    'gpt-5.2', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'o3'
-                  ];
-                  const isReasoning = reasoningModels.includes(newModel);
-                  
-                  if (isReasoning) {
-                    handleChange('temperature', 1);
-                  }
-                  handleChange('model', newModel);
-                }}
-                className="w-full border border-gray-300 rounded-md p-2 text-sm"
-              >
-                <optgroup label="Claude">
-                  <option value="claude-sonnet-4-5">claude-sonnet-4.5</option>
-                  <option value="claude-opus-4">claude-opus-4</option>
-                  <option value="claude-opus-4-5">claude-opus-4.5</option>
-                  <option value="claude-opus-4-6">claude-opus-4.6</option>
-                </optgroup>
-                <optgroup label="Gemini">
-                  <option value="gemini-2.5-flash">gemini-2.5-flash</option>
-                  <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
-                  <option value="gemini-3-pro-preview">gemini-3-pro-preview</option>
-                </optgroup>
-                <optgroup label="OpenAI (Standard)">
-                  <option value="gpt-4.1">gpt-4.1</option>
-                  <option value="gpt-4o">gpt-4o</option>
-                </optgroup>
-                <optgroup label="OpenAI (Reasoning)">
-                  <option value="gpt-5">gpt-5</option>
-                  <option value="gpt-5-chat-latest">gpt-5-chat-latest</option>
-                  <option value="gpt-5-mini">gpt-5-mini</option>
-                  <option value="gpt-5-nano">gpt-5-nano</option>
-                  <option value="gpt-5-pro" disabled={!canUsePremiumModels}>gpt-5-pro {!canUsePremiumModels && '🔒'}</option>
-                  <option value="gpt-5.1">gpt-5.1</option>
-                  <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option>
-                  <option value="gpt-5.2">gpt-5.2</option>
-                  <option value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option>
-                  <option value="gpt-5.2-pro" disabled={!canUsePremiumModels}>gpt-5.2-pro {!canUsePremiumModels && '🔒'}</option>
-                  <option value="o3" disabled={!canUsePremiumModels}>o3 {!canUsePremiumModels && '🔒'}</option>
-                </optgroup>
-              </select>
+              <div className="flex items-center justify-between mb-1">
+                <label className="block text-sm font-medium text-gray-700">Model</label>
+                <button
+                  onClick={() => handleChange('councilMode', !selectedNode.data.councilMode)}
+                  className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full transition-colors ${
+                    selectedNode.data.councilMode
+                      ? 'bg-amber-100 text-amber-700 border border-amber-300 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-700'
+                      : isDark ? 'bg-gray-700 text-gray-400 hover:bg-gray-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+                  }`}
+                >
+                  <Users size={11} />
+                  Council {selectedNode.data.councilMode ? 'ON' : 'OFF'}
+                </button>
+              </div>
+
+              {!selectedNode.data.councilMode ? (
+                /* Single model selector */
+                <select
+                  value={selectedNode.data.model}
+                  onChange={(e) => {
+                    const newModel = e.target.value;
+                    const reasoningModels = [
+                      'gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano',
+                      'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest',
+                      'gpt-5.2', 'gpt-5.2-chat-latest', 'gpt-5.2-pro', 'o3'
+                    ];
+                    if (reasoningModels.includes(newModel)) {
+                      handleChange('temperature', 1);
+                    }
+                    handleChange('model', newModel);
+                  }}
+                  className="w-full border border-gray-300 rounded-md p-2 text-sm"
+                >
+                  <optgroup label="Claude">
+                    <option value="claude-sonnet-4-5">claude-sonnet-4.5</option>
+                    <option value="claude-opus-4">claude-opus-4</option>
+                    <option value="claude-opus-4-5">claude-opus-4.5</option>
+                    <option value="claude-opus-4-6">claude-opus-4.6</option>
+                  </optgroup>
+                  <optgroup label="Gemini">
+                    <option value="gemini-2.5-flash">gemini-2.5-flash</option>
+                    <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
+                    <option value="gemini-3-pro-preview">gemini-3-pro-preview</option>
+                  </optgroup>
+                  <optgroup label="OpenAI (Standard)">
+                    <option value="gpt-4.1">gpt-4.1</option>
+                    <option value="gpt-4o">gpt-4o</option>
+                  </optgroup>
+                  <optgroup label="OpenAI (Reasoning)">
+                    <option value="gpt-5">gpt-5</option>
+                    <option value="gpt-5-chat-latest">gpt-5-chat-latest</option>
+                    <option value="gpt-5-mini">gpt-5-mini</option>
+                    <option value="gpt-5-nano">gpt-5-nano</option>
+                    <option value="gpt-5-pro" disabled={!canUsePremiumModels}>gpt-5-pro {!canUsePremiumModels && '🔒'}</option>
+                    <option value="gpt-5.1">gpt-5.1</option>
+                    <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option>
+                    <option value="gpt-5.2">gpt-5.2</option>
+                    <option value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option>
+                    <option value="gpt-5.2-pro" disabled={!canUsePremiumModels}>gpt-5.2-pro {!canUsePremiumModels && '🔒'}</option>
+                    <option value="o3" disabled={!canUsePremiumModels}>o3 {!canUsePremiumModels && '🔒'}</option>
+                  </optgroup>
+                </select>
+              ) : (
+                /* Council mode: multi-model selector + chairman */
+                <div className={`space-y-2 p-2 rounded border ${isDark ? 'bg-gray-900 border-amber-800/50' : 'bg-amber-50/50 border-amber-200'}`}>
+                  <div>
+                    <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Council Members (pick 2-6)</label>
+                    <div className="grid grid-cols-1 gap-0.5 max-h-48 overflow-y-auto">
+                      {[
+                        { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4.5' },
+                        { value: 'claude-opus-4', label: 'claude-opus-4' },
+                        { value: 'claude-opus-4-5', label: 'claude-opus-4.5' },
+                        { value: 'claude-opus-4-6', label: 'claude-opus-4.6' },
+                        { value: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
+                        { value: 'gemini-2.5-flash-lite', label: 'gemini-2.5-flash-lite' },
+                        { value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
+                        { value: 'gpt-4.1', label: 'gpt-4.1' },
+                        { value: 'gpt-4o', label: 'gpt-4o' },
+                        { value: 'gpt-5', label: 'gpt-5' },
+                        { value: 'gpt-5-mini', label: 'gpt-5-mini' },
+                        { value: 'gpt-5-nano', label: 'gpt-5-nano' },
+                        { value: 'gpt-5.1', label: 'gpt-5.1' },
+                        { value: 'gpt-5.2', label: 'gpt-5.2' },
+                        { value: 'o3', label: 'o3', premium: true },
+                      ].map(m => {
+                        const selected = (selectedNode.data.councilModels || []).includes(m.value);
+                        const disabled = m.premium && !canUsePremiumModels;
+                        return (
+                          <label key={m.value} className={`flex items-center gap-1.5 text-xs py-0.5 px-1 rounded cursor-pointer ${
+                            disabled ? 'opacity-40 cursor-not-allowed' : selected
+                              ? isDark ? 'bg-amber-900/40 text-amber-200' : 'bg-amber-100 text-amber-800'
+                              : isDark ? 'hover:bg-gray-800 text-gray-300' : 'hover:bg-gray-100 text-gray-700'
+                          }`}>
+                            <input
+                              type="checkbox"
+                              checked={selected}
+                              disabled={disabled}
+                              onChange={() => {
+                                const current = selectedNode.data.councilModels || [];
+                                const next = selected
+                                  ? current.filter((v: string) => v !== m.value)
+                                  : [...current, m.value];
+                                handleChange('councilModels', next);
+                                // Auto-set chairman to first selected if current chairman was removed
+                                if (selected && selectedNode.data.chairmanModel === m.value && next.length > 0) {
+                                  handleChange('chairmanModel', next[0]);
+                                }
+                              }}
+                              className="w-3 h-3 accent-amber-500"
+                            />
+                            {m.label}
+                          </label>
+                        );
+                      })}
+                    </div>
+                  </div>
+                  <div>
+                    <label className={`block text-xs font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Chairman Model</label>
+                    <select
+                      value={selectedNode.data.chairmanModel || (selectedNode.data.councilModels || [])[0] || ''}
+                      onChange={(e) => handleChange('chairmanModel', e.target.value)}
+                      className={`w-full border rounded-md p-1.5 text-xs ${isDark ? 'bg-gray-800 border-gray-600 text-gray-200' : 'border-gray-300'}`}
+                    >
+                      {(selectedNode.data.councilModels || []).map((m: string) => (
+                        <option key={m} value={m}>{m}</option>
+                      ))}
+                    </select>
+                  </div>
+                </div>
+              )}
             </div>
 
             {/* Trace Selector - Single Select */}
@@ -1837,18 +2097,33 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
               </div>
             )}
 
-            <button 
-              onClick={handleRun}
-              disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete}
-              className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${
-                selectedNode.data.status === 'loading' || !activeTracesCheck.complete
-                  ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
-                  : 'bg-blue-600 text-white hover:bg-blue-700'
-              }`}
-            >
-              {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />}
-              Run Node
-            </button>
+            {selectedNode.data.councilMode ? (
+              <button
+                onClick={handleRunCouncil}
+                disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.councilModels || []).length < 2}
+                className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${
+                  selectedNode.data.status === 'loading' || !activeTracesCheck.complete || (selectedNode.data.councilModels || []).length < 2
+                    ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
+                    : 'bg-amber-600 text-white hover:bg-amber-700'
+                }`}
+              >
+                {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Users size={16} />}
+                {selectedNode.data.status === 'loading' && councilStage ? councilStage : `Run Council (${(selectedNode.data.councilModels || []).length})`}
+              </button>
+            ) : (
+              <button
+                onClick={handleRun}
+                disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete}
+                className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${
+                  selectedNode.data.status === 'loading' || !activeTracesCheck.complete
+                    ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
+                    : 'bg-blue-600 text-white hover:bg-blue-700'
+                }`}
+              >
+                {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />}
+                Run Node
+              </button>
+            )}
 
             <div className="mt-6">
               <div className="flex items-center justify-between mb-2">
@@ -1858,7 +2133,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                     <>
                       <button
                         onClick={() => {
-                          navigator.clipboard.writeText(selectedNode.data.response || '');
+                          // Copy the currently-viewed content
+                          let textToCopy = selectedNode.data.response || '';
+                          if (selectedNode.data.councilData && councilTab === 'responses' && selectedNode.data.councilData.stage1) {
+                            const r = selectedNode.data.councilData.stage1[councilResponseTab];
+                            if (r) textToCopy = r.response;
+                          }
+                          navigator.clipboard.writeText(textToCopy);
                           setCopiedResponse(true);
                           setTimeout(() => setCopiedResponse(false), 1500);
                         }}
@@ -1902,15 +2183,153 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   )}
               </div>
               </div>
-              
-              {isEditing ? (
+
+              {/* Council tabbed response view */}
+              {selectedNode.data.councilMode && selectedNode.data.councilData && (selectedNode.data.councilData.stage1 || selectedNode.data.status === 'loading') ? (
+                <div>
+                  {/* Council tab bar */}
+                  <div className={`flex gap-0.5 mb-2 text-xs border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+                    {(['final', 'responses', 'rankings'] as const).map(tab => (
+                      <button
+                        key={tab}
+                        onClick={() => setCouncilTab(tab)}
+                        className={`px-3 py-1.5 rounded-t transition-colors capitalize ${
+                          councilTab === tab
+                            ? isDark ? 'bg-gray-800 text-amber-300 border-b-2 border-amber-400' : 'bg-white text-amber-700 border-b-2 border-amber-500'
+                            : isDark ? 'text-gray-500 hover:text-gray-300' : 'text-gray-400 hover:text-gray-600'
+                        }`}
+                      >
+                        {tab === 'final' ? 'Final Answer' : tab === 'responses' ? 'Individual' : 'Rankings'}
+                      </button>
+                    ))}
+                  </div>
+
+                  {/* Final Answer tab (Stage 3) */}
+                  {councilTab === 'final' && (
+                    <div>
+                      {selectedNode.data.councilData.stage3 ? (
+                        <div className={`text-xs mb-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>Chairman: {selectedNode.data.councilData.stage3.model}</div>
+                      ) : selectedNode.data.status === 'loading' && councilStage.includes('Stage 3') ? (
+                        <div className={`text-xs mb-1 flex items-center gap-1 ${isDark ? 'text-amber-400' : 'text-amber-600'}`}><Loader2 className="animate-spin" size={10} /> Synthesizing...</div>
+                      ) : null}
+                      <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${
+                        isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900'
+                      }`}>
+                        {rawTextMode ? (
+                          <pre className="whitespace-pre-wrap break-words">{selectedNode.data.councilData.stage3?.response || councilStreamBuffer || ''}</pre>
+                        ) : (
+                          <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>
+                            {preprocessLaTeX(selectedNode.data.councilData.stage3?.response || councilStreamBuffer || '')}
+                          </ReactMarkdown>
+                        )}
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Individual Responses tab (Stage 1) */}
+                  {councilTab === 'responses' && selectedNode.data.councilData.stage1 && (
+                    <div>
+                      <div className={`flex gap-0.5 mb-2 flex-wrap`}>
+                        {selectedNode.data.councilData.stage1.map((r, i) => (
+                          <button
+                            key={r.model}
+                            onClick={() => setCouncilResponseTab(i)}
+                            className={`px-2 py-0.5 text-xs rounded transition-colors ${
+                              councilResponseTab === i
+                                ? isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700'
+                                : isDark ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
+                            }`}
+                          >
+                            {r.model}
+                          </button>
+                        ))}
+                      </div>
+                      {(() => {
+                        const r = selectedNode.data.councilData.stage1![councilResponseTab];
+                        if (!r) return null;
+                        return (
+                          <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${
+                            isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900'
+                          }`}>
+                            {rawTextMode ? (
+                              <pre className="whitespace-pre-wrap break-words">{r.response}</pre>
+                            ) : (
+                              <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>{preprocessLaTeX(r.response)}</ReactMarkdown>
+                            )}
+                          </div>
+                        );
+                      })()}
+                    </div>
+                  )}
+
+                  {/* Rankings tab (Stage 2) */}
+                  {councilTab === 'rankings' && (
+                    <div>
+                      {selectedNode.data.councilData.stage2 ? (
+                        <div>
+                          {/* Aggregate Rankings */}
+                          <div className={`mb-3 p-2 rounded border ${isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
+                            <div className={`text-xs font-semibold mb-1.5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Aggregate Rankings</div>
+                            {selectedNode.data.councilData.stage2.aggregate_rankings.map((r, i) => (
+                              <div key={r.model} className={`flex items-center justify-between text-xs py-0.5 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
+                                <span className="font-mono">#{i + 1} {r.model}</span>
+                                <span className={`${isDark ? 'text-gray-500' : 'text-gray-400'}`}>avg: {r.average_rank} ({r.rankings_count} votes)</span>
+                              </div>
+                            ))}
+                          </div>
+                          {/* Individual ranker evaluations */}
+                          <div className={`text-xs font-semibold mb-1.5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Individual Evaluations</div>
+                          <div className={`flex gap-0.5 mb-2 flex-wrap`}>
+                            <button
+                              onClick={() => setCouncilRankingTab('aggregate')}
+                              className={`px-2 py-0.5 text-xs rounded ${councilRankingTab === 'aggregate' ? (isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700') : (isDark ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-500')}`}
+                            >Summary</button>
+                            {selectedNode.data.councilData.stage2.rankings.map((r, i) => (
+                              <button
+                                key={r.model}
+                                onClick={() => setCouncilRankingTab(i)}
+                                className={`px-2 py-0.5 text-xs rounded ${councilRankingTab === i ? (isDark ? 'bg-blue-900/50 text-blue-300' : 'bg-blue-100 text-blue-700') : (isDark ? 'bg-gray-800 text-gray-400' : 'bg-gray-100 text-gray-500')}`}
+                              >{r.model}</button>
+                            ))}
+                          </div>
+                          {councilRankingTab !== 'aggregate' && (() => {
+                            const r = selectedNode.data.councilData.stage2!.rankings[councilRankingTab as number];
+                            if (!r) return null;
+                            // De-anonymize the ranking text
+                            let text = r.ranking;
+                            const mapping = selectedNode.data.councilData.stage2!.label_to_model;
+                            for (const [label, model] of Object.entries(mapping)) {
+                              text = text.replaceAll(label, `${label} [${model}]`);
+                            }
+                            return (
+                              <div className={`p-3 rounded-md border text-sm prose prose-sm max-w-none max-h-64 overflow-y-auto ${
+                                isDark ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200' : 'bg-gray-50 border-gray-200 text-gray-900'
+                              }`}>
+                                <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]}>{preprocessLaTeX(text)}</ReactMarkdown>
+                              </div>
+                            );
+                          })()}
+                        </div>
+                      ) : selectedNode.data.status === 'loading' ? (
+                        <div className={`p-3 rounded border min-h-[100px] flex items-center justify-center text-sm ${isDark ? 'bg-gray-900 border-gray-700 text-gray-500' : 'bg-gray-50 border-gray-200 text-gray-400'}`}>
+                          <Loader2 className="animate-spin mr-2" size={14} /> Waiting for rankings...
+                        </div>
+                      ) : (
+                        <div className={`p-3 rounded border min-h-[100px] flex items-center justify-center text-sm ${isDark ? 'bg-gray-900 border-gray-700 text-gray-500' : 'bg-gray-50 border-gray-200 text-gray-400'}`}>
+                          No ranking data
+                        </div>
+                      )}
+                    </div>
+                  )}
+                </div>
+              ) : isEditing ? (
                 <div className="space-y-2">
                   <textarea
                     value={editedResponse}
                     onChange={(e) => setEditedResponse(e.target.value)}
                     className={`w-full border rounded-md p-2 text-sm min-h-[200px] font-mono focus:ring-2 focus:ring-blue-500 ${
-                      isDark 
-                        ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' 
+                      isDark
+                        ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500'
                         : 'bg-white border-blue-300 text-gray-900'
                     }`}
                   />
-- 
cgit v1.2.3

