fix(control-plane): knowledge-pages polish (#3080)

* fix(control-plane): stop next-intl parsing 'type:<x>' tag hint as an unclosed tag

The Tags hint used 'type:<x>', which next-intl reads as an unclosed rich-text
tag (INVALID_MESSAGE: UNCLOSED_TAG), crashing the create/edit page dialog.
Replace the angle-bracket placeholder with 'type:…' across all locales.

* refactor(control-plane): replace loading-emoji hourglasses with a shared Spinner

The UI used a spinning  emoji for loading in several places — inconsistent and
not accessible. Add a shared <Spinner> (wrapping lucide Loader2, sized xs–xl,
role=status) and use it for all 7 loading states: the large centered
'loading…' placeholders (documents / document / chunks / chunk-modal) and the
inline save-button spinners (tags / content / edit-memory).

* feat(control-plane): branded tumbling-logo spinner for loading states

Add a LogoSpinner that renders the Hindsight mark doing a looping 2D
'tumble' (crouch → hop + 360° flip → land squash), ported from the motion
lab. The hop is expressed as a % of the element so one keyframe scales
across sizes, and reduced-motion falls back to an opacity pulse.

Use it for the prominent centered loading states (documents list, document/
chunk modal); the compact lucide Spinner stays for tiny inline spots.

* feat(control-plane): make the tumbling logo the shared spinner everywhere

Fold LogoSpinner into the shared Spinner so every loading state renders the
Hindsight mark, and swap all remaining ad-hoc spinners (lucide Loader2, the
hand-rolled ring-<div> spinners, the sonner toast loader, and the two
RefreshCw-as-loader stand-ins) over to it — ~65 sites across 22 files.

Spinner now has two motions:
- variant "flip" (default): an in-place 360° flip + squash, safe inline and in
  buttons (only a tiny vertical bob, stays on the text baseline).
- variant "jump": the full tumble (crouch + hop + flip), for prominent centered
  loaders that have vertical room.

RefreshCw icons that spin only while refreshing are left as-is — a spinning
refresh arrow reads as "refreshing", which is distinct from a generic loader.

* fix(control-plane): show the loader (not empty state) on Documents, and finish the spinner sweep

Documents: the mount fetch is debounced, so the empty state ("No documents
found" / "0 documents") flashed before the spinner. Track a `loaded` flag and
gate the empty state + count on it, so the loader shows until the first fetch
resolves. Also swap the 📄/📊 emoji empty states for lucide icons.

Finish converting the loaders the first sweep missed (they used non-spinner
patterns, so grepping for Loader2/animate-spin didn't find them):
- pulsing Clock full-view loaders → jump Spinner (stats page, bank profile)
- emoji + text-only loaders (entities graph/list/linked-memories, llm-requests
  and audit-logs chart loaders) → Spinner
- emoji empty states (no chunks / no entities / no data) → lucide icons

* fix(control-plane): render the Documents loader on first paint (hard refresh)

On a hard refresh, bank-context starts currentBank=null and only resolves it
from the URL in an effect (after the first paint, before hydration + theme), so
the previous `!!currentBank` guard made the empty state win the very first
render — you'd see "No documents found" flash before anything else.

Gate the loader on `!loaded` alone. currentBank always resolves on a
/banks/[id] route and the fetch's finally flips `loaded` (even on an invalid
bank whose fetch errors), so it can't get stuck. Verified: the SSR HTML now
renders the loading Spinner, not the empty state.

* fix(control-plane): apply theme before first paint (no light flash on hard refresh)

ThemeProvider only reads the saved/system theme in a useEffect (after paint), so
a dark-mode user saw a light flash on every hard refresh. Add a tiny blocking
inline script as the first <body> child that sets the .dark class synchronously
before the content paints — the standard anti-FOUC pattern (what next-themes does
internally). <html> already has suppressHydrationWarning for the class mutation.
Logic mirrors lib/theme-context.tsx exactly (saved || system).

* feat(control-plane): add the tumbling mascot to the no-bank welcome screen

Loop the jump Spinner above 'Welcome to Hindsight' as a friendly greeting on
the dashboard shown when no bank is selected.

* feat(control-plane): spin the header logo on sidebar navigation

Clicking a sidebar item now gives the header Hindsight logo a one-shot 'spin
round' — a little playful nav feedback. The sidebar dispatches a
'hindsight:logo-spin' window event (decoupled, like DOCUMENTS_REFRESH_EVENT) and
the header listens and toggles a one-shot animation, cleared on animationEnd.

The logo is a wide lockup, so a 2D rotate would swing it vertical and overflow
the header — use a rotateY card-flip that stays within its footprint instead.

* feat(control-plane): spin only the logo mark (not the wordmark) on navigation

Split the header lockup into two pieces: the octopus mark (favicon.png, a
standalone image so it can rotate freely) and the 'Hindsight' wordmark (the
right slice of the full logo.png, shown via a cropped background). Their widths
sum to the full logo so they butt together seamlessly. Only the mark carries the
one-shot spin, so the wordmark stays put.

Because the mark alone is ~square, the spin is now a clean 2D rotate (reverted
the rotateY card-flip workaround that the wide full lockup had required).

* feat(control-plane): soften logo nav feedback from a spin to a subtle wiggle

A full 360 rotate was too much. Replace it with a small tilt that springs back
(logo-wiggle, ~450ms) on the mark — less impactful but still a bit of life.

* chore(control-plane): drop the dead logo-spin-once reduced-motion block

Leftover from the rename to logo-wiggle.

* style(control-plane): apply prettier formatting to the spinner-sweep files

Ran scripts/hooks/lint.sh — the earlier commits were eslint-clean but not
prettier-formatted, which tripped verify-generated-files.
This commit is contained in:
Nicolò Boschi
2026-07-30 19:04:56 +02:00
committed by GitHub
parent d105038323
commit b651f43f22
45 changed files with 375 additions and 137 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

@@ -49,13 +49,13 @@ import {
Brain,
Download,
Trash2,
Loader2,
MoreVertical,
Pencil,
RotateCcw,
Activity,
FlaskConical,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { LlmHealthDialog } from "@/components/llm-health-dialog";
import { ExtractDialog } from "@/components/extract-dialog";
@@ -304,7 +304,7 @@ export default function BankPage() {
}
>
{isConsolidating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Brain className="w-4 h-4 mr-2" />
)}
@@ -321,7 +321,7 @@ export default function BankPage() {
}
>
{isRecoveringConsolidation ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<RotateCcw className="w-4 h-4 mr-2" />
)}
@@ -770,7 +770,7 @@ export default function BankPage() {
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("deleting")}
</>
) : (
@@ -807,7 +807,7 @@ export default function BankPage() {
<AlertDialogAction onClick={handleResetConfig} disabled={isResettingConfig}>
{isResettingConfig ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("resetting")}
</>
) : (
@@ -850,7 +850,7 @@ export default function BankPage() {
>
{isClearingObservations ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("clearing")}
</>
) : (
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { BankSelector } from "@/components/bank-selector";
import { Spinner } from "@/components/ui/spinner";
import { useBank } from "@/lib/bank-context";
import { bankRoute } from "@/lib/bank-url";
@@ -26,6 +27,8 @@ export default function DashboardPage() {
<div className="flex items-center justify-center h-[calc(100vh-80px)] bg-muted/20">
<div className="text-center p-10 bg-card rounded-lg border-2 border-border shadow-lg max-w-md">
{/* Tumbling Hindsight mascot — loops forever as a friendly greeting. */}
<Spinner size="xl" variant="jump" className="mx-auto mb-4" />
<h3 className="text-2xl font-bold mb-3 text-card-foreground">{t("welcome")}</h3>
<p className="text-muted-foreground">{t("selectBank")}</p>
</div>
@@ -11,6 +11,21 @@ export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
// Anti-FOUC: apply the theme's `.dark` class before first paint. ThemeProvider
// only reads localStorage/system preference in a useEffect (after paint), so
// without this a dark-mode user gets a light flash on every hard refresh. This
// runs synchronously as the first <body> child, before the content below it
// paints. Keep the logic identical to lib/theme-context.tsx (saved || system).
const THEME_INIT_SCRIPT = `
(function () {
try {
var saved = localStorage.getItem('theme');
var dark = saved ? saved === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
} catch (e) {}
})();
`;
export default async function LocaleLayout({
children,
params,
@@ -28,6 +43,7 @@ export default async function LocaleLayout({
return (
<html lang={locale} suppressHydrationWarning>
<body className="bg-background text-foreground">
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<ThemeProvider>
<FeaturesProvider>
<BankProvider>
+108
View File
@@ -330,6 +330,114 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
background-color: rgba(255, 255, 255, 0.05);
}
/* Branded loading spinner — the Hindsight mark doing a looping "tumble": a
crouch, a hop with an in-plane 360° flip, then a squash on landing. It's pure
2D squash-and-stretch (scale x/y + in-plane rotate, no 3D transforms) so the
flat logo reads as a bouncy little character. The hop distance is a % of the
element's own height, so one keyframe scales across every LogoSpinner size.
Ported from the "full tumble" motion-lab experiment. */
@keyframes logo-tumble {
0% {
transform: translateY(0) rotate(0deg) scale(1, 1);
}
10% {
transform: translateY(6%) scale(1.1, 0.84); /* crouch / anticipation */
}
34% {
transform: translateY(-72%) rotate(200deg) scale(0.92, 1.08); /* rise + flip, stretched */
}
60% {
transform: translateY(-46%) rotate(360deg) scale(1, 1); /* flip done, falling */
}
80% {
transform: translateY(0) rotate(360deg) scale(1.14, 0.82); /* land squash */
}
90%,
100% {
transform: translateY(0) rotate(360deg) scale(1, 1); /* settle + brief rest */
}
}
.animate-logo-tumble {
/* Overshoot easing (negative/over-1 control points) gives the hop its spring. */
animation: logo-tumble 1400ms cubic-bezier(0.4, -0.15, 0.3, 1.3) infinite;
transform-origin: 50% 54%;
will-change: transform;
}
/* One-shot logo wiggle — subtle nav feedback fired on sidebar navigation.
Applied to the mark ALONE (the header logo is split into an animatable mark +
a static wordmark). A small tilt that springs back — far less impactful than a
full spin, but still a bit of life. */
@keyframes logo-wiggle {
0% {
transform: rotate(0deg);
}
35% {
transform: rotate(-9deg);
}
65% {
transform: rotate(5deg);
}
100% {
transform: rotate(0deg);
}
}
.animate-logo-wiggle {
animation: logo-wiggle 450ms ease-in-out;
transform-origin: 50% 54%;
}
@media (prefers-reduced-motion: reduce) {
.animate-logo-wiggle {
animation: none;
}
}
/* In-place flip variant: same 360° tumble + squash, but only a tiny vertical
bob (±8%) so it stays on the text baseline inside buttons and inline labels.
This is the default spinner motion; the big hop above is reserved for roomy
centered loaders. */
@keyframes logo-flip {
0% {
transform: translateY(0) rotate(0deg) scale(1, 1);
}
15% {
transform: translateY(4%) scale(1.08, 0.9); /* squash */
}
50% {
transform: translateY(-8%) rotate(200deg) scale(0.94, 1.06); /* flip, slight lift + stretch */
}
85% {
transform: translateY(0) rotate(360deg) scale(1.08, 0.9); /* land squash */
}
100% {
transform: translateY(0) rotate(360deg) scale(1, 1);
}
}
.animate-logo-flip {
animation: logo-flip 1200ms cubic-bezier(0.45, 0, 0.25, 1) infinite;
transform-origin: 50% 54%;
will-change: transform;
}
/* Respect reduced-motion: drop the bounce for a calm opacity pulse. */
@media (prefers-reduced-motion: reduce) {
.animate-logo-tumble,
.animate-logo-flip {
animation: logo-tumble-pulse 1200ms ease-in-out infinite;
}
}
@keyframes logo-tumble-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* Themed scrollbars — match the app surface instead of the default white track.
Theme tokens are oklch, so wrap with color-mix to apply opacity. */
* {
@@ -24,6 +24,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { LineChart, Line, XAxis, Tooltip, ResponsiveContainer } from "recharts";
type TranslateFn = (key: string) => string;
@@ -197,8 +198,9 @@ function AuditChart({ bankId }: { bankId: string }) {
<CardContent>
<div className="h-[120px]">
{loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{t("chartLoading")}
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground text-sm">
<Spinner size="md" />
<span>{t("chartLoading")}</span>
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -42,7 +42,8 @@ import {
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Loader2, AlertCircle, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { AlertCircle, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Card } from "@/components/ui/card";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -554,7 +555,7 @@ export function BankConfigView() {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner size="lg" variant="jump" />
</div>
);
}
@@ -1386,7 +1387,7 @@ function ConfigSection({
<Button size="sm" disabled={!dirty || saving} onClick={onSave}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("saving")}
</>
) : (
@@ -32,7 +32,6 @@ import {
Clock,
AlertCircle,
CheckCircle,
Loader2,
X,
RotateCcw,
Code,
@@ -40,6 +39,7 @@ import {
Trash2,
FileText,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { DocumentChunkModal } from "./document-chunk-modal";
interface Operation {
@@ -181,7 +181,7 @@ export function BankOperationsView() {
if (status === "processing") {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
{label}
</span>
);
@@ -625,7 +625,7 @@ export function BankOperationsView() {
disabled={cancellingOpId === op.id}
>
{cancellingOpId === op.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
) : (
<X className="w-3 h-3 mr-1" />
)}
@@ -644,7 +644,7 @@ export function BankOperationsView() {
disabled={retryingOpId === op.id}
>
{retryingOpId === op.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
) : (
<RotateCcw className="w-3 h-3 mr-1" />
)}
@@ -665,7 +665,7 @@ export function BankOperationsView() {
disabled={deletingOpId === op.id}
>
{deletingOpId === op.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
) : (
<Trash2 className="w-3 h-3 mr-1" />
)}
@@ -731,7 +731,7 @@ export function BankOperationsView() {
</DialogHeader>
{loadingDetails ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
</div>
) : selectedOperation ? (
<div className="space-y-4">
@@ -833,7 +833,7 @@ export function BankOperationsView() {
disabled={cancellingOpId === selectedOperation.operation_id}
>
{cancellingOpId === selectedOperation.operation_id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
<Spinner size="xs" className="mr-1" />
) : (
<X className="w-3 h-3 mr-1" />
)}
@@ -850,7 +850,7 @@ export function BankOperationsView() {
disabled={retryingOpId === selectedOperation.operation_id}
>
{retryingOpId === selectedOperation.operation_id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
<Spinner size="xs" className="mr-1" />
) : (
<RotateCcw className="w-3 h-3 mr-1" />
)}
@@ -868,7 +868,7 @@ export function BankOperationsView() {
disabled={deletingOpId === selectedOperation.operation_id}
>
{deletingOpId === selectedOperation.operation_id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
<Spinner size="xs" className="mr-1" />
) : (
<Trash2 className="w-3 h-3 mr-1" />
)}
@@ -976,7 +976,7 @@ export function BankOperationsView() {
disabled={loadingPayload}
>
{loadingPayload ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
<Spinner size="xs" className="mr-1" />
) : (
<Code className="w-3 h-3 mr-1" />
)}
@@ -33,16 +33,15 @@ import {
import {
RefreshCw,
Brain,
Clock,
Trash2,
Target,
AlertTriangle,
Plus,
Tag,
Loader2,
X,
Pencil,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { TagChip } from "@/components/ui/facet-chip";
interface DispositionTraits {
@@ -281,7 +280,7 @@ export function BankProfileView({ hideReflectFields = false }: { hideReflectFiel
return (
<Card>
<CardContent className="text-center py-10">
<Clock className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
<div className="text-lg text-muted-foreground">{t("loadingProfile")}</div>
</CardContent>
</Card>
@@ -520,7 +519,7 @@ export function BankProfileView({ hideReflectFields = false }: { hideReflectFiel
>
{isClearingObservations ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{tBank("clearing")}
</>
) : (
@@ -567,7 +566,7 @@ export function BankProfileView({ hideReflectFields = false }: { hideReflectFiel
disabled={deletingDirective}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deletingDirective ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
{deletingDirective ? <Spinner size="sm" className="mr-1" /> : null}
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -912,7 +911,7 @@ function DirectiveFormDialog({
disabled={submitting || !form.name.trim() || !form.content.trim()}
className="bg-rose-500 hover:bg-rose-600"
>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
{submitting ? <Spinner size="sm" className="mr-1" /> : null}
{mode === "create" ? t("create") : t("save")}
</Button>
</DialogFooter>
@@ -43,6 +43,7 @@ import {
LogOut,
Copy,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { toast } from "sonner";
import { useTheme } from "@/lib/theme-context";
import { useFeatures } from "@/lib/features-context";
@@ -98,6 +99,9 @@ function BankSelectorInner() {
const { theme, toggleTheme } = useTheme();
const { features } = useFeatures();
const [open, setOpen] = React.useState(false);
// One-shot spin of the header logo, fired by sidebar navigation (see the
// "hindsight:logo-spin" listener below). Reset on animationEnd so it can replay.
const [logoSpinning, setLogoSpinning] = React.useState(false);
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const [newBankId, setNewBankId] = React.useState("");
const [isCreating, setIsCreating] = React.useState(false);
@@ -172,6 +176,18 @@ function BankSelectorInner() {
});
}, []);
// Spin the header logo whenever a sidebar item is clicked. Decoupled via a
// window event (like DOCUMENTS_REFRESH_EVENT) since the sidebar and this header
// are siblings, not parent/child. onAnimationEnd clears the flag so the next
// click replays it. (A mid-spin re-click is a no-op — the flag is already set —
// which is fine; we avoid a requestAnimationFrame restart because rAF is paused
// in background tabs, which would drop the spin entirely.)
React.useEffect(() => {
const spin = () => setLogoSpinning(true);
window.addEventListener("hindsight:logo-spin", spin);
return () => window.removeEventListener("hindsight:logo-spin", spin);
}, []);
const sortedBanks = React.useMemo(() => {
// Sort by last document inserted descending, then by created_at
return [...bankInfos].sort((a, b) => {
@@ -477,15 +493,28 @@ function BankSelectorInner() {
return (
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary-gradient">
<div className="flex items-center gap-4 text-sm">
{/* Logo */}
<Image
src={withBasePath("/logo.png")}
alt="Hindsight"
width={40}
height={40}
className="h-10 w-auto"
unoptimized
/>
{/* Logo, split so only the mark spins on navigation while the wordmark
stays put. The mark is the standalone favicon.png (so it can rotate
freely); the wordmark is the right slice of the full lockup (logo.png)
shown via a cropped background. Their widths sum to the full logo, so
the two pieces butt together seamlessly at h-10. */}
<div className="flex items-center h-10 select-none" aria-label="Hindsight">
<img
src={withBasePath("/favicon.png")}
alt=""
className={cn("h-10 w-auto", logoSpinning && "animate-logo-wiggle")}
onAnimationEnd={() => setLogoSpinning(false)}
/>
<div
className="h-10 w-[99px]"
style={{
backgroundImage: `url(${withBasePath("/logo.png")})`,
backgroundSize: "auto 100%",
backgroundPosition: "right center",
backgroundRepeat: "no-repeat",
}}
/>
</div>
{/* Separator */}
<div className="h-8 w-px bg-border" />
@@ -520,7 +549,7 @@ function BankSelectorInner() {
<CommandEmpty>
{banksLoading ? (
<div className="flex items-center justify-center gap-2 py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<Spinner size="sm" />
<span>{tCommon("loading")}</span>
</div>
) : (
@@ -19,6 +19,7 @@ import {
RefreshCw,
ExternalLink,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Dialog,
DialogContent,
@@ -1095,7 +1096,7 @@ export function BankStatsView() {
if (loading && !stats) {
return (
<div className="flex items-center justify-center py-12">
<Clock className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
</div>
);
}
@@ -24,6 +24,7 @@ import {
Search,
Layers,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
@@ -473,7 +474,7 @@ export function DataView({
<div>
{loading && !data ? (
<div className="text-center py-12">
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
<p className="text-muted-foreground">{t("loadingMemories")}</p>
</div>
) : data && data.total_units === 0 && !hasActiveMemoryFilters ? (
@@ -507,7 +508,10 @@ export function DataView({
{/* Text search */}
<div className="relative max-w-xs flex-1">
{loading ? (
<RefreshCw className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none animate-spin" />
<Spinner
size="sm"
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
/>
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
)}
@@ -1057,7 +1061,7 @@ export function DataView({
) : (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">📊</div>
<ScatterChart className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
<div className="text-sm text-muted-foreground">{t("noDataAvailable")}</div>
</div>
</div>
@@ -6,7 +6,7 @@ import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Loader2 } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { TagChip } from "@/components/ui/facet-chip";
@@ -79,7 +79,7 @@ export function DirectiveDetailModal({ directiveId, onClose }: DirectiveDetailMo
</VisuallyHidden>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<Spinner size="lg" variant="jump" />
</div>
) : error ? (
<div className="flex items-center justify-center py-20">
@@ -11,6 +11,7 @@ import {
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
interface DocumentChunkModalProps {
type: "document" | "chunk";
@@ -70,7 +71,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">{t("loadingType", { type })}</div>
</div>
</div>
@@ -79,6 +79,7 @@ import {
} from "lucide-react";
import { TagFilterInput } from "./tag-filter-input";
import { FacetLegend, MetadataChip, TagChip } from "@/components/ui/facet-chip";
import { Spinner } from "@/components/ui/spinner";
import { HarnessLogo } from "@/components/ui/harness-logo";
import { documentHarness, resolveHarnessLogo } from "@/lib/harness-logo";
@@ -690,6 +691,11 @@ export function DocumentsView() {
const [documents, setDocuments] = useState<any[]>([]);
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([]);
const [loading, setLoading] = useState(false);
// Whether the first document fetch has completed. The mount fetch is debounced
// (see the load effect), so without this the empty state ("No documents found")
// flashes for the initial paint + debounce window before `loading` ever flips.
// Gate the empty state on this so we show the loader until we actually know.
const [loaded, setLoaded] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
// The UI exposes the two useful modes; both map to their *_strict variant so
@@ -769,6 +775,7 @@ export function DocumentsView() {
// Error toast is shown automatically by the API client interceptor
} finally {
setLoading(false);
setLoaded(true);
}
},
[currentBank, searchQuery, selectedTags, tagsMatch]
@@ -1371,17 +1378,29 @@ export function DocumentsView() {
)}
</div>
<div className="mb-4 text-sm text-muted-foreground">
{hasActiveFilters
? t("matchingDocuments", { total: displayTotal })
: t("totalDocuments", { total: displayTotal })}
</div>
{/* Hide the count during the very first load so it doesn't read
"0 total documents" above the loading spinner. */}
{(loaded || documents.length > 0 || pendingRows.length > 0) && (
<div className="mb-4 text-sm text-muted-foreground">
{hasActiveFilters
? t("matchingDocuments", { total: displayTotal })
: t("totalDocuments", { total: displayTotal })}
</div>
)}
{/* Documents List Section */}
{loading && documents.length === 0 && pendingRows.length === 0 ? (
{/* Show the loader until the first fetch resolves (`!loaded`), not just while
`loading`. Two reasons the empty state would otherwise flash first:
(1) the mount fetch is debounced, and (2) on a hard refresh currentBank
is null until bank-context resolves it from the URL in an effect (after
the first paint / before hydration + theme). `!loaded` covers both — and
it can't get stuck: on any /banks/[id] route currentBank always resolves,
the fetch runs, and its `finally` flips `loaded` (even if the bank is
invalid and the fetch errors). */}
{(loading || !loaded) && documents.length === 0 && pendingRows.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">{t("loadingDocuments")}</div>
</div>
</div>
@@ -1576,7 +1595,7 @@ export function DocumentsView() {
) : (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">📄</div>
<FileText className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
<div className="text-sm text-muted-foreground">
{hasActiveFilters ? t("noDocumentsMatchSearch") : t("noDocumentsFound")}
</div>
@@ -1605,7 +1624,7 @@ export function DocumentsView() {
{loadingDocument ? (
<div className="flex items-center justify-center flex-1">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">{t("loadingDocument")}</div>
</div>
</div>
@@ -1763,7 +1782,7 @@ export function DocumentsView() {
className="h-7 w-7 p-0"
>
{savingTags ? (
<span className="animate-spin text-xs"></span>
<Spinner size="xs" />
) : (
<Check className="h-3 w-3" />
)}
@@ -1884,7 +1903,7 @@ export function DocumentsView() {
className="h-7 px-3 gap-1 text-xs"
>
{savingContent ? (
<span className="animate-spin"></span>
<Spinner size="xs" />
) : (
<Check className="h-3 w-3" />
)}
@@ -1952,7 +1971,7 @@ export function DocumentsView() {
{loadingChunks ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">{t("loadingChunks")}</div>
</div>
</div>
@@ -1965,7 +1984,7 @@ export function DocumentsView() {
) : chunksLoaded ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">📄</div>
<FileText className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
<div className="text-sm text-muted-foreground">{t("noChunksFound")}</div>
</div>
</div>
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Check } from "lucide-react";
import { EntityChip } from "@/components/ui/facet-chip";
import { Spinner } from "@/components/ui/spinner";
export interface EditMemoryFields {
text: string;
@@ -200,7 +201,7 @@ export function EditMemoryForm({ memory, busy, onCancel, onSave }: EditMemoryFor
}}
>
{busy ? (
<span className="animate-spin mr-1.5"></span>
<Spinner size="xs" className="mr-1.5" />
) : (
<Check className="h-3.5 w-3.5 mr-1.5" />
)}
@@ -12,7 +12,9 @@ import {
ChevronsRight,
List,
ScatterChart,
Users,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
@@ -274,7 +276,7 @@ export function EntitiesView() {
{graphLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">...</div>
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">{t("loadingEntityGraph")}</div>
</div>
</div>
@@ -316,7 +318,7 @@ export function EntitiesView() {
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">...</div>
<Spinner size="xl" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">{t("loadingEntities")}</div>
</div>
</div>
@@ -415,7 +417,7 @@ export function EntitiesView() {
) : (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">...</div>
<Users className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
<div className="text-sm text-muted-foreground">{t("noEntitiesFound")}</div>
<div className="text-xs text-muted-foreground mt-1">
{t("noEntitiesDescription")}
@@ -488,8 +490,11 @@ export function EntitiesView() {
{t("linkedMemoriesLabel")}
</div>
{loadingMemories ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("loadingLinkedMemories")}
<div className="py-8 text-center">
<Spinner size="md" variant="jump" className="mx-auto mb-2" />
<div className="text-sm text-muted-foreground">
{t("loadingLinkedMemories")}
</div>
</div>
) : entityMemories.length > 0 ? (
<div className="-mx-4">
@@ -15,7 +15,7 @@ import {
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useBank } from "@/lib/bank-context";
import { Loader2 } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
@@ -111,7 +111,7 @@ export function ExtractDialog({
{tCommon("close")}
</Button>
<Button onClick={run} disabled={loading || !content.trim() || !currentBank}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{loading ? <Spinner size="sm" className="mr-2" /> : null}
{t("dryRunExtractionRun")}
</Button>
</DialogFooter>
@@ -8,7 +8,8 @@ import { bankRoute } from "@/lib/bank-url";
import { Constellation } from "./constellation";
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
import { Button } from "@/components/ui/button";
import { Loader2, Network, FileText, Layers, FilePlus, ArrowRight } from "lucide-react";
import { Network, FileText, Layers, FilePlus, ArrowRight } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { formatRelativeTime, formatAbsoluteDateTime } from "@/lib/relative-time";
import { MemoryStoreCard, MemoriesActivityChart, type BankStats } from "./bank-stats-view";
import { TreeRow } from "./knowledge-base-view";
@@ -174,7 +175,7 @@ export function HomeView({
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
<Spinner size="lg" variant="jump" />
</div>
);
}
@@ -213,7 +214,7 @@ export function HomeView({
/>
) : graphLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
<Spinner size="lg" variant="jump" />
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
@@ -42,12 +42,12 @@ import {
FolderOpen,
FolderPlus,
Info,
Loader2,
Pencil,
Search,
Trash2,
X,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { formatAbsoluteDateTime, formatRelativeTime } from "@/lib/relative-time";
import { CompactMarkdown } from "./compact-markdown";
import { MentalModelDetailModal } from "./mental-model-detail-modal";
@@ -438,7 +438,7 @@ export function KnowledgeBaseView() {
{query.trim() ? (
searching ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
<Spinner size="md" variant="jump" />
</div>
) : results.length === 0 ? (
<p className="px-3 py-6 text-sm text-muted-foreground text-center">
@@ -472,7 +472,7 @@ export function KnowledgeBaseView() {
)
) : loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-muted-foreground animate-spin" />
<Spinner size="md" variant="jump" />
</div>
) : (
<ul className="py-2">
@@ -533,7 +533,7 @@ export function KnowledgeBaseView() {
)}
{loadingDetail && !selected ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-7 h-7 text-muted-foreground animate-spin" />
<Spinner size="lg" variant="jump" />
</div>
) : selected ? (
<div className="p-8 max-w-3xl mx-auto">
@@ -692,7 +692,7 @@ export function KnowledgeBaseView() {
creating || !form.name.trim() || (createKind === "page" && !form.sourceQuery.trim())
}
>
{creating ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{creating ? <Spinner size="sm" className="mr-1" /> : null}
{t("create")}
</Button>
</DialogFooter>
@@ -738,7 +738,7 @@ export function KnowledgeBaseView() {
{t("cancel")}
</Button>
<Button onClick={handleUpdatePage} disabled={savingEdit || !editForm.name.trim()}>
{savingEdit ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{savingEdit ? <Spinner size="sm" className="mr-1" /> : null}
{t("saveChanges")}
</Button>
</DialogFooter>
@@ -761,7 +761,7 @@ export function KnowledgeBaseView() {
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{deleting ? <Spinner size="sm" className="mr-1" /> : null}
{t("deleteButton")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, RefreshCw } from "lucide-react";
import { RefreshCw } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { client } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
@@ -85,7 +86,7 @@ export function LlmHealthDialog({
<div className="py-2 space-y-2">
{testing && !result ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
{t("testingLlm")}
</div>
) : (
@@ -115,11 +116,7 @@ export function LlmHealthDialog({
<DialogFooter>
<Button variant="outline" size="sm" onClick={run} disabled={testing} className="gap-1.5">
{testing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
{testing ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
{testing ? t("testingLlm") : t("llmRetest")}
</Button>
</DialogFooter>
@@ -24,6 +24,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
LineChart,
Line,
@@ -471,7 +472,10 @@ export function TraceDialog({
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-[minmax(240px,5fr)_7fr] gap-4">
<div className="overflow-y-auto md:border-r border-border md:pr-3 space-y-0.5">
{loading ? (
<div className="text-sm text-muted-foreground py-4">{t("chartLoading")}</div>
<div className="py-4 flex flex-col items-center gap-2 text-sm text-muted-foreground">
<Spinner size="md" />
<span>{t("chartLoading")}</span>
</div>
) : (
spans.map((s) => (
<SpanRow
@@ -731,8 +735,9 @@ function LLMRequestChart({ bankId }: { bankId: string }) {
<CardContent>
<div className="h-[140px]">
{loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{t("chartLoading")}
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground text-sm">
<Spinner size="md" />
<span>{t("chartLoading")}</span>
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -13,7 +13,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Loader2, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { client } from "@/lib/api";
const ENTERPRISE_DEMO_URL = "https://calendly.com/d/ctw6-byb-3kg";
@@ -191,7 +192,7 @@ export function MemoryDefenseSection({ bankId }: MemoryDefenseSectionProps) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner size="lg" variant="jump" />
</div>
);
}
@@ -233,7 +234,7 @@ export function MemoryDefenseSection({ bankId }: MemoryDefenseSectionProps) {
{saving && (
<div className="flex justify-end items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Spinner size="xs" />
{t("saving")}
</div>
)}
@@ -7,7 +7,6 @@ import { useBank } from "@/lib/bank-context";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Loader2,
Calendar,
Users,
FileText,
@@ -18,6 +17,7 @@ import {
Pencil,
Braces,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { TagList } from "@/components/ui/tag-list";
import { Button } from "@/components/ui/button";
import { ObservationHistoryView, type HistoryEntry } from "@/components/observation-history-view";
@@ -261,7 +261,7 @@ export function MemoryDetailModal({
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<Spinner size="lg" variant="jump" />
</div>
) : error ? (
<div className="flex items-center justify-center py-20">
@@ -452,7 +452,7 @@ export function MemoryDetailModal({
<TabsContent value="history" className="mt-0">
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
</div>
) : history && history.length > 0 ? (
<ObservationHistoryView
@@ -692,7 +692,7 @@ export function MemoryDetailModal({
<TabsContent value="chunk" className="mt-0 space-y-4">
{loadingChunk ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
</div>
) : chunk ? (
<>
@@ -747,7 +747,7 @@ export function MemoryDetailModal({
<TabsContent value="document" className="mt-0 space-y-4">
{loadingDocument ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
</div>
) : document ? (
<>
@@ -4,17 +4,8 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { TagList } from "@/components/ui/tag-list";
import {
Copy,
Check,
X,
Loader2,
Calendar,
History,
Activity,
RotateCcw,
Pencil,
} from "lucide-react";
import { Copy, Check, X, Calendar, History, Activity, RotateCcw, Pencil } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { InvalidateMemoryDialog } from "./invalidate-memory-dialog";
import { EditMemoryForm, type EditMemoryFields } from "./edit-memory-form";
import { DocumentChunkModal } from "./document-chunk-modal";
@@ -323,7 +314,7 @@ export function MemoryDetailPanel({
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
<span className="ml-2 text-muted-foreground">{t("loadingDetails")}</span>
</div>
) : editingText ? (
@@ -675,7 +666,7 @@ export function MemoryDetailPanel({
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
<span className="ml-2 text-sm text-muted-foreground">{t("loading")}</span>
</div>
) : (
@@ -15,7 +15,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Loader2,
Zap,
FileText,
History as HistoryIcon,
@@ -28,6 +27,7 @@ import {
RefreshCw,
Trash2,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { toast } from "sonner";
import { CompactMarkdown } from "./compact-markdown";
import { CronSchedulePreview } from "./cron-schedule-preview";
@@ -790,7 +790,7 @@ export function MentalModelDetailModal({
{loading ? (
<div className="flex items-center justify-center flex-1">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<Spinner size="lg" variant="jump" />
</div>
) : error ? (
<div className="flex items-center justify-center flex-1">
@@ -931,7 +931,7 @@ export function MentalModelDetailModal({
<TabsContent value="history" className="mt-0">
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
</div>
) : history && history.length > 0 ? (
<MentalModelHistoryView
@@ -42,7 +42,6 @@ import {
import {
Plus,
Sparkles,
Loader2,
Trash2,
Eraser,
RefreshCw,
@@ -57,6 +56,7 @@ import {
FileText,
Clock,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
DropdownMenu,
DropdownMenuContent,
@@ -284,7 +284,7 @@ export function MentalModelsView() {
<div>
{loading ? (
<div className="text-center py-12">
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<Spinner size="lg" variant="jump" className="mx-auto mb-3" />
<p className="text-muted-foreground">{t("loading")}</p>
</div>
) : (
@@ -558,7 +558,7 @@ export function MentalModelsView() {
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
{deleting ? <Spinner size="sm" className="mr-1" /> : null}
{t("deleteDialogConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -582,7 +582,7 @@ export function MentalModelsView() {
<AlertDialogFooter className="flex-row justify-end space-x-2">
<AlertDialogCancel className="mt-0">{t("cancelButton")}</AlertDialogCancel>
<AlertDialogAction onClick={handleClear} disabled={clearing}>
{clearing ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
{clearing ? <Spinner size="sm" className="mr-1" /> : null}
{t("clearDialogConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -1135,7 +1135,7 @@ function CreateMentalModelDialog({
>
{creating ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-1" />
<Spinner size="sm" className="mr-1" />
{t("creatingButton")}
</>
) : (
@@ -1581,7 +1581,7 @@ function UpdateMentalModelDialog({
>
{updating ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-1" />
<Spinner size="sm" className="mr-1" />
{t("updatingButton")}
</>
) : (
@@ -31,6 +31,7 @@ import {
Tag,
Calendar,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel";
@@ -294,7 +295,7 @@ export function SearchDebugView() {
{loading && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4" />
<Spinner size="lg" variant="jump" className="mb-4" />
<p className="text-muted-foreground">{t("searchingMemories")}</p>
</CardContent>
</Card>
@@ -88,6 +88,8 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
if (e.button === 0 && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
onTabChange(item.id);
// Give the header logo a playful spin on navigation.
window.dispatchEvent(new CustomEvent("hindsight:logo-spin"));
}
// Middle-click or Ctrl/Cmd+click will naturally open in new tab
}}
@@ -28,6 +28,7 @@ import {
Check,
Play,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
@@ -297,7 +298,7 @@ export function ThinkView() {
{loading && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4" />
<Spinner size="lg" variant="jump" className="mb-4" />
<p className="text-muted-foreground">{t("reflectingOnMemories")}</p>
</CardContent>
</Card>
@@ -921,7 +922,7 @@ export function ThinkView() {
</div>
{loadingObservation ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<Spinner size="lg" variant="jump" />
</div>
) : (
<div className="space-y-4">
@@ -1,8 +1,9 @@
"use client";
import { CircleCheck, Info, LoaderCircle, OctagonX, TriangleAlert } from "lucide-react";
import { CircleCheck, Info, OctagonX, TriangleAlert } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
import { Spinner } from "@/components/ui/spinner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
@@ -18,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
info: <Info className="h-4 w-4" />,
warning: <TriangleAlert className="h-4 w-4" />,
error: <OctagonX className="h-4 w-4" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
loading: <Spinner size="sm" />,
}}
toastOptions={{
classNames: {
@@ -0,0 +1,49 @@
import { cn } from "@/lib/utils";
import { withBasePath } from "@/lib/base-path";
// Shared loading spinner — the Hindsight mark as a tumbling mascot. This replaces
// the old lucide Loader2 everywhere so every "please wait" state is on-brand and
// consistent. Two motions:
// - variant "flip" (default): an in-place 360° flip with a squash pulse and only
// a tiny vertical bob, so it's safe inside buttons and inline next to text.
// - variant "jump": the full "tumble" (crouch → hop + flip → land squash). Use it
// for prominent, centered loading states that have vertical room.
// The keyframes live in globals.css. /favicon.png is the mark alone (no wordmark),
// which reads best at these sizes.
const SIZES = {
xs: "w-3.5 h-3.5",
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
xl: "w-14 h-14",
} as const;
export type SpinnerSize = keyof typeof SIZES;
export function Spinner({
size = "md",
variant = "flip",
className,
}: {
size?: SpinnerSize;
variant?: "flip" | "jump";
className?: string;
}) {
return (
// Raw <img>, not next/image: next/image wraps the tag in a positioned span
// that fights the CSS transform animation (and adds no value for a tiny
// static, already-optimized PNG served from /public).
<img
src={withBasePath("/favicon.png")}
alt=""
role="status"
aria-label="Loading"
className={cn(
SIZES[size],
variant === "jump" ? "animate-logo-tumble" : "animate-logo-flip",
"inline-block select-none object-contain",
className
)}
/>
);
}
@@ -38,7 +38,6 @@ import {
Trash2,
Eye,
EyeOff,
Loader2,
CheckCircle,
AlertCircle,
Clock,
@@ -47,6 +46,7 @@ import {
ChevronRight,
Pencil,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
const AVAILABLE_EVENT_TYPES = [
"consolidation.completed",
@@ -589,7 +589,7 @@ export function WebhooksView() {
aria-label={t("deleteWebhookAriaLabel")}
>
{deletingId === webhook.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
) : (
<Trash2 className="w-3 h-3" />
)}
@@ -783,7 +783,7 @@ export function WebhooksView() {
<Button onClick={handleCreate} disabled={creating || !form.url}>
{creating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("creating")}
</>
) : (
@@ -993,7 +993,7 @@ export function WebhooksView() {
<Button onClick={handleSave} disabled={saving || !editForm.url}>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("saving")}
</>
) : (
@@ -1034,7 +1034,7 @@ export function WebhooksView() {
>
{deletingId ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{t("deleting")}
</>
) : (
@@ -1068,7 +1068,7 @@ export function WebhooksView() {
{loadingDeliveries ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="md" variant="jump" />
</div>
) : deliveries.length > 0 ? (
<>
@@ -1105,7 +1105,7 @@ export function WebhooksView() {
onClick={handleLoadMoreDeliveries}
disabled={loadingMoreDeliveries}
>
{loadingMoreDeliveries && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{loadingMoreDeliveries && <Spinner size="sm" className="mr-2" />}
{t("loadMore")}
</Button>
</div>
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "Keine passenden Seiten.",
"fieldTags": "Tags",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "Kommagetrennt. Ein type:<x>-Tag legt den -Typ der Seite fest.",
"fieldTagsHint": "Kommagetrennt. Ein type:-Tag legt den -Typ der Seite fest.",
"editButton": "Bearbeiten",
"editPageTitle": "Seite bearbeiten",
"editPageDescription": "Aktualisieren Sie den Seitennamen, die Frage, die sie neu aufbaut, und ihre Tags.",
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "No matching pages.",
"fieldTags": "Tags",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "Comma-separated. A type:<x> tag sets the page's type.",
"fieldTagsHint": "Comma-separated. A type: tag sets the page's type.",
"editButton": "Edit",
"editPageTitle": "Edit page",
"editPageDescription": "Update the page's name, the question that rebuilds it, and its tags.",
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "No hay páginas coincidentes.",
"fieldTags": "Etiquetas",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "Separadas por comas. Una etiqueta type:<x> define el tipo de la página.",
"fieldTagsHint": "Separadas por comas. Una etiqueta type: define el tipo de la página.",
"editButton": "Editar",
"editPageTitle": "Editar página",
"editPageDescription": "Actualiza el nombre de la página, la pregunta que la reconstruye y sus etiquetas.",
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "Aucune page correspondante.",
"fieldTags": "Étiquettes",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "Séparées par des virgules. Une étiquette type:<x> définit le type de la page.",
"fieldTagsHint": "Séparées par des virgules. Une étiquette type: définit le type de la page.",
"editButton": "Modifier",
"editPageTitle": "Modifier la page",
"editPageDescription": "Modifiez le nom de la page, la question qui la reconstruit et ses étiquettes.",
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "一致するページがありません。",
"fieldTags": "タグ",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "カンマ区切り。type:<x> タグでページの タイプを設定します。",
"fieldTagsHint": "カンマ区切り。type: タグでページの タイプを設定します。",
"editButton": "編集",
"editPageTitle": "ページを編集",
"editPageDescription": "ページ名、再構築の質問、タグを更新します。",
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "일치하는 페이지가 없습니다.",
"fieldTags": "태그",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "쉼표로 구분. type:<x> 태그가 페이지의 유형을 설정합니다.",
"fieldTagsHint": "쉼표로 구분. type: 태그가 페이지의 유형을 설정합니다.",
"editButton": "편집",
"editPageTitle": "페이지 편집",
"editPageDescription": "페이지 이름, 페이지를 재생성하는 질문, 태그를 업데이트합니다.",
+1 -1
View File
@@ -1762,7 +1762,7 @@
"searchEmpty": "Nenhuma página correspondente.",
"fieldTags": "Etiquetas",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "Separadas por vírgulas. Uma etiqueta type:<x> define o tipo da página.",
"fieldTagsHint": "Separadas por vírgulas. Uma etiqueta type: define o tipo da página.",
"editButton": "Editar",
"editPageTitle": "Editar página",
"editPageDescription": "Atualize o nome da página, a pergunta que a reconstrói e as suas etiquetas.",
@@ -1762,7 +1762,7 @@
"searchEmpty": "冇符合嘅頁面。",
"fieldTags": "標籤",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "用逗號分隔。type:<x> 標籤用嚟設定頁面嘅 類型。",
"fieldTagsHint": "用逗號分隔。type: 標籤用嚟設定頁面嘅 類型。",
"editButton": "編輯",
"editPageTitle": "編輯頁面",
"editPageDescription": "更新頁面名稱、重建佢嘅問題同標籤。",
@@ -1762,7 +1762,7 @@
"searchEmpty": "没有匹配的页面。",
"fieldTags": "标签",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "以逗号分隔。type:<x> 标签用于设置页面的 类型。",
"fieldTagsHint": "以逗号分隔。type: 标签用于设置页面的 类型。",
"editButton": "编辑",
"editPageTitle": "编辑页面",
"editPageDescription": "更新页面名称、重建它的问题及其标签。",
@@ -1762,7 +1762,7 @@
"searchEmpty": "沒有符合的頁面。",
"fieldTags": "標籤",
"fieldTagsPlaceholder": "type:policy, revenue, …",
"fieldTagsHint": "以逗號分隔。type:<x> 標籤用於設定頁面的 類型。",
"fieldTagsHint": "以逗號分隔。type: 標籤用於設定頁面的 類型。",
"editButton": "編輯",
"editPageTitle": "編輯頁面",
"editPageDescription": "更新頁面名稱、重建它的問題及其標籤。",