Skip to content

feat(frontend): update frontend #340

Merged
lwaekfjlk merged 14 commits intomainfrom
feature/new-UI
Mar 4, 2026
Merged

feat(frontend): update frontend #340
lwaekfjlk merged 14 commits intomainfrom
feature/new-UI

Conversation

@4R5T
Copy link
Collaborator

@4R5T 4R5T commented Feb 16, 2026

Closes #

📑 Description

✅ Checks

  • My pull request adheres to the code style of this project
  • My code requires changes to the documentation
  • I have updated the documentation as required
  • All the tests have passed
  • Branch name follows type/descript (e.g. feature/add-llm-agents)
  • Ready for code review

ℹ Additional Information

Copilot AI review requested due to automatic review settings February 16, 2026 07:07
@4R5T 4R5T changed the title Feature/new UI (DO NOT MERGE, Still working on it) feat(frontend): update frontend Feb 16, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a major UI overhaul titled "Feature/new UI" that adds interactive 3D visualization, dynamic dimension-based evaluation, and user action tracking capabilities. However, the PR has critical issues related to non-existent model versions and architectural problems.

Changes:

  • Replaces legacy OpenAI/Anthropic/Google model versions with future GPT-5, Claude 4, and Gemini 2.5/3 models that don't exist yet
  • Adds comprehensive 3D evaluation view with React Three Fiber and interactive node manipulation
  • Implements dynamic dimension pair system for customizable idea evaluation beyond fixed Novelty/Feasibility/Impact metrics
  • Introduces user action tracking and plot state logging for research data collection
  • Refactors backend evaluation API to support incremental scoring and dimension-based evaluation

Reviewed changes

