Polish search and embedding UX
- add semantic text search with toolbar mode switching and sqlite-vec query support - improve embedding progress visibility, failure recovery, and similar-image affordances - add search clearing and keyboard controls for filename vs semantic search modes - refine background task interactions and gallery/lightbox embedding states Refs: #4
This commit is contained in:
@@ -1,15 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useGalleryStore } from "../store";
|
||||
|
||||
function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400 transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
type WorkerKey = "thumbnail" | "metadata" | "embedding";
|
||||
|
||||
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
||||
Thumbnails: "thumbnail",
|
||||
Metadata: "metadata",
|
||||
Embeddings: "embedding",
|
||||
};
|
||||
|
||||
interface TaskStage {
|
||||
label: string;
|
||||
detail: string;
|
||||
progress: number | null; // 0–100, or null for indeterminate
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
name: string;
|
||||
stages: TaskStage[];
|
||||
hasFailedEmbeddings: boolean;
|
||||
pendingMediaWork: number;
|
||||
embeddingProcessed: number;
|
||||
embeddingTotal: number;
|
||||
currentFile: string | null;
|
||||
snapshot: string;
|
||||
}
|
||||
|
||||
export function BackgroundTasks() {
|
||||
@@ -17,114 +34,351 @@ export function BackgroundTasks() {
|
||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
||||
const [paused, setPaused] = useState<Record<WorkerKey, boolean>>({
|
||||
thumbnail: false,
|
||||
metadata: false,
|
||||
embedding: false,
|
||||
});
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
useEffect(() => {
|
||||
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>(
|
||||
"get_worker_states",
|
||||
).then((states) => {
|
||||
setPaused({
|
||||
thumbnail: states.thumbnail_paused,
|
||||
metadata: states.metadata_paused,
|
||||
embedding: states.embedding_paused,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleWorker = (worker: WorkerKey) => {
|
||||
const next = !paused[worker];
|
||||
setPaused((prev) => ({ ...prev, [worker]: next }));
|
||||
void invoke("set_worker_paused", { worker, paused: next });
|
||||
};
|
||||
|
||||
const dismissTask = (id: number, snapshot: string) => {
|
||||
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
const tasks = useMemo<Task[]>(() => {
|
||||
return folders
|
||||
.map((folder) => {
|
||||
.map((folder): Task | null => {
|
||||
const index = indexingProgress[folder.id];
|
||||
const jobs = mediaJobProgress[folder.id];
|
||||
const pendingMediaWork =
|
||||
(jobs?.thumbnail_pending ?? 0) +
|
||||
(jobs?.metadata_pending ?? 0) +
|
||||
(jobs?.embedding_pending ?? 0);
|
||||
const embeddingProcessed = (jobs?.embedding_ready ?? 0) + (jobs?.embedding_failed ?? 0);
|
||||
const embeddingTotal = embeddingProcessed + (jobs?.embedding_pending ?? 0);
|
||||
const hasFailedEmbeddings = (jobs?.embedding_failed ?? 0) > 0;
|
||||
|
||||
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) {
|
||||
return null;
|
||||
const thumbnailPending = jobs?.thumbnail_pending ?? 0;
|
||||
const metadataPending = jobs?.metadata_pending ?? 0;
|
||||
const embeddingPending = jobs?.embedding_pending ?? 0;
|
||||
const embeddingReady = jobs?.embedding_ready ?? 0;
|
||||
const embeddingFailed = jobs?.embedding_failed ?? 0;
|
||||
|
||||
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending;
|
||||
const embeddingProcessed = embeddingReady + embeddingFailed;
|
||||
const embeddingTotal = embeddingProcessed + embeddingPending;
|
||||
const hasFailedEmbeddings = embeddingFailed > 0;
|
||||
|
||||
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null;
|
||||
|
||||
const stages: TaskStage[] = [];
|
||||
|
||||
if (index && !index.done) {
|
||||
const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0;
|
||||
stages.push({
|
||||
label: "Scanning",
|
||||
detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`,
|
||||
progress: pct,
|
||||
failed: false,
|
||||
});
|
||||
}
|
||||
|
||||
const indexPercent = index && index.total > 0 ? (index.indexed / index.total) * 100 : 0;
|
||||
const embeddingPercent = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0;
|
||||
if (thumbnailPending > 0) {
|
||||
stages.push({
|
||||
label: "Thumbnails",
|
||||
detail: thumbnailPending.toLocaleString(),
|
||||
progress: null,
|
||||
failed: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (metadataPending > 0) {
|
||||
stages.push({
|
||||
label: "Metadata",
|
||||
detail: metadataPending.toLocaleString(),
|
||||
progress: null,
|
||||
failed: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (embeddingPending > 0) {
|
||||
const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0;
|
||||
stages.push({
|
||||
label: "Embeddings",
|
||||
detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`,
|
||||
progress: pct,
|
||||
failed: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasFailedEmbeddings && pendingMediaWork === 0) {
|
||||
stages.push({
|
||||
label: "Failed",
|
||||
detail: `${embeddingFailed.toLocaleString()} embeddings`,
|
||||
progress: null,
|
||||
failed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = `${pendingMediaWork}:${embeddingFailed}`;
|
||||
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
index,
|
||||
jobs,
|
||||
stages,
|
||||
hasFailedEmbeddings,
|
||||
pendingMediaWork,
|
||||
indexPercent,
|
||||
embeddingProcessed,
|
||||
embeddingTotal,
|
||||
embeddingPercent,
|
||||
hasFailedEmbeddings,
|
||||
currentFile: index && !index.done ? (index.current_file || null) : null,
|
||||
snapshot,
|
||||
};
|
||||
})
|
||||
.filter((task) => task !== null);
|
||||
}, [folders, indexingProgress, mediaJobProgress]);
|
||||
.filter((t): t is Task => t !== null)
|
||||
.filter((t) => dismissed[t.id] !== t.snapshot);
|
||||
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
const primary = tasks[0];
|
||||
const extraCount = tasks.length - 1;
|
||||
const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0);
|
||||
|
||||
// Best progress bar value: use embedding progress if available (most informative),
|
||||
// otherwise fall back to scanning progress, otherwise indeterminate.
|
||||
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
||||
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
||||
const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-white/5 bg-gray-950/40 px-5 py-2 backdrop-blur-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Background Tasks</h3>
|
||||
<span className="text-xs text-gray-500">{tasks.length} active</span>
|
||||
</div>
|
||||
<div className="grid gap-2 xl:grid-cols-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{task.name}</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{task.index && !task.index.done
|
||||
? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned`
|
||||
: task.hasFailedEmbeddings && task.pendingMediaWork === 0
|
||||
? `Embedding failures require attention`
|
||||
: `${task.pendingMediaWork.toLocaleString()} media jobs remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-gray-400">
|
||||
{task.jobs?.thumbnail_pending ? <div>{task.jobs.thumbnail_pending.toLocaleString()} thumbnails</div> : null}
|
||||
{task.jobs?.metadata_pending ? <div>{task.jobs.metadata_pending.toLocaleString()} metadata</div> : null}
|
||||
{task.embeddingTotal > 0 ? (
|
||||
<div>
|
||||
{task.embeddingProcessed.toLocaleString()} / {task.embeddingTotal.toLocaleString()} embeddings
|
||||
</div>
|
||||
) : null}
|
||||
{task.jobs?.embedding_failed ? <div>{task.jobs.embedding_failed.toLocaleString()} failed</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-white/[0.06]">
|
||||
{/* Slim bar */}
|
||||
<div
|
||||
className={`group flex items-center gap-3 px-5 h-11 cursor-pointer select-none transition-colors ${
|
||||
expanded ? "bg-white/[0.03]" : "hover:bg-white/[0.02]"
|
||||
}`}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{/* Pulse dot */}
|
||||
<div className="relative shrink-0">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} />
|
||||
<div className={`absolute inset-0 h-1.5 w-1.5 rounded-full animate-ping opacity-60 ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} />
|
||||
</div>
|
||||
|
||||
{task.index && !task.index.done ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={task.indexPercent} />
|
||||
<p className="truncate text-[11px] text-gray-500">{task.index.current_file || "Scanning..."}</p>
|
||||
</div>
|
||||
) : task.embeddingTotal > 0 && (task.jobs?.embedding_pending ?? 0) > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={task.embeddingPercent} />
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{task.embeddingProcessed.toLocaleString()} completed, {task.jobs?.embedding_pending?.toLocaleString() ?? 0} remaining
|
||||
</p>
|
||||
</div>
|
||||
) : task.hasFailedEmbeddings ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={100} />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-amber-300">
|
||||
{task.jobs?.embedding_failed?.toLocaleString() ?? 0} embedding failures need attention
|
||||
</p>
|
||||
{/* Folder name */}
|
||||
<span className="text-[13px] font-medium text-white/60 shrink-0">{primary.name}</span>
|
||||
|
||||
{/* Stage tags — all active stages visible simultaneously */}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
{primary.stages.map((stage) => {
|
||||
const workerKey = WORKER_FOR_STAGE[stage.label];
|
||||
const isPaused = workerKey ? paused[workerKey] : false;
|
||||
return (
|
||||
<span
|
||||
key={stage.label}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
|
||||
stage.failed
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: isPaused
|
||||
? "bg-white/4 text-gray-600"
|
||||
: "bg-white/5 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span>{stage.label}</span>
|
||||
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
|
||||
{stage.detail}
|
||||
</span>
|
||||
{workerKey && (
|
||||
<button
|
||||
className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-200 hover:bg-amber-500/20"
|
||||
onClick={() => void retryFailedEmbeddings(task.id)}
|
||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
|
||||
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
||||
onClick={(e) => { e.stopPropagation(); toggleWorker(workerKey); }}
|
||||
>
|
||||
Retry
|
||||
{isPaused ? (
|
||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress bar — embedding or scanning progress, pulsing if indeterminate */}
|
||||
<div className="w-24 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
hasFailed
|
||||
? "bg-amber-400/60"
|
||||
: barProgress === null
|
||||
? "bg-blue-500/40 animate-pulse w-full"
|
||||
: "bg-blue-500"
|
||||
}`}
|
||||
style={barProgress !== null ? { width: `${barProgress}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Extra folders badge */}
|
||||
{extraCount > 0 && (
|
||||
<span className="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500 shrink-0">
|
||||
+{extraCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Retry (failed embeddings only) */}
|
||||
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && (
|
||||
<button
|
||||
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Expand chevron (only when multiple folders) */}
|
||||
{tasks.length > 1 && (
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Dismiss */}
|
||||
<button
|
||||
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
|
||||
title="Dismiss"
|
||||
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded panel — one row per folder */}
|
||||
{expanded && (
|
||||
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
|
||||
{tasks.map((task) => {
|
||||
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
||||
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
||||
const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null;
|
||||
const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0;
|
||||
|
||||
return (
|
||||
<div key={task.id}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px] text-white/50 w-28 truncate shrink-0">{task.name}</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
{task.stages.map((stage) => {
|
||||
const workerKey = WORKER_FOR_STAGE[stage.label];
|
||||
const isPaused = workerKey ? paused[workerKey] : false;
|
||||
return (
|
||||
<span
|
||||
key={stage.label}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
|
||||
stage.failed
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: isPaused
|
||||
? "bg-white/4 text-gray-600"
|
||||
: "bg-white/5 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{isPaused && (
|
||||
<svg className="h-2 w-2 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{stage.label}</span>
|
||||
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}>
|
||||
{stage.detail}
|
||||
</span>
|
||||
{workerKey && (
|
||||
<button
|
||||
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
|
||||
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
|
||||
onClick={() => toggleWorker(workerKey)}
|
||||
>
|
||||
{isPaused ? (
|
||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-20 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
taskHasFailed
|
||||
? "bg-amber-400/60"
|
||||
: taskBarProgress === null
|
||||
? "bg-blue-500/40 animate-pulse w-full"
|
||||
: "bg-blue-500"
|
||||
}`}
|
||||
style={taskBarProgress !== null ? { width: `${taskBarProgress}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{taskHasFailed && (
|
||||
<button
|
||||
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0"
|
||||
onClick={() => void retryFailedEmbeddings(task.id)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
|
||||
title="Dismiss"
|
||||
onClick={() => dismissTask(task.id, task.snapshot)}
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{task.currentFile && (
|
||||
<p className="text-[10px] text-gray-600 truncate mt-1 pl-[calc(7rem+0.75rem)]">
|
||||
{task.currentFile}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : task.pendingMediaWork > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<ProgressBar value={0} />
|
||||
<p className="text-[11px] text-gray-500">Processing thumbnails, metadata, and embeddings</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user