fix(ui): reconcile shared accessibility and read recovery

This commit is contained in:
cesnimda
2026-10-20 20:26:48 +02:00
parent bba52af4b8
commit d8c0187305
25 changed files with 802 additions and 25 deletions
@@ -445,13 +445,13 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<Stepper activeStep={activeStep} alternativeLabel sx={{ mb: 3, display: { xs: "none", sm: "flex" } }}>
{steps.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
<Typography variant="subtitle2" sx={{ display: { xs: "block", sm: "none" }, mb: 2 }}>
<Typography component="p" variant="subtitle2" sx={{ display: { xs: "block", sm: "none" }, mb: 2 }}>
{t("addJobModalStepProgress", { current: activeStep + 1, total: steps.length, step: steps[activeStep] })}
</Typography>
{activeStep === 0 ? <Box sx={{ display: "grid", gap: 2 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{t("addJobModalStartWithAdvert")}</Typography>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900 }}>{t("addJobModalStartWithAdvert")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{t("addJobModalStartWithAdvertBody")}
</Typography>
@@ -609,7 +609,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
{activeStep >= 2 ? <Box sx={{ display: "grid", gap: 2 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{activeStep === 2 ? t("addJobModalCvStepHelp") : null}
{activeStep === 3 ? t("addJobModalCoverLetterStepHelp") : null}
@@ -150,21 +150,21 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title={t("checklistMoveUp")}>
<Tooltip describeChild title={t("checklistMoveUp")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveUpItem", { title: display.title })} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={t("checklistMoveDown")}>
<Tooltip describeChild title={t("checklistMoveDown")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveDownItem", { title: display.title })} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<Tooltip describeChild title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistRemoveItem", { title: display.title })} onClick={() => remove(item)}>
<DeleteOutlineIcon fontSize="inherit" />
@@ -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>
);
}
@@ -381,7 +381,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
</Stack>
)}
</Box>
<Tooltip title={t("interviewPrepDelete")}>
<Tooltip describeChild title={t("interviewPrepDelete")}>
<span>
<IconButton
size="small"
@@ -147,7 +147,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
};
return (
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch"), "aria-labelledby": undefined }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
<DialogContent onKeyDown={onKeys} sx={{ p: 1.5, maxHeight: "min(70vh, 620px)" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
<SearchIcon fontSize="small" />