Copilot reviewed 40 out of 42 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
tiny_scientist/utils/pricing.py Updates pricing for non-existent future model versions
tiny_scientist/utils/llm.py Adds support for GPT-5/4.1/O3/O4, Claude 4, Gemini 2.5/3 with model-specific parameters
tiny_scientist/thinker.py Implements dimension suggestion, single-dimension evaluation, and dynamic scoring
backend/app.py Major refactor of evaluation endpoint with global state management and complex matching logic
frontend/src/utils/* New tracking utilities for user actions and plot state changes
frontend/src/components/Evaluation3D.jsx 1000+ line 3D visualization component with drag-and-drop
frontend/src/components/DimensionSelector*.jsx Multiple dimension selection UI variants (panel, modal, dropdown)
Various example scripts Updates default models from gpt-4o to gpt-5-mini
Files not reviewed (1)
  • frontend/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +696 to +726
def find_idea_id(scored_idea):
scored_titles = [
scored_idea.get("Title", "").strip(),
scored_idea.get("Name", "").strip(),
scored_idea.get("title", "").strip(),
]

# Rank ideas
scored_ideas = thinker.rank(ideas=thinker_ideas, intent=intent)
print(f"[DEBUG] Finding ID for scored idea with titles: {scored_titles}")

for title in scored_titles:
if not title:
continue

for i, title_map in enumerate(title_maps):
if title in title_map:
print(
f"[DEBUG] Found exact match in map {i}: '{title}' -> {title_map[title]}"
)
return title_map[title]

title_lower = title.lower()
for i, title_map in enumerate(title_maps):
for stored_title, stored_id in title_map.items():
if stored_title.lower() == title_lower:
print(
f"[DEBUG] Found case-insensitive match in map {i}: '{title}' -> {stored_id}"
)
return stored_id

print(f"[DEBUG] No match found for titles: {scored_titles}")
return None
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The find_idea_id function is defined inside the evaluate_ideas route handler but relies on title_maps from the outer scope. This creates a large nested function (30+ lines) that would be better extracted as a standalone helper function or method for better maintainability and testability.

Additionally, the function has complex matching logic across multiple title variations and maps, but there's no clear documentation explaining the matching priority order or why three separate title maps are needed.

Copilot uses AI. Check for mistakes.
Comment on lines 570 to +878
@app.route("/api/evaluate", methods=["POST"])
def evaluate_ideas() -> Union[Response, tuple[Response, int]]:
"""Evaluate ideas (evaluateIdeas)"""
data = request.json
if data is None:
return jsonify({"error": "No JSON data provided"}), 400
if thinker is None:
return jsonify({"error": "Thinker not configured"}), 400
ideas = data.get("ideas")

incoming_ideas = data.get("ideas", [])
intent = data.get("intent")
dimension_pairs = data.get("dimension_pairs", [])
user_score_corrections = data.get("userScoreCorrections", [])
mode = data.get("mode", "incremental")
explicit_target_ids = set(data.get("targetIds", []) or [])

print("[DEBUG] Evaluation request received:")
print(f" - Mode: {mode}")
print(f" - Incoming ideas count: {len(incoming_ideas)}")
print(f" - Incoming idea IDs: {[i.get('id', 'NO_ID') for i in incoming_ideas]}")
print(f" - Intent: {intent[:50] if intent else 'None'}...")
print(f" - Target IDs: {list(explicit_target_ids)}")
print(f" - Dimension pairs: {len(dimension_pairs)}")

existing_ids = {i["id"] for i in _list_stored_ideas()}
new_ids = []
for inc in incoming_ideas:
inc_id = inc.get("id") or str(uuid.uuid4())
inc["id"] = inc_id
if inc_id not in existing_ids:
new_ids.append(inc_id)
_store_or_update_idea({**inc})

stored_list = _list_stored_ideas()

if mode == "full":
target_ids_local = {idea.get("id") for idea in stored_list}
else:
target_ids_local = set(new_ids) | explicit_target_ids
if not explicit_target_ids:
for idea in stored_list:
if not (
idea.get("NoveltyScore") is not None
and idea.get("FeasibilityScore") is not None
and idea.get("ImpactScore") is not None
):
target_ids_local.add(idea.get("id"))

if mode == "full":
evaluation_input = stored_list
else:
evaluation_input = []
for idea in stored_list:
iid = idea.get("id")
has_scores = (
idea.get("NoveltyScore") is not None
and idea.get("FeasibilityScore") is not None
and idea.get("ImpactScore") is not None
)
idea_copy = idea.copy()
if iid in target_ids_local:
idea_copy.pop("AlreadyScored", None)
evaluation_input.append(idea_copy)
else:
if has_scores:
idea_copy["AlreadyScored"] = True
evaluation_input.append(idea_copy)

print("[DEBUG] About to call thinker.rank with:")
print(f" - Evaluation input count: {len(evaluation_input)}")
print(f" - Evaluation input IDs: {[i.get('id') for i in evaluation_input]}")
print(f" - Intent length: {len(intent) if intent else 0}")
print(f" - Partial mode: {mode != 'full'}")

# Use original data directly (no conversion needed)
thinker_ideas = ideas
try:
scored_ideas = thinker.rank(
ideas=evaluation_input,
intent=intent,
dimension_pairs=dimension_pairs if dimension_pairs else None,
user_score_corrections=user_score_corrections,
partial=(mode != "full"),
)
print(
f"[DEBUG] thinker.rank returned {len(scored_ideas) if scored_ideas else 0} scored ideas"
)
if scored_ideas:
print(
f"[DEBUG] First scored idea: {scored_ideas[0].get('Title', 'NO_TITLE')}"
)
except Exception as e:
print(f"[ERROR] thinker.rank failed: {str(e)}")
print(f"[ERROR] Exception type: {type(e).__name__}")
import traceback

# Store original IDs in order - LLM doesn't preserve titles, use index mapping
original_ids = [idea.get("id") for idea in ideas]
print(f"[ERROR] Traceback: {traceback.format_exc()}")
scored_ideas = []

title_maps = [{}, {}, {}]
for idea in stored_list:
iid = idea.get("id")
if not iid:
continue

for key in [idea.get("Title"), idea.get("Name"), idea.get("title")]:
if key and key.strip():
title_maps[0][key.strip()] = iid

for raw_name in [idea.get("Name"), idea.get("Title")]:
if raw_name and raw_name.strip():
formatted = format_name_for_display(raw_name)
title_maps[1][formatted] = iid

for key in [idea.get("Title"), idea.get("Name")]:
if key and key.strip():
variations = [
key.strip().lower(),
key.strip().replace(" ", "_").lower(),
key.strip().replace("_", " ").lower(),
]
for variation in variations:
title_maps[2][variation] = iid

print("[DEBUG] Title maps built:")
print(f" - Map 0 (original): {list(title_maps[0].keys())}")
print(f" - Map 1 (formatted): {list(title_maps[1].keys())}")
print(f" - Map 2 (variations): {list(title_maps[2].keys())}")

def find_idea_id(scored_idea):
scored_titles = [
scored_idea.get("Title", "").strip(),
scored_idea.get("Name", "").strip(),
scored_idea.get("title", "").strip(),
]

# Rank ideas
scored_ideas = thinker.rank(ideas=thinker_ideas, intent=intent)
print(f"[DEBUG] Finding ID for scored idea with titles: {scored_titles}")

for title in scored_titles:
if not title:
continue

for i, title_map in enumerate(title_maps):
if title in title_map:
print(
f"[DEBUG] Found exact match in map {i}: '{title}' -> {title_map[title]}"
)
return title_map[title]

title_lower = title.lower()
for i, title_map in enumerate(title_maps):
for stored_title, stored_id in title_map.items():
if stored_title.lower() == title_lower:
print(
f"[DEBUG] Found case-insensitive match in map {i}: '{title}' -> {stored_id}"
)
return stored_id

print(f"[DEBUG] No match found for titles: {scored_titles}")
return None

payloads = []
unmatched_ideas = []
matched_ids = set()

pair_count = len(dimension_pairs) if dimension_pairs else 0
use_dimension_pairs = pair_count >= 2

for i, scored in enumerate(scored_ideas):
iid = find_idea_id(scored)

if not iid and mode == "incremental" and i < len(new_ids):
potential_id = new_ids[i]
if potential_id in target_ids_local and potential_id not in matched_ids:
iid = potential_id
print(f"[DEBUG] Fallback ID match: scored[{i}] -> {iid}")

if not iid:
unmatched_ideas.append(
{
"title": scored.get("Title", scored.get("Name", "Unknown")),
"scored_keys": list(scored.keys()),
"index": i,
}
)
continue

if mode == "incremental" and iid not in target_ids_local:
continue

matched_ids.add(iid)
if iid and ("-CUSTOM-" in iid or iid.startswith("C-")):
print(f"[DEBUG] Custom idea {iid} reasoning fields:")
if use_dimension_pairs:
print(f" Dimension1Reason: {scored.get('Dimension1Reason', '')}")
print(f" Dimension2Reason: {scored.get('Dimension2Reason', '')}")
if pair_count >= 3:
print(f" Dimension3Reason: {scored.get('Dimension3Reason', '')}")
else:
print(f" NoveltyReason: {scored.get('NoveltyReason', '')}")
print(f" FeasibilityReason: {scored.get('FeasibilityReason', '')}")
print(f" ImpactReason: {scored.get('ImpactReason', '')}")
print(f" All scored keys: {list(scored.keys())}")

if use_dimension_pairs:
payload = {
"id": iid,
"scores": scored.get("scores", {}),
}
payload["dimension1Score"] = scored.get("Dimension1Score")
payload["dimension2Score"] = scored.get("Dimension2Score")
payload["Dimension1Reason"] = scored.get("Dimension1Reason", "")
payload["Dimension2Reason"] = scored.get("Dimension2Reason", "")
if pair_count >= 3:
payload["dimension3Score"] = scored.get("Dimension3Score")
payload["Dimension3Reason"] = scored.get("Dimension3Reason", "")
payloads.append(payload)
else:
payloads.append(
{
"id": iid,
"noveltyScore": scored.get("NoveltyScore"),
"feasibilityScore": scored.get("FeasibilityScore"),
"impactScore": scored.get("ImpactScore"),
"NoveltyReason": scored.get("NoveltyReason", ""),
"FeasibilityReason": scored.get("FeasibilityReason", ""),
"ImpactReason": scored.get("ImpactReason", ""),
}
)

# Return in the format expected by TreePlot
# Use index-based mapping since LLM changes titles but preserves order
response = []
for i, idea in enumerate(scored_ideas):
# Use index to map back to original ID
original_id = original_ids[i] if i < len(original_ids) else f"idea_{i}"
print("[DEBUG] Evaluation matching results:")
print(f" - Mode: {mode}")
print(f" - Stored ideas: {len(stored_list)}")
print(f" - Scored ideas from LLM: {len(scored_ideas)}")
print(f" - Target IDs: {list(target_ids_local)}")
print(f" - New IDs: {new_ids}")
print(f" - Successful matches: {len(payloads)}")
print(f" - Failed matches: {len(unmatched_ideas)}")

if unmatched_ideas:
print(f"[WARNING] Could not match {len(unmatched_ideas)} ideas:")
for um in unmatched_ideas[:3]:
print(
f" - [{um.get('index', '?')}] Title: '{um['title']}', Keys: {um['scored_keys']}"
)
print(f"[DEBUG] Available stored titles: {list(title_maps[0].keys())}")

if payloads:
print("[DEBUG] Matched payloads:")
for p in payloads[:2]:
print(
f" - ID: {p['id']}, Scores: N={p.get('noveltyScore')}, F={p.get('feasibilityScore')}, I={p.get('impactScore')}, "
f"D1={p.get('dimension1Score')}, D2={p.get('dimension2Score')}, D3={p.get('dimension3Score')}"
)
if p["id"] and ("-CUSTOM-" in p["id"] or p["id"].startswith("C-")):
print(
f" Custom idea reasoning: N={p.get('NoveltyReason', 'MISSING')[:50]}..., F={p.get('FeasibilityReason', 'MISSING')[:50]}..., I={p.get('ImpactReason', 'MISSING')[:50]}..."
)

response.append(
{
"id": original_id, # Use index-based mapping
"noveltyScore": idea.get("NoveltyScore"),
"noveltyReason": idea.get("NoveltyReason", ""),
"feasibilityScore": idea.get("FeasibilityScore"),
"feasibilityReason": idea.get("FeasibilityReason", ""),
"impactScore": idea.get("ImpactScore"),
"impactReason": idea.get("ImpactReason", ""),
}
)
return jsonify(response)
_bulk_update_scores(payloads)

updated_all = _list_stored_ideas()

client_return = []
for i in updated_all:
item = {
"id": i.get("id"),
"title": format_name_for_display(i.get("Name") or i.get("Title")),
"content": format_idea_content(i),
"originalData": i,
}

if i.get("scores"):
item["scores"] = i.get("scores")
item["dimension1Score"] = i.get("Dimension1Score")
item["dimension2Score"] = i.get("Dimension2Score")
item["dimension3Score"] = i.get("Dimension3Score")
item["Dimension1Reason"] = i.get("Dimension1Reason", "")
item["Dimension2Reason"] = i.get("Dimension2Reason", "")
item["Dimension3Reason"] = i.get("Dimension3Reason", "")

item["noveltyScore"] = i.get("NoveltyScore")
item["feasibilityScore"] = i.get("FeasibilityScore")
item["impactScore"] = i.get("ImpactScore")
item["NoveltyReason"] = i.get("NoveltyReason", "")
item["FeasibilityReason"] = i.get("FeasibilityReason", "")
item["ImpactReason"] = i.get("ImpactReason", "")

client_return.append(item)

custom_ideas_in_return = [
item
for item in client_return
if item["id"] and ("-CUSTOM-" in item["id"] or item["id"].startswith("C-"))
]
if custom_ideas_in_return:
print("[DEBUG] Custom ideas in client return:")
for item in custom_ideas_in_return:
print(f" - ID: {item['id']}")
print(f" NoveltyReason: {item.get('NoveltyReason', 'MISSING')[:50]}...")
print(
f" FeasibilityReason: {item.get('FeasibilityReason', 'MISSING')[:50]}..."
)
print(f" ImpactReason: {item.get('ImpactReason', 'MISSING')[:50]}...")

meta = {
"mode": mode,
"scoredCount": len(payloads),
"totalIdeas": len(updated_all),
"targets": list(target_ids_local),
}
return jsonify({"ideas": client_return, "meta": meta})
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The evaluation endpoint has grown significantly complex (over 300 lines) with multiple responsibilities:

  1. Idea storage management
  2. Target ID computation
  3. Evaluation input preparation
  4. LLM calling
  5. Title matching logic
  6. Score updating
  7. Response formatting

This violates the Single Responsibility Principle and makes the code difficult to maintain, test, and debug. Consider refactoring this into smaller, focused functions:

  • prepare_evaluation_input(stored_ideas, mode, target_ids)
  • match_scored_ideas(scored_ideas, stored_ideas, title_maps)
  • format_evaluation_response(ideas, meta)

The extensive debug logging (20+ print statements) also suggests this code is still in development and hasn't been properly tested.

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +166
# Apply model-specific parameter constraints
if "gpt-5.2" in model:
# GPT-5.2 variants use max_completion_tokens instead of max_tokens
completion_params["max_completion_tokens"] = MAX_NUM_TOKENS
completion_params["temperature"] = temperature
completion_params["seed"] = 0
elif model == "gpt-5-mini":
# gpt-5-mini only supports temperature=1
completion_params["temperature"] = 1.0
completion_params["max_tokens"] = MAX_NUM_TOKENS
completion_params["seed"] = 0
elif model == "gpt-5":
# Base gpt-5 does not support temperature or max_tokens
pass
else:
# Standard GPT models (gpt-5-pro, gpt-5-nano, gpt-4.1 family)
completion_params["temperature"] = temperature
completion_params["max_tokens"] = MAX_NUM_TOKENS
completion_params["seed"] = 0

response = client.chat.completions.create(**completion_params)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model-specific parameter handling for GPT-5 models introduces fragile logic that depends on exact model name matching:

if "gpt-5.2" in model:
    # GPT-5.2 variants use max_completion_tokens
elif model == "gpt-5-mini":
    # gpt-5-mini only supports temperature=1
elif model == "gpt-5":
    # Base gpt-5 does not support temperature or max_tokens

Issues:

  1. String matching fragility: "gpt-5.2" in model will match "gpt-5.2-pro" but the order matters (checked before model == "gpt-5")
  2. Hardcoded constraints: Comments claim specific parameter constraints without API documentation reference
  3. No validation: If the API rejects these parameters, there's no error handling
  4. Duplicated logic: Same code appears in both get_batch_responses_from_llm (lines 147-164) and get_response_from_llm (lines 342-359)

Consider:

  • Creating a model configuration registry with per-model parameter specifications
  • Adding validation/error handling for unsupported parameters
  • Extracting shared logic into a helper function

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +1038
import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { TrackballControls, Text, Line, OrthographicCamera, Billboard } from '@react-three/drei';
import * as THREE from 'three';
import userActionTracker from '../utils/userActionTracker';

// --- Constants ---
const CUBE_SIZE = 100;
const HALF_SIZE = CUBE_SIZE / 2;
const NODE_RANGE = 90;
const LABEL_COLOR = '#374151';
const GRID_COLOR = '#e5e7eb';
const CROSSHAIR_COLOR = '#9ca3af';

// Keep node coloring consistent with Exploration view
const getNodeColor = (node, colorMap) => {
if (!node) return '#FF6B6B';
if (node.isMergedResult) return '#B22222';
if (node.isNewlyGenerated || node.isModified) return '#FFD700';
return (colorMap && colorMap[node.type]) || '#FF6B6B';
};

// --- Helpers ---
const scoreToPos = (score) => {
// Score range: -50 to 50 → Position range: -NODE_RANGE/2 to NODE_RANGE/2
const clamped = Math.max(-50, Math.min(50, score));
return (clamped / 50) * (NODE_RANGE / 2);
};
const posToScore = (pos) => Math.max(-50, Math.min(50, (pos / (NODE_RANGE / 2)) * 50));
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const hasVector2 = (pos) => !!pos && isFiniteNumber(pos.x) && isFiniteNumber(pos.y);
const hasVector3 = (pos) => !!pos && isFiniteNumber(pos.x) && isFiniteNumber(pos.y) && isFiniteNumber(pos.z);

// --- Components ---

const CameraController = ({ targetFaceIndex, targetUp, isSnapping, setSnapped, setIsSnapping, controlsRef }) => {
const { camera } = useThree();

// 6 orthogonal view positions
const faceConfigs = useMemo(() => [
{ pos: [0, 0, 1] }, // Front
{ pos: [1, 0, 0] }, // Right
{ pos: [0, 0, -1] }, // Back
{ pos: [-1, 0, 0] }, // Left
{ pos: [0, 1, 0] }, // Top
{ pos: [0, -1, 0] }, // Bottom
], []);

useFrame(() => {
if (isSnapping && targetFaceIndex !== null && targetUp) {
const R = 200;
const config = faceConfigs[targetFaceIndex];
const targetPos = new THREE.Vector3(...config.pos).multiplyScalar(R);

// Lerp position and up vector
camera.position.lerp(targetPos, 0.15);
camera.up.lerp(targetUp, 0.15);

// Sync OrbitControls internal state
if (controlsRef?.current) {
controlsRef.current.update();
}

const dist = camera.position.distanceTo(targetPos);

if (dist < 0.5) {
camera.position.copy(targetPos);
camera.up.copy(targetUp);
if (controlsRef?.current) {
controlsRef.current.update();
}
setSnapped(true);
setIsSnapping(false);
}
}
});
return null;
};

const WireframeBox = () => {
return (
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE)]} />
<lineBasicMaterial color={GRID_COLOR} />
</lineSegments>
);
};

const ProjectedAxis = ({ type }) => {
const H = HALF_SIZE;
const points = useMemo(() => {
if (type === 'x') return [[-H, 0, 0], [H, 0, 0]];
if (type === 'y') return [[0, -H, 0], [0, H, 0]];
if (type === 'z') return [[0, 0, -H], [0, 0, H]];
return [[0, 0, 0], [0, 0, 0]];
}, [type]);

return (
<group>
<Line points={points} color={CROSSHAIR_COLOR} lineWidth={1.5} />
</group>
);
};

const AxisLabel = ({ position, text, rotationZ, anchorX, anchorY, fontSize = 3.5 }) => {
const { camera } = useThree();
const ref = useRef();
const rotQuat = useMemo(() => new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), rotationZ), [rotationZ]);

useFrame(() => {
if (!ref.current) return;
ref.current.quaternion.copy(camera.quaternion);
if (rotationZ !== 0) {
ref.current.quaternion.multiply(rotQuat);
}
});

return (
<group ref={ref} position={position}>
<Text fontSize={fontSize} color={LABEL_COLOR} anchorX={anchorX} anchorY={anchorY} fontWeight="bold" outlineWidth={0.25} outlineColor="#ffffff">
{text}
</Text>
</group>
);
};

const AxisLabels3D = ({ activeFace, dims, isSnapped }) => {
const faceNormals = [
{ id: 0, v: new THREE.Vector3(0, 0, 1), perpAxis: 'z' }, // Front
{ id: 1, v: new THREE.Vector3(1, 0, 0), perpAxis: 'x' }, // Right
{ id: 2, v: new THREE.Vector3(0, 0, -1), perpAxis: 'z' }, // Back
{ id: 3, v: new THREE.Vector3(-1, 0, 0), perpAxis: 'x' }, // Left
{ id: 4, v: new THREE.Vector3(0, 1, 0), perpAxis: 'y' }, // Top
{ id: 5, v: new THREE.Vector3(0, -1, 0), perpAxis: 'y' }, // Bottom
];

const axisDefs = [
{ key: 'x', vec: new THREE.Vector3(1, 0, 0), dimIndex: 0 },
{ key: 'y', vec: new THREE.Vector3(0, 1, 0), dimIndex: 1 },
{ key: 'z', vec: new THREE.Vector3(0, 0, 1), dimIndex: 2 }
];

const faceNormal = faceNormals[activeFace].v || faceNormals[0].v;

return (
<>
{axisDefs.map(({ key, vec, dimIndex }) => {
const dim = dims[dimIndex];
if (!dim) return null;

const isPerpendicular = Math.abs(vec.clone().normalize().dot(faceNormal)) > 0.999;
if (isSnapped && isPerpendicular) return null;

const posA = vec.clone().multiplyScalar(-HALF_SIZE);
const posB = vec.clone().multiplyScalar(HALF_SIZE);

return (
<React.Fragment key={key}>
<AxisLabel position={posA} text={dim.dimensionA} rotationZ={0} anchorX="center" anchorY="middle" fontSize={3} />
<AxisLabel position={posB} text={dim.dimensionB} rotationZ={0} anchorX="center" anchorY="middle" fontSize={3} />
</React.Fragment>
);
})}
</>
);
};

const Node3D = ({ node, position, color, isSelected, isHovered, onPointerOver, onPointerOut, onClick, onDragEnd, axisMapping, isSnapped, allNodes, dims, setIsDraggingNode, setDragVisual3D, setDragVisualState, projectToFace, dragVisual3D, dragVisualState, activeCount, activeDimensionIndices, dragHoverTarget }) => {
const meshRef = useRef();
const [isDragging, setIsDragging] = useState(false);
const { camera, raycaster } = useThree();
const dragPlane = useMemo(() => new THREE.Plane(), []);
const intersection = useMemo(() => new THREE.Vector3(), []);
const dragOffset = useMemo(() => new THREE.Vector3(), []);
const dragStart = useRef(new THREE.Vector3());
const movedRef = useRef(false);

// Nodes stay at their true 3D position (no projection to face)
const targetPos = new THREE.Vector3(...position);

useFrame(() => {
if (meshRef.current && !isDragging) {
meshRef.current.position.lerp(targetPos, 0.15);
}
});

// Get normal vector for the perpendicular axis
const getPerpendicularNormal = (axis) => {
switch (axis) {
case 'x': return new THREE.Vector3(1, 0, 0);
case 'y': return new THREE.Vector3(0, 1, 0);
case 'z': return new THREE.Vector3(0, 0, 1);
default: return new THREE.Vector3(0, 0, 1);
}
};

const handlePointerDown = (e) => {
e.stopPropagation();
if (e.button !== 0) return;

// Prevent canvas mousedown from triggering rotation
e.nativeEvent?.stopImmediatePropagation();

setIsDragging(true);
if (setIsDraggingNode) setIsDraggingNode(true);
movedRef.current = false;

let normal;
if (activeCount === 1) {
normal = new THREE.Vector3(0, 0, 1); // Force drag plane to be XY for 1D (allowing X movement)
} else {
normal = isSnapped && axisMapping
? getPerpendicularNormal(axisMapping.perpAxis)
: new THREE.Vector3().copy(camera.position).normalize();
}
dragPlane.setFromNormalAndCoplanarPoint(normal, meshRef.current.position);
if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
dragOffset.copy(intersection).sub(meshRef.current.position);
}
dragStart.current.copy(meshRef.current.position);
e.target.setPointerCapture(e.pointerId);
};

const handlePointerMove = (e) => {
if (!isDragging) return;
e.stopPropagation();
if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
const newPos = intersection.sub(dragOffset);
const lim = NODE_RANGE / 2;
newPos.x = Math.max(-lim, Math.min(lim, newPos.x));

// For 1D view, restrict dragging to X-axis only
if (activeCount === 1) {
newPos.y = 0; // Force Y to 0
newPos.z = 0; // Force Z to 0
} else {
newPos.y = Math.max(-lim, Math.min(lim, newPos.y));
newPos.z = Math.max(-lim, Math.min(lim, newPos.z));
}
meshRef.current.position.copy(newPos);
movedRef.current = true;

// live visual for modify/merge - lock perpendicular axis
const curr = newPos.clone();
if (isSnapped && axisMapping) {
if (axisMapping.perpAxis === 'x') curr.x = position[0];
if (axisMapping.perpAxis === 'y') curr.y = position[1];
if (axisMapping.perpAxis === 'z') curr.z = position[2];
}
let mergeTargetId = null;
let mergeTargetPos = null;
let best = Infinity;
const thresh = 8;
allNodes.forEach(other => {
if (other.id === node.id || other.isGhost) return;
const px = scoreToPos(other.scores?.[`${dims[0]?.dimensionA}-${dims[0]?.dimensionB}`] ?? 0);
const py = scoreToPos(other.scores?.[`${dims[1]?.dimensionA}-${dims[1]?.dimensionB}`] ?? 0);
const pz = scoreToPos(other.scores?.[`${dims[2]?.dimensionA}-${dims[2]?.dimensionB}`] ?? 0);
const facePos = projectToFace(new THREE.Vector3(px, py, pz));
const dragFace = projectToFace(curr);
const d = Math.hypot(dragFace.a - facePos.a, dragFace.b - facePos.b);
if (d < thresh && d < best) {
best = d;
mergeTargetId = other.id;
mergeTargetPos = new THREE.Vector3(px, py, pz);
}
});
if (setDragVisual3D) {
if (mergeTargetId) {
setDragVisual3D({ type: 'merge', sourceId: node.id, current: curr.clone(), targetId: mergeTargetId, targetPos: mergeTargetPos });
} else {
setDragVisual3D({ type: 'modify', sourceId: node.id, start: dragStart.current.clone(), current: curr.clone() });
}
}
if (setDragVisualState) {
if (mergeTargetId && mergeTargetPos) {
setDragVisualState({
type: 'merge',
sourceNodeId: node.id,
targetNodeId: mergeTargetId,
ghostPosition: { x: dragStart.current.x, y: dragStart.current.y, z: dragStart.current.z },
targetPosition: { x: mergeTargetPos.x, y: mergeTargetPos.y, z: mergeTargetPos.z }
});
} else {
setDragVisualState({
type: 'modify',
sourceNodeId: node.id,
ghostPosition: { x: dragStart.current.x, y: dragStart.current.y, z: dragStart.current.z },
newPosition: { x: curr.x, y: curr.y, z: curr.z }
});
}
}
}
};

const handlePointerUp = (e) => {
if (!isDragging) return;
e.stopPropagation();
setIsDragging(false);
if (setIsDraggingNode) setIsDraggingNode(false);
e.target.releasePointerCapture(e.pointerId);
if (!movedRef.current) {
if (onClick) onClick(node);
return;
}
const curr = meshRef.current.position.clone();

// Lock perpendicular axis when snapped
if (isSnapped && activeCount > 1 && axisMapping) { // Apply for 2D/3D only
if (axisMapping.perpAxis === 'x') curr.x = position[0];
if (axisMapping.perpAxis === 'y') curr.y = position[1];
if (axisMapping.perpAxis === 'z') curr.z = position[2];
} else if (activeCount === 1) { // For 1D, ensure Y and Z are 0
curr.y = 0;
curr.z = 0;
}

// Build new score map
const scoreFromAxis = (val) => posToScore(val);
const scoresMap = {};
if (activeCount === 1) {
const activeDimIndex = activeDimensionIndices[0];
if (dims[activeDimIndex]) {
scoresMap[`${dims[activeDimIndex].dimensionA}-${dims[activeDimIndex].dimensionB}`] = scoreFromAxis(curr.x);
}
} else { // 2D or 3D
if (dims[0]) scoresMap[`${dims[0].dimensionA}-${dims[0].dimensionB}`] = scoreFromAxis(curr.x);
if (dims[1]) scoresMap[`${dims[1].dimensionA}-${dims[1].dimensionB}`] = scoreFromAxis(curr.y);
if (dims[2]) scoresMap[`${dims[2].dimensionA}-${dims[2].dimensionB}`] = scoreFromAxis(curr.z);
}

// Merge detection in 2D plane (use passed-in projectToFace)
const dragged2D = projectToFace(curr);
let mergeTargetId = null;
let mergeTargetPos = null;
let bestDist = Infinity;
const threshold = 8; // scene units

allNodes.forEach(other => {
if (other.id === node.id || other.isGhost) return;
const targetVec = new THREE.Vector3(
scoreToPos(other.scores?.[`${dims[0]?.dimensionA}-${dims[0]?.dimensionB}`] ?? 0),
scoreToPos(other.scores?.[`${dims[1]?.dimensionA}-${dims[1]?.dimensionB}`] ?? 0),
scoreToPos(other.scores?.[`${dims[2]?.dimensionA}-${dims[2]?.dimensionB}`] ?? 0)
);
const pos = projectToFace(targetVec);
const dx = dragged2D.a - pos.a;
const dy = dragged2D.b - pos.b;
const d = Math.hypot(dx, dy);
if (d < threshold && d < bestDist) {
bestDist = d;
mergeTargetId = other.id;
mergeTargetPos = targetVec;
}
});

if (setDragVisual3D) {
if (mergeTargetId && mergeTargetPos) {
setDragVisual3D({ type: 'merge', sourceId: node.id, current: curr.clone(), targetId: mergeTargetId, targetPos: mergeTargetPos.clone(), hold: true });
} else {
setDragVisual3D({ type: 'modify', sourceId: node.id, start: dragStart.current.clone(), current: curr.clone(), hold: true });
}
}
if (onDragEnd) onDragEnd(node.id, { scoresMap, mergeTargetId, clientX: e.clientX, clientY: e.clientY }, e);
};

return (
<group>
<mesh ref={meshRef} position={position} userData={{ nodeId: node.id }}
onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp}
onPointerOver={(e) => { e.stopPropagation(); onPointerOver(node) }}
onPointerOut={(e) => { e.stopPropagation(); onPointerOut() }}
scale={
(dragVisualState?.type === 'merge' && dragVisualState.targetNodeId === node.id) ||
(dragVisual3D?.type === 'merge' && dragVisual3D.targetId === node.id) ||
(dragHoverTarget === node.id)
? 1.35
: ((isHovered || isDragging) && !isSelected ? (activeCount === 1 ? 1.5 : 1.2) : (activeCount === 1 ? 1.2 : 1))
}
>
<sphereGeometry args={[isSelected ? 5 : (activeCount === 1 ? 4 : 3.5), 32, 32]} />
<meshBasicMaterial
color={color}
opacity={
(dragVisualState?.type === 'merge' && dragVisualState.sourceNodeId === node.id) ||
(dragVisual3D?.type === 'merge' && dragVisual3D.sourceId === node.id)
? 0.15
: 1
}
transparent={
(dragVisualState?.type === 'merge' && dragVisualState.sourceNodeId === node.id) ||
(dragVisual3D?.type === 'merge' && dragVisual3D.sourceId === node.id)
}
toneMapped={false}
/>
{isSelected && (
<mesh scale={[1.08, 1.08, 1.08]}>
<sphereGeometry args={[isSelected ? 5.3 : (activeCount === 1 ? 4.3 : 3.8), 32, 32]} />
<meshBasicMaterial color="#000" side={THREE.BackSide} />
</mesh>
)}
</mesh>
<Billboard position={position}>
<Text
fontSize={3}
color="#1f2937"
fillOpacity={isHovered || isSelected ? 1 : 0.3}
outlineWidth={0.2}
outlineColor="#ffffff"
anchorY="bottom"
position={[0, (activeCount === 1 ? 4 : 5), 0]} // Adjusted position for 1D
>
{node.title}
</Text>
</Billboard>
</group>
);
};

const SceneContent = ({
nodes,
dimensions,
onNodeDragEnd,
selectedNode,
onNodeClick,
hoveredNode,
onNodeHover,
targetFaceIndex,
setTargetFaceIndex,
pendingChange,
pendingMerge,
colorMap,
dragVisualState,
setDragVisualState,
mergeAnimationState,
activeDimensionIndices,
onDropExternal,
dragHoverTarget,
onDragHover
}) => {
const { camera, gl, scene, raycaster } = useThree();
const controlsRef = useRef();
const [isSnapped, setSnapped] = useState(true);
const [isSnapping, setIsSnapping] = useState(false);
const [isDraggingNode, setIsDraggingNode] = useState(false);
const [dragVisual3D, setDragVisual3D] = useState(null);
const [targetUp, setTargetUp] = useState(null);
const [axisMapping, setAxisMapping] = useState({
perpAxis: 'z',
horizontalAxis: 'x',
verticalAxis: 'y'
});

// Handle external drops (HTML5 DnD)
useEffect(() => {
const canvas = gl.domElement;
// Prevent global DOM tracker from double-counting canvas-level events; track nodes semantically instead.
canvas.setAttribute('data-tracking-name', 'evaluation_3d_canvas');
canvas.setAttribute('data-uatrack-suppress-click', 'true');
canvas.setAttribute('data-uatrack-suppress-hover', 'true');
canvas.setAttribute('data-uatrack-suppress-drag', 'true');

const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();

if (!onDragHover) return;

const rect = canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);

raycaster.setFromCamera(mouse, camera);

const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(intersect =>
intersect.object.userData && intersect.object.userData.nodeId
);

if (hit) {
onDragHover(hit.object.userData.nodeId);
} else {
onDragHover(null);
}
};

const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();

// Clear hover state on drop
if (onDragHover) onDragHover(null);

if (!onDropExternal) return;

const rect = canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);

raycaster.setFromCamera(mouse, camera);

// Filter for objects that are likely our nodes
const intersects = raycaster.intersectObjects(scene.children, true);

// Find the first intersected object that has nodeId in userData
const hit = intersects.find(intersect =>
intersect.object.userData && intersect.object.userData.nodeId
);

if (hit) {
const nodeId = hit.object.userData.nodeId;
const targetNode = nodes.find(n => n.id === nodeId);
if (targetNode) {
onDropExternal(e, targetNode);
return;
}
}

// If no valid node hit, drop on empty space
onDropExternal(e, null);
};

canvas.addEventListener('dragover', handleDragOver);
canvas.addEventListener('drop', handleDrop);
// Also handle dragleave to clear hover
const handleDragLeave = () => {
if (onDragHover) onDragHover(null);
};
canvas.addEventListener('dragleave', handleDragLeave);

return () => {
canvas.removeEventListener('dragover', handleDragOver);
canvas.removeEventListener('drop', handleDrop);
canvas.removeEventListener('dragleave', handleDragLeave);
};
}, [gl, camera, scene, raycaster, nodes, onDropExternal, onDragHover]);

// Compute axis mapping based on camera orientation
const computeAxisMapping = useCallback((cam) => {
const direction = new THREE.Vector3();
cam.getWorldDirection(direction);

const up = cam.up.clone().normalize();
const right = new THREE.Vector3().crossVectors(direction, up).normalize();

const axes = ['x', 'y', 'z'];
const vecs = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1)
};

// 1. perpAxis: axis most aligned with view direction
let perpAxis = 'z', maxDirDot = 0;
axes.forEach(axis => {
const dot = Math.abs(direction.dot(vecs[axis]));
if (dot > maxDirDot) {
maxDirDot = dot;
perpAxis = axis;
}
});

// 2. horizontalAxis: remaining axis most aligned with screen right
const remaining = axes.filter(a => a !== perpAxis);
let horizontalAxis = remaining[0], maxRightDot = 0;
remaining.forEach(axis => {
const dot = Math.abs(right.dot(vecs[axis]));
if (dot > maxRightDot) {
maxRightDot = dot;
horizontalAxis = axis;
}
});

// 3. verticalAxis: the remaining one
const verticalAxis = remaining.find(a => a !== horizontalAxis);

return { perpAxis, horizontalAxis, verticalAxis };
}, []);

// Compute target up vector based on current camera orientation
const computeTargetUp = useCallback((cam, perpAxis) => {
// Get actual screen Y direction from camera matrix (not camera.up)
const screenUp = new THREE.Vector3();
cam.matrixWorld.extractBasis(new THREE.Vector3(), screenUp, new THREE.Vector3());
screenUp.normalize();

// Two axes on screen (non-perp)
const axes = ['x', 'y', 'z'].filter(a => a !== perpAxis);
const vecs = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1)
};

// Find which axis is closer to screen vertical direction
const dot0 = Math.abs(screenUp.dot(vecs[axes[0]]));
const dot1 = Math.abs(screenUp.dot(vecs[axes[1]]));
const verticalAxis = dot0 > dot1 ? axes[0] : axes[1];

// Determine sign based on dot product (preserve visual direction)
const dot = screenUp.dot(vecs[verticalAxis]);
return dot > 0 ? vecs[verticalAxis].clone() : vecs[verticalAxis].clone().negate();
}, []);

const handleEnd = () => {
const camDir = camera.position.clone().normalize();
const directions = [
{ id: 0, v: new THREE.Vector3(0, 0, 1), perpAxis: 'z' }, // Front
{ id: 1, v: new THREE.Vector3(1, 0, 0), perpAxis: 'x' }, // Right
{ id: 2, v: new THREE.Vector3(0, 0, -1), perpAxis: 'z' }, // Back
{ id: 3, v: new THREE.Vector3(-1, 0, 0), perpAxis: 'x' }, // Left
{ id: 4, v: new THREE.Vector3(0, 1, 0), perpAxis: 'y' }, // Top
{ id: 5, v: new THREE.Vector3(0, -1, 0), perpAxis: 'y' }, // Bottom
];

let best = 0, maxDot = -Infinity;
directions.forEach(d => {
const dot = camDir.dot(d.v);
if (dot > maxDot) {
maxDot = dot;
best = d.id;
}
});

// Compute axis mapping based on current camera orientation
const mapping = computeAxisMapping(camera);
setAxisMapping(mapping);

// Only snap if very close to an orthogonal face (threshold: 0.95)
const SNAP_THRESHOLD = 0.95;
const shouldSnap = maxDot > SNAP_THRESHOLD;

if (shouldSnap) {
// Compute target up vector based on current screen orientation
const up = computeTargetUp(camera, directions[best].perpAxis);
setTargetUp(up);

setTargetFaceIndex(best);
setIsSnapping(true);
setSnapped(false);
}

// Helper to get dimension info
const axisToIndex = { x: 0, y: 1, z: 2 };
const getDimInfo = (axisName) => {
const index = axisToIndex[axisName];
const dim = dimensions[index];
if (!dim) return null;
return {
id: dim.id, // Assuming dimensions might have IDs, otherwise optional
label: `${dim.dimensionA} vs ${dim.dimensionB}`,
dimensionA: dim.dimensionA,
dimensionB: dim.dimensionB
};
};

userActionTracker.trackAction('3d_rotate', 'evaluation_3d_view', {
axisMapping: mapping,
targetFaceIndex: best,
snapped: shouldSnap,
snapDistance: maxDot,
viewDimensions: {
horizontal: getDimInfo(mapping.horizontalAxis),
vertical: getDimInfo(mapping.verticalAxis),
depth: getDimInfo(mapping.perpAxis)
}
});
};

const handleStart = () => {
setSnapped(false);
setIsSnapping(false);
};

useEffect(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return;

const handleWheel = (e) => {
e.preventDefault();
camera.zoom = Math.max(1, Math.min(10, camera.zoom - e.deltaY * 0.005));
camera.updateProjectionMatrix();
};

canvas.addEventListener('wheel', handleWheel, { passive: false });

return () => {
canvas.removeEventListener('wheel', handleWheel);
};
}, [camera]);

// Project 3D position to 2D plane based on axis mapping
const projectToPlane = useCallback((vec) => {
const getVal = (axis) => axis === 'x' ? vec.x : axis === 'y' ? vec.y : vec.z;
return {
a: getVal(axisMapping.horizontalAxis),
b: getVal(axisMapping.verticalAxis)
};
}, [axisMapping]);

// Filter out root node once at the top level
const validNodes = useMemo(() => nodes.filter(node => node.type !== 'root'), [nodes]);

// Clear internal dragVisual3D when parent clears dragVisualState (on cancel/error)
useEffect(() => {
if (!dragVisualState && dragVisual3D) {
setDragVisual3D(null);
}
}, [dragVisualState]); // Remove dragVisual3D from dependencies to avoid unnecessary reruns

// Camera Transition Logic based on Active Dimensions
const activeCount = activeDimensionIndices ? activeDimensionIndices.length : 3;

const mergeTargetPosition = useMemo(() => {
if (!dragVisualState || dragVisualState.type !== 'merge') return null;
if (hasVector3(dragVisualState.targetPosition)) return dragVisualState.targetPosition;
if (!dragVisualState.targetNodeId) return null;
const targetNode = nodes.find(n => n.id === dragVisualState.targetNodeId);
if (!targetNode) return null;

const getScore = (dimObj) => {
if (!dimObj) return 0;
const key = `${dimObj.dimensionA}-${dimObj.dimensionB}`;
if (targetNode.scores && targetNode.scores[key] !== undefined) return targetNode.scores[key];
return 0;
};

let x, y, z;
if (activeCount === 1) {
const activeIdx = activeDimensionIndices ? activeDimensionIndices[0] : 0;
const activeDim = dimensions[activeIdx];
x = getScore(activeDim);
y = 0;
z = 0;
} else {
x = getScore(dimensions[0]);
y = getScore(dimensions[1]);
z = getScore(dimensions[2]);
}

return { x: scoreToPos(x), y: scoreToPos(y), z: scoreToPos(z) };
}, [dragVisualState, nodes, dimensions, activeCount, activeDimensionIndices]);
const mergeGhostPosition = useMemo(() => {
if (!dragVisualState || dragVisualState.type !== 'merge') return null;
if (hasVector3(dragVisualState.ghostPosition)) return dragVisualState.ghostPosition;
if (!hasVector2(dragVisualState.ghostPosition)) return null;
if (!gl?.domElement) return null;

const rect = gl.domElement.getBoundingClientRect();
if (!rect.width || !rect.height) return null;

const mouse = new THREE.Vector2(
(dragVisualState.ghostPosition.x / rect.width) * 2 - 1,
-((dragVisualState.ghostPosition.y / rect.height) * 2 - 1)
);

raycaster.setFromCamera(mouse, camera);
const viewDir = new THREE.Vector3();
camera.getWorldDirection(viewDir);
const planePoint = mergeTargetPosition
? new THREE.Vector3(mergeTargetPosition.x, mergeTargetPosition.y, mergeTargetPosition.z)
: new THREE.Vector3(0, 0, 0);
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(viewDir, planePoint);
const hit = new THREE.Vector3();
const intersected = raycaster.ray.intersectPlane(plane, hit);
if (!intersected) return null;
return { x: hit.x, y: hit.y, z: hit.z };
}, [dragVisualState, gl, camera, raycaster, mergeTargetPosition]);

useEffect(() => {
if (activeCount < 3) {
// Determine target face and up vector to align axes with screen
// Requirement: First dimension (Horizontal) -> Left to Right
// Second dimension (Vertical) -> Bottom to Top
let face = 0; // Default Front
let up = new THREE.Vector3(0, 1, 0);

if (activeCount === 2) {
const indices = activeDimensionIndices || [0, 1];
const [a, b] = [...indices].sort((i, j) => i - j);

if (a === 0 && b === 1) {
// XY: Front View (Looking at Z-)
// X (Horizontal): Left(-X) -> Right(+X)
// Y (Vertical): Bottom(-Y) -> Top(+Y)
face = 0;
up.set(0, 1, 0);
} else if (a === 0 && b === 2) {
// XZ: Bottom View (Looking at Y+)
// X (Horizontal): Left(-X) -> Right(+X)
// Z (Vertical): Bottom(-Z is Down? No, we want A(-Z) at Bottom).
// If Up is Z+, Screen Top is Z+, Screen Bottom is Z-.
// So A(-Z) is Bottom. B(+Z) is Top. Correct.
face = 5;
up.set(0, 0, 1);
} else if (a === 1 && b === 2) {
// YZ: Right View (Looking at X-)
// Y (Horizontal): Left(-Y) -> Right(+Y)
// Z (Vertical): Bottom(-Z) -> Top(+Z) (With Up=Z+)
face = 1;
up.set(0, 0, 1);
}
} else {
// 1D: Always Front (mapped to X)
face = 0;
up.set(0, 1, 0);
}

setTargetFaceIndex(face);
setTargetUp(up);
setIsSnapping(true);
setSnapped(false);
}
}, [activeCount, activeDimensionIndices]);

return (
<>
<CameraController targetFaceIndex={targetFaceIndex} targetUp={targetUp} isSnapping={isSnapping} setSnapped={setSnapped} setIsSnapping={setIsSnapping} controlsRef={controlsRef} />
<ambientLight intensity={0.8} />
<pointLight position={[100, 100, 100]} intensity={1} />
<pointLight position={[-100, -100, -100]} intensity={0.5} />

<TrackballControls
ref={controlsRef}
noZoom
noPan
rotateSpeed={3}
enabled={!isDraggingNode && !isSnapping && activeCount === 3}
onStart={handleStart}
onEnd={handleEnd}
/>

{activeCount === 3 && <WireframeBox />}

{/* Render ProjectedAxis based on activeCount */}
{activeCount === 1 && <ProjectedAxis type="x" />}
{activeCount === 2 && (
<>
{activeDimensionIndices.includes(0) && <ProjectedAxis type="x" />}
{activeDimensionIndices.includes(1) && <ProjectedAxis type="y" />}
{activeDimensionIndices.includes(2) && <ProjectedAxis type="z" />}
</>
)}
{activeCount === 3 && (
<>
<ProjectedAxis type="x" />
<ProjectedAxis type="y" />
<ProjectedAxis type="z" />
</>
)}

