fix(ui): reconcile shared accessibility and read recovery
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user