From 9d53340cfdab9a45f77078834f01b3c2feef15a5 Mon Sep 17 00:00:00 2001 From: juchechu Date: Sun, 16 Aug 2026 19:28:02 +0100 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20follow-up=20AI=20chat=20clearing=20p?= =?UTF-8?q?artial=20answer=20=E2=80=94=20ref=20read=20inside=20deferred=20?= =?UTF-8?q?state=20updater?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendChat's post-stream cleanup read pendingAnswerRef.current inside the setMessages updater function, but React 18 batches the adjacent state updates and defers the updater until after the synchronous block. By then pendingAnswerRef.current had already been reset to "", so the final AI message was committed empty and the streaming bubble appeared to vanish after a partial answer. Fix: snapshot the accumulated text into a local const before clearing the ref, then pass that value (not a ref deref) to the updater. No ref reads remain inside state updater functions. Verified: tsc --noEmit 0 errors, ESLint 0 errors. --- .../components/best-practices/ExplainPanel.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/components/best-practices/ExplainPanel.tsx b/frontend/components/best-practices/ExplainPanel.tsx index 8f5d8e2..83c239a 100644 --- a/frontend/components/best-practices/ExplainPanel.tsx +++ b/frontend/components/best-practices/ExplainPanel.tsx @@ -176,15 +176,17 @@ export function ExplainPanel({ }, }); - if (pendingAnswerRef.current) { - setMessages((prev) => [ - ...prev, - { role: "ai", content: pendingAnswerRef.current }, - ]); - } + // Snapshot the accumulated text before clearing the ref. Reading the ref + // inside the setMessages updater would see "" instead: React batches these + // state updates and runs the updater after this synchronous block, by which + // point pendingAnswerRef has already been reset. + const finalAnswer = pendingAnswerRef.current; pendingAnswerRef.current = ""; setStreamingAnswer(null); setChatLoading(false); + if (finalAnswer) { + setMessages((prev) => [...prev, { role: "ai", content: finalAnswer }]); + } }; const copyAnalysis = async () => { From aa687642d5187a4ab432dac9e24327b96a339428 Mon Sep 17 00:00:00 2001 From: juchechu Date: Sun, 16 Aug 2026 20:16:21 +0100 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20compress=20AI=20analysis=20pane?= =?UTF-8?q?l=20=E2=80=94=20replace=20decorative=20charts=20with=20compact?= =?UTF-8?q?=20inline=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analysis panel dominated the solution accordion at ~650-850px tall; the scorecard section alone (QualityGauge radial, ScoreRadar recharts radar, and 4 full-width MetricTiles) consumed ~480px and pushed code/content out of view. New layout (~260px): - Header row: title + NIM/cached badges + Ask AI + Copy buttons (chat moved out of the full-width footer button into a compact header action) - Summary / Approach / Key techniques / Strengths / Improvements rendered with a shared tiny uppercase AnalysisLabel instead of per-section card wrappers - Scorecard: single shadcn Progress bar (grade-colored) + inline dimension chips with colored dots, replacing gauge + radar + tiles - Complexity: two inline badges; ComplexityScale reference chart removed Deleted: ScoreRadar, QualityGauge, MetricTile, ComplexityScale, AnalysisSection, AnalysisFooter (6 files) Added: AnalysisLabel (shared label primitive) Cleaned: ~110 lines of now-unused CSS (.stats/.stat/.radial-progress/.divider) in globals.css Verified: tsc --noEmit 0 errors, ESLint 0 errors on all changed files, next build success. --- frontend/app/globals.css | 112 ------------------ .../best-practices/AnalysisApproach.tsx | 9 +- .../best-practices/AnalysisComplexity.tsx | 12 +- .../best-practices/AnalysisFooter.tsx | 19 --- .../best-practices/AnalysisHydration.tsx | 29 ++--- .../best-practices/AnalysisLabel.tsx | 9 ++ .../best-practices/AnalysisPoints.tsx | 46 ++++--- .../best-practices/AnalysisScorecard.tsx | 105 ++++++++-------- .../best-practices/AnalysisSection.tsx | 18 --- .../best-practices/AnalysisSummary.tsx | 9 +- .../best-practices/AnalysisTechniques.tsx | 9 +- .../best-practices/ComplexityScale.tsx | 51 -------- .../best-practices/ExplainPanel.tsx | 70 +++++++++-- .../components/best-practices/MetricTile.tsx | 35 ------ .../best-practices/QualityGauge.tsx | 52 -------- .../components/best-practices/ScoreRadar.tsx | 53 --------- 16 files changed, 182 insertions(+), 456 deletions(-) delete mode 100644 frontend/components/best-practices/AnalysisFooter.tsx create mode 100644 frontend/components/best-practices/AnalysisLabel.tsx delete mode 100644 frontend/components/best-practices/AnalysisSection.tsx delete mode 100644 frontend/components/best-practices/ComplexityScale.tsx delete mode 100644 frontend/components/best-practices/MetricTile.tsx delete mode 100644 frontend/components/best-practices/QualityGauge.tsx delete mode 100644 frontend/components/best-practices/ScoreRadar.tsx diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 671404c..f73e80c 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -325,118 +325,6 @@ } } -/* --- Stats ---------------------------------------------------------------- */ - -.stats { - display: flex; - width: 100%; - overflow-x: auto; - border: 1px solid var(--color-border); - border-radius: 0.75rem; - background-color: var(--color-muted); -} - -.stat { - display: inline-flex; - flex: 1 1 0; - flex-direction: column; - gap: 0.25rem; - padding: 1rem; - border-inline-start: 1px solid var(--color-border); -} - -.stat:first-child { - border-inline-start-width: 0; -} - -.stat-title { - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--color-brand-offwhite-muted); - line-height: 1.3; -} - -.stat-value { - font-size: 1.375rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1.1; - color: var(--color-brand-offwhite); -} - -.stat-desc { - font-size: 0.75rem; - color: var(--color-brand-offwhite-muted); -} - -.stat-figure { - display: inline-flex; - align-items: center; - justify-content: center; -} - -@media (max-width: 640px) { - .stats { - flex-direction: column; - } - .stat { - border-inline-start-width: 0; - border-top: 1px solid var(--color-border); - } - .stat:first-child { - border-top-width: 0; - } -} - -/* --- Radial progress ------------------------------------------------------ */ - -.radial-progress { - position: relative; - display: inline-grid; - place-content: center; - width: var(--size, 5rem); - height: var(--size, 5rem); - border-radius: 9999px; - background: - radial-gradient( - farthest-side, - var(--color-brand-charcoal-panel) calc(100% - var(--thickness, 0.5rem) - 1px), - transparent calc(100% - var(--thickness, 0.5rem)) - ), - conic-gradient( - currentColor calc(var(--value, 0) * 1%), - rgba(255, 255, 255, 0.08) 0 - ); - color: var(--color-brand-offwhite); -} - -/* --- Divider --------------------------------------------------------------- */ - -.divider { - display: flex; - align-items: center; - gap: 1rem; - margin: 0.75rem 0; - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-brand-offwhite-muted); -} - -.divider::before, -.divider::after { - content: ""; - flex: 1 1 0; - height: 1px; - background-color: var(--color-border); -} - -.divider-start::before { display: none; } -.divider-end::after { display: none; } - /* --- Window mockup --------------------------------------------------------- */ .mockup-window { diff --git a/frontend/components/best-practices/AnalysisApproach.tsx b/frontend/components/best-practices/AnalysisApproach.tsx index 1a22a46..ee97082 100644 --- a/frontend/components/best-practices/AnalysisApproach.tsx +++ b/frontend/components/best-practices/AnalysisApproach.tsx @@ -1,15 +1,16 @@ "use client"; import { renderMarkdown } from "@/lib/markdown"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisApproach({ approach }: { approach: string }) { return ( - +
+ Approach
- +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisComplexity.tsx b/frontend/components/best-practices/AnalysisComplexity.tsx index 144bc9e..8e8fabb 100644 --- a/frontend/components/best-practices/AnalysisComplexity.tsx +++ b/frontend/components/best-practices/AnalysisComplexity.tsx @@ -1,7 +1,6 @@ "use client"; import { ComplexityBadge } from "./ComplexityBadge"; -import { ComplexityScale } from "./ComplexityScale"; export function AnalysisComplexity({ time, @@ -11,12 +10,9 @@ export function AnalysisComplexity({ space: string; }) { return ( -
-
- - -
- +
+ +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisFooter.tsx b/frontend/components/best-practices/AnalysisFooter.tsx deleted file mode 100644 index b2c4f28..0000000 --- a/frontend/components/best-practices/AnalysisFooter.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { MessageSquare } from "lucide-react"; - -export function AnalysisFooter({ onOpenChat }: { onOpenChat: () => void }) { - return ( -
-
Have a question?
- -
- ); -} diff --git a/frontend/components/best-practices/AnalysisHydration.tsx b/frontend/components/best-practices/AnalysisHydration.tsx index e64f406..84b961e 100644 --- a/frontend/components/best-practices/AnalysisHydration.tsx +++ b/frontend/components/best-practices/AnalysisHydration.tsx @@ -5,6 +5,7 @@ import type { SolutionExplanation } from "@/lib/types"; import { AnalysisSummary } from "./AnalysisSummary"; import { AnalysisApproach } from "./AnalysisApproach"; import { AnalysisScorecard } from "./AnalysisScorecard"; +import { AnalysisComplexity } from "./AnalysisComplexity"; function Shimmer({ className }: { className?: string }) { return
; @@ -16,7 +17,7 @@ export function AnalysisHydration({ partial: Partial | null; }) { return ( -
+
Generating analysis @@ -32,7 +33,7 @@ export function AnalysisHydration({ ) : (
- +
@@ -41,27 +42,27 @@ export function AnalysisHydration({ {partial?.quality_score != null ? ( ) : ( -
-
- - -
-
- - -
-
+ )} {partial?.approach ? ( ) : (
- +
)} + + {partial?.time_complexity && partial?.space_complexity ? ( + + ) : ( + + )}
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisLabel.tsx b/frontend/components/best-practices/AnalysisLabel.tsx new file mode 100644 index 0000000..e0a0258 --- /dev/null +++ b/frontend/components/best-practices/AnalysisLabel.tsx @@ -0,0 +1,9 @@ +"use client"; + +export function AnalysisLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisPoints.tsx b/frontend/components/best-practices/AnalysisPoints.tsx index 8146a95..0229137 100644 --- a/frontend/components/best-practices/AnalysisPoints.tsx +++ b/frontend/components/best-practices/AnalysisPoints.tsx @@ -1,6 +1,6 @@ "use client"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisPoints({ strengths, @@ -11,37 +11,33 @@ export function AnalysisPoints({ }) { if (!strengths.length && !improvements.length) return null; return ( -
+
{strengths.length > 0 && ( - -
    +
    + Strengths +

    {strengths.map((s, i) => ( -

  • - - {s} -
  • + + + {s} + ))} -
-
+

+
)} {improvements.length > 0 && ( - -
    +
    + Improvements +

    {improvements.map((s, i) => ( -

  • - - {s} -
  • + + + {s} + ))} -
-
+

+
)}
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisScorecard.tsx b/frontend/components/best-practices/AnalysisScorecard.tsx index ee79779..cfcefa1 100644 --- a/frontend/components/best-practices/AnalysisScorecard.tsx +++ b/frontend/components/best-practices/AnalysisScorecard.tsx @@ -1,64 +1,71 @@ "use client"; -import { Eye, ShieldCheck, ThumbsUp, Zap } from "lucide-react"; +import type { CSSProperties } from "react"; +import { Progress } from "@/components/ui/progress"; import type { SolutionExplanation } from "@/lib/types"; -import { gradeScore, QualityGauge } from "./QualityGauge"; -import { ScoreRadar } from "./ScoreRadar"; -import { MetricTile } from "./MetricTile"; + +export function gradeScore(score: number): { label: string; color: string } { + if (score >= 85) return { label: "Excellent", color: "#10b981" }; + if (score >= 70) return { label: "Good", color: "#14b8a6" }; + if (score >= 50) return { label: "Fair", color: "#f59e0b" }; + return { label: "Needs work", color: "#f43f5e" }; +} + +const DIMENSIONS: Array<{ + label: string; + score: (e: SolutionExplanation) => number; +}> = [ + { label: "Efficiency", score: (e) => e.efficiency_score }, + { label: "Readability", score: (e) => e.readability_score }, + { label: "Correctness", score: (e) => e.correctness_score }, + { label: "Best practices", score: (e) => e.best_practices_score }, +]; export function AnalysisScorecard({ explanation, }: { explanation: SolutionExplanation; }) { - const hasSubScores = - (explanation.efficiency_score ?? 0) + - (explanation.readability_score ?? 0) + - (explanation.correctness_score ?? 0) + - (explanation.best_practices_score ?? 0) > - 0; + const overall = gradeScore(explanation.quality_score); return ( -
-
- - +
+ + {explanation.quality_score} + + + {overall.label} + +
- - {hasSubScores && ( -
- - - - -
- )} +
+ {DIMENSIONS.map((d) => { + const g = gradeScore(d.score(explanation)); + return ( + + + {d.label} + + {d.score(explanation)} + + + ); + })} +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisSection.tsx b/frontend/components/best-practices/AnalysisSection.tsx deleted file mode 100644 index 0a21ef7..0000000 --- a/frontend/components/best-practices/AnalysisSection.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -export function AnalysisSection({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-

- {label} -

- {children} -
- ); -} diff --git a/frontend/components/best-practices/AnalysisSummary.tsx b/frontend/components/best-practices/AnalysisSummary.tsx index 1986f2b..5909d5c 100644 --- a/frontend/components/best-practices/AnalysisSummary.tsx +++ b/frontend/components/best-practices/AnalysisSummary.tsx @@ -1,11 +1,12 @@ "use client"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisSummary({ summary }: { summary: string }) { return ( - +
+ Summary

{summary}

- +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisTechniques.tsx b/frontend/components/best-practices/AnalysisTechniques.tsx index 5892d42..0fc32c1 100644 --- a/frontend/components/best-practices/AnalysisTechniques.tsx +++ b/frontend/components/best-practices/AnalysisTechniques.tsx @@ -1,11 +1,12 @@ "use client"; -import { AnalysisSection } from "./AnalysisSection"; +import { AnalysisLabel } from "./AnalysisLabel"; export function AnalysisTechniques({ techniques }: { techniques: string[] }) { if (!techniques.length) return null; return ( - +
+ Key techniques
{techniques.map((t, i) => ( ))}
- +
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/ComplexityScale.tsx b/frontend/components/best-practices/ComplexityScale.tsx deleted file mode 100644 index 2b2d130..0000000 --- a/frontend/components/best-practices/ComplexityScale.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; - -const TIERS: Array<{ label: string; desc: string; width: string; barCls: string }> = [ - { label: "O(1)", desc: "Constant — one operation, regardless of input", width: "8%", barCls: "bg-emerald-500" }, - { label: "O(log n)", desc: "Logarithmic — halves the input each step (binary search)", width: "20%", barCls: "bg-teal-500" }, - { label: "O(n)", desc: "Linear — one pass over the input", width: "45%", barCls: "bg-amber-500" }, - { label: "O(n log n)", desc: "Linearithmic — efficient sorts (merge sort)", width: "62%", barCls: "bg-orange-500" }, - { label: "O(n²)", desc: "Quadratic — nested loops over the input", width: "78%", barCls: "bg-orange-500" }, - { label: "O(2ⁿ)", desc: "Exponential — doubles per extra element", width: "90%", barCls: "bg-rose-500" }, - { label: "O(n!)", desc: "Factorial — permutations of the input", width: "100%", barCls: "bg-rose-500" }, -]; - -export function ComplexityScale() { - const [open, setOpen] = useState(false); - - return ( -
- - - {open && ( -
- {TIERS.map((t) => ( -
- - {t.label} - -
-
-
- - {t.desc} - -
- ))} -
- )} -
- ); -} diff --git a/frontend/components/best-practices/ExplainPanel.tsx b/frontend/components/best-practices/ExplainPanel.tsx index 83c239a..5bee05b 100644 --- a/frontend/components/best-practices/ExplainPanel.tsx +++ b/frontend/components/best-practices/ExplainPanel.tsx @@ -1,14 +1,20 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Bot, Sparkles } from "lucide-react"; +import { + Bot, + Check, + CheckCircle2, + Copy, + MessageSquare, + Sparkles, +} from "lucide-react"; import type { CommunitySolution, SolutionExplanation } from "@/lib/types"; import { explainSolutionStream, explainSolutionChatStream, } from "@/lib/api"; import { toast } from "@/lib/toast"; -import { AnalysisHeader } from "./AnalysisHeader"; import { AnalysisHydration } from "./AnalysisHydration"; import { AnalysisError, type ExplainErrorInfo } from "./AnalysisError"; import { AnalysisSummary } from "./AnalysisSummary"; @@ -17,7 +23,6 @@ import { AnalysisApproach } from "./AnalysisApproach"; import { AnalysisComplexity } from "./AnalysisComplexity"; import { AnalysisTechniques } from "./AnalysisTechniques"; import { AnalysisPoints } from "./AnalysisPoints"; -import { AnalysisFooter } from "./AnalysisFooter"; import { FollowUpDrawer } from "./FollowUpDrawer"; import type { ChatMessage } from "./chat/types"; @@ -108,6 +113,8 @@ export function ExplainPanel({ const [error, setError] = useState(null); const [partial, setPartial] = useState | null>(null); const rawRef = useRef(""); + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); const [chatOpen, setChatOpen] = useState(false); const [messages, setMessages] = useState([]); @@ -116,6 +123,13 @@ export function ExplainPanel({ const [streamingAnswer, setStreamingAnswer] = useState(null); const pendingAnswerRef = useRef(""); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + const loadExplanation = useCallback(async () => { if (explanation || loading) return; setLoading(true); @@ -214,6 +228,9 @@ export function ExplainPanel({ } try { await navigator.clipboard.writeText(sections.join("\n")); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); } catch { toast.error("Could not copy analysis"); } @@ -253,11 +270,49 @@ export function ExplainPanel({ )} {explanation && ( -
- +
+
+
+ + AI Analysis + + NIM + +
+ {cached && ( + + + cached + + )} +
+ + +
+
+ - + - setChatOpen(true)} />
)} @@ -284,4 +338,4 @@ export function ExplainPanel({ />
); -} +} \ No newline at end of file diff --git a/frontend/components/best-practices/MetricTile.tsx b/frontend/components/best-practices/MetricTile.tsx deleted file mode 100644 index a25c247..0000000 --- a/frontend/components/best-practices/MetricTile.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { LucideIcon } from "lucide-react"; - -export function MetricTile({ - icon: Icon, - label, - value, - sublabel, - tone = "default", -}: { - icon: LucideIcon; - label: string; - value: string; - sublabel?: string; - tone?: "default" | "gold" | "emerald"; -}) { - const toneClasses = - tone === "gold" - ? "text-amber-400" - : tone === "emerald" - ? "text-emerald-400" - : "text-purple-300"; - - return ( -
-
- - {label} -
-
{value}
- {sublabel &&
{sublabel}
} -
- ); -} diff --git a/frontend/components/best-practices/QualityGauge.tsx b/frontend/components/best-practices/QualityGauge.tsx deleted file mode 100644 index 89e7830..0000000 --- a/frontend/components/best-practices/QualityGauge.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import type { CSSProperties } from "react"; -import { RatingBadge } from "@/components/ui/rating-badge"; - -export function gradeScore(score: number): { label: string; color: string } { - if (score >= 85) return { label: "Excellent", color: "#10b981" }; - if (score >= 70) return { label: "Good", color: "#14b8a6" }; - if (score >= 50) return { label: "Fair", color: "#f59e0b" }; - return { label: "Needs work", color: "#f43f5e" }; -} - -export function QualityGauge({ score, label }: { score: number; label: string }) { - const g = gradeScore(score); - const stars = Math.round((score / 100) * 5 * 2) / 2; - - return ( -
-
-
- - {score} - - - {g.label} - -
-
-
- - - {label} - -
-
- ); -} diff --git a/frontend/components/best-practices/ScoreRadar.tsx b/frontend/components/best-practices/ScoreRadar.tsx deleted file mode 100644 index 2cee8bb..0000000 --- a/frontend/components/best-practices/ScoreRadar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { - RadarChart, - PolarGrid, - PolarAngleAxis, - Radar, - ResponsiveContainer, -} from "recharts"; - -export function ScoreRadar({ - efficiency, - readability, - correctness, - bestPractices, -}: { - efficiency: number; - readability: number; - correctness: number; - bestPractices: number; -}) { - const data = [ - { subject: "Efficiency", score: efficiency }, - { subject: "Readability", score: readability }, - { subject: "Correctness", score: correctness }, - { subject: "Best practices", score: bestPractices }, - ]; - - return ( -
-
- Skill breakdown -
- - - - - - - -
- ); -}