{activeCount === 1 ? (
// For 1D, pass only the single active dimension (mapped to index 0 for correct X-axis labeling)
<AxisLabels3D activeFace={targetFaceIndex} dims={[dimensions[activeDimensionIndices[0]], null, null]} isSnapped={isSnapped} />
) : (
// For 2D/3D, pass all dims (nulls will be filtered inside AxisLabels3D component logic itself)
<AxisLabels3D activeFace={targetFaceIndex} dims={dimensions} isSnapped={isSnapped} />
)}

{dragVisualState && dragVisualState.type === 'modify' && dragVisualState.ghostPosition && dragVisualState.newPosition && (
<>
<Line points={[
[dragVisualState.ghostPosition.x, dragVisualState.ghostPosition.y, dragVisualState.ghostPosition.z],
[dragVisualState.newPosition.x, dragVisualState.newPosition.y, dragVisualState.newPosition.z]
]} color="#9ca3af" lineWidth={2} dashed dashSize={1} gapSize={1} />
<mesh position={[dragVisualState.ghostPosition.x, dragVisualState.ghostPosition.y, dragVisualState.ghostPosition.z]}>
<sphereGeometry args={[3.8, 24, 24]} />
<meshBasicMaterial color="#9ca3af" opacity={0.5} transparent />
</mesh>
</>
)}
{dragVisualState && dragVisualState.type === 'merge' && mergeGhostPosition && mergeTargetPosition && dragVisualState.targetNodeId && (
<>
<Line points={[
[mergeGhostPosition.x, mergeGhostPosition.y, mergeGhostPosition.z],
[mergeTargetPosition.x, mergeTargetPosition.y, mergeTargetPosition.z]
]} color="#9ca3af" lineWidth={2} dashed dashSize={1} gapSize={1} />
<mesh position={[mergeGhostPosition.x, mergeGhostPosition.y, mergeGhostPosition.z]}>
<sphereGeometry args={[3.8, 24, 24]} />
<meshBasicMaterial color="#9ca3af" opacity={0.5} transparent />
</mesh>
</>
)}

