From 6ab43a468bbd428f1d07b81079babb2e24c32dfd Mon Sep 17 00:00:00 2001 From: AetherForge Date: Mon, 8 Jun 2026 19:48:24 -0700 Subject: [PATCH] Forge success label and real progress bar tied to server compile stages. Replace Dispensed with Forged in the reveal modal, poll builder/progress with indeterminate-until-first-byte UX, and emit interpolated compile progress during long garble builds. --- server/internal/builder/build_apk.go | 3 +- server/internal/builder/build_universal.go | 11 ++- server/internal/builder/handler.go | 5 +- server/internal/builder/progress.go | 45 +++++++++++ .../Forge/ForgeDispenseReveal.test.tsx | 36 +++++++++ .../components/Forge/ForgeDispenseReveal.tsx | 2 +- .../Forge/ForgeProgressBar.test.tsx | 48 ++++++++++++ .../src/components/Forge/ForgeProgressBar.tsx | 47 +++++++++++ server/web/src/hooks/useForgeProgressPoll.ts | 57 ++++++++++++++ server/web/src/pages/BuilderPage.tsx | 78 ++----------------- server/web/src/pages/MissionDeckPage.tsx | 72 ++--------------- server/web/src/pages/Pages.css | 16 +++- 12 files changed, 273 insertions(+), 147 deletions(-) create mode 100644 server/internal/builder/progress.go create mode 100644 server/web/src/components/Forge/ForgeDispenseReveal.test.tsx create mode 100644 server/web/src/components/Forge/ForgeProgressBar.test.tsx create mode 100644 server/web/src/components/Forge/ForgeProgressBar.tsx create mode 100644 server/web/src/hooks/useForgeProgressPoll.ts diff --git a/server/internal/builder/build_apk.go b/server/internal/builder/build_apk.go index 5ced338..f1eaaab 100644 --- a/server/internal/builder/build_apk.go +++ b/server/internal/builder/build_apk.go @@ -253,8 +253,9 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe } platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""} - h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25) + stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent (linux/arm64)", 25, 54, false) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false) + stopCompileProgress() if err != nil { cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go index e25f275..eabd7d8 100644 --- a/server/internal/builder/build_universal.go +++ b/server/internal/builder/build_universal.go @@ -34,10 +34,17 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr platforms := platformsForRequest(req) workerPaths := map[string]string{} total := len(platforms) + obfuscated := h.shouldObfuscate(req) && h.garblePath != "" for i, p := range platforms { - pct := 14 + (i*56)/total - h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct) + startPct := 14 + (i*56)/total + endPct := 14 + ((i+1)*56)/total + if endPct > 71 { + endPct = 71 + } + stage := fmt.Sprintf("Compiling %s", p.Label()) + stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, stage, startPct, endPct, obfuscated) wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) + stopCompileProgress() if err != nil { cleanupBuild() return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 6b9057e..3774a03 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -690,15 +690,16 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st platforms := platformsForRequest(req) p := platforms[0] - h.setProgress(req.CancelToken, "Compiling agent", 20) + obfuscated := h.shouldObfuscate(req) && h.garblePath != "" + stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent", 20, 71, obfuscated) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) + stopCompileProgress() if err != nil { cleanupBuild() log.Printf("Build failed: %v", err) return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } h.setProgress(req.CancelToken, "Compiled — linking output", 72) - obfuscated := h.shouldObfuscate(req) && h.garblePath != "" workerName := filepath.Base(outputPath) finalPath := outputPath finalName := workerName diff --git a/server/internal/builder/progress.go b/server/internal/builder/progress.go new file mode 100644 index 0000000..c54d8a7 --- /dev/null +++ b/server/internal/builder/progress.go @@ -0,0 +1,45 @@ +package builder + +import ( + "context" + "time" +) + +// tickCompileProgress emits interpolated progress during long compile steps (garble can run 10+ minutes). +// Returns a stop function; call it when the compile finishes. +func (h *Handler) tickCompileProgress(ctx context.Context, token, stage string, startPct, capPct int, obfuscated bool) func() { + if token == "" || capPct <= startPct { + return func() {} + } + est := 3 * time.Minute + if obfuscated { + est = 12 * time.Minute + } + done := make(chan struct{}) + go func() { + h.setProgress(token, stage, startPct) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + start := time.Now() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + elapsed := time.Since(start) + ratio := float64(elapsed) / float64(est) + if ratio > 0.92 { + ratio = 0.92 + } + pct := startPct + int(float64(capPct-startPct)*ratio) + if pct >= capPct { + pct = capPct - 1 + } + h.setProgress(token, stage, pct) + } + } + }() + return func() { close(done) } +} diff --git a/server/web/src/components/Forge/ForgeDispenseReveal.test.tsx b/server/web/src/components/Forge/ForgeDispenseReveal.test.tsx new file mode 100644 index 0000000..689c203 --- /dev/null +++ b/server/web/src/components/Forge/ForgeDispenseReveal.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import ForgeDispenseReveal from './ForgeDispenseReveal'; + +vi.mock('../../context/AmbientMusicContext', () => ({ + useModalAmbientDuck: () => {}, +})); + +vi.mock('../../context/SoundContext', () => ({ + useSound: () => ({ play: vi.fn() }), +})); + +vi.mock('../DownloadButton', () => ({ + default: ({ children }: { children: React.ReactNode }) => , +})); + +describe('ForgeDispenseReveal', () => { + it('shows Forged title on successful forge', () => { + render( + {}} + />, + ); + expect(screen.getByRole('heading', { name: 'Forged' })).toBeInTheDocument(); + expect(screen.queryByText('Dispensed')).not.toBeInTheDocument(); + }); +}); diff --git a/server/web/src/components/Forge/ForgeDispenseReveal.tsx b/server/web/src/components/Forge/ForgeDispenseReveal.tsx index d705a00..cdeec68 100644 --- a/server/web/src/components/Forge/ForgeDispenseReveal.tsx +++ b/server/web/src/components/Forge/ForgeDispenseReveal.tsx @@ -35,7 +35,7 @@ export default function ForgeDispenseReveal({ result, onClose }: Props) {

