failed tasks inside task queue and image generation session (#1084)

- failed tasks to show in image generation session view
- failed tasks now show in task queue dropdown
- completed and failed tasks clearable on "clear all" click
- styling updates
This commit is contained in:
BFlat authored and GitHub committed 2026-03-04 00:21:09 +07:00
1 parent 367f4fb675
commit 2a2e1ddf30
4 files changed
+341 -43

No files matched your search

@@ -9,6 +9,7 @@ import {
faTasks,
faBroom,
faBomb,
faCircleExclamation,
} from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import {
@@ -69,6 +70,14 @@ type CompletedTask = {
batchImageToken?: string;
};
type FailedTask = {
id: string;
title: string;
subtitle?: string;
failedAt?: Date;
status: string;
};
const formatTimeLeft = (ms: number): string => {
const totalSeconds = Math.ceil(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
@@ -219,10 +228,70 @@ const CompletedCard = ({
);
};
const FAILED_STATUS_LABEL: Record<string, string> = {
complete_failure: "Failed",
attempt_failed: "Failed",
dead: "Failed",
cancelled_by_user: "Cancelled",
cancelled_by_provider: "Cancelled by provider",
cancelled_by_us: "Cancelled",
};
const FailedCard = ({
task,
onDismiss,
}: {
task: FailedTask;
onDismiss?: () => void;
}) => {
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<FontAwesomeIcon
icon={faCircleExclamation}
className="text-red-400"
size="lg"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="truncate font-medium text-base-fg/90">
{task.title}
</div>
</div>
{task.subtitle && (
<div className="mt-0.5 truncate text-xs text-base-fg opacity-60">
{task.subtitle}
</div>
)}
<div className="mt-1 text-xs font-medium text-red-400">
{statusLabel}
</div>
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
</div>
);
};
export const TaskQueue = () => {
const [isModalOpen, setModalOpen] = useState(false);
const [inProgress, setInProgress] = useState<InProgressTask[]>([]);
const [completed, setCompleted] = useState<CompletedTask[]>([]);
const [failed, setFailed] = useState<FailedTask[]>([]);
const [lastReadAt, setLastReadAt] = useState<number>(() => {
const stored = localStorage.getItem("taskQueueLastReadAt");
return stored ? parseInt(stored, 10) : 0;
@@ -290,6 +359,25 @@ export const TaskQueue = () => {
});
};
const handleClearFailed = () => {
setConfirmationConfig({
isOpen: true,
title: "Clear failed tasks?",
message: (
<span className="text-sm text-white/80">
This will remove all failed/cancelled tasks from the queue.
</span>
),
primaryActionText: "Clear failed",
primaryActionIcon: faTrashAlt,
primaryActionBtnClassName:
"bg-red-500/10 hover:bg-red-500/20 text-red-500",
onConfirm: async () => {
await dismissFailed();
},
});
};
const handleRemoveAll = () => {
setConfirmationConfig({
isOpen: true,
@@ -452,8 +540,32 @@ export const TaskQueue = () => {
};
});
const FAILED_STATUSES = new Set([
"complete_failure",
"attempt_failed",
"dead",
"cancelled_by_user",
"cancelled_by_provider",
"cancelled_by_us",
]);
const failedTasks = tasks
.filter((t) => FAILED_STATUSES.has(t.task_status))
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
.map((t: TaskQueueItem) => {
const parts = formatTitleParts(t);
return {
id: t.id,
title: parts.title || "Task",
subtitle: parts.subtitle,
failedAt: t.completed_at || t.updated_at,
status: t.task_status,
};
});
setInProgress(inProg);
setCompleted(done);
setFailed(failedTasks);
// Track newly completed IDs when popover is closed
const newCompletedIdSet = new Set(done.map((d) => d.id));
@@ -486,10 +598,17 @@ export const TaskQueue = () => {
};
window.addEventListener("cover-image-uploaded", handleCoverUploaded);
let unlisten: Promise<UnlistenFn> | null = null;
let unlistenComplete: Promise<UnlistenFn> | null = null;
let unlistenFailed: Promise<UnlistenFn> | null = null;
(async () => {
// Update immediately when Tauri signals a generation completion
unlisten = listen("generation-complete-event", () => {
unlistenComplete = listen("generation-complete-event", () => {
if (!cancelled) {
load();
}
});
// Also update immediately when a generation fails
unlistenFailed = listen("generation-failed-event", () => {
if (!cancelled) {
load();
}
@@ -499,8 +618,11 @@ export const TaskQueue = () => {
cancelled = true;
clearInterval(id);
window.removeEventListener("cover-image-uploaded", handleCoverUploaded);
if (unlisten) {
unlisten.then((f) => f());
if (unlistenComplete) {
unlistenComplete.then((f) => f());
}
if (unlistenFailed) {
unlistenFailed.then((f) => f());
}
};
}, [
@@ -511,8 +633,9 @@ export const TaskQueue = () => {
]);
const hasNothing = useMemo(
() => inProgress.length === 0 && completed.length === 0,
[inProgress.length, completed.length],
() =>
inProgress.length === 0 && completed.length === 0 && failed.length === 0,
[inProgress.length, completed.length, failed.length],
);
const inProgressCount = inProgress.length;
@@ -533,6 +656,7 @@ export const TaskQueue = () => {
await MarkTaskAsDismissed(id);
setInProgress((prev) => prev.filter((t) => t.id !== id));
setCompleted((prev) => prev.filter((t) => t.id !== id));
setFailed((prev) => prev.filter((t) => t.id !== id));
setUnreadCompletedIds((prev) => (prev ?? []).filter((x) => x !== id));
taskDurationRef.current.delete(id);
} catch (_) {
@@ -552,6 +676,17 @@ export const TaskQueue = () => {
}
};
const dismissFailed = async () => {
const ids = failed.map((t) => t.id);
try {
await Promise.all(ids.map((id) => MarkTaskAsDismissed(id)));
} catch (_) {
// ignore
} finally {
setFailed([]);
}
};
const dismissStale = async () => {
const staleIds = inProgress.filter((t) => t.canDismiss).map((t) => t.id);
try {
@@ -575,6 +710,7 @@ export const TaskQueue = () => {
} finally {
setCompleted([]);
setInProgress([]);
setFailed([]);
setUnreadCompletedIds([]);
taskDurationRef.current.clear();
}
@@ -609,8 +745,8 @@ export const TaskQueue = () => {
>
{(close) => (
<>
<div className="flex max-h-[480px] flex-col">
<div className="max-h-[420px] overflow-y-auto p-1">
<div className="flex max-h-[80vh] flex-col">
<div className="max-h-[80vh] overflow-y-auto p-1">
{hasNothing ? (
<div className="flex w-full flex-col items-center justify-center p-5 text-base-fg/60">
<div className="flex items-center gap-2.5 text-sm opacity-60">
@@ -637,10 +773,48 @@ export const TaskQueue = () => {
))}
</div>
)}
{failed.length > 0 && (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between px-1">
<div className="text-xs font-semibold uppercase tracking-wide text-red-400/70">
Failed
</div>
<button
className="text-xs tracking-wide text-red-400/70 transition-colors hover:text-red-300"
onClick={() => handleClearFailed()}
>
<FontAwesomeIcon
icon={faXmark}
className="mr-1"
/>
Clear failed
</button>
</div>
{failed.map((t) => (
<FailedCard
key={t.id}
task={t}
onDismiss={() => dismissTask(t.id)}
/>
))}
</div>
)}
{completed.length > 0 && (
<div>
<div className="mb-1 px-1 text-xs uppercase tracking-wide text-base-fg/50">
Completed
<div className="mb-1 flex items-center justify-between px-1">
<div className="text-xs font-semibold uppercase tracking-wide text-base-fg/50">
Completed
</div>
<button
className="text-xs tracking-wide text-base-fg/50 transition-colors hover:text-base-fg/100"
onClick={() => handleClearCompleted()}
>
<FontAwesomeIcon
icon={faXmark}
className="mr-1"
/>
Clear completed
</button>
</div>
{completed.map((t) => (
<CompletedCard
@@ -680,11 +854,11 @@ export const TaskQueue = () => {
</div>
)}
</div>
<div className="pt-1">
<div className="flex items-center justify-between gap-2">
<div className="pt-3">
<div className="flex items-center justify-center">
<Button
className="grow"
variant="secondary"
className="grow border-none bg-white/5 text-white/70 hover:bg-white/10"
variant="ghost"
onClick={() => {
setModalOpen(true);
close();
@@ -692,19 +866,6 @@ export const TaskQueue = () => {
>
Show all
</Button>
<Tooltip
content="Clear completed"
position="bottom"
closeOnClick={true}
>
<Button
className="flex h-9 w-9 items-center justify-center rounded-md bg-green-500/10 text-green-500 hover:bg-green-500/20"
aria-label="Clear completed"
onClick={() => handleClearCompleted(() => close())}
>
<FontAwesomeIcon icon={faBroom} />
</Button>
</Tooltip>
</div>
</div>
</div>
@@ -738,6 +899,13 @@ export const TaskQueue = () => {
<FontAwesomeIcon icon={faTrashAlt} className="mr-1.5" />
Clear stale
</Button>
<Button
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
onClick={() => handleClearFailed()}
>
<FontAwesomeIcon icon={faTrashAlt} className="mr-1.5" />
Clear failed
</Button>
<Button
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
onClick={() => handleRemoveAll()}
@@ -775,6 +943,20 @@ export const TaskQueue = () => {
))}
</div>
)}
{failed.length > 0 && (
<div className="mb-4">
<div className="mb-2 px-1 text-xs uppercase tracking-wide text-red-400/70">
Failed
</div>
{failed.map((t) => (
<FailedCard
key={t.id}
task={t}
onDismiss={() => dismissTask(t.id)}
/>
))}
</div>
)}
{completed.length > 0 && (
<div>
<div className="mb-2 px-1 text-xs uppercase tracking-wide text-base-fg/50">
@@ -3,6 +3,7 @@ import { JobContextType } from "@storyteller/common";
import { PromptBoxImage } from "@storyteller/ui-promptbox";
import { uploadImage } from "../../components/reusable/UploadModalMedia/uploadImage";
import BackgroundGallery from "./BackgroundGallery";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import {
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
ModelPage,
@@ -13,11 +14,13 @@ import {
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
interface TextToImageProps {
imageMediaId?: string;
imageUrl?: string;
}
import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
//import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
import { useTextToImageStore } from "./TextToImageStore";
import { animated, useSpring } from "@react-spring/web";
import {
@@ -40,7 +43,9 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const batches = useTextToImageStore((s) => s.batches);
const startBatch = useTextToImageStore((s) => s.startBatch);
const completeBatch = useTextToImageStore((s) => s.completeBatch);
//const completeBatch = useTextToImageStore((s) => s.completeBatch);
const failBatch = useTextToImageStore((s) => s.failBatch);
const dismissBatch = useTextToImageStore((s) => s.dismissBatch);
const resetBatches = useTextToImageStore((s) => s.reset);
const [imageRowVisible, setImageRowVisible] = useState(false);
const promptContentRef = useRef<HTMLDivElement>(null);
@@ -95,6 +100,23 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
config: { tension: 200, friction: 28, mass: 1.1 },
});
// Listen for generation-failed-event and mark the oldest pending image batch as failed
useEffect(() => {
let unlisten: Promise<UnlistenFn> | null = null;
unlisten = listen<{ data: { action: string; reason?: string } }>(
"generation-failed-event",
(event) => {
const { action, reason } = event.payload.data;
if (action === "generate_image") {
failBatch(reason || "Generation failed");
}
},
);
return () => {
if (unlisten) unlisten.then((f) => f());
};
}, [failBatch]);
// Show the batches in reverse order, with the newest items at top.
// Like Midjourney instead of a "chat history" style.
const inverseBatch = [...batches].reverse();
@@ -129,10 +151,40 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 pr-2">
{inverseBatch.map((batch) => (
<div key={batch.id} className="flex items-start gap-4">
<div
key={batch.id}
className="relative flex items-stretch gap-4"
>
<div className="grid flex-1 grid-cols-4 gap-4">
{batch.status === "pending" && batch.images.length === 0
? Array.from({
{batch.status === "failed" ? (
<>
<div className="flex aspect-square w-full flex-col items-center justify-center gap-3 rounded-lg bg-red-500/10 text-red-400">
<FontAwesomeIcon
icon={faCircleExclamation}
size="2x"
/>
<span className="px-4 text-center text-sm font-medium">
{batch.failureReason || "Generation failed"}
</span>
<button
onClick={() => dismissBatch(batch.id)}
className="mt-1 flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70"
>
<FontAwesomeIcon icon={faXmark} />
Dismiss
</button>
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={`empty-${batch.id}-${i}`}
className="aspect-square w-full rounded-lg bg-white/[0.02]"
/>
))}
</>
) : batch.status === "pending" &&
batch.images.length === 0 ? (
<>
{Array.from({
length: Math.max(
1,
Math.min(4, batch.requestedCount ?? 4),
@@ -140,10 +192,30 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
}).map((_, i) => (
<div
key={`sk-${batch.id}-${i}`}
className="aspect-square w-full animate-pulse rounded-lg bg-white/5"
className="aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]"
>
<div className="animate-shimmer h-full w-full bg-gradient-to-r from-transparent via-white/10 to-transparent bg-[length:200%_100%]" />
</div>
))}
{Array.from({
length: Math.max(
0,
4 -
Math.max(
1,
Math.min(4, batch.requestedCount ?? 4),
),
),
}).map((_, i) => (
<div
key={`filler-sk-${batch.id}-${i}`}
className="aspect-square w-full rounded-lg bg-white/[0.02]"
/>
))
: batch.images.slice(0, 4).map((img) => (
))}
</>
) : (
<>
{batch.images.slice(0, 4).map((img) => (
<button
key={img.media_token}
onClick={() => {
@@ -170,21 +242,38 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
galleryModalLightboxImage.value = lightboxItem;
galleryModalLightboxVisible.value = true;
}}
className="aspect-square w-full overflow-hidden rounded-lg"
className="aspect-square w-full overflow-hidden rounded-lg transition-opacity duration-200 hover:cursor-pointer hover:opacity-75"
>
<img
src={img.cdn_url}
alt="Generated"
className="h-full w-full object-cover"
className="h-full w-full bg-black/10 object-cover"
/>
</button>
))}
{Array.from({
length: Math.max(
0,
4 - batch.images.slice(0, 4).length,
),
}).map((_, i) => (
<div
key={`filler-${batch.id}-${i}`}
className="aspect-square w-full rounded-lg bg-white/[0.02]"
/>
))}
</>
)}
</div>
<div>
<div className="glass inline-block w-[320px] shrink-0 rounded-xl px-4 py-3 text-left text-sm text-base-fg/90">
<div
className="flex w-[320px] shrink-0"
aria-hidden="true"
/>
<div className="absolute bottom-0 right-0 top-0 flex w-[320px] flex-col">
<div className="glass min-h-0 overflow-y-auto rounded-xl px-4 py-3 text-left text-sm text-base-fg/90">
<div>{batch.prompt}</div>
</div>
<div className="mt-2 flex justify-end">
<div className="flex justify-end pt-2">
<Badge
label={batch.modelLabel}
className="px-2 py-1 text-xs opacity-70"
@@ -4,12 +4,13 @@ import type { GeneratedImage } from "@storyteller/tauri-events";
export type TextToImageBatch = {
id: string;
prompt: string;
status: "pending" | "complete";
status: "pending" | "complete" | "failed";
images: GeneratedImage[];
createdAt: number;
requestedCount: number;
modelLabel: string;
subscriberId: string;
failureReason?: string;
};
type TextToImageState = {
@@ -25,6 +26,8 @@ type TextToImageState = {
maybeSubscriberId?: string,
maybePrompt?: string,
) => void;
failBatch: (reason?: string) => void;
dismissBatch: (id: string) => void;
reset: () => void;
};
@@ -62,8 +65,8 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
const pending = maybeSubscriberId
? get().batches.find((b) => b.subscriberId === maybeSubscriberId)
: get().batches.find((b) => b.status === "pending");
const prompt = pending?.prompt ?? maybePrompt ?? "";
const modelLabel = pending?.modelLabel ?? "";
//const prompt = pending?.prompt ?? maybePrompt ?? "";
//const modelLabel = pending?.modelLabel ?? "";
// Mark the most recent pending batch complete, or create one if none exists
set((s) => {
const idx = pending
@@ -81,5 +84,24 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
return { batches: updated };
});
},
failBatch: (reason?: string) => {
set((s) => {
// Mark the oldest pending batch as failed
const idx = s.batches.findIndex((b) => b.status === "pending");
if (idx === -1) return { batches: s.batches };
const updated = [...s.batches];
updated[idx] = {
...updated[idx],
status: "failed",
failureReason: reason,
};
return { batches: updated };
});
},
dismissBatch: (id: string) => {
set((s) => ({
batches: s.batches.filter((b) => b.id !== id),
}));
},
reset: () => set({ batches: [] }),
}));
@@ -18,9 +18,14 @@ export default {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
shimmer: {
"0%": { backgroundPosition: "200% 0" },
"100%": { backgroundPosition: "-200% 0" },
},
},
animation: {
fadeIn: "fadeIn 0.2s ease-in-out",
shimmer: "shimmer 2s ease-in-out infinite",
},
},
colors: {