{dimensions.filter(d => d !== null).length === 0 && (<Billboard position={[0, 0, 0]}><Text fontSize={6} color="#ef4444" bg="#fee2e2">Select Dimensions</Text></Billboard>)}

{validNodes.map(node => {
if (node.isGhost) return null;

// Check if all required dimension scores are available
// In 1D/2D mode, we only care about active dimensions
const hasAllScores = dimensions.every((dimObj, idx) => {
if (!dimObj) return true; // Inactive dimension
// For 1D, we only care about the single active dimension
if (activeCount === 1 && idx !== activeDimensionIndices[0]) return true;
// For 2D, we only care about the two active dimensions
if (activeCount === 2 && !activeDimensionIndices.includes(idx)) return true;

const key = `${dimObj.dimensionA}-${dimObj.dimensionB}`;
return node.scores && node.scores[key] !== undefined;
});

// Don't render node if scoring is incomplete
if (!hasAllScores) return null;

const getScore = (dimObj) => {
if (!dimObj) return 0; // Changed from 50 to 0 (center in -50 to 50 range)
const key = `${dimObj.dimensionA}-${dimObj.dimensionB}`;
if (node.scores && node.scores[key] !== undefined) return node.scores[key];
return 0; // Changed from 50 to 0
};

let x, y, z;
if (activeCount === 1) {
// 1D View: Remap the single active dimension to X axis
// Find the active dimension
const activeIdx = activeDimensionIndices ? activeDimensionIndices[0] : 0;
const activeDim = dimensions[activeIdx];
x = getScore(activeDim);
y = 0; // Center (0 is the center in -50 to 50 range)
z = 0; // Center
} else {
// 2D/3D View: Standard mapping
x = getScore(dimensions[0]);
y = getScore(dimensions[1]);
z = getScore(dimensions[2]);
}

const overridePos = dragVisual3D && dragVisual3D.sourceId === node.id && dragVisual3D.current;
const pos = overridePos
? [overridePos.x, overridePos.y, overridePos.z]
: [scoreToPos(x), scoreToPos(y), scoreToPos(z)];

const color = getNodeColor(node, colorMap);

return (
<Node3D
key={node.id} node={node} position={pos} color={color}
isSelected={selectedNode?.id === node.id} isHovered={hoveredNode?.id === node.id}
onPointerOver={onNodeHover} onPointerOut={(n) => onNodeHover(null)}
onClick={onNodeClick} onDragEnd={onNodeDragEnd}
axisMapping={axisMapping} isSnapped={isSnapped} allNodes={validNodes} dims={dimensions} setIsDraggingNode={setIsDraggingNode}
setDragVisual3D={setDragVisual3D} setDragVisualState={setDragVisualState} projectToFace={projectToPlane} dragVisual3D={dragVisual3D} dragVisualState={dragVisualState}
activeCount={activeCount} // Pass activeCount to Node3D
activeDimensionIndices={activeDimensionIndices} // Pass activeDimensionIndices to Node3D
dragHoverTarget={dragHoverTarget}
/>
);
})}
</>
);
};

