From 6e9a79191ad9bf32bcad6a4bcf8273b94b4b7752 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sat, 18 Apr 2026 02:50:56 +0800 Subject: [PATCH] feat: add FeatureInstallPrompt component for uninstalled AI tool pages Show an install prompt instead of the normal tool UI when an AI feature bundle is not installed. Admins see a one-click install button with SSE progress tracking and polling fallback; non-admins see a message to contact their administrator. --- .../features/feature-install-prompt.tsx | 192 ++++++++++++++++++ apps/web/src/pages/tool-page.tsx | 20 +- 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/features/feature-install-prompt.tsx diff --git a/apps/web/src/components/features/feature-install-prompt.tsx b/apps/web/src/components/features/feature-install-prompt.tsx new file mode 100644 index 00000000..2591e791 --- /dev/null +++ b/apps/web/src/components/features/feature-install-prompt.tsx @@ -0,0 +1,192 @@ +import type { FeatureBundleState } from "@ashim/shared"; +import { AlertCircle, Download, Loader2, RotateCcw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { apiPost } from "@/lib/api"; +import { useFeaturesStore } from "@/stores/features-store"; + +interface FeatureInstallPromptProps { + bundle: FeatureBundleState; + isAdmin: boolean; +} + +interface ProgressState { + percent: number; + stage: string; +} + +export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptProps) { + const [installing, setInstalling] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const eventSourceRef = useRef(null); + const pollingRef = useRef | null>(null); + const refresh = useFeaturesStore((s) => s.refresh); + + // If bundle is already installing on mount, show progress state immediately + useEffect(() => { + if (bundle.status === "installing") { + setInstalling(true); + setProgress(bundle.progress ?? { percent: 0, stage: "Installing..." }); + } + }, [bundle.status, bundle.progress]); + + // Cleanup on unmount + useEffect(() => { + return () => { + eventSourceRef.current?.close(); + if (pollingRef.current) clearInterval(pollingRef.current); + }; + }, []); + + function startPollingFallback() { + if (pollingRef.current) return; + pollingRef.current = setInterval(async () => { + try { + await refresh(); + const updated = useFeaturesStore.getState().bundles.find((b) => b.id === bundle.id); + if (!updated || updated.status !== "installing") { + if (pollingRef.current) clearInterval(pollingRef.current); + pollingRef.current = null; + setInstalling(false); + if (updated?.status === "error") { + setError(updated.error ?? "Installation failed"); + } else { + setProgress(null); + } + } else if (updated.progress) { + setProgress(updated.progress); + } + } catch { + // ignore polling errors + } + }, 3000); + } + + function listenToProgress(jobId: string) { + const es = new EventSource(`/api/v1/jobs/${jobId}/progress`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as { + phase: string; + percent: number; + stage: string; + error?: string; + }; + + if (data.phase === "complete") { + es.close(); + eventSourceRef.current = null; + setInstalling(false); + setProgress(null); + refresh(); + return; + } + + if (data.phase === "failed") { + es.close(); + eventSourceRef.current = null; + setInstalling(false); + setError(data.error ?? "Installation failed"); + return; + } + + setProgress({ percent: data.percent, stage: data.stage }); + } catch { + // ignore malformed messages + } + }; + + es.onerror = () => { + es.close(); + eventSourceRef.current = null; + startPollingFallback(); + }; + } + + async function handleInstall() { + setInstalling(true); + setError(null); + setProgress({ percent: 0, stage: "Starting installation..." }); + + try { + const result = await apiPost<{ jobId: string }>(`/v1/admin/features/${bundle.id}/install`); + listenToProgress(result.jobId); + } catch (err) { + setInstalling(false); + setError(err instanceof Error ? err.message : "Failed to start installation"); + } + } + + // Non-admin: show "not enabled" message + if (!isAdmin) { + return ( +
+ +

Feature Not Enabled

+

+ This feature is not enabled. Ask your administrator to enable it in Settings. +

+
+ ); + } + + // Admin: show install prompt + return ( +
+ +
+

{bundle.name}

+

{bundle.description}

+

+ This feature requires an additional download (~{bundle.estimatedSize}) +

+
+ + {/* Error banner */} + {error && ( +
+ + {error} + +
+ )} + + {/* Progress bar */} + {installing && progress && ( +
+
+
+
+
+ + {progress.stage} +
+
+ )} + + {/* Install button (hidden when installing or showing error) */} + {!installing && !error && ( + + )} +
+ ); +} diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index f2bc346b..cb4a56b0 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -1,4 +1,4 @@ -import { TOOLS } from "@ashim/shared"; +import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@ashim/shared"; import { CheckCircle2, ChevronLeft, @@ -16,15 +16,18 @@ import { type BgPreviewState, ImageViewer } from "@/components/common/image-view import { ReviewPanel } from "@/components/common/review-panel"; import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; +import { FeatureInstallPrompt } from "@/components/features/feature-install-prompt"; import { AppLayout } from "@/components/layout/app-layout"; import { CropCanvas } from "@/components/tools/crop-canvas"; import type { EraserCanvasRef } from "@/components/tools/eraser-canvas"; import { EraserCanvas } from "@/components/tools/eraser-canvas"; import type { PreviewTransform } from "@/components/tools/rotate-settings"; +import { useAuth } from "@/hooks/use-auth"; import { useMobile } from "@/hooks/use-mobile"; import { formatFileSize } from "@/lib/download"; import { ICON_MAP } from "@/lib/icon-map"; import { getToolRegistryEntry } from "@/lib/tool-registry"; +import { useFeaturesStore } from "@/stores/features-store"; import { useFileStore } from "@/stores/file-store"; /** Formats that browsers can render in tags. */ @@ -106,6 +109,13 @@ export function ToolPage() { () => (toolId ? getToolRegistryEntry(toolId) : undefined), [toolId], ); + const isAiTool = toolId ? (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId) : false; + const getBundleForTool = useFeaturesStore((s) => s.getBundleForTool); + const isToolInstalled = useFeaturesStore((s) => s.isToolInstalled); + const featureBundle = toolId ? getBundleForTool(toolId) : null; + const toolInstalled = toolId ? isToolInstalled(toolId) : true; + const { hasPermission } = useAuth(); + const isAdmin = hasPermission("settings:write"); const { files, entries, @@ -234,6 +244,14 @@ export function ToolPage() { ); } + if (isAiTool && !toolInstalled && featureBundle) { + return ( + + + + ); + } + const IconComponent = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;