Forge success label and real progress bar tied to server compile stages.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
This commit is contained in:
@@ -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, ""
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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
|
||||
|
||||
45
server/internal/builder/progress.go
Normal file
45
server/internal/builder/progress.go
Normal file
@@ -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) }
|
||||
}
|
||||
36
server/web/src/components/Forge/ForgeDispenseReveal.test.tsx
Normal file
36
server/web/src/components/Forge/ForgeDispenseReveal.test.tsx
Normal file
@@ -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 }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
describe('ForgeDispenseReveal', () => {
|
||||
it('shows Forged title on successful forge', () => {
|
||||
render(
|
||||
<ForgeDispenseReveal
|
||||
result={{
|
||||
success: true,
|
||||
file_name: 'worker.exe',
|
||||
download_url: '/api/v1/builds/x/download',
|
||||
stealth_score: 72,
|
||||
}}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Forged' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dispensed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export default function ForgeDispenseReveal({ result, onClose }: Props) {
|
||||
<div className="forge-dispense-panel">
|
||||
<div className="forge-dispense-sigil" aria-hidden />
|
||||
<h2 id="forge-dispense-title" className="forge-dispense-title">
|
||||
Dispensed
|
||||
Forged
|
||||
</h2>
|
||||
<p className="forge-dispense-sub">
|
||||
{result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature.
|
||||
|
||||
48
server/web/src/components/Forge/ForgeProgressBar.test.tsx
Normal file
48
server/web/src/components/Forge/ForgeProgressBar.test.tsx
Normal file
@@ -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(
|
||||
<ForgeProgressBar building={false} stage="" progress={0} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows indeterminate track before server progress', () => {
|
||||
const { container } = render(
|
||||
<ForgeProgressBar building stage={FORGE_INITIAL_STAGE} progress={0} />,
|
||||
);
|
||||
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(
|
||||
<ForgeProgressBar building stage="Compiling agent" progress={42} />,
|
||||
);
|
||||
expect(screen.getByText('Compiling agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('42%')).toBeInTheDocument();
|
||||
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeNull();
|
||||
});
|
||||
});
|
||||
47
server/web/src/components/Forge/ForgeProgressBar.tsx
Normal file
47
server/web/src/components/Forge/ForgeProgressBar.tsx
Normal file
@@ -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 (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{displayStage}</span>
|
||||
<span className="forge-progress-pct">{pctLabel}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`forge-progress-track${indeterminate ? ' indeterminate' : ''}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : Math.round(progress)}
|
||||
aria-busy={indeterminate}
|
||||
aria-label={displayStage}
|
||||
>
|
||||
<div
|
||||
className="forge-progress-fill"
|
||||
style={indeterminate ? undefined : { width: `${progress}%` }}
|
||||
/>
|
||||
{!indeterminate && (
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
server/web/src/hooks/useForgeProgressPoll.ts
Normal file
57
server/web/src/hooks/useForgeProgressPoll.ts
Normal file
@@ -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<string>,
|
||||
) {
|
||||
const { startForge, endForge, setStage } = useForge();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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]);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="forge-progress-track">
|
||||
<div
|
||||
className="forge-progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||
|
||||
const [form, setForm] = useState<BuildRequest | null>(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);
|
||||
|
||||
@@ -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 (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="forge-progress-track">
|
||||
<div className="forge-progress-fill" style={{ width: `${progress}%` }} />
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ReturnType<typeof setTimeout> | 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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user