Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 59 additions & 25 deletions app/pages/dashboard/jobs/[id]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<SwipeApplicationDetail | null>(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"
Expand All @@ -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<typeof setTimeout> | 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<typeof setTimeout> | 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()))
Expand Down Expand Up @@ -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
// ─────────────────────────────────────────────
Expand Down Expand Up @@ -1445,7 +1465,7 @@ function closeDocPreview() {

<!-- Error -->
<div
v-else-if="jobError || appError"
v-else-if="(jobError && !jobData) || (appError && !appData)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Surface failed background refetches without hiding stale data.

When appError exists and appData is still present, this condition is false. The previous candidate list remains visible with no error or retry action. After a failed search, filter, sort, or stage request, the controls can describe data that was not loaded. A user can act on stale candidates without knowing the refresh failed.

Keep the previous content mounted, but show a non-blocking error message with a retry action when appError && appData.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/dashboard/jobs/`[id]/index.vue at line 1458, Update the dashboard
error-rendering logic around the job error condition so appError is surfaced
even when appData remains available. Keep the existing candidate content mounted
for stale data, and add a non-blocking error message with a retry action for
failed background search, filter, sort, or stage requests, reusing the page’s
existing retry mechanism.

class="m-6 rounded-xl border border-danger-200/80 bg-danger-50 p-5 text-sm text-danger-700 dark:border-danger-800/60 dark:bg-danger-950/40 dark:text-danger-300"
>
{{ jobError ? 'Job not found or failed to load.' : 'Failed to load applications.' }}
Expand Down Expand Up @@ -1680,11 +1700,17 @@ function closeDocPreview() {

<!-- Count bar -->
<div class="shrink-0 px-3.5 pb-2 flex items-center justify-between">
<span class="text-xs font-medium text-surface-500 dark:text-surface-400">
Showing {{ pageStart }}-{{ pageEnd }} of {{ focusedApplicationTotal }} candidate{{ focusedApplicationTotal === 1 ? '' : 's' }}
<span v-if="searchTerm.trim() || hasActiveFilters" class="text-surface-400 dark:text-surface-500">
{{ hasActiveFilters ? ' filtered' : ' matching' }}
<span class="flex min-w-0 items-center gap-1.5 text-xs font-medium text-surface-500 dark:text-surface-400">
<span class="truncate">
Showing {{ pageStart }}-{{ pageEnd }} of {{ focusedApplicationTotal }} candidate{{ focusedApplicationTotal === 1 ? '' : 's' }}
<span v-if="searchTerm.trim() || hasActiveFilters" class="text-surface-400 dark:text-surface-500">
{{ hasActiveFilters ? ' filtered' : ' matching' }}
</span>
</span>
<span
v-if="isRefreshingApps"
class="size-3 shrink-0 rounded-full border border-surface-300 border-t-brand-500 animate-spin dark:border-surface-600 dark:border-t-brand-400"
/>
</span>
<div v-if="totalPages > 1" class="flex shrink-0 items-center gap-1">
<button
Expand All @@ -1710,7 +1736,11 @@ function closeDocPreview() {
</div>

<!-- Scrollable list -->
<div ref="sidebarList" class="flex-1 overflow-y-auto scrollbar-thin border-t border-surface-100 dark:border-surface-800/60">
<div
ref="sidebarList"
class="flex-1 overflow-y-auto scrollbar-thin border-t border-surface-100 transition-opacity duration-150 dark:border-surface-800/60"
:class="isRefreshingApps ? 'opacity-60' : 'opacity-100'"
>
<div v-if="filteredApplications.length === 0" class="p-8 text-center">
<div class="flex size-12 items-center justify-center rounded-xl bg-surface-100 dark:bg-surface-800/60 mx-auto mb-3">
<UserRound class="size-5 text-surface-400 dark:text-surface-500" />
Expand Down Expand Up @@ -2117,10 +2147,14 @@ function closeDocPreview() {

<!-- Detail content -->
<div
class="bg-surface-50/80 px-4 dark:bg-surface-950/80 sm:px-6"
:class="detailTab === 'inbox' ? 'flex min-h-0 flex-1 flex-col overflow-hidden py-4 sm:py-5' : 'py-5 sm:py-8'"
class="bg-surface-50/80 px-4 transition-opacity duration-300 ease-out dark:bg-surface-950/80 sm:px-6"
:class="[
detailTab === 'inbox' ? 'flex min-h-0 flex-1 flex-col overflow-hidden py-4 sm:py-5' : 'py-5 sm:py-8',
showDetailLoadingVeil ? 'pointer-events-none select-none opacity-50' : 'opacity-100',
]"
:aria-busy="isDetailStale ? 'true' : undefined"
>
<div v-if="!resolvedCurrentApplication || showDetailSkeleton" class="space-y-5 mx-auto animate-pulse" :class="detailWidthClass" aria-label="Loading candidate details">
<div v-if="!resolvedCurrentApplication" class="space-y-5 mx-auto animate-pulse" :class="detailWidthClass" aria-label="Loading candidate details">
<div class="h-28 rounded-xl border border-surface-200/80 bg-white dark:border-surface-800/60 dark:bg-surface-900" />
<div class="h-40 rounded-xl border border-surface-200/80 bg-white dark:border-surface-800/60 dark:bg-surface-900" />
<div class="h-32 rounded-xl border border-surface-200/80 bg-white dark:border-surface-800/60 dark:bg-surface-900" />
Expand Down
73 changes: 59 additions & 14 deletions ee/app/components/CandidateMessagingPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
CANDIDATE_MESSAGE_MAX_ATTACHMENTS,
} from '~~/shared/candidate-messaging'
import { candidateMessageKindLabel } from '../composables/useCandidateMessages'
import type { CandidateConversation, CandidateMessageStatus } from '../composables/useCandidateMessages'
import type { CandidateConversation, CandidateMessageStatus, CandidateMessageSummary } from '../composables/useCandidateMessages'

const props = defineProps<{
applicationId: string
Expand Down Expand Up @@ -47,9 +47,35 @@ const isSending = ref(false)

const loading = computed(() => loadingList.value || loadingConversation.value)
const hasConversation = computed(() => selected.value?.applicationId === props.applicationId)
const messages = computed(() => hasConversation.value ? selected.value!.messages : [])

// The panel is remounted per candidate, so a switch always starts from empty.
// `hasSettled` marks the first load for *this* candidate as finished: until
// then we don't know whether there is a thread, and rendering the "start a
// conversation" composer on a guess makes the subject field flash in and back
// out under the new candidate's header.
const hasSettled = ref(false)

// The inbox list already carries each conversation's messages, so we can paint
// the thread as soon as that first request lands instead of waiting on the
// second (full conversation) round trip. Per-message extras — attachments,
// delivery errors — fill in a moment later.
type ThreadMessage = CandidateMessageSummary
& Partial<Omit<CandidateConversation['messages'][number], keyof CandidateMessageSummary>>
const previewMessages = ref<ThreadMessage[]>([])

const messages = computed<ThreadMessage[]>(() =>
hasConversation.value ? selected.value!.messages : previewMessages.value,
)
// True once we know this candidate has a thread — from either request.
const hasThread = computed(() => hasConversation.value || previewMessages.value.length > 0)
const defaultSubject = computed(() => `Regarding ${props.jobTitle}`)

const messageSkeletons = [
{ id: 1, outbound: false, height: 'h-16' },
{ id: 2, outbound: true, height: 'h-24' },
{ id: 3, outbound: false, height: 'h-12' },
]

const inboxLink = computed(() => localePath({
path: '/dashboard/inbox',
query: hasConversation.value && selected.value
Expand All @@ -72,15 +98,19 @@ async function loadThread(options: { quiet?: boolean } = {}) {
await loadInbox({ applicationId: props.applicationId })
const conversation = conversations.value.find(item => item.applicationId === props.applicationId)
if (conversation) {
previewMessages.value = conversation.messages
await loadConversation(conversation.id)
} else {
previewMessages.value = []
selected.value = null
subject.value ||= defaultSubject.value
}
} catch (err: any) {
if (!options.quiet) {
toast.error('Could not load messages', { message: err.data?.statusMessage })
}
} finally {
hasSettled.value = true
}
}

Expand All @@ -90,7 +120,7 @@ async function refreshThread() {

async function submitMessage() {
const trimmedBody = body.value.trim()
const messageSubject = hasConversation.value
const messageSubject = hasThread.value
? messages.value.at(-1)?.subject ?? defaultSubject.value
: subject.value.trim()
if (!trimmedBody || !messageSubject) return
Expand All @@ -116,7 +146,7 @@ async function submitMessage() {
}
}

async function retryMessage(message: CandidateConversation['messages'][number]) {
async function retryMessage(message: ThreadMessage) {
isSending.value = true
try {
if (message.interviewId) {
Expand Down Expand Up @@ -171,9 +201,24 @@ onUnmounted(() => clearInterval(pollTimer))

<div v-else class="flex h-full min-h-0 min-w-0 flex-col gap-4 overflow-hidden">
<div class="scrollbar-thin min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pr-1">
<div v-if="!billingResolved || (loading && messages.length === 0)" class="py-12 text-center text-surface-400">
<div class="mx-auto mb-3 size-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-600 dark:border-brand-800 dark:border-t-brand-400" />
<p class="text-sm">Loading messages…</p>
<!-- Thread-shaped placeholder: the same bubble rhythm the messages land
in, so the panel settles into place instead of swapping layouts. -->
<div
v-if="!billingResolved || (!hasSettled && messages.length === 0)"
class="flex min-w-0 animate-pulse flex-col gap-5"
aria-label="Loading messages"
>
<div v-for="bubble in messageSkeletons" :key="bubble.id" class="flex min-w-0 flex-col" :class="bubble.outbound ? 'items-end' : 'items-start'">
<div class="mb-1.5 h-3 w-16 rounded bg-surface-200/80 dark:bg-surface-800" />
<div
class="w-full max-w-[88%] rounded-xl sm:max-w-[76%]"
:class="[
bubble.height,
bubble.outbound ? 'bg-brand-100 dark:bg-brand-950/50' : 'border border-surface-200/80 bg-white dark:border-surface-800/60 dark:bg-surface-900',
]"
/>
<div class="mt-1.5 h-3 w-24 rounded bg-surface-200/60 dark:bg-surface-800/70" />
</div>
</div>

<div
Expand Down Expand Up @@ -222,7 +267,7 @@ onUnmounted(() => clearInterval(pollTimer))
: 'border border-surface-200/80 bg-white text-surface-800 shadow-sm shadow-surface-900/[0.03] dark:border-surface-800/60 dark:bg-surface-900 dark:text-surface-200 dark:shadow-none'"
>{{ message.bodyText }}</div>
<CandidateMessageAttachmentList
:attachments="message.attachments"
:attachments="message.attachments ?? []"
:outbound="message.direction === 'outbound'"
/>
<div class="mt-1.5 flex max-w-[88%] flex-wrap items-center gap-2 px-1 text-xs text-surface-400 sm:max-w-[76%]">
Expand All @@ -231,7 +276,7 @@ onUnmounted(() => clearInterval(pollTimer))
<component :is="statusMeta[message.status].icon" class="size-3.5" :class="statusMeta[message.status].class" />
<span :class="statusMeta[message.status].class">{{ statusMeta[message.status].label }}</span>
<button
v-if="['failed', 'bounced'].includes(message.status)"
v-if="hasConversation && ['failed', 'bounced'].includes(message.status)"
type="button"
class="font-medium text-brand-600 hover:underline disabled:opacity-50 dark:text-brand-400"
:disabled="isSending"
Expand All @@ -248,11 +293,11 @@ onUnmounted(() => clearInterval(pollTimer))
</div>

<form
v-if="hasConversation || allowance.canSend"
v-if="!hasSettled || hasThread || allowance.canSend"
class="w-full min-w-0 shrink-0 rounded-xl border border-surface-200 bg-white p-4 shadow-sm dark:border-surface-800 dark:bg-surface-900"
@submit.prevent="submitMessage"
>
<label v-if="!hasConversation" class="mb-2 block">
<label v-if="hasSettled && !hasThread" class="mb-2 block">
<span class="sr-only">Subject</span>
<input
v-model="subject"
Expand Down Expand Up @@ -286,7 +331,7 @@ onUnmounted(() => clearInterval(pollTimer))
<textarea
v-model="body"
maxlength="20000"
:placeholder="hasConversation ? 'Write a reply…' : 'Write a message…'"
:placeholder="hasThread ? 'Write a reply…' : 'Write a message…'"
class="min-w-0 flex-1 resize-none rounded-lg border border-surface-200 bg-white px-3.5 py-2.5 text-sm leading-relaxed text-surface-800 transition-[height] placeholder:text-surface-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-surface-700 dark:bg-surface-800 dark:text-surface-100 dark:placeholder:text-surface-500 dark:focus:border-brand-400 dark:focus:ring-brand-400/20"
:class="expanded ? 'h-44' : 'h-11'"
@input="requestId = null"
Expand All @@ -305,7 +350,7 @@ onUnmounted(() => clearInterval(pollTimer))
<button
type="submit"
class="inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-brand-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-brand-500 dark:hover:bg-brand-400"
:disabled="isSending || !body.trim() || (!hasConversation && !subject.trim())"
:disabled="isSending || !hasSettled || !body.trim() || (!hasThread && !subject.trim())"
>
<RefreshCw v-if="isSending" class="size-4 animate-spin" />
<Send v-else class="size-4" />
Expand All @@ -314,7 +359,7 @@ onUnmounted(() => clearInterval(pollTimer))
</div>
<div class="mt-2.5 flex items-end justify-between gap-3 px-1">
<p class="min-w-0 break-words px-0.5 text-[11px] text-surface-400 [overflow-wrap:anywhere] dark:text-surface-500">
<template v-if="allowance.remaining != null && !hasConversation"><span class="font-semibold text-surface-500 dark:text-surface-400">{{ allowance.remaining }} of {{ allowance.limit }} free conversations left.</span> Starting this uses one; replies stay unlimited. </template>
<template v-if="hasSettled && allowance.remaining != null && !hasThread"><span class="font-semibold text-surface-500 dark:text-surface-400">{{ allowance.remaining }} of {{ allowance.limit }} free conversations left.</span> Starting this uses one; replies stay unlimited. </template>
Messages are emailed to {{ candidateEmail }}. <kbd class="rounded border border-surface-200 bg-surface-50 px-1 py-px font-mono text-[10px] font-medium text-surface-500 dark:border-surface-700 dark:bg-surface-800 dark:text-surface-400">⌘</kbd><kbd class="rounded border border-surface-200 bg-surface-50 px-1 py-px font-mono text-[10px] font-medium text-surface-500 dark:border-surface-700 dark:bg-surface-800 dark:text-surface-400">Enter</kbd> to send.
</p>
<div class="flex shrink-0 items-center gap-1">
Expand Down
Loading
Loading