const Evaluation3D = (props) => {
const [targetFaceIndex, setTargetFaceIndex] = useState(0);
const colorMap = useMemo(() => ({
root: '#4C84FF',
simple: '#45B649',
complex: '#FF6B6B',
}), []);
const activeDims = useMemo(() => {
if (!props.selectedDimensionPairs) return [null, null, null];
const indices = props.activeDimensionIndices || [0, 1, 2];
return [0, 1, 2].map(i => {
if (indices.includes(i)) return props.selectedDimensionPairs[i];
return null;
});
}, [props.selectedDimensionPairs, props.activeDimensionIndices]);

// Determine if we should show the operation status
const showOperationStatus = props.operationStatus &&
(props.operationStatus.toLowerCase().includes('merging') ||
props.operationStatus.toLowerCase().includes('evaluating') ||
props.operationStatus.toLowerCase().includes('modifying') ||
props.isGenerating);

return (
<div
data-uatrack-suppress-click="true"
data-uatrack-suppress-hover="true"
style={{ width: '100%', height: '100%', position: 'relative', background: '#fff', borderRadius: '8px', border: '1px solid #e5e7eb', overflow: 'hidden' }}
>
{/* Operation Status Banner */}
{showOperationStatus && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
background: 'rgba(255, 255, 255, 0.95)',
padding: '12px',
textAlign: 'center',
fontSize: '1.2rem',
fontWeight: '600',
color: '#374151',
borderBottom: '1px solid #e5e7eb',
pointerEvents: 'none'
}}>
{props.operationStatus || 'Processing...'}
</div>
)}
<Canvas shadows>
<OrthographicCamera makeDefault zoom={4} position={[0, 0, 200]} near={0.1} far={1000} />
<SceneContent
nodes={props.nodes}
dimensions={activeDims}
activeDimensionIndices={props.activeDimensionIndices}
onNodeDragEnd={props.onNodeDragEnd}
selectedNode={props.selectedNode}
onNodeClick={props.onNodeClick}
hoveredNode={props.hoveredNode}
onNodeHover={props.onNodeHover}
targetFaceIndex={targetFaceIndex}
setTargetFaceIndex={setTargetFaceIndex}
pendingChange={props.pendingChange}
pendingMerge={props.pendingMerge}
colorMap={colorMap}
dragVisualState={props.dragVisualState}
setDragVisualState={props.setDragVisualState}
mergeAnimationState={props.mergeAnimationState}
onDropExternal={props.onDropExternal}
dragHoverTarget={props.dragHoverTarget}
onDragHover={props.onDragHover}
/>
</Canvas>
<div style={{ position: 'absolute', bottom: 10, left: 10, pointerEvents: 'none', background: 'rgba(255,255,255,0.8)', padding: '4px 8px', borderRadius: '4px', fontSize: '12px', color: '#666' }}>
Drag Cube to Rotate • Drag Nodes to Edit
</div>
</div>
);
};