- Dispensed + Forged

{result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature. diff --git a/server/web/src/components/Forge/ForgeProgressBar.test.tsx b/server/web/src/components/Forge/ForgeProgressBar.test.tsx new file mode 100644 index 0000000..d74cde1 --- /dev/null +++ b/server/web/src/components/Forge/ForgeProgressBar.test.tsx @@ -0,0 +1,48 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import ForgeProgressBar, { + FORGE_INITIAL_STAGE, + isForgeProgressIndeterminate, +} from './ForgeProgressBar'; + +describe('isForgeProgressIndeterminate', () => { + it('is indeterminate before server reports progress', () => { + expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 0)).toBe(true); + expect(isForgeProgressIndeterminate('', 0)).toBe(true); + }); + + it('is determinate once server reports pct or stage advances', () => { + expect(isForgeProgressIndeterminate('Compiling agent', 20)).toBe(false); + expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 5)).toBe(false); + }); +}); + +describe('ForgeProgressBar', () => { + it('renders nothing when not building', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('shows indeterminate track before server progress', () => { + const { container } = render( + , + ); + expect(screen.getByText('…')).toBeInTheDocument(); + expect(container.querySelector('.forge-progress-track.indeterminate')).toBeTruthy(); + cleanup(); + }); + + it('shows server stage and pct when progress is reported', () => { + const { container } = render( + , + ); + expect(screen.getByText('Compiling agent')).toBeInTheDocument(); + expect(screen.getByText('42%')).toBeInTheDocument(); + expect(container.querySelector('.forge-progress-track.indeterminate')).toBeNull(); + }); +}); diff --git a/server/web/src/components/Forge/ForgeProgressBar.tsx b/server/web/src/components/Forge/ForgeProgressBar.tsx new file mode 100644 index 0000000..6d4e332 --- /dev/null +++ b/server/web/src/components/Forge/ForgeProgressBar.tsx @@ -0,0 +1,47 @@ +export const FORGE_INITIAL_STAGE = 'Initializing forge...'; + +interface Props { + building: boolean; + stage: string; + progress: number; +} + +/** True while waiting for the first server-reported forge stage. */ +export function isForgeProgressIndeterminate(stage: string, progress: number): boolean { + return progress === 0 && (stage === '' || stage === FORGE_INITIAL_STAGE); +} + +export default function ForgeProgressBar({ building, stage, progress }: Props) { + if (!building) return null; + + const indeterminate = isForgeProgressIndeterminate(stage, progress); + const displayStage = stage || 'Initializing...'; + const pctLabel = indeterminate ? '…' : `${Math.round(progress)}%`; + + return ( +

+
+ + {displayStage} + {pctLabel} +
+
+
+ {!indeterminate && ( +
+ )} +
+
+ ); +} diff --git a/server/web/src/hooks/useForgeProgressPoll.ts b/server/web/src/hooks/useForgeProgressPoll.ts new file mode 100644 index 0000000..757820b --- /dev/null +++ b/server/web/src/hooks/useForgeProgressPoll.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import { useForge } from '../context/ForgeContext'; + +const FORGE_POLL_MS = 1000; + +/** Poll GET /api/v1/builder/progress/{token} while a forge is running. */ +export function useForgeProgressPoll( + active: boolean, + cancelTokenRef: React.RefObject, +) { + const { startForge, endForge, setStage } = useForge(); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (!active) { + endForge(); + if (timerRef.current) clearTimeout(timerRef.current); + return; + } + startForge(); + + const token = cancelTokenRef.current; + if (!token) return; + + let alive = true; + + const poll = async () => { + if (!alive) return; + try { + const { authHeaders } = await import('../api/auth'); + const res = await fetch(`/api/v1/builder/progress/${encodeURIComponent(token)}`, { + headers: authHeaders(), + }); + if (res.ok) { + const data: { stage?: string; pct?: number } = await res.json(); + const pct = typeof data.pct === 'number' ? data.pct : 0; + if (alive && (data.stage || pct > 0)) { + setStage(data.stage || 'Forging...', pct); + } + } + } catch { + // network hiccup — keep polling + } + if (alive) { + timerRef.current = setTimeout(poll, FORGE_POLL_MS); + } + }; + + poll(); + + return () => { + alive = false; + if (timerRef.current) clearTimeout(timerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); +} diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 08002ac..6911b25 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -28,6 +28,8 @@ import { } from '../help/forgeFormNormalize'; import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; +import ForgeProgressBar from '../components/Forge/ForgeProgressBar'; +import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import DownloadButton from '../components/DownloadButton'; import PoolPresetPicker from '../components/PoolPresetPicker'; @@ -101,29 +103,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds return forgeDefaultsFromServerSmart(config, serverInfo, builds); } -// Poll interval (ms) for real server-side build progress. -const FORGE_POLL_MS = 1000; - -function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) { - if (!building) return null; - return ( -
-
- - {stage || 'Initializing...'} - {Math.round(progress)}% -
-
-
-
-
-
- ); -} - const FORGE_MODE_KEY = 'aetherforge-forge-mode'; function loadSimpleMode(): boolean { @@ -139,8 +118,7 @@ function loadSimpleMode(): boolean { export default function BuilderPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); - const forgeStageTimerRef = useRef | null>(null); + const { setStage, stage: forgeStage, progress: forgeProgress } = useForge(); const [form, setForm] = useState(null); const [building, setBuilding] = useState(false); @@ -217,52 +195,7 @@ export default function BuilderPage() { const forgeSkinClass = forgePageClass(operationMode, forgeTheme); - // Poll real server-side build progress while a single build is running. - // The server exposes GET /api/v1/builder/progress/{token} which returns - // {stage, pct} updated at each key compile stage, so the bar reflects - // actual server activity instead of a client-side time estimate. - useEffect(() => { - if (!building || batchJob) { - endForge(); - if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); - return; - } - startForge(); - - const token = cancelTokenRef.current; - if (!token) return; - - let active = true; - - const poll = async () => { - if (!active) return; - try { - const { authHeaders } = await import('../api/auth'); - const res = await fetch(`/api/v1/builder/progress/${token}`, { - headers: authHeaders(), - }); - if (res.ok) { - const data: { stage: string; pct: number } = await res.json(); - if (active && data.stage) { - setStage(data.stage, data.pct); - } - } - } catch { - // network hiccup — keep polling - } - if (active) { - forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS); - } - }; - - poll(); - - return () => { - active = false; - if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [building, batchJob]); + useForgeProgressPoll(Boolean(building && !batchJob), cancelTokenRef); const setForgeMode = (simple: boolean) => { setSimpleMode(simple); @@ -348,7 +281,7 @@ export default function BuilderPage() { }, [searchParams, recentBuilds, form]); const finishForgeSuccess = async (result: BuildResponse) => { - setStage('Build complete!', 100); + setStage('Forged!', 100); setLastBuild(result); setDispenseReveal(result); loadRecentBuilds(); @@ -789,7 +722,6 @@ export default function BuilderPage() { cancelToken, onStep: (step) => { setMissionStep(step); - if (step === 'forge') startForge(); }, }); setMissionExportSkipped(result.exportSkipped); diff --git a/server/web/src/pages/MissionDeckPage.tsx b/server/web/src/pages/MissionDeckPage.tsx index fd7fe25..eea6a38 100644 --- a/server/web/src/pages/MissionDeckPage.tsx +++ b/server/web/src/pages/MissionDeckPage.tsx @@ -13,6 +13,8 @@ import NeonCard from '../components/NeonCard/NeonCard'; import { HelpTip } from '../components/HelpTip'; import AlsoHere from '../components/Presence/AlsoHere'; import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; +import ForgeProgressBar from '../components/Forge/ForgeProgressBar'; +import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll'; import { useModalAmbientDuck } from '../context/AmbientMusicContext'; import { useForge } from '../context/ForgeContext'; import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; @@ -53,25 +55,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds return forgeDefaultsFromServerSmart(config, serverInfo, builds); } -const FORGE_POLL_MS = 1000; - -function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) { - if (!building) return null; - return ( -
-
- - {stage || 'Initializing...'} - {Math.round(progress)}% -
-
-
-
-
-
- ); -} - function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' { if (chip === 'ghost') return 'cyan'; if (chip === 'loud') return 'magenta'; @@ -80,9 +63,7 @@ function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | export default function MissionDeckPage() { - const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); - - const forgeStageTimerRef = useRef | null>(null); + const { setStage, stage: forgeStage, progress: forgeProgress } = useForge(); const cancelTokenRef = useRef(''); @@ -166,52 +147,10 @@ export default function MissionDeckPage() { .catch(() => setError('Failed to load server config — is the control server running?')) .finally(() => setLoadingDefaults(false)); }, []); - // Poll real server-side build progress (same as BuilderPage). - useEffect(() => { - if (!building) { - endForge(); - if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); - return; - } - startForge(); + useForgeProgressPoll(building, cancelTokenRef); - const token = cancelTokenRef.current; - if (!token) return; - - let active = true; - - const poll = async () => { - if (!active) return; - try { - const { authHeaders } = await import('../api/auth'); - const res = await fetch(`/api/v1/builder/progress/${token}`, { - headers: authHeaders(), - }); - if (res.ok) { - const data: { stage: string; pct: number } = await res.json(); - if (active && data.stage) { - setStage(data.stage, data.pct); - } - } - } catch { - // network hiccup — keep polling - } - if (active) { - forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS); - } - }; - - poll(); - - return () => { - active = false; - if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [building]); useEffect(() => { return () => { - const tok = cancelTokenRef.current; if (tok) api.cancelBuild(tok).catch(() => {}); }; @@ -247,7 +186,7 @@ export default function MissionDeckPage() { }; const finishForgeSuccess = async (result: BuildResponse) => { - setStage('Build complete!', 100); + setStage('Forged!', 100); setDispenseReveal(result); }; @@ -301,7 +240,6 @@ export default function MissionDeckPage() { cancelToken, onStep: (step) => { setMissionStep(step); - if (step === 'forge') startForge(); }, }); setMissionExportSkipped(result.exportSkipped); diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index aee3e61..ab33cbc 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1591,11 +1591,25 @@ button.deliverable-card .form-hint { border-radius: 4px; background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700); box-shadow: 0 0 8px rgba(255, 160, 0, 0.6); - transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); position: relative; z-index: 1; } +.forge-progress-track.indeterminate { + overflow: hidden; +} + +.forge-progress-track.indeterminate .forge-progress-fill { + width: 35%; + animation: forge-progress-indeterminate 1.4s ease-in-out infinite; +} + +@keyframes forge-progress-indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(320%); } +} + .forge-progress-glow { position: absolute; top: 50%;