- 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%;