Merge commit '11942c89ac8cb443bad62198cfcfdc2d5f3647a1' into fleet/story-d2-job-error-banner-home

This commit is contained in:
Giancarmine Salucci
2026-07-21 18:31:57 +02:00
3 changed files with 189 additions and 144 deletions
+148 -2
View File
@@ -8,6 +8,7 @@
onDelete?: (jobId: string) => void; onDelete?: (jobId: string) => void;
retryLabel?: string; retryLabel?: string;
deleteLabel?: string; deleteLabel?: string;
detailed?: boolean;
} }
let { let {
@@ -17,36 +18,181 @@
onDelete, onDelete,
retryLabel = 'Retry', retryLabel = 'Retry',
deleteLabel = 'Delete', deleteLabel = 'Delete',
detailed = false,
}: Props = $props(); }: Props = $props();
const isDetailed = $derived(detailed || Boolean(onRetry || onDelete));
function handleRetry(e: MouseEvent) { function handleRetry(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onRetry?.(job.id); onRetry?.(job.id);
} }
function handleDelete(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onDelete?.(job.id);
}
</script> </script>
{#if job.status === 'failed' && job.error} {#if job.status === 'failed' && job.error}
{#if isDetailed}
<!-- Detailed error banner for detail page -->
<div class="error-banner" role="alert">
<div class="eb-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 3L2 21h20L12 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="12" cy="18" r="0.8" fill="currentColor"/>
</svg>
</div>
<div class="eb-body">
<div class="eb-title">Transcription failed</div>
<div class="eb-msg">{job.error}</div>
</div>
<div class="eb-actions">
{#if onRetry}
<button class="btn primary" onclick={handleRetry} aria-label="Retry transcription">↺ Retry transcription</button>
{/if}
{#if onDelete}
<button class="btn ghost danger-ghost" onclick={handleDelete}>Delete</button>
{/if}
</div>
</div>
{:else}
<!-- Minimal error line for list view -->
<div class="recent-err-line" role="alert" title={job.error}> <div class="recent-err-line" role="alert" title={job.error}>
<span class="error-text" style="color: {accentColor}">{job.error}</span> <span class="error-text" style="color: {accentColor}">{job.error}</span>
{#if onRetry} {#if onRetry}
<button class="retry-btn" onclick={handleRetry} aria-label={retryLabel}>{retryLabel}</button> <button class="retry-btn" onclick={handleRetry} aria-label={retryLabel}>{retryLabel}</button>
{/if} {/if}
{#if onDelete} {#if onDelete}
<button class="delete-btn" onclick={(e) => { e.preventDefault(); e.stopPropagation(); onDelete?.(job.id); }} aria-label={deleteLabel}>{deleteLabel}</button> <button class="delete-btn" onclick={handleDelete} aria-label={deleteLabel}>{deleteLabel}</button>
{/if} {/if}
</div> </div>
{/if}
{:else if job.status === 'cancelled'} {:else if job.status === 'cancelled'}
<div class="recent-err-line" role="status"> <div class="recent-err-line" role="status">
<span>Job was cancelled</span> <span>Job was cancelled</span>
</div> </div>
{:else if job.status === 'done' && job.segmentsJson} {:else if job.status === 'done' && job.segmentsJson}
<div class="recent-err-line"> <div class="recent-err-line">
<button class="delete-btn" onclick={(e) => { e.preventDefault(); e.stopPropagation(); onDelete?.(job.id); }} aria-label={deleteLabel}>{deleteLabel}</button> {#if onDelete}
<button class="delete-btn" onclick={handleDelete} aria-label={deleteLabel}>{deleteLabel}</button>
{/if}
</div> </div>
{/if} {/if}
<style> <style>
.error-banner {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 16px 18px;
border-radius: 10px;
background: var(--error-bg);
border: 1px solid var(--error-border);
margin-bottom: 24px;
}
.eb-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 9px;
background: rgba(255,90,90,0.12);
color: var(--error-text);
flex-shrink: 0;
}
.eb-body {
flex: 1;
min-width: 0;
}
.eb-title {
font-size: 14px;
font-weight: 600;
color: var(--error-text);
}
.eb-msg {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: rgba(255,180,180,0.85);
margin-top: 5px;
overflow-wrap: anywhere;
max-height: 96px;
overflow-y: auto;
}
.eb-actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.btn {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 550;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 13px;
border-radius: 9px;
border: 1px solid var(--border-strong);
background: var(--surface-hover);
color: var(--text);
cursor: pointer;
white-space: nowrap;
transition: background 140ms ease, border-color 140ms ease;
}
.btn:hover {
background: rgba(255,255,255,0.07);
border-color: rgba(255,255,255,0.18);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--accent-ring);
border-color: var(--accent);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #10120a;
font-weight: 600;
padding: 10px 18px;
font-size: 13.5px;
border-radius: 10px;
box-shadow: 0 0 18px rgba(205,242,78,0.18);
}
.btn.primary:hover {
background: #d9f566;
border-color: #d9f566;
}
.btn.ghost {
background: transparent;
border-color: transparent;
}
.btn.ghost:hover {
background: rgba(255,255,255,0.04);
}
.btn.danger-ghost {
color: var(--error-text);
}
.btn.danger-ghost:hover {
background: rgba(255,90,90,0.08);
border-color: var(--error-border);
}
.recent-err-line { .recent-err-line {
display: flex; display: flex;
align-items: center; align-items: center;
+8 -122
View File
@@ -5,6 +5,7 @@
import { getDisplayJobProgress, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js'; import { getDisplayJobProgress, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
import SourceIcon from '$lib/components/SourceIcon.svelte'; import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte'; import Waveform from '$lib/components/Waveform.svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import { accent } from '$lib/accent.js'; import { accent } from '$lib/accent.js';
const ACCENT = $derived($accent.value); const ACCENT = $derived($accent.value);
@@ -257,24 +258,8 @@
{/if} {/if}
<!-- ── Error ─────────────────────────────────────────── --> <!-- ── Error ─────────────────────────────────────────── -->
{#if job.error} {#if job && job.error}
<div class="error-banner" role="alert"> <JobErrorLine {job} accentColor={ACCENT} onRetry={retryJobAction} onDelete={deleteJob} />
<div class="eb-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 3L2 21h20L12 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="12" cy="18" r="0.8" fill="currentColor"/>
</svg>
</div>
<div class="eb-body">
<div class="eb-title">Transcription failed</div>
<div class="eb-msg">{job.error}</div>
</div>
<div class="eb-actions">
<button class="btn primary" onclick={retryJobAction} aria-label="Retry transcription">↺ Retry transcription</button>
<button class="btn ghost danger-ghost" onclick={deleteJob}>Delete</button>
</div>
</div>
{/if} {/if}
<!-- ── Two-column: pipeline + downloads/transcript ───── --> <!-- ── Two-column: pipeline + downloads/transcript ───── -->
@@ -406,111 +391,12 @@
} }
.error-banner { .error-banner {
display: flex; padding: 12px 16px;
gap: 14px;
align-items: flex-start;
padding: 16px 18px;
border-radius: 10px; border-radius: 10px;
background: var(--error-bg); background: rgba(255, 90, 90, 0.08);
border: 1px solid var(--error-border); border: 1px solid rgba(255, 90, 90, 0.2);
margin-bottom: 24px; color: #ff8a8a;
} font-size: 13px;
.eb-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 9px;
background: rgba(255,90,90,0.12);
color: var(--error-text);
flex-shrink: 0;
}
.eb-body {
flex: 1;
min-width: 0;
}
.eb-title {
font-size: 14px;
font-weight: 600;
color: var(--error-text);
}
.eb-msg {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: rgba(255,180,180,0.85);
margin-top: 5px;
overflow-wrap: anywhere;
max-height: 96px;
overflow-y: auto;
}
.eb-actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.btn {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 550;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 13px;
border-radius: 9px;
border: 1px solid var(--border-strong);
background: var(--surface-hover);
color: var(--text);
cursor: pointer;
white-space: nowrap;
transition: background 140ms ease, border-color 140ms ease;
}
.btn:hover {
background: rgba(255,255,255,0.07);
border-color: rgba(255,255,255,0.18);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--accent-ring);
border-color: var(--accent);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #10120a;
font-weight: 600;
padding: 10px 18px;
font-size: 13.5px;
border-radius: 10px;
box-shadow: 0 0 18px rgba(205,242,78,0.18);
}
.btn.primary:hover {
background: #d9f566;
border-color: #d9f566;
}
.btn.ghost {
background: transparent;
border-color: transparent;
}
.btn.ghost:hover {
background: rgba(255,255,255,0.04);
}
.btn.danger-ghost {
color: var(--error-text);
}
.btn.danger-ghost:hover {
background: rgba(255,90,90,0.08);
border-color: var(--error-border);
} }
/* ── Breadcrumb ─────────────────────────────────────────── */ /* ── Breadcrumb ─────────────────────────────────────────── */
+25 -12
View File
@@ -35,13 +35,14 @@ describe('JobErrorLine component', () => {
expect(getByText('transcription timeout')).toBeTruthy(); expect(getByText('transcription timeout')).toBeTruthy();
}); });
it('shows retry button', () => { it('shows retry button in detailed mode', () => {
const { getByRole } = render(JobErrorLine, { const { getByRole } = render(JobErrorLine, {
job: makeJob(), job: makeJob(),
accentColor: '#cdf24e', accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined) onRetry: vi.fn().mockResolvedValue(undefined)
}); });
expect(getByRole('button', { name: 'Retry' })).toBeTruthy(); // Detailed mode shows "Retry transcription"
expect(getByRole('button', { name: 'Retry transcription' })).toBeTruthy();
}); });
it('calls onRetry when retry button clicked', async () => { it('calls onRetry when retry button clicked', async () => {
@@ -51,11 +52,11 @@ describe('JobErrorLine component', () => {
accentColor: '#cdf24e', accentColor: '#cdf24e',
onRetry onRetry
}); });
await fireEvent.click(getByRole('button', { name: 'Retry' })); await fireEvent.click(getByRole('button', { name: 'Retry transcription' }));
expect(onRetry).toHaveBeenCalled(); expect(onRetry).toHaveBeenCalled();
}); });
it('shows delete button when onDelete provided', () => { it('shows delete button when onDelete provided in detailed mode', () => {
const { getByRole } = render(JobErrorLine, { const { getByRole } = render(JobErrorLine, {
job: makeJob(), job: makeJob(),
accentColor: '#cdf24e', accentColor: '#cdf24e',
@@ -65,16 +66,18 @@ describe('JobErrorLine component', () => {
expect(getByRole('button', { name: 'Delete' })).toBeTruthy(); expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
}); });
it('hides delete button when onDelete not provided', () => { it('hides delete button when onDelete not provided in detailed mode', () => {
const { queryByRole } = render(JobErrorLine, { const { queryByRole } = render(JobErrorLine, {
job: makeJob(), job: makeJob(),
accentColor: '#cdf24e', accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined) onRetry: vi.fn().mockResolvedValue(undefined)
}); });
expect(queryByRole('button', { name: 'Delete' })).toBeNull(); // In detailed mode, only shows delete button if onDelete is provided
const deleteButton = queryByRole('button', { name: 'Delete' });
expect(deleteButton).toBeNull();
}); });
it('container has role alert for failed jobs', () => { it('container has role alert for failed jobs in detailed mode', () => {
const { getByRole } = render(JobErrorLine, { const { getByRole } = render(JobErrorLine, {
job: makeJob({ status: 'failed' }), job: makeJob({ status: 'failed' }),
accentColor: '#cdf24e', accentColor: '#cdf24e',
@@ -86,27 +89,37 @@ describe('JobErrorLine component', () => {
it('container has role status for cancelled jobs', () => { it('container has role status for cancelled jobs', () => {
const { getByRole } = render(JobErrorLine, { const { getByRole } = render(JobErrorLine, {
job: makeJob({ status: 'cancelled' }), job: makeJob({ status: 'cancelled' }),
accentColor: '#cdf24e', accentColor: '#cdf24e'
onRetry: vi.fn().mockResolvedValue(undefined)
}); });
expect(getByRole('status')).toBeTruthy(); expect(getByRole('status')).toBeTruthy();
}); });
it('does not render when job has no error text', () => { it('does not render error when job has no error text', () => {
const { container } = render(JobErrorLine, { const { container } = render(JobErrorLine, {
job: makeJob({ status: 'failed', error: null }), job: makeJob({ status: 'failed', error: null }),
accentColor: '#cdf24e', accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined) onRetry: vi.fn().mockResolvedValue(undefined)
}); });
// Should not render any error container
expect(container.querySelector('.error-banner')).toBeNull();
expect(container.querySelector('.recent-err-line')).toBeNull(); expect(container.querySelector('.recent-err-line')).toBeNull();
}); });
it('renders "Job was cancelled" for cancelled jobs', () => { it('renders "Job was cancelled" for cancelled jobs', () => {
const { getByText } = render(JobErrorLine, { const { getByText } = render(JobErrorLine, {
job: makeJob({ status: 'cancelled' }), job: makeJob({ status: 'cancelled' }),
accentColor: '#cdf24e', accentColor: '#cdf24e'
onRetry: vi.fn().mockResolvedValue(undefined)
}); });
expect(getByText('Job was cancelled')).toBeTruthy(); expect(getByText('Job was cancelled')).toBeTruthy();
}); });
it('renders detailed display with title when handlers provided', () => {
const { getByText } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined)
});
expect(getByText('Transcription failed')).toBeTruthy();
});
}); });