export default Evaluation3D;
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Evaluation3D component has very complex coordinate transformation and drag handling logic spanning over 600 lines. This creates several maintenance concerns:

  1. Testing difficulty: The component mixes React state, Three.js scene management, and complex geometric calculations making it nearly impossible to unit test
  2. Performance: Multiple useFrame hooks, useMemo calculations, and state updates on every frame could cause performance issues with many nodes
  3. State management: Manages 10+ pieces of state internally (isDragging, dragVisual3D, targetUp, axisMapping, etc.) with complex interdependencies

Consider:

  • Extracting drag logic into a custom hook: useDragHandler
  • Separating coordinate transformation logic into a utility module
  • Using a state machine library for complex interaction states (dragging, snapping, hovering)
  • Breaking into smaller sub-components: Camera, Scene, DragHandlers, Nodes3D

The extensive inline comments and TODOs also suggest this code needs further refinement before production use.

Copilot uses AI. Check for mistakes.
reviewer: Optional[Reviewer] = None
global_cost_tracker: Optional[BudgetChecker] = None

global_idea_storage: Dict[str, Any] = {}
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global global_idea_storage dictionary is used for storing ideas across requests without any synchronization mechanism. In a multi-threaded Flask deployment (which is the default for production), this can lead to race conditions where concurrent requests corrupt the stored data.

