fix(web): add user-facing dependency states
This commit is contained in:
parent
40c03441ee
commit
a72d6b79d3
@ -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" }}>
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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) => (
|
||||
|
||||
25
web/src/components/StateNotice.test.tsx
Normal file
25
web/src/components/StateNotice.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
40
web/src/components/StateNotice.tsx
Normal file
40
web/src/components/StateNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
web/src/lib/user-facing-states.test.ts
Normal file
56
web/src/lib/user-facing-states.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
107
web/src/lib/user-facing-states.ts
Normal file
107
web/src/lib/user-facing-states.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user