diff --git a/web/src/app/(app)/dashboard/page.tsx b/web/src/app/(app)/dashboard/page.tsx index 3e69883..4a92f53 100644 --- a/web/src/app/(app)/dashboard/page.tsx +++ b/web/src/app/(app)/dashboard/page.tsx @@ -2,14 +2,16 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { CreateNoteModal } from "@/components/CreateNoteModal"; +import { StateNotice } from "@/components/StateNotice"; import { Button, Card } from "@/components/ui/Primitives"; import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } from "@/lib/notes-client"; import { listApprovalQueue } from "@/lib/review-client"; import { listSavedViews, type SavedView } from "@/lib/saved-views-client"; import { toast } from "@/lib/toast"; +import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states"; import { IntakeUrlBar } from "@/components/IntakeUrlBar"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; @@ -30,7 +32,7 @@ function DashboardContent() { const [workspaces, setWorkspaces] = useState([]); const [apiSavedViews, setApiSavedViews] = useState([]); const [pendingReviewCount, setPendingReviewCount] = useState(0); - const [error, setError] = useState(null); + const [errorState, setErrorState] = useState(null); const [showCreateNote, setShowCreateNote] = useState(false); const [seeding, setSeeding] = useState(false); @@ -41,25 +43,28 @@ function DashboardContent() { } }, [searchParams, router]); - useEffect(() => { - void (async () => { - try { - const [nextNotes, nextWorkspaces, nextApprovalQueue, saved] = await Promise.all([ - listNoteSummaries(), - listWorkspaceSummaries(), - listApprovalQueue(), - listSavedViews().catch(() => [] as SavedView[]), - ]); - setNotes(nextNotes); - setWorkspaces(nextWorkspaces); - setPendingReviewCount(nextApprovalQueue.length); - setApiSavedViews(saved); - } catch (err) { - setError(err instanceof Error ? err.message : "Unable to load dashboard data"); - } - })(); + const loadDashboard = useCallback(async () => { + try { + setErrorState(null); + const [nextNotes, nextWorkspaces, nextApprovalQueue, saved] = await Promise.all([ + listNoteSummaries(), + listWorkspaceSummaries(), + listApprovalQueue(), + listSavedViews().catch(() => [] as SavedView[]), + ]); + setNotes(nextNotes); + setWorkspaces(nextWorkspaces); + setPendingReviewCount(nextApprovalQueue.length); + setApiSavedViews(saved); + } catch (err) { + setErrorState(toUserFacingState(err, "backend")); + } }, []); + useEffect(() => { + void loadDashboard(); + }, [loadDashboard]); + const quickLinks = [ { id: "workspace-all", @@ -186,7 +191,11 @@ function DashboardContent() { toast.success("Sample workspace created"); window.location.reload(); }) - .catch((e) => toast.error(e instanceof Error ? e.message : "Seed failed")) + .catch((e) => { + const state = toUserFacingState(e, "backend"); + setErrorState(state); + toast.error(state.message); + }) .finally(() => setSeeding(false)); }} > @@ -280,8 +289,11 @@ function DashboardContent() {
Recent note activity
- {error ?
{error}
: null} + {errorState ? void loadDashboard()} compact /> : null}
+ {recentNotes.length === 0 && !errorState ? ( + + ) : null} {recentNotes.map((note) => (
diff --git a/web/src/components/ArtifactPanel.tsx b/web/src/components/ArtifactPanel.tsx index 2981531..5ec364d 100644 --- a/web/src/components/ArtifactPanel.tsx +++ b/web/src/components/ArtifactPanel.tsx @@ -1,8 +1,11 @@ "use client"; import { useRef, useState } from "react"; +import { StateNotice } from "@/components/StateNotice"; +import { Button } from "@/components/ui/Primitives"; import { getArtifactReadUrl, uploadArtifact } from "@/lib/blob-client"; import { toast } from "@/lib/toast"; +import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states"; import type { ArtifactSummary } from "@/lib/types"; export function ArtifactPanel({ @@ -26,6 +29,7 @@ export function ArtifactPanel({ const [artifactType, setArtifactType] = useState<"file" | "summary" | "extraction" | "citation" | "export">("file"); const [description, setDescription] = useState(""); const [blobPath, setBlobPath] = useState(""); + const [noticeState, setNoticeState] = useState(null); async function handleOpenArtifact(artifact: ArtifactSummary) { if (!artifact.blobPath) { @@ -46,6 +50,7 @@ export function ArtifactPanel({ if (!file) return; setUploading(true); + setNoticeState(null); try { const path = `artifacts/${crypto.randomUUID()}/${file.name}`; const result = await uploadArtifact(file, path); @@ -57,7 +62,9 @@ export function ArtifactPanel({ }); toast.success("File uploaded"); } catch (err) { - toast.error(err instanceof Error ? err.message : "Upload failed"); + const state = toUserFacingState(err, "blob"); + setNoticeState(state); + toast.error(state.message); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; @@ -80,6 +87,7 @@ export function ArtifactPanel({ setArtifactType("file"); setDescription(""); setBlobPath(""); + setNoticeState(null); } return ( @@ -133,34 +141,35 @@ export function ArtifactPanel({ />
- - +
+ {noticeState ? ( + fileInputRef.current?.click() : undefined} + /> + ) : null} + {artifacts.length === 0 ? ( + + ) : null} {artifacts.map((artifact) => (
diff --git a/web/src/components/AuthGuard.test.tsx b/web/src/components/AuthGuard.test.tsx index 79a2fdb..f9a4093 100644 --- a/web/src/components/AuthGuard.test.tsx +++ b/web/src/components/AuthGuard.test.tsx @@ -70,4 +70,13 @@ describe("AuthGuard", () => { expect(await screen.findByText("Maintenance window")).toBeInTheDocument(); expect(screen.queryByText("Private app")).not.toBeInTheDocument(); }); + + it("shows a platform-down state when readiness cannot be checked", async () => { + checkKillSwitchMock.mockRejectedValue(new Error("HTTP 503 Service Unavailable")); + + render(
Private app
); + + expect(await screen.findByText("Platform services are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("Private app")).not.toBeInTheDocument(); + }); }); diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx index bcb01d8..4c0fa8c 100644 --- a/web/src/components/AuthGuard.tsx +++ b/web/src/components/AuthGuard.tsx @@ -5,11 +5,13 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; import { refreshStoredAuthSession } from "@/lib/auth-session"; import { checkKillSwitch } from "@/lib/kill-switch"; +import { StateNotice } from "@/components/StateNotice"; +import { toUserFacingState, type UserFacingState } from "@/lib/user-facing-states"; export function AuthGuard({ children }: { children: ReactNode }) { const { isAuthenticated, isLoading: authLoading, logout } = useAuth(); const router = useRouter(); - const [killSwitchMsg, setKillSwitchMsg] = useState(null); + const [blockingState, setBlockingState] = useState(null); const [ready, setReady] = useState(false); useEffect(() => { @@ -35,13 +37,16 @@ export function AuthGuard({ children }: { children: ReactNode }) { const { disabled, message } = await checkKillSwitch(); if (!active) return; if (disabled) { - setKillSwitchMsg(message || "This application is currently unavailable."); + setBlockingState({ + ...toUserFacingState(new Error(message || "This application is currently disabled"), "feature"), + message: message || "This application is currently unavailable.", + }); } else { setReady(true); } - } catch { + } catch (error) { if (!active) return; - setReady(true); + setBlockingState(toUserFacingState(error, "platform")); } } @@ -50,12 +55,11 @@ export function AuthGuard({ children }: { children: ReactNode }) { return () => { active = false; }; }, [isAuthenticated, authLoading, logout, router]); - if (killSwitchMsg) { + if (blockingState) { return (
-
-

Service Unavailable

-

{killSwitchMsg}

+
+ window.location.reload() : undefined} />
); diff --git a/web/src/components/ExtractedTasksPanel.tsx b/web/src/components/ExtractedTasksPanel.tsx index 3f73684..f2d971c 100644 --- a/web/src/components/ExtractedTasksPanel.tsx +++ b/web/src/components/ExtractedTasksPanel.tsx @@ -2,9 +2,11 @@ import { useCallback, useMemo, useState } from "react"; import { extractSuggestedTasks } from "@/lib/extraction-client"; +import { StateNotice } from "@/components/StateNotice"; import { Button, Card } from "@/components/ui/Primitives"; import { createNoteTask } from "@/lib/notes-client"; import { toast } from "@/lib/toast"; +import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states"; import type { NoteTask } from "@/lib/types"; export function ExtractedTasksPanel({ @@ -23,6 +25,7 @@ export function ExtractedTasksPanel({ const [proposals, setProposals] = useState([]); const [scanning, setScanning] = useState(false); const [acceptingId, setAcceptingId] = useState(null); + const [noticeState, setNoticeState] = useState(null); const persistedTitles = useMemo( () => new Set(persistedTasks.map((t) => t.title.trim().toLowerCase())), @@ -31,15 +34,18 @@ export function ExtractedTasksPanel({ const handleScan = useCallback(async () => { setScanning(true); + setNoticeState(null); try { const extracted = await extractSuggestedTasks(noteBody); const filtered = extracted.filter((t) => !persistedTitles.has(t.title.trim().toLowerCase())); setProposals(filtered); if (filtered.length === 0) { - toast.success("No new suggested tasks found"); + setNoticeState(getEmptyState("extraction", "suggested tasks")); } } catch (e) { - toast.error(e instanceof Error ? e.message : "Scan failed"); + const state = toUserFacingState(e, "extraction"); + setNoticeState(state); + toast.error(state.message); } finally { setScanning(false); } @@ -60,7 +66,9 @@ export function ExtractedTasksPanel({ toast.success("Task added"); onTaskAccepted(); } catch (e) { - toast.error(e instanceof Error ? e.message : "Could not create task"); + const state = toUserFacingState(e, "backend"); + setNoticeState(state); + toast.error(state.message); } finally { setAcceptingId(null); } @@ -89,6 +97,13 @@ export function ExtractedTasksPanel({

Runs extraction on demand. Accept adds a backend task; dismiss only hides the suggestion for this session.

+ {noticeState ? ( + void handleScan() : undefined} + /> + ) : null} {proposals.length === 0 ? null : (
    {proposals.map((task) => ( diff --git a/web/src/components/StateNotice.test.tsx b/web/src/components/StateNotice.test.tsx new file mode 100644 index 0000000..12b16f1 --- /dev/null +++ b/web/src/components/StateNotice.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { StateNotice } from "@/components/StateNotice"; + +describe("StateNotice", () => { + it("renders user-facing state copy and action", async () => { + const onAction = vi.fn(); + + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("Backend is unavailable"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onAction).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/components/StateNotice.tsx b/web/src/components/StateNotice.tsx new file mode 100644 index 0000000..d13b470 --- /dev/null +++ b/web/src/components/StateNotice.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Button, Card } from "@/components/ui/Primitives"; +import type { UserFacingState } from "@/lib/user-facing-states"; + +export function StateNotice({ + state, + onAction, + compact = false, +}: { + state: UserFacingState; + onAction?: () => void; + compact?: boolean; +}) { + return ( + +
    + + {state.title} + +

    + {state.message} +

    +
    + {onAction && state.actionLabel ? ( + + ) : null} +
    + ); +} diff --git a/web/src/lib/user-facing-states.test.ts b/web/src/lib/user-facing-states.test.ts new file mode 100644 index 0000000..2b95529 --- /dev/null +++ b/web/src/lib/user-facing-states.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { getEmptyState, toUserFacingState } from "@/lib/user-facing-states"; + +describe("user-facing states", () => { + it("maps backend connectivity failures to stable backend copy", () => { + expect(toUserFacingState(new Error("Failed to fetch"), "backend")).toMatchObject({ + kind: "backend", + title: "Backend is unavailable", + actionLabel: "Retry", + }); + }); + + it("maps platform failures to platform copy", () => { + expect(toUserFacingState(new Error("HTTP 503 Service Unavailable"), "platform")).toMatchObject({ + kind: "platform", + title: "Platform services are unavailable", + }); + }); + + it("maps extraction failures to scan retry copy", () => { + expect(toUserFacingState(new Error("Network error"), "extraction")).toMatchObject({ + kind: "extraction", + title: "Extraction service is unavailable", + actionLabel: "Scan again", + }); + }); + + it("maps blob failures to upload copy", () => { + expect(toUserFacingState(new Error("HTTP 504"), "blob")).toMatchObject({ + kind: "blob", + title: "File upload failed", + actionLabel: "Choose file again", + }); + }); + + it("maps disabled feature responses even when another dependency kind is preferred", () => { + expect(toUserFacingState(new Error("Smart Actions are disabled"), "backend")).toMatchObject({ + kind: "feature", + title: "Feature is disabled", + }); + }); + + it("keeps validation-style messages when the service responded", () => { + expect(toUserFacingState(new Error("Workspace name is required"), "backend")).toMatchObject({ + kind: "backend", + message: "Workspace name is required", + }); + }); + + it("provides empty-state copy for extraction results", () => { + expect(getEmptyState("extraction", "suggested tasks")).toMatchObject({ + title: "No suggested tasks found", + message: "Extraction completed, but there were no new suggestions to add.", + }); + }); +}); diff --git a/web/src/lib/user-facing-states.ts b/web/src/lib/user-facing-states.ts new file mode 100644 index 0000000..953b7a3 --- /dev/null +++ b/web/src/lib/user-facing-states.ts @@ -0,0 +1,107 @@ +export type DependencyKind = "backend" | "platform" | "extraction" | "blob" | "feature" | "generic"; + +export type UserFacingState = { + kind: DependencyKind; + title: string; + message: string; + actionLabel?: string; +}; + +const STATE_COPY: Record = { + backend: { + kind: "backend", + title: "Backend is unavailable", + message: "NoteLett could not reach the notes backend. Check the service status and retry.", + actionLabel: "Retry", + }, + platform: { + kind: "platform", + title: "Platform services are unavailable", + message: "Authentication, feature flags, or kill switch checks are not responding. Retry once the platform service is healthy.", + actionLabel: "Retry", + }, + extraction: { + kind: "extraction", + title: "Extraction service is unavailable", + message: "AI task extraction could not run right now. Your note is safe; try scanning again after the service recovers.", + actionLabel: "Scan again", + }, + blob: { + kind: "blob", + title: "File upload failed", + message: "Blob storage did not accept the upload. Keep the file locally and retry when storage is healthy.", + actionLabel: "Choose file again", + }, + feature: { + kind: "feature", + title: "Feature is disabled", + message: "This workflow is currently disabled by a NoteLett feature flag.", + }, + generic: { + kind: "generic", + title: "Something went wrong", + message: "The request could not be completed. Try again.", + actionLabel: "Retry", + }, +}; + +export function getEmptyState(kind: DependencyKind, noun: string): UserFacingState { + if (kind === "backend") { + return { + kind, + title: `No ${noun} yet`, + message: "Create your first item to start building this workspace.", + }; + } + + if (kind === "extraction") { + return { + kind, + title: `No ${noun} found`, + message: "Extraction completed, but there were no new suggestions to add.", + }; + } + + return { + kind, + title: `No ${noun} yet`, + message: "There is nothing to show here yet.", + }; +} + +export function toUserFacingState( + error: unknown, + preferredKind: DependencyKind = "generic", +): UserFacingState { + const rawMessage = error instanceof Error ? error.message : String(error ?? ""); + const message = rawMessage.toLowerCase(); + const status = matchStatus(message); + + const kind = + message.includes("disabled") || message.includes("not enabled") || message.includes("feature flag") + ? "feature" + : preferredKind; + + const base = STATE_COPY[kind]; + const isConnectivityIssue = + message.includes("failed to fetch") || + message.includes("network") || + message.includes("service unavailable") || + status === 502 || + status === 503 || + status === 504; + + if (kind !== "feature" && rawMessage && !isConnectivityIssue) { + return { + ...base, + message: rawMessage, + }; + } + + return base; +} + +function matchStatus(message: string): number | null { + const match = message.match(/\b(4\d\d|5\d\d)\b/); + return match ? Number(match[1]) : null; +}