Additionally, this approach stores data in memory which means:

  1. All data is lost on server restart
  2. Memory usage grows unbounded as ideas accumulate
  3. Won't work correctly in multi-worker deployments (each worker has its own memory)

Consider using proper session management, a database, or at minimum, Flask's session object with appropriate configuration.

Copilot uses AI. Check for mistakes.
Comment on lines 882 to 995
def _parse_evaluation_result(
self, evaluation_text: str, original_ideas: List[Dict[str, Any]]
self,
evaluation_text: str,
original_ideas: List[Dict[str, Any]],
dimension_pairs: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""Parse evaluation result and update idea dictionaries with scores"""
# Extract JSON from response
evaluation_data = extract_json_between_markers(evaluation_text)

if not evaluation_data:
print("Failed to extract JSON from evaluation response")
return []
# Create mapping from idea title to original idea dict (check both Title and title)

def _get_field(scored_item: Dict[str, Any], variants: List[str]) -> Any:
for key in variants:
if key in scored_item and scored_item[key] is not None:
return scored_item[key]
return None

def _score_variants(prefix: str) -> List[str]:
camel = prefix[0].lower() + prefix[1:]
snake = "".join(
["_" + c.lower() if c.isupper() else c for c in prefix]
).lstrip("_")
compact = snake.replace("_", "")
return [
prefix,
camel,
prefix.lower(),
snake,
compact,
]

idea_map = {}
for idea in original_ideas:
title = idea.get("Title", "") or idea.get("title", "")
if title:
idea_map[title] = idea

# Create scored list
scored_ideas = []
scored_items = evaluation_data.get("scored_ideas", [])

# FIX: The key from the prompt is "scored_ideas", not "ranked_ideas"
pair_count = len(dimension_pairs) if dimension_pairs else 0
use_dimension_pairs = pair_count >= 2

def _to_signed(v):
try:
if v is None:
return None
fv = float(v)
if -50.0 <= fv <= 50.0:
return int(round(fv))
if 0.0 <= fv <= 100.0:
return int(round(fv)) - 50
if fv > 50.0:
return 50
if fv < -50.0:
return -50
return int(round(fv))
except Exception:
return None

for scored_item in scored_items:
idea_name = scored_item.get("Title", "")

if idea_name in idea_map:
# Get original idea and update with scoring data
idea = idea_map[idea_name].copy()

# Add scoring information
idea["FeasibilityScore"] = scored_item.get("FeasibilityScore")
idea["NoveltyScore"] = scored_item.get("NoveltyScore")
idea["ImpactScore"] = scored_item.get("ImpactScore")
idea["NoveltyReason"] = scored_item.get("NoveltyReason", "")
idea["FeasibilityReason"] = scored_item.get("FeasibilityReason", "")
idea["ImpactReason"] = scored_item.get("ImpactReason", "")
if use_dimension_pairs:
idea["scores"] = {}
max_dimensions = min(pair_count, 5) if pair_count else 0
for idx in range(max_dimensions):
pair = dimension_pairs[idx]
dim_score_raw = _get_field(
scored_item, _score_variants(f"Dimension{idx + 1}Score")
)
dim_reason = (
_get_field(
scored_item,
_score_variants(f"Dimension{idx + 1}Reason"),
)
or ""
)
dim_score = _to_signed(dim_score_raw)
dim_key = f"{pair.get('dimensionA', f'Dim{idx + 1}A')}-{pair.get('dimensionB', f'Dim{idx + 1}B')}"

idea["scores"][dim_key] = dim_score
idea[f"Dimension{idx + 1}Score"] = dim_score
idea[f"Dimension{idx + 1}Reason"] = dim_reason
else:
raw_feas = _get_field(
scored_item, _score_variants("FeasibilityScore")
)
raw_nov = _get_field(scored_item, _score_variants("NoveltyScore"))
raw_imp = _get_field(scored_item, _score_variants("ImpactScore"))

def _to_signed_legacy(v):
return _to_signed(v)

idea["FeasibilityScore"] = _to_signed_legacy(raw_feas)
idea["NoveltyScore"] = _to_signed_legacy(raw_nov)
idea["ImpactScore"] = _to_signed_legacy(raw_imp)
idea["NoveltyReason"] = (
_get_field(scored_item, _score_variants("NoveltyReason")) or ""
)
idea["FeasibilityReason"] = (
_get_field(scored_item, _score_variants("FeasibilityReason"))
or ""
)
idea["ImpactReason"] = (
_get_field(scored_item, _score_variants("ImpactReason")) or ""
)

scored_ideas.append(idea)

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _parse_evaluation_result method has become significantly more complex with the addition of dynamic dimension pair support. The function now:

  1. Defines helper functions (_get_field, _score_variants, _to_signed) inside the method
  2. Has conditional logic based on use_dimension_pairs flag
  3. Handles multiple score format variations

This makes the function difficult to test and maintain. Consider:

  1. Extracting the helper functions as module-level or class methods
  2. Splitting into separate methods: _parse_legacy_scores and _parse_dimension_scores
  3. Adding comprehensive unit tests for the various score format combinations

The extensive score normalization logic (_to_signed) also suggests inconsistent LLM output formats that should be addressed at the prompt level rather than with complex parsing.

Copilot uses AI. Check for mistakes.
Comment on lines +467 to +559
@@ -299,23 +492,71 @@ def modify_idea(
"noveltyScore": "Novelty",
"feasibilityScore": "Feasibility",
"impactScore": "Impact",
}.get(mod["metric"])

direction = mod["direction"]
instruction_lines.append(
{
"metric": metric_name,
"direction": direction,
"reference": behind_content,
}
)
"dimension1Score": "dimension1",
"dimension2Score": "dimension2",
"dimension3Score": "dimension3",
}.get(mod["metric"], mod["metric"])

# Handle both old direction-based and new score-based formats
if "previousScore" in mod and "newScore" in mod:
# New score-based format (from dynamic dimensions)
instruction_lines.append(
{
"metric": metric_name,
"previousScore": mod["previousScore"],
"newScore": mod["newScore"],
"change": mod.get("change", 0),
"reference": behind_content,
}
)
else:
# Legacy direction-based format
direction = mod.get("direction", "increase")
instruction_lines.append(
{
"metric": metric_name,
"direction": direction,
"reference": behind_content,
}
)

# Prepare the prompt using the template from YAML
prompt = self.prompts.modify_idea_prompt.format(
idea=json.dumps(original_idea),
modifications=json.dumps(instruction_lines),
intent=self.intent,
)
# Format varies based on whether dimension_pairs are provided
pair_count = len(dimension_pairs) if dimension_pairs else 0

if dimension_pairs and pair_count >= 2:
# Use dimension pairs (up to 3)
pair1 = dimension_pairs[0]
pair2 = dimension_pairs[1]
pair3 = dimension_pairs[2] if pair_count >= 3 else {}

prompt = self.prompts.modify_idea_prompt.format(
idea=json.dumps(original_idea),
modifications=json.dumps(instruction_lines),
intent=self.intent,
dimension_pair_1_name=f"{pair1.get('dimensionA', '')} - {pair1.get('dimensionB', '')}",
dimension_1_a=pair1.get("dimensionA", "Dimension 1A"),
dimension_1_b=pair1.get("dimensionB", "Dimension 1B"),
dimension_1_a_desc=pair1.get("descriptionA", ""),
dimension_1_b_desc=pair1.get("descriptionB", ""),
dimension_pair_2_name=f"{pair2.get('dimensionA', '')} - {pair2.get('dimensionB', '')}",
dimension_2_a=pair2.get("dimensionA", "Dimension 2A"),
dimension_2_b=pair2.get("dimensionB", "Dimension 2B"),
dimension_2_a_desc=pair2.get("descriptionA", ""),
dimension_2_b_desc=pair2.get("descriptionB", ""),
dimension_pair_3_name=f"{pair3.get('dimensionA', '')} - {pair3.get('dimensionB', '')}",
dimension_3_a=pair3.get("dimensionA", "Dimension 3A"),
dimension_3_b=pair3.get("dimensionB", "Dimension 3B"),
dimension_3_a_desc=pair3.get("descriptionA", ""),
dimension_3_b_desc=pair3.get("descriptionB", ""),
)
else:
# Legacy format without dimension pairs
prompt = self.prompts.modify_idea_prompt.format(
idea=json.dumps(original_idea),
modifications=json.dumps(instruction_lines),
intent=self.intent,
)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modify_idea method now has complex prompt formatting logic that spans 70+ lines (lines 484-559) with nested conditionals based on dimension_pairs presence and count. This creates several issues:

  1. Maintainability: The method mixes business logic (score adjustment interpretation) with formatting concerns
  2. Testing complexity: Hard to test all branches due to multiple conditional paths
  3. Error-prone: Easy to miss cases like when pair_count == 2 or when dimension_pairs is None

The code would benefit from:

  • A separate prompt builder class/method
  • Template-based approach for different dimension configurations
  • Clear separation between legacy (Novelty/Feasibility/Impact) and new (dimension-based) paths

Copilot uses AI. Check for mistakes.
Comment on lines 220 to 227
env_var_map = {
"gpt-5.2": "OPENAI_API_KEY",
"gpt-5.2-pro": "OPENAI_API_KEY",
"gpt-5-mini": "OPENAI_API_KEY",
"claude-opus-4-6": "ANTHROPIC_API_KEY",
"claude-sonnet-4-5": "ANTHROPIC_API_KEY",
"deepseek-chat": "DEEPSEEK_API_KEY",
"deepseek-reasoner": "DEEPSEEK_API_KEY",
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env_var_map in the configure endpoint maps model names to environment variables, but several models reference environment variables that may not exist:

  • "gemini-3-pro" and "gemini-3-flash" → "GOOGLE_API_KEY" (new requirement)
  • Multiple new model versions without documentation

Users upgrading will need to set GOOGLE_API_KEY even if they don't use Gemini models. The code should either:

  1. Only check for required API keys when those specific models are selected
  2. Provide clear error messages about missing API keys for selected models
  3. Document all required environment variables in a migration guide

Additionally, old models like "gpt-4o", "claude-3-5-sonnet-20241022" are removed from the map, which could break existing user configurations.

Suggested change
env_var_map = {
"gpt-5.2": "OPENAI_API_KEY",
"gpt-5.2-pro": "OPENAI_API_KEY",
"gpt-5-mini": "OPENAI_API_KEY",
"claude-opus-4-6": "ANTHROPIC_API_KEY",
"claude-sonnet-4-5": "ANTHROPIC_API_KEY",
"deepseek-chat": "DEEPSEEK_API_KEY",
"deepseek-reasoner": "DEEPSEEK_API_KEY",
env_var_map = {
# OpenAI models
"gpt-5.2": "OPENAI_API_KEY",
"gpt-5.2-pro": "OPENAI_API_KEY",
"gpt-5-mini": "OPENAI_API_KEY",
# Legacy OpenAI models for backward compatibility
"gpt-4o": "OPENAI_API_KEY",
# Anthropic models
"claude-opus-4-6": "ANTHROPIC_API_KEY",
"claude-sonnet-4-5": "ANTHROPIC_API_KEY",
# Legacy Anthropic models for backward compatibility
"claude-3-5-sonnet-20241022": "ANTHROPIC_API_KEY",
# DeepSeek models
"deepseek-chat": "DEEPSEEK_API_KEY",
"deepseek-reasoner": "DEEPSEEK_API_KEY",
# Gemini models

Copilot uses AI. Check for mistakes.
}

generateSessionId() {
return `plot_session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plotStatusTracker.js uses the same insecure random session ID generation pattern as userActionTracker.js:

return `plot_session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

This has the same issues: deprecated substr method and weak randomness. Consider using crypto.randomUUID() or crypto.getRandomValues() for better uniqueness guarantees.

Suggested change
return `plot_session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Prefer cryptographically strong randomness when available
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `plot_session_${crypto.randomUUID()}`;
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const buffer = new Uint32Array(4);
crypto.getRandomValues(buffer);
const randomPart = Array.from(buffer)
.map(value => value.toString(16).padStart(8, '0'))
.join('');
return `plot_session_${randomPart}`;
}
// Fallback for very old environments: keep behavior similar but avoid deprecated substr
const fallbackRandom = Math.random().toString(36).slice(2, 11);
return `plot_session_${Date.now()}_${fallbackRandom}`;

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +9
/**
* 下拉展开面板式维度选择器
* 在 Intent 输入框下方展开,覆盖在界面上
* 显示 3 个 AI 建议的维度对,用户可选择或自定义 3 对维度
*/
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple files contain extensive Chinese comments mixed with English code, particularly in:

  • DimensionSelectorPanel.jsx (lines 6-9)
  • DimensionSelectorModal.jsx (lines 5-8)
  • DimensionSelector.jsx
  • DimensionEditDropdown.jsx (line 19)

This creates inconsistency and accessibility issues. All comments should be in English to maintain a consistent codebase that's accessible to all contributors.

Copilot uses AI. Check for mistakes.
@4R5T 4R5T changed the title (DO NOT MERGE, Still working on it) feat(frontend): update frontend feat(frontend): update frontend Feb 20, 2026
4R5T added 8 commits February 20, 2026 23:13
- Expand env_var_map in backend to cover all model options exposed in
  the frontend selector (GPT-5 variants, GPT-4.1 variants, o3/o4, all
  Claude 4.x variants, Gemini 2.x variants)
- Replace hardcoded http://localhost:5000 URLs with relative paths so
  the app works in any deployment environment:
  - DimensionSelectorModal.jsx: /api/suggest-dimensions
  - LogDisplay.jsx: socket.io now connects via window.location.origin
Merge latest upstream changes from main, accepting main's version for
all conflicts in examples/, tiny_scientist/, and poetry.lock.
- /api/code: backend returns HTTP 200 with success=false when code
  execution fails; frontend was only checking response.ok/data.error
  and would incorrectly advance to code_done state. Now also checks
  data.success === false and surfaces error_details to the user.

- generateChildNodes: function uses selectedNode state but was being
  called with pendingMerge.sourceNode as an argument (ignored). Added
  optional overrideNode parameter so the caller can pass a node
  directly, bypassing stale React state.

- DimensionSelector.jsx: /api/suggest-dimensions call was missing
  credentials:include and response.ok check, causing silent failures
  on backend errors.
@lwaekfjlk
Copy link
Member

@4R5T is this PR ready to be merged?

@4R5T
Copy link
Collaborator Author

4R5T commented Mar 3, 2026

@4R5T is this PR ready to be merged?

Yes

@lwaekfjlk lwaekfjlk merged commit 888c5e3 into main Mar 4, 2026
1 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants