From ad19bc921d5dc65904d023cefb2e638b86f5fd20 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 26 Feb 2026 16:33:48 -0600 Subject: [PATCH] feat(ui): onboarding wizard, comment thread, markdown editor, and UX polish Refactor onboarding wizard with ASCII art animation and expanded adapter support. Enhance markdown editor with code block, table, and CodeMirror plugins. Improve comment thread layout. Add activity charts to agent detail page. Polish metric cards, issue detail reassignment, and new issue dialog. Simplify agent detail page structure. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/AgentConfigForm.tsx | 73 +- ui/src/components/AsciiArtAnimation.tsx | 199 ++++++ ui/src/components/MarkdownEditor.tsx | 26 + ui/src/components/MetricCard.tsx | 37 +- ui/src/components/NewIssueDialog.tsx | 148 ++++- ui/src/components/OnboardingWizard.tsx | 840 ++++++++++++------------ ui/src/context/LiveUpdatesProvider.tsx | 7 +- ui/src/pages/AgentDetail.tsx | 407 +++--------- ui/src/pages/IssueDetail.tsx | 121 ++-- 9 files changed, 1014 insertions(+), 844 deletions(-) create mode 100644 ui/src/components/AsciiArtAnimation.tsx diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 35c50055..5467ac97 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -31,6 +31,7 @@ import { help, adapterLabels, } from "./agent-config-primitives"; +import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; @@ -210,8 +211,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } if (overlay.adapterType !== undefined) { patch.adapterType = overlay.adapterType; - } - if (Object.keys(overlay.adapterConfig).length > 0) { + // When adapter type changes, send only the new config — don't merge + // with old config since old adapter fields are meaningless for the new type + patch.adapterConfig = overlay.adapterConfig; + } else if (Object.keys(overlay.adapterConfig).length > 0) { const existing = (agent.adapterConfig ?? {}) as Record; patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; } @@ -432,12 +435,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) { value={adapterType} onChange={(t) => { if (isCreate) { - set!({ adapterType: t, model: "", thinkingEffort: "" }); + // Reset all adapter-specific fields to defaults when switching adapter type + const { adapterType: _at, ...defaults } = defaultCreateValues; + set!({ ...defaults, adapterType: t }); } else { + // Clear all adapter config and explicitly blank out model + both effort keys + // so the old adapter's values don't bleed through via eff() setOverlay((prev) => ({ ...prev, adapterType: t, - adapterConfig: {}, // clear adapter config when type changes + adapterConfig: { + model: "", + effort: "", + modelReasoningEffort: "", + }, })); } }} @@ -794,10 +805,10 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; const statusClass = result.status === "pass" - ? "text-green-300 border-green-500/40 bg-green-500/10" + ? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10" : result.status === "warn" - ? "text-amber-300 border-amber-500/40 bg-amber-500/10" - : "text-red-300 border-red-500/40 bg-red-500/10"; + ? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10" + : "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10"; return (
@@ -1154,37 +1165,39 @@ function ModelDropdown({ onChange={(e) => setModelSearch(e.target.value)} autoFocus /> - - {filteredModels.map((m) => ( +
- ))} - {filteredModels.length === 0 && ( -

No models found.

- )} + {filteredModels.map((m) => ( + + ))} + {filteredModels.length === 0 && ( +

No models found.

+ )} +
diff --git a/ui/src/components/AsciiArtAnimation.tsx b/ui/src/components/AsciiArtAnimation.tsx new file mode 100644 index 00000000..f46941e2 --- /dev/null +++ b/ui/src/components/AsciiArtAnimation.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef } from "react"; + +const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·."; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + char: string; + life: number; + maxLife: number; + phase: number; +} + +function measureChar(container: HTMLElement): { w: number; h: number } { + const span = document.createElement("span"); + span.textContent = "M"; + span.style.cssText = + "position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;"; + container.appendChild(span); + const rect = span.getBoundingClientRect(); + container.removeChild(span); + return { w: rect.width, h: rect.height }; +} + +export function AsciiArtAnimation() { + const preRef = useRef(null); + const frameRef = useRef(0); + const particlesRef = useRef([]); + + useEffect(() => { + if (!preRef.current) return; + const preEl: HTMLPreElement = preRef.current; + + const charSize = measureChar(preEl); + let charW = charSize.w; + let charH = charSize.h; + let cols = Math.ceil(preEl.clientWidth / charW); + let rows = Math.ceil(preEl.clientHeight / charH); + let particles = particlesRef.current; + + function spawnParticle() { + const edge = Math.random(); + let x: number, y: number, vx: number, vy: number; + if (edge < 0.5) { + x = -1; + y = Math.random() * rows; + vx = 0.3 + Math.random() * 0.5; + vy = (Math.random() - 0.5) * 0.2; + } else { + x = Math.random() * cols; + y = rows + 1; + vx = (Math.random() - 0.5) * 0.2; + vy = -(0.2 + Math.random() * 0.4); + } + const maxLife = 60 + Math.random() * 120; + particles.push({ + x, y, vx, vy, + char: CHARS[Math.floor(Math.random() * CHARS.length)], + life: 0, + maxLife, + phase: Math.random() * Math.PI * 2, + }); + } + + function render(time: number) { + const t = time * 0.001; + + // Spawn particles + const targetCount = Math.floor((cols * rows) / 12); + while (particles.length < targetCount) { + spawnParticle(); + } + + // Build grid + const grid: string[][] = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => " ") + ); + const opacity: number[][] = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => 0) + ); + + // Background wave pattern + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const wave = + Math.sin(c * 0.08 + t * 0.7 + r * 0.04) * + Math.sin(r * 0.06 - t * 0.5) * + Math.cos((c + r) * 0.03 + t * 0.3); + if (wave > 0.65) { + grid[r][c] = wave > 0.85 ? "·" : "."; + opacity[r][c] = Math.min(1, (wave - 0.65) * 3); + } + } + } + + // Update and render particles + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.life++; + + // Flow field influence + const angle = + Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) * + Math.PI; + p.vx += Math.cos(angle) * 0.02; + p.vy += Math.sin(angle) * 0.02; + + // Damping + p.vx *= 0.98; + p.vy *= 0.98; + + p.x += p.vx; + p.y += p.vy; + + // Life fade + const lifeFrac = p.life / p.maxLife; + const alpha = lifeFrac < 0.1 + ? lifeFrac / 0.1 + : lifeFrac > 0.8 + ? (1 - lifeFrac) / 0.2 + : 1; + + // Remove dead or out-of-bounds particles + if ( + p.life >= p.maxLife || + p.x < -2 || p.x > cols + 2 || + p.y < -2 || p.y > rows + 2 + ) { + particles.splice(i, 1); + continue; + } + + const col = Math.round(p.x); + const row = Math.round(p.y); + if (row >= 0 && row < rows && col >= 0 && col < cols) { + if (alpha > opacity[row][col]) { + // Cycle through characters based on life + const charIdx = Math.floor( + (lifeFrac + Math.sin(p.phase + t)) * CHARS.length + ) % CHARS.length; + grid[row][col] = CHARS[Math.abs(charIdx)]; + opacity[row][col] = alpha; + } + } + } + + // Render to string + let output = ""; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const a = opacity[r][c]; + if (a > 0 && grid[r][c] !== " ") { + const o = Math.round(a * 60 + 40); + output += `${grid[r][c]}`; + } else { + output += " "; + } + } + if (r < rows - 1) output += "\n"; + } + + preEl.innerHTML = output; + frameRef.current = requestAnimationFrame(render); + } + + // Handle resize + const observer = new ResizeObserver(() => { + const size = measureChar(preEl); + charW = size.w; + charH = size.h; + cols = Math.ceil(preEl.clientWidth / charW); + rows = Math.ceil(preEl.clientHeight / charH); + // Cull out-of-bounds particles on resize + particles = particles.filter( + (p) => p.x >= -2 && p.x <= cols + 2 && p.y >= -2 && p.y <= rows + 2 + ); + particlesRef.current = particles; + }); + observer.observe(preEl); + + frameRef.current = requestAnimationFrame(render); + + return () => { + cancelAnimationFrame(frameRef.current); + observer.disconnect(); + }; + }, []); + + return ( +