fix(web): add user-facing dependency states

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:59:17 -07:00
parent 40c03441ee
commit a72d6b79d3
9 changed files with 324 additions and 47 deletions

View File

@ -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<WorkspaceSummary[]>([]);
const [apiSavedViews, setApiSavedViews] = useState<SavedView[]>([]);
const [pendingReviewCount, setPendingReviewCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const [errorState, setErrorState] = useState<UserFacingState | null>(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() {
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Recent note activity</div>
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
{errorState ? <StateNotice state={errorState} onAction={() => void loadDashboard()} compact /> : null}
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{recentNotes.length === 0 && !errorState ? (
<StateNotice state={getEmptyState("backend", "recent notes")} compact />
) : null}
{recentNotes.map((note) => (
<div key={note.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>

View File

@ -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<UserFacingState | null>(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({
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-2)" }}>
<input ref={fileInputRef} type="file" hidden onChange={handleFileUpload} />
<button
<Button
type="button"
variant="secondary"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="surface-muted"
style={{ padding: "8px 12px", border: "none", fontSize: "var(--nl-fs-sm)" }}
>
{uploading ? "Uploading…" : "Upload file"}
</button>
<button
</Button>
<Button
type="button"
disabled={isCreating}
onClick={() => {
void handleCreateArtifact();
}}
style={{
border: "none",
borderRadius: "var(--nl-radius-md)",
padding: "8px 12px",
background: "var(--nl-accent-primary)",
color: "var(--nl-text-primary)",
fontWeight: 600,
}}
>
{isCreating ? "Adding…" : "Add artifact"}
</button>
</Button>
</div>
</div>
{noticeState ? (
<StateNotice
state={noticeState}
compact
onAction={noticeState.actionLabel ? () => fileInputRef.current?.click() : undefined}
/>
) : null}
{artifacts.length === 0 ? (
<StateNotice state={getEmptyState("blob", "artifacts")} compact />
) : null}
{artifacts.map((artifact) => (
<div key={artifact.id} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center" }}>
<div style={{ display: "grid", gap: 4 }}>

View File

@ -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(<AuthGuard><div>Private app</div></AuthGuard>);
expect(await screen.findByText("Platform services are unavailable")).toBeInTheDocument();
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
});
});

View File

@ -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<string | null>(null);
const [blockingState, setBlockingState] = useState<UserFacingState | null>(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 (
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: "var(--nl-space-8)" }}>
<div className="surface-card" style={{ padding: "var(--nl-space-8)", maxWidth: 480, textAlign: "center" }}>
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-xl)", color: "var(--nl-danger)" }}>Service Unavailable</h1>
<p style={{ marginTop: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>{killSwitchMsg}</p>
<div style={{ maxWidth: 520, width: "100%" }}>
<StateNotice state={blockingState} onAction={blockingState.actionLabel ? () => window.location.reload() : undefined} />
</div>
</div>
);

View File

@ -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<NoteTask[]>([]);
const [scanning, setScanning] = useState(false);
const [acceptingId, setAcceptingId] = useState<string | null>(null);
const [noticeState, setNoticeState] = useState<UserFacingState | null>(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({
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Runs extraction on demand. Accept adds a backend task; dismiss only hides the suggestion for this session.
</p>
{noticeState ? (
<StateNotice
state={noticeState}
compact
onAction={noticeState.actionLabel ? () => void handleScan() : undefined}
/>
) : null}
{proposals.length === 0 ? null : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--nl-space-2)" }}>
{proposals.map((task) => (

View File

@ -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(
<StateNotice
state={{
kind: "backend",
title: "Backend is unavailable",
message: "Retry when the backend is healthy.",
actionLabel: "Retry",
}}
onAction={onAction}
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("Backend is unavailable");
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onAction).toHaveBeenCalledOnce();
});
});

View File

@ -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 (
<Card
role="status"
aria-live="polite"
style={{
padding: compact ? "var(--nl-space-4)" : "var(--nl-space-5)",
display: "grid",
gap: "var(--nl-space-3)",
}}
>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong style={{ color: state.kind === "feature" ? "var(--nl-warning)" : "var(--nl-text-primary)" }}>
{state.title}
</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
{state.message}
</p>
</div>
{onAction && state.actionLabel ? (
<Button type="button" variant="secondary" onClick={onAction} style={{ justifySelf: "start" }}>
{state.actionLabel}
</Button>
) : null}
</Card>
);
}

View File

@ -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.",
});
});
});

View File

@ -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<DependencyKind, UserFacingState> = {
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;
}