fix(ui): reconcile shared accessibility and read recovery
This commit is contained in:
@@ -62,19 +62,41 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
const [plan, setPlan] = useState<FocusPlanResponse | null>(null);
|
||||
const [operation, setOperation] = useState<UserOperation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [readingPlan, setReadingPlan] = useState(false);
|
||||
const [readError, setReadError] = useState<{ cause: unknown; retryable: boolean } | null>(null);
|
||||
const announced = useRef<string | null>(null);
|
||||
const owner = useRef({ mounted: false, jobId, generation: 0 });
|
||||
const readSequence = useRef(0);
|
||||
const readPending = useRef(false);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
const loadPlan = useCallback(async () => {
|
||||
if (!owner.current.mounted || owner.current.jobId !== jobId || readPending.current) return;
|
||||
const lifetime = owner.current.generation;
|
||||
const request = ++readSequence.current;
|
||||
const active = () => owner.current.mounted && owner.current.jobId === jobId && owner.current.generation === lifetime && request === readSequence.current;
|
||||
readPending.current = true;
|
||||
setReadingPlan(true);
|
||||
try {
|
||||
const { data } = await api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`);
|
||||
if (!active()) return;
|
||||
setPlan(data);
|
||||
} catch {
|
||||
setPlan(null);
|
||||
setReadError(null);
|
||||
} catch (cause) {
|
||||
if (!active()) return;
|
||||
const response = (cause as { response?: { status?: number; data?: { code?: string } } })?.response;
|
||||
if (response?.status === 404 && response.data?.code === "strategy_not_generated") { setPlan(null); setReadError(null); }
|
||||
else setReadError({ cause, retryable: !response?.status || response.status >= 500 });
|
||||
} finally {
|
||||
if (active()) { readPending.current = false; setReadingPlan(false); }
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
owner.current = { mounted: true, jobId, generation: owner.current.generation + 1 };
|
||||
readPending.current = false;
|
||||
setPlan(null); setReadError(null); setOperation(null);
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadPlan(),
|
||||
@@ -82,17 +104,18 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
.then(({ data }) => { if (active) setOperation(data); })
|
||||
.catch(() => { if (active) setOperation(null); }),
|
||||
]).finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
return () => { active = false; owner.current.mounted = false; owner.current.generation += 1; readSequence.current += 1; readPending.current = false; };
|
||||
}, [jobId, loadPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!operation || terminal(operation.status)) return;
|
||||
let active = true;
|
||||
const timer = window.setTimeout(() => {
|
||||
api.get<UserOperation>(`/operations/${operation.id}`)
|
||||
.then(({ data }) => setOperation(data))
|
||||
.then(({ data }) => { if (active) setOperation(data); })
|
||||
.catch(() => undefined);
|
||||
}, 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => { active = false; window.clearTimeout(timer); };
|
||||
}, [operation]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -133,17 +156,24 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
};
|
||||
|
||||
const working = !!operation && !terminal(operation.status);
|
||||
const reading = loading || readingPlan;
|
||||
const readErrorTitle = t("workspaceStrategyLoadFailed");
|
||||
const readErrorDetail = readError ? getApiErrorMessage(readError.cause, readErrorTitle) : "";
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
|
||||
<Typography ref={headingRef} tabIndex={-1} component="h2" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
|
||||
<Button variant="outlined" size="small" disabled={!canUseAi || reading || working} onClick={() => void generate()}>
|
||||
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{readError ? <Alert severity="error" sx={{ mb: 2, overflowWrap: "anywhere" }} action={readError.retryable ? <Button color="inherit" size="small" disabled={reading} onClick={() => { headingRef.current?.focus(); void loadPlan(); }}>{t("retry")}</Button> : undefined}>
|
||||
<Typography variant="body2">{readErrorTitle}</Typography>
|
||||
{readErrorDetail !== readErrorTitle ? <Typography variant="body2">{readErrorDetail}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{operation && operation.status !== "succeeded" ? (
|
||||
<Alert severity={operation.status === "failed" ? "error" : operation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }} action={<>
|
||||
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>{t("cancel")}</Button> : null}
|
||||
@@ -152,14 +182,14 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
|
||||
</Alert>
|
||||
) : null}
|
||||
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : plan ? (
|
||||
{reading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : plan ? (
|
||||
<Stack spacing={2}>
|
||||
<DraftCard title={t("jobDetailsFocusSummary")} content={plan.strategicSummary} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={plan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={plan.proofPointsToLeadWith} />
|
||||
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={plan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={plan.coverLetterAngles} />
|
||||
<ListCard title={t("jobDetailsFollowUpApproach")} items={plan.followUpApproach} />
|
||||
</Stack>
|
||||
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
|
||||
) : !readError ? <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography> : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user