diff --git a/app/pages/dashboard/jobs/[id]/index.vue b/app/pages/dashboard/jobs/[id]/index.vue index 9248ce76..3f258ee2 100644 --- a/app/pages/dashboard/jobs/[id]/index.vue +++ b/app/pages/dashboard/jobs/[id]/index.vue @@ -457,9 +457,16 @@ const { // Keep the last successfully loaded application visible while the next one is // being fetched, so switching candidates swaps the detail in place instead of -// flashing the skeleton (and header fields don't vanish/reappear). Only before -// the very first load is this null. -const resolvedCurrentApplication = computed(() => currentApplication.value ?? null) +// flashing the skeleton (and header fields don't vanish/reappear). `useFetch` +// resets its own data ref every time the keyed request changes, so we hold on +// to the previous payload ourselves. Only before the very first load is this +// null. +const lastLoadedApplication = shallowRef(currentApplication.value ?? null) +watch(currentApplication, (app) => { + if (app) lastLoadedApplication.value = app +}, { immediate: true }) + +const resolvedCurrentApplication = computed(() => currentApplication.value ?? lastLoadedApplication.value) // The automation rule (if any) that auto-set this application's status on // submit, plus a lookup of which responses triggered it — drives the "Auto" @@ -475,25 +482,28 @@ const isDetailStale = computed(() => !currentApplication.value || currentApplication.value.id !== currentApplicationId.value, ) -// Fall back to the skeleton only when a fetch is genuinely slow. Fast/cached -// navigations resolve before this fires, so the content simply swaps in place. -const showDetailSkeleton = ref(false) -let detailSkeletonTimer: ReturnType | null = null +// Once a candidate has been shown we never fall back to the skeleton again — +// the previous detail stays on screen and the new one swaps into it. For a +// genuinely slow fetch we instead dim the (now outdated) content after a short +// delay, so the user gets a loading cue without the layout tearing down. Fast +// and cached switches resolve before this fires and show nothing at all. +const showDetailLoadingVeil = ref(false) +let detailVeilTimer: ReturnType | null = null watch(isDetailStale, (stale) => { - if (detailSkeletonTimer) { - clearTimeout(detailSkeletonTimer) - detailSkeletonTimer = null + if (detailVeilTimer) { + clearTimeout(detailVeilTimer) + detailVeilTimer = null } if (stale) { - detailSkeletonTimer = setTimeout(() => { - showDetailSkeleton.value = true - }, 180) + detailVeilTimer = setTimeout(() => { + showDetailLoadingVeil.value = true + }, 250) } else { - showDetailSkeleton.value = false + showDetailLoadingVeil.value = false } }) onBeforeUnmount(() => { - if (detailSkeletonTimer) clearTimeout(detailSkeletonTimer) + if (detailVeilTimer) clearTimeout(detailVeilTimer) }) const hasCoverLetter = computed(() => Boolean(resolvedCurrentApplication.value?.coverLetterText?.trim())) @@ -1389,10 +1399,20 @@ onBeforeUnmount(() => { document.removeEventListener('click', handleOverviewDropdownClickOutside) }) +/** + * Only the very first load may swap the whole pipeline for a spinner. Later + * fetches — search, sort, filters, changing stage — keep the previous list on + * screen: tearing down the layout mid-request unmounts the search input and + * steals focus on every keystroke. + */ const isLoading = computed(() => { - return jobFetchStatus.value === 'pending' || appFetchStatus.value === 'pending' + return (jobFetchStatus.value === 'pending' && !jobData.value) + || (appFetchStatus.value === 'pending' && !appData.value) }) +/** Background refetch of the candidate list — shown inline, never as a full-page state. */ +const isRefreshingApps = computed(() => appFetchStatus.value === 'pending' && !!appData.value) + // ───────────────────────────────────────────── // Document preview // ───────────────────────────────────────────── @@ -1445,7 +1465,7 @@ function closeDocPreview() {
{{ jobError ? 'Job not found or failed to load.' : 'Failed to load applications.' }} @@ -1680,11 +1700,17 @@ function closeDocPreview() {
- - Showing {{ pageStart }}-{{ pageEnd }} of {{ focusedApplicationTotal }} candidate{{ focusedApplicationTotal === 1 ? '' : 's' }} - - {{ hasActiveFilters ? ' filtered' : ' matching' }} + + + Showing {{ pageStart }}-{{ pageEnd }} of {{ focusedApplicationTotal }} candidate{{ focusedApplicationTotal === 1 ? '' : 's' }} + + {{ hasActiveFilters ? ' filtered' : ' matching' }} + +
-